Changes backend in preparation for frontend work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user