· 14 min read
Part 3: Securing Your API - JWT Authentication, User Registration & Login
Alright, let’s move on to Part 3, focusing on securing our API with JWTs and implementing user authentication endpoints.
Astrowind Blog Post: Building a Modern Web App with ASP.NET Core & React
Part 3: Securing Your API - JWT Authentication, User Registration & Login
In Part 2, we built our service layer, API controllers for books, and integrated ASP.NET Core Identity for user management. Now, it’s time to secure our API. We’ll implement JSON Web Token (JWT) based authentication, create endpoints for user registration and login, and start protecting our existing endpoints.
3.1. JWT (JSON Web Token) Authentication
APIs, especially those serving Single Page Applications (SPAs) like React apps, commonly use token-based authentication. JWT is a popular standard.
How it works (briefly):
- User provides credentials (e.g., email/password) to a login endpoint.
- Server validates credentials.
- If valid, the server generates a JWT (a signed string containing user claims like ID, username, roles) and sends it back to the client.
- Client stores this token (e.g., in
localStorageorsessionStorage). - For subsequent requests to protected API endpoints, the client includes the JWT in the
Authorizationheader (typically asBearer <token>). - The API validates the token’s signature and expiration. If valid, it extracts user information from the token and grants access.
3.1.1. Install JWT Bearer NuGet Package
// Using Package Manager Console in Visual Studio
Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
# Output: (NuGet installation messages)3.1.2. Configure JWT Authentication in Program.cs
We need to tell ASP.NET Core how to validate incoming JWTs. This involves specifying the token issuer, audience, and the signing key.
Modify Program.cs:
// eLibrary.API/Program.cs
// ... (other usings)
using Microsoft.AspNetCore.Authentication.JwtBearer; // Add this
using Microsoft.IdentityModel.Tokens; // Add this
using System.Text; // Add this
var builder = WebApplication.CreateBuilder(args);
// ... (Identity configuration from Part 2)
// builder.Services.AddIdentity<ApplicationUser, IdentityRole>(...)
// .AddEntityFrameworkStores<ApplicationDbContext>()
// .AddDefaultTokenProviders();
// 6. Configure JWT Authentication
// Retrieve JWT settings from appsettings.json
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
// Output (example): jwtSettings["Issuer"] would be "eLibraryAPI", jwtSettings["Audience"] would be "eLibraryClient"
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; // For general usage
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtSettings["Issuer"], // From appsettings.json
// Output (runtime check): Token issuer must match "eLibraryAPI"
ValidateAudience = true,
ValidAudience = jwtSettings["Audience"], // From appsettings.json
// Output (runtime check): Token audience must match "eLibraryClient"
ValidateLifetime = true, // Check token expiration
// Output (runtime check): Token must not be expired
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"]!)),
// Output (runtime check): Token signature must be valid using this key
// Key must be long and secret enough!
ClockSkew = TimeSpan.Zero // Optional: reduce or remove clock skew tolerance
};
});
// Output: (No direct output, but authentication services are configured for JWT)
builder.Services.AddAuthorization(); // Ensure Authorization services are added (might be there from Identity setup)
// ... (Controller, Swagger, etc. registrations)
var app = builder.Build();
// ... (Seeding, Swagger, HttpsRedirection)
app.UseAuthentication(); // Already added in Part 2, ensures it's in the pipeline
app.UseAuthorization(); // Already added in Part 2
app.MapControllers();
app.Run();3.1.3. Add JWT Settings to appsettings.json (and appsettings.Development.json)
Store your JWT configuration in appsettings.json. CRITICAL SECURITY NOTE: The Key should be a strong, randomly generated secret. For production, NEVER hardcode it here or commit it to source control. Use User Secrets for local development and Azure Key Vault (or similar) for production.
Open appsettings.json:
{
// ... (Logging, ConnectionStrings)
"JwtSettings": {
"Key": "THIS_IS_A_SUPER_SECRET_KEY_REPLACE_IT_LATER_WITH_A_STRONG_ONE_FROM_USER_SECRETS_OR_KEY_VAULT_MINIMUM_32_CHARS_LONG_FOR_HS256", // MUST BE STRONG AND SECRET!
"Issuer": "eLibraryAPI", // Who issues the token (your API)
"Audience": "eLibraryClient", // Who the token is for (your React client)
"DurationInMinutes": 60 // How long the token is valid
// Output (when read by app): These values configure JWT generation and validation.
},
"AppSettings": {
// ... (AdminEmail, AdminPassword)
}
}- Key: A long, random string. For HS256 (HMAC SHA256), it should be at least 32 characters (256 bits). You can generate one online or programmatically.
- Issuer: A string identifying your API as the token issuer.
- Audience: A string identifying the intended recipient of the token (your client app).
- DurationInMinutes: How long the token will be valid.
For local development, you can override the Key in appsettings.Development.json if you wish, or use User Secrets Manager:
- Right-click the
eLibrary.APIproject in Solution Explorer. - Select “Manage User Secrets”. This opens a
secrets.jsonfile (not part of your project, stored separately). - Add your JWT key there:
The configuration system will automatically pick values from// secrets.json (User Secrets for eLibrary.API) { "JwtSettings": { "Key": "YOUR_ACTUAL_DEVELOPMENT_SECRET_KEY_THAT_IS_VERY_STRONG_AND_UNIQUE" } // You can also move AdminEmail/Password here // "AppSettings": { // "AdminEmail": "[email protected]", // "AdminPassword": "Password123!" // } }secrets.jsonoverappsettings.Development.jsonandappsettings.json.
3.2. Creating an Authentication Service (IAuthService)
We’ll create a service to handle user registration, login, and token generation logic.
3.2.1. DTOs for Authentication
Create DTOs/Auth/RegisterDto.cs:
// eLibrary.API/DTOs/Auth/RegisterDto.cs
using System.ComponentModel.DataAnnotations;
namespace eLibrary.API.DTOs.Auth
{
public class RegisterDto
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
public string Password { get; set; } = string.Empty;
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = string.Empty;
// You could add UserName here if it's different from Email
// [Required]
// public string UserName { get; set; } = string.Empty;
}
}Create DTOs/Auth/LoginDto.cs:
// eLibrary.API/DTOs/Auth/LoginDto.cs
using System.ComponentModel.DataAnnotations;
namespace eLibrary.API.DTOs.Auth
{
public class LoginDto
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
}Create DTOs/Auth/AuthResponseDto.cs (for the data returned after successful login/registration):
// eLibrary.API/DTOs/Auth/AuthResponseDto.cs
namespace eLibrary.API.DTOs.Auth
{
public class AuthResponseDto
{
public string UserId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public IList<string>? Roles { get; set; } // List of user roles
}
}3.2.2. IAuthService Interface
Create Services/IAuthService.cs:
// eLibrary.API/Services/IAuthService.cs
using eLibrary.API.DTOs.Auth;
using Microsoft.AspNetCore.Identity; // For IdentityResult
namespace eLibrary.API.Services
{
public interface IAuthService
{
Task<(IdentityResult result, AuthResponseDto? authResponse)> RegisterAsync(RegisterDto registerDto, string role = "User");
Task<AuthResponseDto?> LoginAsync(LoginDto loginDto);
// Potentially: Task<AuthResponseDto?> RefreshTokenAsync(string token, string refreshToken);
}
}The RegisterAsync method returns a tuple: the IdentityResult (to check for success/errors) and the AuthResponseDto (if successful).
3.2.3. AuthService Implementation
Create Services/AuthService.cs:
// eLibrary.API/Services/AuthService.cs
using eLibrary.API.DTOs.Auth;
using eLibrary.API.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace eLibrary.API.Services
{
public class AuthService : IAuthService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager; // To assign roles
private readonly IConfiguration _configuration;
private readonly ILogger<AuthService> _logger;
public AuthService(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IConfiguration configuration,
ILogger<AuthService> logger)
{
_userManager = userManager;
_roleManager = roleManager;
_configuration = configuration;
_logger = logger;
}
public async Task<(IdentityResult result, AuthResponseDto? authResponse)> RegisterAsync(RegisterDto registerDto, string role = "User")
{
_logger.LogInformation("Attempting to register user: {Email}", registerDto.Email);
var existingUser = await _userManager.FindByEmailAsync(registerDto.Email);
if (existingUser != null)
{
_logger.LogWarning("Registration failed: Email {Email} already exists.", registerDto.Email);
return (IdentityResult.Failed(new IdentityError { Description = "Email already exists." }), null);
// Output (if email exists): (IdentityResult with "Email already exists." error, null)
}
var newUser = new ApplicationUser
{
Email = registerDto.Email,
UserName = registerDto.Email // Or use a separate UserName from DTO
};
var result = await _userManager.CreateAsync(newUser, registerDto.Password);
if (!result.Succeeded)
{
_logger.LogError("User creation failed for {Email}: {@Errors}", registerDto.Email, result.Errors);
return (result, null);
// Output (if password too short): (IdentityResult with password errors, null)
}
// Ensure the role exists
if (!await _roleManager.RoleExistsAsync(role))
{
_logger.LogWarning("Role {RoleName} does not exist. Creating it.", role);
await _roleManager.CreateAsync(new IdentityRole(role));
}
await _userManager.AddToRoleAsync(newUser, role);
_logger.LogInformation("User {Email} created successfully and added to role {RoleName}.", registerDto.Email, role);
// Generate token for the new user
var authResponse = await GenerateTokenResponseAsync(newUser);
return (result, authResponse);
// Output (if successful): (IdentityResult.Success, AuthResponseDto with token and user details)
}
public async Task<AuthResponseDto?> LoginAsync(LoginDto loginDto)
{
_logger.LogInformation("Attempting to login user: {Email}", loginDto.Email);
var user = await _userManager.FindByEmailAsync(loginDto.Email);
if (user == null)
{
_logger.LogWarning("Login failed: User {Email} not found.", loginDto.Email);
return null; // User not found
}
var isPasswordValid = await _userManager.CheckPasswordAsync(user, loginDto.Password);
if (!isPasswordValid)
{
_logger.LogWarning("Login failed: Invalid password for user {Email}.", loginDto.Email);
return null; // Invalid password
}
_logger.LogInformation("User {Email} logged in successfully.", loginDto.Email);
return await GenerateTokenResponseAsync(user);
// Output (if successful): AuthResponseDto with token and user details
// Output (if failed): null
}
private async Task<AuthResponseDto> GenerateTokenResponseAsync(ApplicationUser user)
{
var userRoles = await _userManager.GetRolesAsync(user);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id), // Standard claim for User ID
new Claim(JwtRegisteredClaimNames.Sub, user.Id), // Subject, often same as NameIdentifier
new Claim(JwtRegisteredClaimNames.Email, user.Email!),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // Unique token ID
// Add other claims as needed, e.g., user.UserName
// new Claim(JwtRegisteredClaimNames.Name, user.UserName!),
};
foreach (var userRole in userRoles)
{
claims.Add(new Claim(ClaimTypes.Role, userRole));
}
var jwtKey = _configuration["JwtSettings:Key"];
var issuer = _configuration["JwtSettings:Issuer"];
var audience = _configuration["JwtSettings:Audience"];
var durationInMinutes = _configuration.GetValue<int>("JwtSettings:DurationInMinutes");
if (string.IsNullOrEmpty(jwtKey) || string.IsNullOrEmpty(issuer) || string.IsNullOrEmpty(audience))
{
_logger.LogError("JWT settings (Key, Issuer, or Audience) are missing or invalid in configuration.");
throw new InvalidOperationException("JWT settings are not configured properly.");
}
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(durationInMinutes),
Issuer = issuer,
Audience = audience,
SigningCredentials = credentials
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
var tokenString = tokenHandler.WriteToken(token);
// Output (tokenString): A long, encoded JWT string like "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOi..."
return new AuthResponseDto
{
UserId = user.Id,
Email = user.Email!,
Token = tokenString,
ExpiresAt = token.ValidTo, // The actual expiration time of the token
Roles = userRoles
};
}
}
}3.2.4. Register AuthService
In Program.cs:
// eLibrary.API/Program.cs
// ... (other service registrations)
builder.Services.AddScoped<IAuthService, AuthService>();3.3. Creating an AuthController
This controller will handle /api/auth/register and /api/auth/login requests.
Create Controllers/AuthController.cs:
// eLibrary.API/Controllers/AuthController.cs
using eLibrary.API.DTOs.Auth;
using eLibrary.API.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.Linq; // For Select on IdentityError
using System.Threading.Tasks; // For Task
namespace eLibrary.API.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
public AuthController(IAuthService authService, ILogger<AuthController> logger)
{
_authService = authService;
_logger = logger;
}
// POST: api/auth/register
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterDto registerDto)
{
_logger.LogInformation("API endpoint called: POST api/auth/register for email: {Email}", registerDto.Email);
if (!ModelState.IsValid)
{
_logger.LogWarning("Invalid model state for registration: {@ModelState}", ModelState);
return BadRequest(ModelState);
// Output (HTTP 400 - if password and confirm password don't match):
// { ..., "errors": { "ConfirmPassword": ["The password and confirmation password do not match."] } }
}
var (result, authResponse) = await _authService.RegisterAsync(registerDto);
if (!result.Succeeded || authResponse == null)
{
_logger.LogWarning("Registration failed for {Email}: {@Errors}", registerDto.Email, result.Errors);
// AddErrors(result); // Helper to add IdentityErrors to ModelState
return BadRequest(new { message = "Registration failed.", errors = result.Errors.Select(e => e.Description) });
// Output (HTTP 400 - if email exists):
// { "message": "Registration failed.", "errors": ["Email already exists."] }
}
_logger.LogInformation("User {Email} registered successfully.", registerDto.Email);
return Ok(authResponse); // HTTP 200 OK with token and user details
// Output (HTTP 200 OK):
// {
// "userId": "generated-guid-user-id",
// "email": "[email protected]",
// "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
// "expiresAt": "2023-10-27T15:30:00Z",
// "roles": ["User"]
// }
}
// POST: api/auth/login
[HttpPost("login")]
public async Task<IActionResult> Login(LoginDto loginDto)
{
_logger.LogInformation("API endpoint called: POST api/auth/login for email: {Email}", loginDto.Email);
if (!ModelState.IsValid)
{
_logger.LogWarning("Invalid model state for login: {@ModelState}", ModelState);
return BadRequest(ModelState);
}
var authResponse = await _authService.LoginAsync(loginDto);
if (authResponse == null)
{
_logger.LogWarning("Login failed for {Email}. Invalid credentials.", loginDto.Email);
return Unauthorized(new { message = "Invalid email or password." }); // HTTP 401 Unauthorized
// Output (HTTP 401 Unauthorized):
// { "message": "Invalid email or password." }
}
_logger.LogInformation("User {Email} logged in successfully.", loginDto.Email);
return Ok(authResponse);
// Output (HTTP 200 OK):
// {
// "userId": "user-id-from-db",
// "email": "[email protected]",
// "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
// "expiresAt": "2023-10-27T16:00:00Z", // Assuming token duration is 60 mins
// "roles": ["User"] // or ["Admin", "User"] if applicable
// }
}
// Helper method (optional, can be part of a BaseController)
// private void AddErrors(IdentityResult result)
// {
// foreach (var error in result.Errors)
// {
// ModelState.AddModelError(string.Empty, error.Description);
// }
// }
}
}Testing Registration and Login:
- Run your API.
- Use Swagger UI (or Postman/Insomnia):
- Register a new user:
- Go to
POST /api/auth/register. - Provide an email and password (e.g.,
[email protected],Password123!). - Execute. You should get a 200 OK with a token. Copy this token.
- Go to
- Login with the new user:
- Go to
POST /api/auth/login. - Provide the same email and password.
- Execute. You should get another 200 OK with a new token.
- Go to
- Register a new user:
3.4. Securing Endpoints with [Authorize]
Now that users can get tokens, let’s protect our BooksController endpoints.
Modify Controllers/BooksController.cs:
// eLibrary.API/Controllers/BooksController.cs
using eLibrary.API.DTOs;
using eLibrary.API.Services;
using Microsoft.AspNetCore.Authorization; // Add this
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace eLibrary.API.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize] // Apply Authorize attribute to the entire controller
public class BooksController : ControllerBase
{
// ... (constructor and existing methods)
// GET: api/books (Now requires authentication)
[HttpGet]
public async Task<ActionResult<IEnumerable<BookDto>>> GetBooks()
{
_logger.LogInformation("API endpoint called: GET api/books (Authorized)");
// You can access user information if needed:
// var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); // Get user's ID
// var userEmail = User.FindFirstValue(ClaimTypes.Email); // Get user's email
// _logger.LogInformation("User ID: {UserId}, Email: {UserEmail}", userId, userEmail);
var books = await _bookService.GetAllBooksAsync();
return Ok(books);
}
// GET: api/books/5 (Now requires authentication)
[HttpGet("{id}")]
public async Task<ActionResult<BookDto>> GetBook(int id)
{
// ...
return Ok(book);
}
// POST: api/books (Requires authentication and "Admin" role)
[HttpPost]
[Authorize(Roles = "Admin")] // Only users in "Admin" role can create books
public async Task<ActionResult<BookDto>> PostBook(CreateBookDto createBookDto)
{
// ...
return CreatedAtAction(nameof(GetBook), new { id = createdBook.Id }, createdBook);
}
// PUT: api/books/5 (Requires authentication and "Admin" role)
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> PutBook(int id, UpdateBookDto updateBookDto)
{
// ...
return NoContent();
}
// DELETE: api/books/5 (Requires authentication and "Admin" role)
[HttpDelete("{id}")]
[Authorize(Roles = "Admin,Editor")] // Example: Admin OR Editor can delete
public async Task<IActionResult> DeleteBook(int id)
{
// ...
return NoContent();
}
}
}Changes:
[Authorize]attribute added at the controller level: All actions inBooksControllernow require a valid JWT.[Authorize(Roles = "Admin")]added toPostBookandPutBook: Only users with the “Admin” role can access these.[Authorize(Roles = "Admin,Editor")]added toDeleteBookas an example of allowing multiple roles (comma-separated means OR). We’d need to create an “Editor” role and assign it to users if we wanted this to work.
Testing Secured Endpoints:
- Run the API.
- Try accessing
GET /api/bookswithout a token:- In Swagger UI, try to execute it.
- You should receive an HTTP 401 Unauthorized response.
- Output (HTTP 401 Unauthorized): (No body, just the status code)
- Authorize Swagger UI to use JWT:
- At the top right of the Swagger UI page, there’s usually an “Authorize” button. Click it. (Imagine a screenshot of Swagger UI’s “Authorize” button)
- A dialog will appear. In the
jwt_bearer(or similar name) section, paste the token you got fromloginorregisterinto the “Value” field, prefixed withBearer(e.g.,Bearer eyJhbGciOi...). (Imagine a screenshot of the Swagger Authorize dialog with “Bearer<token>” pasted) - Click “Authorize” and then “Close”. The lock icon should now appear closed.
- Retry
GET /api/books:- Execute it again.
- Now you should get an HTTP 200 OK with the list of books.
- Output (HTTP 200 OK): (JSON array of books, as before)
- Test Role-Based Authorization:
- Log in as a regular user (who should have the “User” role by default from our
RegisterAsynclogic). Get their token. - Authorize Swagger with this user’s token.
- Try
POST /api/books:- You should receive an HTTP 403 Forbidden response because the “User” role is not “Admin”.
- Output (HTTP 403 Forbidden): (No body, just the status code)
- Log in as the admin user (e.g.,
[email protected]that we seeded). Get their token. - Authorize Swagger with the admin’s token.
- Retry
POST /api/books:- You should now be able to create a book successfully (HTTP 201 Created).
- Log in as a regular user (who should have the “User” role by default from our
3.5. Role Management (Brief Overview)
ASP.NET Core Identity provides RoleManager<IdentityRole> for managing roles.
- Creating Roles: We did this in
SeedData.csandAuthService.cs(await _roleManager.CreateAsync(new IdentityRole(roleName));) - Assigning Roles to Users:
await _userManager.AddToRoleAsync(user, "Admin"); - Removing Roles from Users:
await _userManager.RemoveFromRoleAsync(user, "User"); - Checking if a User is in a Role:
await _userManager.IsInRoleAsync(user, "Admin"); - Getting User Roles:
await _userManager.GetRolesAsync(user);
You could create an AdminController with endpoints to manage users and roles if your application requires it (e.g., an admin dashboard).
Example placeholder for an admin action:
// In a hypothetical AdminController.cs
// [Authorize(Roles = "Admin")]
// [HttpPost("users/{userId}/roles")]
// public async Task<IActionResult> AssignRoleToUser(string userId, [FromBody] string roleName)
// {
// var user = await _userManager.FindByIdAsync(userId);
// if (user == null) return NotFound("User not found.");
//
// if (!await _roleManager.RoleExistsAsync(roleName))
// {
// // Optionally create the role if it doesn't exist or return BadRequest
// return BadRequest($"Role '{roleName}' does not exist.");
// }
//
// var result = await _userManager.AddToRoleAsync(user, roleName);
// if (result.Succeeded) return Ok($"Role '{roleName}' assigned to user '{user.Email}'.");
//
// return BadRequest(result.Errors);
// }We’ve successfully set up JWT authentication, registration, login, and protected our book endpoints with both general authorization and role-based authorization!
Next Up (Part 4 Preview):
- Handling PDF File Uploads: Storing files, associating them with books.
- Handling PDF File Downloads: Securely serving PDF files.
- Advanced Error Handling and Global Exception Middleware.
- Implementing Pagination for lists of books.
Ready for file handling and more advanced topics?