ASP.NET CORE MVC - Error Handling, Logging and Security Hardening
Error Handling, Logging and Security Hardening
Add structured application logging, improve 404/status-code handling, review production exception handling, log denied authorization attempts, and audit the security controls already built into the Product Management application.
By the end of this tutorial, the application will produce useful structured logs, display friendly status-code pages, keep production errors generic, and apply a clear security checklist covering anti-forgery protection, ViewModels, authentication, ownership and administrator authorization.
Part 15 added aggregate reporting. Part 16 does not change the database schema. It strengthens the application around failures, diagnostics and security.
- Understand error handling versus logging
- Review development and production errors
- Add status-code handling in
Program.cs - Update
HomeController.cs - Create
Status.cshtml - Inject
ILoggerinto ProductsController - Log Create, Edit and Delete operations
- Log denied authorization attempts
- Review anti-forgery protection
- Review overposting protection
- Review authentication and authorization
- Test the hardened application
- Compare full final files in the appendix
1. Open and Verify the Part 15 Project
cd ~/aspnet-mvc-tutorial/ProductManagement
pwd
dotnet build
code .
The expected path is:
/home/xubuntu/aspnet-mvc-tutorial/ProductManagement
Continue only when Part 15 builds successfully.
2. Error Handling and Logging Are Different
| Concern | Purpose |
|---|---|
| Error handling | Control what the user receives when something goes wrong. |
| Logging | Record diagnostic and operational information for developers and administrators. |
| Security | Prevent unauthorized or unsafe application behaviour. |
3. Review Production Exception Handling in Program.cs
Open:
code Program.cs
Find the existing Part 13/15 production block:
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
Keep it. In non-development environments, unhandled exceptions are routed through the generic /Home/Error endpoint instead of exposing exception details directly to users.
Do not display stack traces, database connection information, secrets, source paths or raw exception messages on public production error pages.
4. Add Friendly Status-Code Handling to Program.cs
Still in Program.cs, find the closing brace of the production error block shown above.
Immediately after it and before app.UseHttpsRedirection();, add:
app.UseStatusCodePagesWithReExecute(
"/Home/Status",
"?code={0}");
The relevant section becomes:
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute(
"/Home/Status",
"?code={0}");
app.UseHttpsRedirection();
This allows responses such as 404 to use a friendly MVC page while preserving the original HTTP status code.
5. Update HomeController.cs
Open:
code Controllers/HomeController.cs
Replace the entire file with:
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ProductManagement.Models;
namespace ProductManagement.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(
ILogger<HomeController> logger)
{
_logger = logger;
}
public IActionResult Index()
{
return View();
}
public IActionResult Privacy()
{
return View();
}
[ResponseCache(
Duration = 0,
Location = ResponseCacheLocation.None,
NoStore = true)]
public IActionResult Error()
{
var exceptionFeature =
HttpContext.Features
.Get<IExceptionHandlerPathFeature>();
if (exceptionFeature?.Error != null)
{
_logger.LogError(
exceptionFeature.Error,
"Unhandled exception on path {Path}.",
exceptionFeature.Path);
}
return View(
new ErrorViewModel
{
RequestId =
Activity.Current?.Id
?? HttpContext.TraceIdentifier
});
}
[ResponseCache(
Duration = 0,
Location = ResponseCacheLocation.None,
NoStore = true)]
public IActionResult Status(int code)
{
var statusFeature =
HttpContext.Features
.Get<IStatusCodeReExecuteFeature>();
var originalPath =
statusFeature?.OriginalPath
?? HttpContext.Request.Path;
_logger.LogWarning(
"HTTP status code {StatusCode} returned for {Path}.",
code,
originalPath);
Response.StatusCode = code;
return View(code);
}
}
6. Understand ILogger<HomeController>
The controller constructor receives:
ILogger<HomeController> logger
ASP.NET Core provides logging through dependency injection. The logger category is associated with HomeController.
The application can then write structured logs such as:
_logger.LogWarning(
"HTTP status code {StatusCode} returned for {Path}.",
code,
originalPath);
Use named placeholders such as {StatusCode} and {Path} rather than manually building one long string. Logging providers can retain those values as structured fields.
7. Understand the Error() Action
Inside HomeController.cs, find:
var exceptionFeature =
HttpContext.Features
.Get<IExceptionHandlerPathFeature>();
When production exception handling re-executes /Home/Error, this feature can provide information about the original exception and path.
The controller logs the exception:
_logger.LogError(
exceptionFeature.Error,
"Unhandled exception on path {Path}.",
exceptionFeature.Path);
The user-facing Error view remains generic.
8. Create the Friendly Status View
Create:
touch Views/Home/Status.cshtml
code Views/Home/Status.cshtml
Add:
@model int
@{
ViewData["Title"] = "Request Error";
}
<h1>Request Error</h1>
@if (Model == 404)
{
<h2>404 - Page Not Found</h2>
<p>
The page or resource you requested could not be found.
</p>
}
else if (Model == 403)
{
<h2>403 - Access Denied</h2>
<p>
You do not have permission to access this resource.
</p>
}
else
{
<h2>Error @Model</h2>
<p>
The request could not be completed.
</p>
}
<p>
<a asp-controller="Home"
asp-action="Index"
class="btn btn-primary">
Return Home
</a>
</p>
9. How Status-Code Re-execution Works
10. Add Logging to ProductsController.cs
Open:
code Controllers/ProductsController.cs
Find these existing fields:
private readonly ApplicationDbContext _context;
private readonly UserManager<IdentityUser> _userManager;
Immediately below them, add:
private readonly ILogger<ProductsController> _logger;
11. Update the ProductsController Constructor
Find:
public ProductsController(
ApplicationDbContext context,
UserManager<IdentityUser> userManager)
{
_context = context;
_userManager = userManager;
}
Replace it with:
public ProductsController(
ApplicationDbContext context,
UserManager<IdentityUser> userManager,
ILogger<ProductsController> logger)
{
_context = context;
_userManager = userManager;
_logger = logger;
}
12. Log Successful Product Creation
Inside Create POST, find:
_context.Products.Add(product);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
Insert the log after SaveChangesAsync():
_context.Products.Add(product);
await _context.SaveChangesAsync();
_logger.LogInformation(
"Product {ProductId} created by user {UserId}.",
product.Id,
userId);
return RedirectToAction(nameof(Index));
13. Log Successful Product Updates
Inside Edit POST, find:
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
Immediately after saving, add:
_logger.LogInformation(
"Product {ProductId} updated by user {UserId}.",
product.Id,
userId);
14. Log Successful Product Deletion
Inside DeleteConfirmed(), find:
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
Replace that section with:
_context.Products.Remove(product);
await _context.SaveChangesAsync();
_logger.LogInformation(
"Product {ProductId} deleted by user {UserId}.",
id,
userId);
return RedirectToAction(nameof(Index));
15. Log Denied Edit Attempts
Inside Edit GET, find:
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
return Forbid();
}
Replace it with:
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
_logger.LogWarning(
"User {UserId} was denied Edit access to Product {ProductId}.",
userId,
product.Id);
return Forbid();
}
Apply the same principle to Edit POST and Delete GET/POST. The full controller is provided in the appendix.
16. Why Log Authorization Failures?
A forbidden request may be an innocent mistake, but repeated denied requests can also help identify unexpected behaviour or attempted misuse.
Never log passwords, authentication cookies, security tokens, connection-string secrets or other credentials. Logs themselves must also be protected in production.
17. Review Anti-Forgery Protection
Open:
code Controllers/ProductsController.cs
Verify every state-changing Product POST action contains:
[ValidateAntiForgeryToken]
This includes Create POST, Edit POST and Delete POST.
The MVC Form Tag Helper generates the corresponding anti-forgery token for normal forms.
Anti-forgery validation helps defend authenticated users against cross-site request forgery. It does not replace authentication, authorization, validation or ownership checks.
18. Review Overposting Protection
Open:
code ViewModels/ProductFormViewModel.cs
Verify that the form ViewModel contains only editable form data and does not contain:
OwnerId
Then open:
code Controllers/ProductsController.cs
Verify Create assigns ownership from the server:
OwnerId = userId
and Edit loads the real Product before copying allowed properties from the ViewModel.
Microsoft's model-binding guidance recommends ViewModels as a protection against overposting, particularly for edit scenarios.
19. Review Authentication and Authorization
Verify the controller still uses:
[Authorize]
for Create/Edit/Delete, and ownership/Admin checks such as:
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
return Forbid();
}
The layers now work together:
20. Review HTTPS and HSTS
Open Program.cs and keep:
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
HTTPS protects data in transit. HSTS instructs compatible browsers to prefer HTTPS for the configured site after receiving the policy.
21. Build the Project
Save:
Program.cs
Controllers/HomeController.cs
Controllers/ProductsController.cs
Views/Home/Status.cshtml
Then run:
dotnet build
Continue only when the project builds successfully.
22. Test the 404 Page
Run:
dotnet run
Open a URL that does not exist, for example:
/this-page-does-not-exist
You should see the friendly 404 page rather than an empty response.
Watch the Terminal. A warning log should be written for the 404 status.
23. Test Product Operation Logs
While logged in:
- Create a Product.
- Edit the Product.
- Delete the Product.
- Observe the Terminal after each action.
You should see structured Information-level messages containing Product ID and the acting user ID.
24. Test a Denied Authorization Attempt
Log in as a normal user and manually request another user's Edit URL:
/Products/Edit/ID
The server should deny access. The Terminal should also contain a Warning-level log recording the denied Product access.
25. Logging Levels
| Level | Typical use |
|---|---|
| Trace / Debug | Detailed development diagnostics. |
| Information | Normal important application events. |
| Warning | Unexpected or suspicious conditions that do not stop the application. |
| Error | An operation failed because of an exception or serious problem. |
| Critical | Severe failures threatening application availability. |
26. Logging Configuration in appsettings.json
Open:
code appsettings.json
Your existing logging section resembles:
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
This controls the minimum log levels for categories. No change is required for this tutorial.
27. Security Audit Checklist
| Control | Current implementation |
|---|---|
| Input validation | Data annotations and ModelState. |
| Overposting protection | ProductFormViewModel plus explicit mapping. |
| CSRF protection | [ValidateAntiForgeryToken] on state-changing MVC POST actions. |
| Authentication | ASP.NET Core Identity. |
| Ownership authorization | OwnerId compared with current Identity user ID. |
| Role authorization | Admin role and User.IsInRole("Admin"). |
| Admin-only endpoint | [Authorize(Roles = "Admin")]. |
| Transport security | HTTPS redirection and production HSTS. |
| Error disclosure | Generic production error page. |
| Diagnostics | Structured ILogger messages. |
28. No Migration Is Required
Part 16 modifies middleware, controllers, logging and views only.
The database schema has not changed.
29. Troubleshooting
IStatusCodeReExecuteFeature cannot be found
Open HomeController.cs and confirm:
using Microsoft.AspNetCore.Diagnostics;ILogger cannot be found
ILogger<T> is supplied by the standard ASP.NET Core logging infrastructure. Confirm the project still targets the ASP.NET Core Web SDK and rebuild with dotnet build.
The custom 404 page does not appear
Open Program.cs and verify UseStatusCodePagesWithReExecute appears before UseRouting(), and verify Views/Home/Status.cshtml exists.
Production Error() does not show exception text
This is intentional. Exception details are logged rather than displayed to the public user.
No Product logs appear
Confirm the controller receives ILogger<ProductsController> and that appsettings.json allows Information-level logs for the application category.
30. Hands-On Exercise
- Visit a nonexistent URL and observe the friendly 404 page.
- Create a Product and locate the Information log.
- Edit it and locate the update log.
- Delete it and locate the delete log.
- Attempt an unauthorized edit with another normal user and locate the Warning log.
- Verify each Product POST action still has
[ValidateAntiForgeryToken]. - Verify
OwnerIdis absent fromProductFormViewModel.
31. Knowledge Check
- What is the difference between error handling and logging?
- Why should production error pages be generic?
- What does
UseStatusCodePagesWithReExecute()do? - What is
ILogger<T>used for? - Why use structured logging placeholders?
- Why log denied authorization attempts?
- What does anti-forgery validation protect against?
- Why does a ViewModel help prevent overposting?
- Why must ownership checks remain on the server?
- Does Part 16 require a migration?
32. Part 16 Summary
- reviewed production exception handling;
- added friendly status-code handling;
- updated
HomeControllerto log errors and HTTP status codes; - created a custom status page;
- injected
ILogger<ProductsController>; - logged Product Create/Edit/Delete operations;
- logged denied authorization attempts;
- reviewed anti-forgery protection;
- reviewed ViewModel overposting protection;
- reviewed Identity, ownership and role authorization;
- reviewed HTTPS/HSTS; and
- completed an application security checklist.
Appendix — Full Code for Final Verification
Use this appendix after completing Part 16. Compare every file modified in this tutorial with the complete final version below.
Appendix A — Program.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(
builder.Configuration.GetConnectionString(
"DefaultConnection")));
builder.Services
.AddDefaultIdentity<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
await IdentitySeedData.InitializeAsync(
scope.ServiceProvider,
app.Configuration);
}
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute(
"/Home/Status",
"?code={0}");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.Run();
Appendix B — Controllers/HomeController.cs
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ProductManagement.Models;
namespace ProductManagement.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(
ILogger<HomeController> logger)
{
_logger = logger;
}
public IActionResult Index()
{
return View();
}
public IActionResult Privacy()
{
return View();
}
[ResponseCache(
Duration = 0,
Location = ResponseCacheLocation.None,
NoStore = true)]
public IActionResult Error()
{
var exceptionFeature =
HttpContext.Features
.Get<IExceptionHandlerPathFeature>();
if (exceptionFeature?.Error != null)
{
_logger.LogError(
exceptionFeature.Error,
"Unhandled exception on path {Path}.",
exceptionFeature.Path);
}
return View(
new ErrorViewModel
{
RequestId =
Activity.Current?.Id
?? HttpContext.TraceIdentifier
});
}
[ResponseCache(
Duration = 0,
Location = ResponseCacheLocation.None,
NoStore = true)]
public IActionResult Status(int code)
{
var statusFeature =
HttpContext.Features
.Get<IStatusCodeReExecuteFeature>();
var originalPath =
statusFeature?.OriginalPath
?? HttpContext.Request.Path;
_logger.LogWarning(
"HTTP status code {StatusCode} returned for {Path}.",
code,
originalPath);
Response.StatusCode = code;
return View(code);
}
}
Appendix C — Views/Home/Status.cshtml
@model int
@{
ViewData["Title"] = "Request Error";
}
<h1>Request Error</h1>
@if (Model == 404)
{
<h2>404 - Page Not Found</h2>
<p>
The page or resource you requested could not be found.
</p>
}
else if (Model == 403)
{
<h2>403 - Access Denied</h2>
<p>
You do not have permission to access this resource.
</p>
}
else
{
<h2>Error @Model</h2>
<p>
The request could not be completed.
</p>
}
<p>
<a asp-controller="Home"
asp-action="Index"
class="btn btn-primary">
Return Home
</a>
</p>
Appendix D — Controllers/ProductsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;
namespace ProductManagement.Controllers;
public class ProductsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly UserManager<IdentityUser> _userManager;
private readonly ILogger<ProductsController> _logger;
public ProductsController(
ApplicationDbContext context,
UserManager<IdentityUser> userManager,
ILogger<ProductsController> logger)
{
_context = context;
_userManager = userManager;
_logger = logger;
}
public async Task<IActionResult> Index(
string? search,
int? categoryId,
decimal? maxPrice,
string? sortOrder,
bool mine = false)
{
var products = _context.Products
.Include(p => p.Category)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
products = products.Where(
p => p.Name.Contains(search));
}
if (categoryId.HasValue)
{
products = products.Where(
p => p.CategoryId == categoryId.Value);
}
if (maxPrice.HasValue)
{
products = products.Where(
p => p.Price <= maxPrice.Value);
}
if (mine)
{
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
products = products.Where(
p => p.OwnerId == userId);
}
products = sortOrder switch
{
"name_desc" =>
products.OrderByDescending(p => p.Name),
"price" =>
products.OrderBy(p => p.Price),
"price_desc" =>
products.OrderByDescending(p => p.Price),
_ =>
products.OrderBy(p => p.Name)
};
var viewModel = new ProductIndexViewModel
{
Products = await products.ToListAsync(),
Search = search,
CategoryId = categoryId,
MaxPrice = maxPrice,
Mine = mine,
SortOrder = sortOrder,
NameSort =
sortOrder == "name_desc"
? ""
: "name_desc",
PriceSort =
sortOrder == "price"
? "price_desc"
: "price",
Categories = await _context.Categories
.OrderBy(c => c.Name)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.Name,
Selected = c.Id == categoryId
})
.ToListAsync()
};
return View(viewModel);
}
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
return NotFound();
}
return View(product);
}
[Authorize]
[HttpGet]
public async Task<IActionResult> Create()
{
var viewModel = new ProductFormViewModel
{
Categories = await GetCategoryItemsAsync()
};
return View(viewModel);
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
ProductFormViewModel viewModel)
{
if (!ModelState.IsValid)
{
viewModel.Categories =
await GetCategoryItemsAsync(
viewModel.CategoryId);
return View(viewModel);
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
var product = new Product
{
Name = viewModel.Name,
Description = viewModel.Description,
Price = viewModel.Price,
Quantity = viewModel.Quantity,
CategoryId = viewModel.CategoryId,
OwnerId = userId
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
_logger.LogInformation(
"Product {ProductId} created by user {UserId}.",
product.Id,
userId);
return RedirectToAction(nameof(Index));
}
[Authorize]
[HttpGet]
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
_logger.LogWarning(
"User {UserId} was denied Edit access to Product {ProductId}.",
userId,
product.Id);
return Forbid();
}
var viewModel = new ProductFormViewModel
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Quantity = product.Quantity,
CategoryId = product.CategoryId,
Categories =
await GetCategoryItemsAsync(
product.CategoryId)
};
return View(viewModel);
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(
int id,
ProductFormViewModel viewModel)
{
if (id != viewModel.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
viewModel.Categories =
await GetCategoryItemsAsync(
viewModel.CategoryId);
return View(viewModel);
}
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
_logger.LogWarning(
"User {UserId} was denied update access to Product {ProductId}.",
userId,
product.Id);
return Forbid();
}
product.Name = viewModel.Name;
product.Description = viewModel.Description;
product.Price = viewModel.Price;
product.Quantity = viewModel.Quantity;
product.CategoryId = viewModel.CategoryId;
await _context.SaveChangesAsync();
_logger.LogInformation(
"Product {ProductId} updated by user {UserId}.",
product.Id,
userId);
return RedirectToAction(nameof(Index));
}
[Authorize]
[HttpGet]
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
_logger.LogWarning(
"User {UserId} was denied Delete access to Product {ProductId}.",
userId,
product.Id);
return Forbid();
}
return View(product);
}
[Authorize]
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId
&& !User.IsInRole("Admin"))
{
_logger.LogWarning(
"User {UserId} was denied delete submission for Product {ProductId}.",
userId,
product.Id);
return Forbid();
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
_logger.LogInformation(
"Product {ProductId} deleted by user {UserId}.",
id,
userId);
return RedirectToAction(nameof(Index));
}
private async Task<List<SelectListItem>>
GetCategoryItemsAsync(int? selectedId = null)
{
return await _context.Categories
.OrderBy(c => c.Name)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.Name,
Selected = c.Id == selectedId
})
.ToListAsync();
}
}
Appendix E — Final Verification Commands
cd ~/aspnet-mvc-tutorial/ProductManagement
dotnet build
dotnet run
Test:
/Products
/this-page-does-not-exist
/Products/Create
/Products/Edit/1
/Products/Delete/1
If the project builds, nonexistent pages show a friendly 404 response, Product operations produce useful logs, denied ownership requests are logged and forbidden, and the existing security controls remain intact, Part 16 is complete.
Part 17 will expose Product data through JSON using an [ApiController], HTTP GET/POST/PUT/DELETE endpoints and appropriate HTTP status codes while keeping the existing MVC interface.