Changes backend in preparation for frontend work

This commit is contained in:
2026-06-19 12:15:08 +02:00
parent b5357f213a
commit 23e2150140
22 changed files with 631 additions and 92 deletions
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Availability;
@@ -12,6 +13,7 @@ using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Api.Infrastructure;
using System.Text;
using System.Threading.RateLimiting;
namespace SlpModularCms.Api.Extensions;
@@ -93,4 +95,49 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddCmsCors(this IServiceCollection services, IConfiguration configuration)
{
var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
return services;
}
public static IServiceCollection AddCmsRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", opt =>
{
var settings = configuration.GetSection("RateLimiting:Login");
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 5);
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
opt.QueueLimit = 0;
});
options.AddSlidingWindowLimiter("refresh", opt =>
{
var settings = configuration.GetSection("RateLimiting:Refresh");
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 20);
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
opt.SegmentsPerWindow = 4;
opt.QueueLimit = 0;
});
});
return services;
}
}
+6
View File
@@ -14,6 +14,8 @@ orchestrator.DiscoverModules();
// 2. Add Core Infrastructure
builder.Services.AddCoreInfrastructure(builder.Configuration);
builder.Services.AddCmsCors(builder.Configuration);
builder.Services.AddCmsRateLimiting(builder.Configuration);
// 3. Add Module Services
orchestrator.RegisterModuleServices(builder.Services);
@@ -29,6 +31,8 @@ var app = builder.Build();
// 5. Global Exception Handling
app.UseExceptionHandler();
app.UseRateLimiter();
// 6. Configure Pipeline
if (app.Environment.IsDevelopment())
{
@@ -38,6 +42,8 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseCors();
// 7. Use Module Middleware
orchestrator.UseModules(app);
@@ -18,5 +18,21 @@
"Availability": {
"CircuitBreakerSeconds": 30,
"StatusCacheSeconds": 1
},
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"https://localhost:5173"
]
},
"RateLimiting": {
"Login": {
"PermitLimit": 100,
"WindowSeconds": 60
},
"Refresh": {
"PermitLimit": 500,
"WindowSeconds": 60
}
}
}
+13
View File
@@ -19,5 +19,18 @@
"Availability": {
"CircuitBreakerSeconds": 30,
"StatusCacheSeconds": 1
},
"Cors": {
"AllowedOrigins": []
},
"RateLimiting": {
"Login": {
"PermitLimit": 5,
"WindowSeconds": 60
},
"Refresh": {
"PermitLimit": 20,
"WindowSeconds": 60
}
}
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -43,12 +44,12 @@ public class GlobalExceptionHandlerTests
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var body = await reader.ReadToEndAsync();
var response = JsonSerializer.Deserialize<ApiErrorResponse>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var response = JsonSerializer.Deserialize<ProblemDetails>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Message.Should().Be("Er is een interne serverfout opgetreden.");
response.Detail.Should().BeNull();
response.TraceId.Should().NotBeNullOrEmpty();
response!.Title.Should().Be("Internal Server Error");
response.Detail.Should().Be("Er is een interne serverfout opgetreden.");
response.Extensions.Should().ContainKey("traceId");
}
[Fact]
@@ -69,7 +70,7 @@ public class GlobalExceptionHandlerTests
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var body = await reader.ReadToEndAsync();
var response = JsonSerializer.Deserialize<ApiErrorResponse>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var response = JsonSerializer.Deserialize<ProblemDetails>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response!.Detail.Should().Contain("Test error");
}
@@ -1,13 +0,0 @@
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Gestandaardiseerd error response object voor de API.
/// </summary>
/// <param name="Message">De (veilige) foutmelding.</param>
/// <param name="Detail">Extra details, zoals een stacktrace (alleen in Development).</param>
/// <param name="TraceId">Uniek correlatie ID voor log-analyse.</param>
public record ApiErrorResponse(
string Message,
string? Detail = null,
string? TraceId = null
);
@@ -1,13 +1,14 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Globale exception handler conform .NET 8+ IExceptionHandler.
/// Globale exception handler conform .NET 8+ IExceptionHandler en RFC 9457 (ProblemDetails).
/// </summary>
public class GlobalExceptionHandler : IExceptionHandler
{
@@ -33,28 +34,31 @@ public class GlobalExceptionHandler : IExceptionHandler
Environment.MachineName,
traceId);
var (statusCode, message) = MapException(exception);
var (statusCode, title) = MapException(exception);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = _env.IsDevelopment() ? exception.ToString() : "Er is een interne serverfout opgetreden.",
Instance = httpContext.Request.Path
};
problemDetails.Extensions["traceId"] = traceId;
httpContext.Response.StatusCode = statusCode;
var response = new ApiErrorResponse(
Message: message,
Detail: _env.IsDevelopment() ? exception.ToString() : null,
TraceId: traceId
);
await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
private static (int StatusCode, string Message) MapException(Exception exception)
private static (int StatusCode, string Title) MapException(Exception exception)
{
return exception switch
{
// Hier kunnen specifieke uitzonderingen worden toegevoegd
// Bijv: ValidationException => (StatusCodes.Status400BadRequest, exception.Message),
_ => (StatusCodes.Status500InternalServerError, "Er is een interne serverfout opgetreden.")
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error")
};
}
}
@@ -17,6 +17,11 @@ public class ApplicationUser : IdentityUser<Guid>
/// </summary>
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// De naam van de gebruiker die wordt weergegeven in de UI.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
/// Navigatie-eigenschap naar module-specifieke rechten.
/// </summary>
@@ -2,6 +2,5 @@ namespace SlpModularCms.Core.Identity.Models;
public record CreateOwnerRequest(string Email, string Password);
public record LoginRequest(string Email, string Password);
public record RefreshTokenRequest(string AccessToken, string RefreshToken);
public record InviteUserRequest(string Email, string Role);
public record CompleteSetupRequest(string Token, string Password);
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SlpModularCms.Core.Identity.Models;
/// <summary>
@@ -5,6 +7,18 @@ namespace SlpModularCms.Core.Identity.Models;
/// </summary>
public record TokenResponse(
string AccessToken,
string RefreshToken,
DateTimeOffset Expiry
[property: JsonIgnore] string RefreshToken,
DateTimeOffset ExpiresAt,
UserResponse User
);
/// <summary>
/// Model voor gebruikersinformatie in de auth response.
/// </summary>
public record UserResponse(
Guid Id,
string Email,
string Name,
string Role,
bool IsActive
);
@@ -40,14 +40,11 @@ public class AuthService : IAuthService
return await GenerateTokenResponseAsync(user);
}
public async Task<TokenResponse> RefreshTokenAsync(string accessToken, string refreshToken)
public async Task<TokenResponse> RefreshTokenAsync(string refreshToken)
{
var principal = GetPrincipalFromExpiredToken(accessToken);
var userId = Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
var savedRefreshToken = await _context.RefreshTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == refreshToken && t.UserId == userId);
.FirstOrDefaultAsync(t => t.Token == refreshToken);
if (savedRefreshToken == null || !savedRefreshToken.IsActive)
{
@@ -57,6 +54,7 @@ public class AuthService : IAuthService
if (savedRefreshToken.IsUsed)
{
// Re-use detection: trek alle tokens van de gebruiker in
var userId = savedRefreshToken.UserId;
var allTokens = await _context.RefreshTokens.Where(t => t.UserId == userId).ToListAsync();
foreach (var token in allTokens) token.IsRevoked = true;
await _context.SaveChangesAsync();
@@ -82,6 +80,8 @@ public class AuthService : IAuthService
private async Task<TokenResponse> GenerateTokenResponseAsync(ApplicationUser user)
{
var roles = await _userManager.GetRolesAsync(user);
var primaryRole = roles.FirstOrDefault() ?? "User";
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
@@ -119,7 +119,15 @@ public class AuthService : IAuthService
await _context.SaveChangesAsync();
return new TokenResponse(accessToken, refreshToken, expiry);
var userResponse = new UserResponse(
Id: user.Id,
Email: user.Email!,
Name: user.DisplayName ?? user.Email!,
Role: primaryRole,
IsActive: user.IsActive
);
return new TokenResponse(accessToken, refreshToken, expiry, userResponse);
}
private static string GenerateRefreshToken()
@@ -129,27 +137,4 @@ public class AuthService : IAuthService
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)),
ValidateLifetime = false // We willen juist het verlopen token lezen
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
if (securityToken is not JwtSecurityToken jwtSecurityToken ||
!jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
{
throw new SecurityTokenException("Ongeldig token.");
}
return principal;
}
}
@@ -15,7 +15,7 @@ public interface IAuthService
/// <summary>
/// Vernieuwt een verlopen access token met een geldig refresh token.
/// </summary>
Task<TokenResponse> RefreshTokenAsync(string accessToken, string refreshToken);
Task<TokenResponse> RefreshTokenAsync(string refreshToken);
/// <summary>
/// Trekt een refresh token in (uitloggen).
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Exceptions;
@@ -48,13 +49,17 @@ public class AvailabilityMiddleware
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
var response = new ApiErrorResponse(
Message: status == AvailabilityStatus.Maintenance
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Service Unavailable",
Detail = status == AvailabilityStatus.Maintenance
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
: "De service is tijdelijk niet beschikbaar."
);
: "De service is tijdelijk niet beschikbaar.",
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(response);
await context.Response.WriteAsJsonAsync(problemDetails);
}
private bool IsAdminBypass(HttpContext context)
@@ -1,12 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
namespace SlpModularCms.Modules.Identity.Controllers;
[ApiController]
[Route("[controller]")]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
@@ -18,25 +20,58 @@ public class AuthController : ControllerBase
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var response = await _authService.AuthenticateAsync(request.Email, request.Password);
SetTokenCookie(response.RefreshToken);
return Ok(response);
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
[EnableRateLimiting("refresh")]
public async Task<IActionResult> Refresh()
{
var response = await _authService.RefreshTokenAsync(request.AccessToken, request.RefreshToken);
var refreshToken = Request.Cookies["refreshToken"];
if (string.IsNullOrEmpty(refreshToken))
{
return Unauthorized();
}
var response = await _authService.RefreshTokenAsync(refreshToken);
SetTokenCookie(response.RefreshToken);
return Ok(response);
}
[HttpPost("revoke")]
[Authorize]
public async Task<IActionResult> Revoke([FromBody] string refreshToken)
[AllowAnonymous]
public async Task<IActionResult> Revoke()
{
await _authService.RevokeTokenAsync(refreshToken);
var refreshToken = Request.Cookies["refreshToken"];
if (!string.IsNullOrEmpty(refreshToken))
{
await _authService.RevokeTokenAsync(refreshToken);
}
Response.Cookies.Delete("refreshToken", GetCookieOptions());
return NoContent();
}
private void SetTokenCookie(string refreshToken)
{
Response.Cookies.Append("refreshToken", refreshToken, GetCookieOptions());
}
private CookieOptions GetCookieOptions()
{
return new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/v1/auth",
Expires = DateTimeOffset.UtcNow.AddDays(7)
};
}
}