Initial commit with inital CMS
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Core.Identity.Authorization;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
using SlpModularCms.Api.Infrastructure;
|
||||
using System.Text;
|
||||
|
||||
namespace SlpModularCms.Api.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCoreInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// 1. Database
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// 2. Identity
|
||||
services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = true;
|
||||
options.Password.RequiredLength = 8;
|
||||
})
|
||||
.AddRoles<ApplicationRole>()
|
||||
.AddRoleManager<RoleManager<ApplicationRole>>()
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>();
|
||||
|
||||
// 3. Auth & Identity Services
|
||||
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()
|
||||
?? throw new InvalidOperationException("JwtSettings not found in configuration.");
|
||||
|
||||
services.Configure<JwtSettings>(configuration.GetSection("JwtSettings"));
|
||||
services.Configure<AvailabilityOptions>(configuration.GetSection("Availability"));
|
||||
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IInvitationService, InvitationService>();
|
||||
services.AddScoped<ISetupService, SetupService>();
|
||||
|
||||
// 4. Authentication
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtSettings.Issuer,
|
||||
ValidAudience = jwtSettings.Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)),
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
});
|
||||
|
||||
// 5. Authorization
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("OwnerOnly", policy => policy.Requirements.Add(new HierarchicalRoleRequirement("Owner")));
|
||||
options.AddPolicy("AdminOnly", policy => policy.Requirements.Add(new HierarchicalRoleRequirement("Administrator")));
|
||||
options.AddPolicy("UserOnly", policy => policy.Requirements.Add(new HierarchicalRoleRequirement("User")));
|
||||
});
|
||||
|
||||
services.AddSingleton<IAuthorizationHandler, HierarchicalRoleHandler>();
|
||||
|
||||
// 6. Exception Handling
|
||||
services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
services.AddProblemDetails();
|
||||
|
||||
// 7. Versioning
|
||||
services.AddApiVersioning(options =>
|
||||
{
|
||||
options.ReportApiVersions = true;
|
||||
});
|
||||
|
||||
// 8. Native .NET OpenAPI
|
||||
services.AddOpenApi();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
|
||||
namespace SlpModularCms.Api.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Conventie die automatisch de /api/v1/ prefix toevoegt aan alle controllers.
|
||||
/// </summary>
|
||||
public class ApiPrefixConvention : IApplicationModelConvention
|
||||
{
|
||||
private readonly AttributeRouteModel _routePrefix;
|
||||
|
||||
public ApiPrefixConvention(string prefix)
|
||||
{
|
||||
_routePrefix = new AttributeRouteModel(new RouteAttribute(prefix));
|
||||
}
|
||||
|
||||
public void Apply(ApplicationModel application)
|
||||
{
|
||||
foreach (var controller in application.Controllers)
|
||||
{
|
||||
foreach (var selector in controller.Selectors)
|
||||
{
|
||||
if (selector.AttributeRouteModel != null)
|
||||
{
|
||||
selector.AttributeRouteModel.Template = _routePrefix.Template + "/" + selector.AttributeRouteModel.Template;
|
||||
}
|
||||
else
|
||||
{
|
||||
selector.AttributeRouteModel = _routePrefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Modules;
|
||||
|
||||
namespace SlpModularCms.Api.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Orkestrator die modules ontdekt en hun lifecycle beheert.
|
||||
/// </summary>
|
||||
public class ModuleOrchestrator
|
||||
{
|
||||
private readonly List<IModule> _modules = new();
|
||||
private readonly ILogger<ModuleOrchestrator> _logger;
|
||||
|
||||
public ModuleOrchestrator(ILogger<ModuleOrchestrator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void DiscoverModules()
|
||||
{
|
||||
_logger.LogInformation("Start module discovery...");
|
||||
|
||||
// Forceer het laden van module assemblies van disk
|
||||
var path = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var moduleFiles = Directory.GetFiles(path, "SlpModularCms.Modules.*.dll");
|
||||
|
||||
foreach (var file in moduleFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assemblyName = AssemblyName.GetAssemblyName(file);
|
||||
if (AppDomain.CurrentDomain.GetAssemblies().All(a => a.FullName != assemblyName.FullName))
|
||||
{
|
||||
Assembly.Load(assemblyName);
|
||||
_logger.LogDebug("Assembly geladen: {AssemblyName}", assemblyName.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Kon assembly niet laden van disk: {FilePath}", file);
|
||||
}
|
||||
}
|
||||
|
||||
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Where(a => a.FullName != null && a.FullName.StartsWith("SlpModularCms.Modules"))
|
||||
.ToList();
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
var moduleTypes = assembly.GetTypes()
|
||||
.Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);
|
||||
|
||||
foreach (var type in moduleTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Activator.CreateInstance(type) is IModule module)
|
||||
{
|
||||
_modules.Add(module);
|
||||
_logger.LogInformation("Module ontdekt: {ModuleName} v{Version}", module.Name, module.Version);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fout bij het instantiëren van module type {TypeName}", type.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("{Count} modules succesvol geladen.", _modules.Count);
|
||||
}
|
||||
|
||||
public void RegisterModuleServices(IServiceCollection services)
|
||||
{
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
try
|
||||
{
|
||||
module.RegisterServices(services);
|
||||
_logger.LogInformation("Services geregistreerd voor module: {ModuleName}", module.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fout bij het registreren van services voor module {ModuleName}", module.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UseModules(IApplicationBuilder app)
|
||||
{
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
try
|
||||
{
|
||||
module.UseModule(app);
|
||||
_logger.LogInformation("Module geactiveerd in pipeline: {ModuleName}", module.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fout bij het activeren van module {ModuleName} in de pipeline", module.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using SlpModularCms.Api.Extensions;
|
||||
using SlpModularCms.Api.Infrastructure;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Load local developer overrides
|
||||
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true);
|
||||
|
||||
// 1. Initialize Module Orchestrator
|
||||
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
|
||||
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>());
|
||||
orchestrator.DiscoverModules();
|
||||
|
||||
// 2. Add Core Infrastructure
|
||||
builder.Services.AddCoreInfrastructure(builder.Configuration);
|
||||
|
||||
// 3. Add Module Services
|
||||
orchestrator.RegisterModuleServices(builder.Services);
|
||||
|
||||
// 4. Global Controller Configuration with Conventions
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Conventions.Add(new ApiPrefixConvention("api/v1"));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// 5. Global Exception Handling
|
||||
app.UseExceptionHandler();
|
||||
|
||||
// 6. Configure Pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// 7. Use Module Middleware
|
||||
orchestrator.UseModules(app);
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5284",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"launchUrl": "scalar"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7221;http://localhost:5284",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"launchUrl": "scalar"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# SlpModularCms.Api
|
||||
|
||||
## AIDLC Context
|
||||
Als ontwikkelaar wil ik een modulaire CMS API bouwen. Deze API zal een raamwerk hebben met veelgebruikte functionaliteiten en gebruikersbeheer.
|
||||
Het doel is dat ik deze API kan gebruiken voor klanten om websites en applicaties te bouwen.
|
||||
Elke klant kan andere wensen hebben, maar per branch of type website zullen er altijd functionaliteiten zijn die hetzelfde zijn.
|
||||
Extra functionaliteiten moeten dus met modules worden geïmplementeerd, zodat ze eenvoudig kunnen worden toegevoegd of verwijderd.
|
||||
Om mijzelf in te dekken wil ik iets inbouwen zodat ik modules op afstand kan uitzetten of dat ik de CMS kan blokkeren in het geval dat een klant zich niet aan de afspraak houd zoals een betaling niet doen of andere dingen.
|
||||
|
||||
Dit betekent dat er standaard iets moet worden ingebouwd dat de API een controle uitvoert met een call naar een zogenoemde "master"-API om te controleren of alles nog beschikbaar is. Misschien moet het zelfs bij de master worden opgeslagen in de database én bij de client zelf.
|
||||
De "master"-API moet een module krijgen waarin alles wordt opgeslagen en waarin de API kan controleren of de klant nog toegang heeft tot de CMS, maar deze module mag later pas. Voor de MVP wil ik het raamwerk hebben met authenticatie, authorisatie en gebruikersbeheer voor gebruikers.
|
||||
|
||||
Zodra dat staat kan er gekeken worden naar een simpele eerste module.
|
||||
|
||||
Daarna kan de module voor de "master"-API worden geïmplementeerd waar ook de connectie tussen "master"- en client-API moet worden bedacht. Hier kan later over worden nagedacht, maar den kaan iets als client-secret pairs.
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
|
||||
<ProjectReference Include="..\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@SlpModularCms.Api_HostAddress = http://localhost:5284
|
||||
|
||||
GET {{SlpModularCms.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=SlpModularCms;Trusted_Connection=True;MultipleActiveResultSets=true"
|
||||
},
|
||||
"JwtSettings": {
|
||||
"Secret": "SuperSecretKeyForDevelopmentOnly_MustBeLongerThan32Bytes!",
|
||||
"Issuer": "SlpModularCms",
|
||||
"Audience": "SlpModularCmsPortal",
|
||||
"ExpiryMinutes": 60,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"Availability": {
|
||||
"CircuitBreakerSeconds": 30,
|
||||
"StatusCacheSeconds": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=<production-db-host>;Database=SlpModularCms;User Id=<db-user>;Password=<db-password>;TrustServerCertificate=True"
|
||||
},
|
||||
"JwtSettings": {
|
||||
"Secret": "<secure-long-random-secret-key-from-env>",
|
||||
"Issuer": "SlpModularCms",
|
||||
"Audience": "SlpModularCmsPortal",
|
||||
"ExpiryMinutes": 60,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"Availability": {
|
||||
"CircuitBreakerSeconds": 30,
|
||||
"StatusCacheSeconds": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Exceptions;
|
||||
|
||||
public class GlobalExceptionHandlerTests
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly GlobalExceptionHandler _handler;
|
||||
|
||||
public GlobalExceptionHandlerTests()
|
||||
{
|
||||
_logger = Substitute.For<ILogger<GlobalExceptionHandler>>();
|
||||
_env = Substitute.For<IHostEnvironment>();
|
||||
_handler = new GlobalExceptionHandler(_logger, _env);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryHandleAsync_ShouldReturnInternalServerError_ForGenericException()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
var exception = new Exception("Test error");
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
_env.EnvironmentName.Returns("Production");
|
||||
|
||||
// Act
|
||||
var result = await _handler.TryHandleAsync(context, exception, cancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
|
||||
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 });
|
||||
|
||||
response.Should().NotBeNull();
|
||||
response!.Message.Should().Be("Er is een interne serverfout opgetreden.");
|
||||
response.Detail.Should().BeNull();
|
||||
response.TraceId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryHandleAsync_ShouldIncludeDetails_WhenInDevelopment()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
var exception = new Exception("Test error");
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
_env.EnvironmentName.Returns("Development");
|
||||
|
||||
// Act
|
||||
await _handler.TryHandleAsync(context, exception, cancellationToken);
|
||||
|
||||
// Assert
|
||||
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 });
|
||||
|
||||
response!.Detail.Should().Contain("Test error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using SlpModularCms.Core.Identity.Authorization;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Identity;
|
||||
|
||||
public class HierarchicalRoleHandlerTests
|
||||
{
|
||||
private readonly HierarchicalRoleHandler _handler;
|
||||
|
||||
public HierarchicalRoleHandlerTests()
|
||||
{
|
||||
_handler = new HierarchicalRoleHandler();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Owner", "Owner", true)]
|
||||
[InlineData("Owner", "Administrator", true)]
|
||||
[InlineData("Owner", "User", true)]
|
||||
[InlineData("Administrator", "Administrator", true)]
|
||||
[InlineData("Administrator", "User", true)]
|
||||
[InlineData("Administrator", "Owner", false)]
|
||||
[InlineData("User", "User", true)]
|
||||
[InlineData("User", "Administrator", false)]
|
||||
[InlineData("User", "Owner", false)]
|
||||
public async Task HandleAsync_ShouldValidateHierarchyCorrectly(string userRole, string requiredRole, bool shouldSucceed)
|
||||
{
|
||||
// Arrange
|
||||
var requirement = new HierarchicalRoleRequirement(requiredRole);
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Role, userRole)
|
||||
}));
|
||||
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, user, null);
|
||||
|
||||
// Act
|
||||
await _handler.HandleAsync(context);
|
||||
|
||||
// Assert
|
||||
if (shouldSucceed)
|
||||
{
|
||||
context.HasSucceeded.Should().BeTrue();
|
||||
}
|
||||
else
|
||||
{
|
||||
context.HasSucceeded.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Identity;
|
||||
|
||||
public class InvitationServiceTests
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly InvitationService _service;
|
||||
|
||||
public InvitationServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new ApplicationDbContext(options);
|
||||
|
||||
var store = Substitute.For<IUserStore<ApplicationUser>>();
|
||||
_userManager = Substitute.For<UserManager<ApplicationUser>>(store, null, null, null, null, null, null, null, null);
|
||||
|
||||
_service = new InvitationService(_context, _userManager);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateInvitationAsync_ShouldCreateRecordInDb()
|
||||
{
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var role = "User";
|
||||
|
||||
// Act
|
||||
var token = await _service.CreateInvitationAsync(email, role);
|
||||
|
||||
// Assert
|
||||
token.Should().NotBeNullOrEmpty();
|
||||
var invitation = await _context.Invitations.FirstOrDefaultAsync(i => i.Token == token);
|
||||
invitation.Should().NotBeNull();
|
||||
invitation!.Email.Should().Be(email);
|
||||
invitation.Role.Should().Be(role);
|
||||
invitation.IsAccepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateInvitationAsync_ShouldReturnTrue_ForValidToken()
|
||||
{
|
||||
// Arrange
|
||||
var token = await _service.CreateInvitationAsync("test@example.com", "User");
|
||||
|
||||
// Act
|
||||
var isValid = await _service.ValidateInvitationAsync(token);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteInvitationAsync_ShouldCreateUserAndMarkAsAccepted()
|
||||
{
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var role = "User";
|
||||
var password = "SafePassword123!";
|
||||
var token = await _service.CreateInvitationAsync(email, role);
|
||||
|
||||
_userManager.CreateAsync(Arg.Any<ApplicationUser>(), password)
|
||||
.Returns(IdentityResult.Success);
|
||||
_userManager.AddToRoleAsync(Arg.Any<ApplicationUser>(), role)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act
|
||||
await _service.CompleteInvitationAsync(token, password);
|
||||
|
||||
// Assert
|
||||
var invitation = await _context.Invitations.FirstOrDefaultAsync(i => i.Token == token);
|
||||
invitation!.IsAccepted.Should().BeTrue();
|
||||
await _userManager.Received(1).CreateAsync(Arg.Is<ApplicationUser>(u => u.Email == email), password);
|
||||
await _userManager.Received(1).AddToRoleAsync(Arg.Any<ApplicationUser>(), role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteInvitationAsync_ShouldThrow_WhenTokenInvalid()
|
||||
{
|
||||
// Act
|
||||
var act = () => _service.CompleteInvitationAsync("invalid-token", "password");
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SlpModularCms.Core.Availability;
|
||||
|
||||
/// <summary>
|
||||
/// Configuratie-opties voor de Availability module.
|
||||
/// </summary>
|
||||
public class AvailabilityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// De tijd (in seconden) dat de status gecached wordt na een fout (Circuit Breaker).
|
||||
/// </summary>
|
||||
public int CircuitBreakerSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// De tijd (in seconden) dat de status gecached wordt bij een succesvolle check.
|
||||
/// </summary>
|
||||
public int StatusCacheSeconds { get; set; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace SlpModularCms.Core.Availability;
|
||||
|
||||
/// <summary>
|
||||
/// Mogelijke statussen voor de beschikbaarheid van de API.
|
||||
/// </summary>
|
||||
public enum AvailabilityStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// De API is volledig operationeel.
|
||||
/// </summary>
|
||||
Available,
|
||||
|
||||
/// <summary>
|
||||
/// De API is momenteel niet beschikbaar (bijv. storing).
|
||||
/// </summary>
|
||||
NotAvailable,
|
||||
|
||||
/// <summary>
|
||||
/// De API is in onderhoud.
|
||||
/// </summary>
|
||||
Maintenance,
|
||||
|
||||
/// <summary>
|
||||
/// De API is beperkt beschikbaar.
|
||||
/// </summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>
|
||||
/// De status van de API kan niet worden vastgesteld (bijv. timeout).
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SlpModularCms.Core.Availability;
|
||||
|
||||
/// <summary>
|
||||
/// Service voor het controleren van de beschikbaarheid van het systeem.
|
||||
/// </summary>
|
||||
public interface IAvailabilityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Controleert de algemene beschikbaarheid van de API.
|
||||
/// </summary>
|
||||
/// <returns>De huidige <see cref="AvailabilityStatus"/>.</returns>
|
||||
Task<AvailabilityStatus> IsAvailableAsync();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
namespace SlpModularCms.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Database context voor de applicatie, inclusief Identity en RBAC tabellen.
|
||||
/// </summary>
|
||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>
|
||||
{
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<Invitation> Invitations => Set<Invitation>();
|
||||
public DbSet<ModulePermission> ModulePermissions => Set<ModulePermission>();
|
||||
public DbSet<GlobalAvailabilityState> AvailabilityStates => Set<GlobalAvailabilityState>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// Hernoemen van standaard Identity tabellen (NFR-ID-MAINT-01)
|
||||
builder.Entity<ApplicationUser>(entity => { entity.ToTable("Users"); });
|
||||
builder.Entity<ApplicationRole>(entity => { entity.ToTable("Roles"); });
|
||||
builder.Entity<IdentityUserRole<Guid>>(entity => { entity.ToTable("UserRoles"); });
|
||||
builder.Entity<IdentityUserClaim<Guid>>(entity => { entity.ToTable("UserClaims"); });
|
||||
builder.Entity<IdentityUserLogin<Guid>>(entity => { entity.ToTable("UserLogins"); });
|
||||
builder.Entity<IdentityRoleClaim<Guid>>(entity => { entity.ToTable("RoleClaims"); });
|
||||
builder.Entity<IdentityUserToken<Guid>>(entity => { entity.ToTable("UserTokens"); });
|
||||
|
||||
// Configuratie voor GlobalAvailabilityState
|
||||
builder.Entity<GlobalAvailabilityState>(entity =>
|
||||
{
|
||||
entity.ToTable("AvailabilityState");
|
||||
entity.HasKey(e => e.Id);
|
||||
});
|
||||
|
||||
// Configuratie voor RefreshTokens
|
||||
builder.Entity<RefreshToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Token).IsUnique();
|
||||
entity.Property(e => e.Token).IsRequired().HasMaxLength(256);
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.RefreshTokens)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configuratie voor Invitations
|
||||
builder.Entity<Invitation>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Token).IsUnique();
|
||||
entity.Property(e => e.Token).IsRequired().HasMaxLength(256);
|
||||
entity.Property(e => e.Email).IsRequired().HasMaxLength(256);
|
||||
});
|
||||
|
||||
// Configuratie voor ModulePermissions
|
||||
builder.Entity<ModulePermission>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.UserId, e.ModuleName, e.Permission });
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.ModulePermissions)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.Property(e => e.ModuleName).IsRequired().HasMaxLength(128);
|
||||
entity.Property(e => e.Permission).IsRequired().HasMaxLength(128);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SlpModularCms.Core.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Globale exception handler conform .NET 8+ IExceptionHandler.
|
||||
/// </summary>
|
||||
public class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
private readonly IHostEnvironment _env;
|
||||
|
||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger, IHostEnvironment env)
|
||||
{
|
||||
_logger = logger;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
|
||||
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"Ongehandled exception opgetreden op {MachineName}. TraceId: {TraceId}",
|
||||
Environment.MachineName,
|
||||
traceId);
|
||||
|
||||
var (statusCode, message) = MapException(exception);
|
||||
|
||||
httpContext.Response.StatusCode = statusCode;
|
||||
|
||||
var response = new ApiErrorResponse(
|
||||
Message: message,
|
||||
Detail: _env.IsDevelopment() ? exception.ToString() : null,
|
||||
TraceId: traceId
|
||||
);
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (int StatusCode, string Message) 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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SlpModularCms.Core.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception die gegooid wordt bij authenticatie- of autorisatiefouten.
|
||||
/// </summary>
|
||||
public class InvitationOrUserAlreadyExistsException : Exception
|
||||
{
|
||||
public InvitationOrUserAlreadyExistsException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SlpModularCms.Core.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception die gegooid wordt bij authenticatie- of autorisatiefouten.
|
||||
/// </summary>
|
||||
public class UnauthorizedException : Exception
|
||||
{
|
||||
public UnauthorizedException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SlpModularCms.Core.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception die gegooid wordt bij validatiefouten.
|
||||
/// </summary>
|
||||
public class ValidationException : Exception
|
||||
{
|
||||
public ValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Handler die valideert of de gebruiker de minimaal vereiste rol heeft in de hiërarchie.
|
||||
/// </summary>
|
||||
public class HierarchicalRoleHandler : AuthorizationHandler<HierarchicalRoleRequirement>
|
||||
{
|
||||
private static readonly Dictionary<string, int> RoleHierarchy = new()
|
||||
{
|
||||
{ "Owner", 100 },
|
||||
{ "Administrator", 50 },
|
||||
{ "User", 10 }
|
||||
};
|
||||
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
HierarchicalRoleRequirement requirement)
|
||||
{
|
||||
var userRole = context.User.FindFirstValue(ClaimTypes.Role);
|
||||
|
||||
if (userRole == null || !RoleHierarchy.ContainsKey(userRole))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var userLevel = RoleHierarchy[userRole];
|
||||
var requiredLevel = RoleHierarchy.ContainsKey(requirement.MinimumRole)
|
||||
? RoleHierarchy[requirement.MinimumRole]
|
||||
: 0;
|
||||
|
||||
if (userLevel >= requiredLevel)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Requirement voor hiërarchische rol-gebaseerde autorisatie.
|
||||
/// </summary>
|
||||
public class HierarchicalRoleRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string MinimumRole { get; }
|
||||
|
||||
public HierarchicalRoleRequirement(string minimumRole)
|
||||
{
|
||||
MinimumRole = minimumRole;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Aangepaste Role entiteit voor SlpModularCms.
|
||||
/// </summary>
|
||||
public class ApplicationRole : IdentityRole<Guid>
|
||||
{
|
||||
public ApplicationRole() : base()
|
||||
{
|
||||
}
|
||||
|
||||
public ApplicationRole(string roleName) : base(roleName)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Aangepaste User entiteit voor SlpModularCms.
|
||||
/// </summary>
|
||||
public class ApplicationUser : IdentityUser<Guid>
|
||||
{
|
||||
/// <summary>
|
||||
/// Geeft aan of het account actief is.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Datum en tijd van aanmaak.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Navigatie-eigenschap naar module-specifieke rechten.
|
||||
/// </summary>
|
||||
public virtual ICollection<ModulePermission> ModulePermissions { get; set; } = new List<ModulePermission>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigatie-eigenschap naar refresh tokens.
|
||||
/// </summary>
|
||||
public virtual ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using SlpModularCms.Core.Availability;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Persistente opslag voor de globale systeemstatus.
|
||||
/// </summary>
|
||||
public class GlobalAvailabilityState
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public AvailabilityStatus Status { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public DateTimeOffset LastUpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entiteit voor gebruikersuitnodigingen.
|
||||
/// </summary>
|
||||
public class Invitation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public DateTimeOffset ExpiryDate { get; set; }
|
||||
public bool IsAccepted { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiryDate;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entiteit voor module-specifieke rechten per gebruiker.
|
||||
/// </summary>
|
||||
public class ModulePermission
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public virtual ApplicationUser User { get; set; } = null!;
|
||||
public string ModuleName { get; set; } = string.Empty;
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entiteit voor het opslaan van refresh tokens.
|
||||
/// </summary>
|
||||
public class RefreshToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public Guid UserId { get; set; }
|
||||
public virtual ApplicationUser User { get; set; } = null!;
|
||||
public DateTimeOffset ExpiryDate { get; set; }
|
||||
public bool IsUsed { get; set; }
|
||||
public bool IsRevoked { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string? CreatedByIp { get; set; }
|
||||
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiryDate;
|
||||
public bool IsActive => !IsRevoked && !IsExpired;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SlpModularCms.Core.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configuratie-instellingen voor JWT.
|
||||
/// </summary>
|
||||
public class JwtSettings
|
||||
{
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public int ExpiryMinutes { get; set; } = 60;
|
||||
public int RefreshTokenExpiryDays { get; set; } = 7;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SlpModularCms.Core.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model voor het retourneren van authenticatie tokens.
|
||||
/// </summary>
|
||||
public record TokenResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset Expiry
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Services;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
|
||||
public AuthService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
IOptions<JwtSettings> jwtSettings)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_jwtSettings = jwtSettings.Value;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> AuthenticateAsync(string email, string password)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user == null || !user.IsActive || !await _userManager.CheckPasswordAsync(user, password))
|
||||
{
|
||||
throw new UnauthorizedException("Ongeldige inloggegevens.");
|
||||
}
|
||||
|
||||
return await GenerateTokenResponseAsync(user);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> RefreshTokenAsync(string accessToken, 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);
|
||||
|
||||
if (savedRefreshToken == null || !savedRefreshToken.IsActive)
|
||||
{
|
||||
throw new UnauthorizedException("Ongeldig refresh token.");
|
||||
}
|
||||
|
||||
if (savedRefreshToken.IsUsed)
|
||||
{
|
||||
// Re-use detection: trek alle tokens van de gebruiker in
|
||||
var allTokens = await _context.RefreshTokens.Where(t => t.UserId == userId).ToListAsync();
|
||||
foreach (var token in allTokens) token.IsRevoked = true;
|
||||
await _context.SaveChangesAsync();
|
||||
throw new UnauthorizedException("Mogelijk token misbruik gedetecteerd. Alle sessies zijn beëindigd.");
|
||||
}
|
||||
|
||||
savedRefreshToken.IsUsed = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GenerateTokenResponseAsync(savedRefreshToken.User);
|
||||
}
|
||||
|
||||
public async Task RevokeTokenAsync(string refreshToken)
|
||||
{
|
||||
var token = await _context.RefreshTokens.FirstOrDefaultAsync(t => t.Token == refreshToken);
|
||||
if (token != null)
|
||||
{
|
||||
token.IsRevoked = true;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponseAsync(ApplicationUser user)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Email, user.Email!),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
foreach (var role in roles)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var expiry = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
_jwtSettings.Issuer,
|
||||
_jwtSettings.Audience,
|
||||
claims,
|
||||
expires: expiry,
|
||||
signingCredentials: creds
|
||||
);
|
||||
|
||||
var accessToken = new JwtSecurityTokenHandler().WriteToken(token);
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
|
||||
_context.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshToken,
|
||||
UserId = user.Id,
|
||||
ExpiryDate = DateTimeOffset.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays),
|
||||
CreatedByIp = "Unknown" // In echte implementatie via HttpContextAccessor
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new TokenResponse(accessToken, refreshToken, expiry);
|
||||
}
|
||||
|
||||
private static string GenerateRefreshToken()
|
||||
{
|
||||
var randomNumber = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface voor authenticatie en token management.
|
||||
/// </summary>
|
||||
public interface IAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticeert een gebruiker en retourneert tokens.
|
||||
/// </summary>
|
||||
Task<TokenResponse> AuthenticateAsync(string email, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Vernieuwt een verlopen access token met een geldig refresh token.
|
||||
/// </summary>
|
||||
Task<TokenResponse> RefreshTokenAsync(string accessToken, string refreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Trekt een refresh token in (uitloggen).
|
||||
/// </summary>
|
||||
Task RevokeTokenAsync(string refreshToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace SlpModularCms.Core.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface voor het uitnodigingsproces van nieuwe gebruikers.
|
||||
/// </summary>
|
||||
public interface IInvitationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Maakt een nieuwe uitnodiging aan en retourneert het token.
|
||||
/// </summary>
|
||||
Task<string> CreateInvitationAsync(string email, string role);
|
||||
|
||||
/// <summary>
|
||||
/// Valideert of een uitnodigings-token nog geldig is.
|
||||
/// </summary>
|
||||
Task<bool> ValidateInvitationAsync(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Voltooit de uitnodiging door een gebruiker aan te maken met het opgegeven wachtwoord.
|
||||
/// </summary>
|
||||
Task CompleteInvitationAsync(string token, string password);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Services;
|
||||
|
||||
public class InvitationService : IInvitationService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public InvitationService(ApplicationDbContext context, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public async Task<string> CreateInvitationAsync(string email, string role)
|
||||
{
|
||||
var invitationForEmail = await _context.Invitations.FirstOrDefaultAsync(x => x.Email == email);
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
|
||||
if (invitationForEmail != null || user != null)
|
||||
{
|
||||
throw new InvitationOrUserAlreadyExistsException("Een uitnodiging of gebruiker voor dit e-mailadres bestaat al.");
|
||||
}
|
||||
|
||||
var token = GenerateSecureToken();
|
||||
var invitation = new Invitation
|
||||
{
|
||||
Email = email,
|
||||
Role = role,
|
||||
Token = token,
|
||||
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
|
||||
IsAccepted = false
|
||||
};
|
||||
|
||||
_context.Invitations.Add(invitation);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateInvitationAsync(string token)
|
||||
{
|
||||
var invitation = await _context.Invitations.FirstOrDefaultAsync(i => i.Token == token);
|
||||
return invitation != null && !invitation.IsAccepted && !invitation.IsExpired;
|
||||
}
|
||||
|
||||
public async Task CompleteInvitationAsync(string token, string password)
|
||||
{
|
||||
var invitation = await _context.Invitations.FirstOrDefaultAsync(i => i.Token == token);
|
||||
if (invitation == null || invitation.IsAccepted || invitation.IsExpired)
|
||||
{
|
||||
throw new UnauthorizedException("Ongeldige of verlopen uitnodiging.");
|
||||
}
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = invitation.Email,
|
||||
Email = invitation.Email,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new ValidationException("Kan gebruiker niet aanmaken: " + string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
await _userManager.AddToRoleAsync(user, invitation.Role);
|
||||
|
||||
invitation.IsAccepted = true;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Services;
|
||||
|
||||
public interface ISetupService
|
||||
{
|
||||
Task<bool> IsSystemInitializedAsync();
|
||||
Task CreateInitialOwnerAsync(string email, string password);
|
||||
}
|
||||
|
||||
public class SetupService : ISetupService
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly RoleManager<ApplicationRole> _roleManager;
|
||||
|
||||
public SetupService(UserManager<ApplicationUser> userManager, RoleManager<ApplicationRole> roleManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
}
|
||||
|
||||
public async Task<bool> IsSystemInitializedAsync()
|
||||
{
|
||||
return await _userManager.Users.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task CreateInitialOwnerAsync(string email, string password)
|
||||
{
|
||||
if (await IsSystemInitializedAsync())
|
||||
{
|
||||
throw new ValidationException("Systeem is al geïnitialiseerd.");
|
||||
}
|
||||
|
||||
// Zorg dat rollen bestaan
|
||||
string[] roles = { "Owner", "Administrator", "User" };
|
||||
foreach (var roleName in roles)
|
||||
{
|
||||
if (!await _roleManager.RoleExistsAsync(roleName))
|
||||
{
|
||||
await _roleManager.CreateAsync(new ApplicationRole { Name = roleName });
|
||||
}
|
||||
}
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new ValidationException("Fout bij aanmaken eigenaar: " + string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
await _userManager.AddToRoleAsync(user, "Owner");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SlpModularCms.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260612191736_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("RoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("UserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("UserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex")
|
||||
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||
|
||||
b.ToTable("Roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex")
|
||||
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||
|
||||
b.ToTable("Users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.GlobalAvailabilityState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("LastUpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AvailabilityState", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.Invitation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiryDate")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsAccepted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Invitations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ModuleName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("UserId", "ModuleName", "Permission");
|
||||
|
||||
b.ToTable("ModulePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("CreatedByIp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiryDate")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User")
|
||||
.WithMany("ModulePermissions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("ModulePermissions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AvailabilityState",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Message = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LastUpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AvailabilityState", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Invitations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Role = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Token = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ExpiryDate = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
IsAccepted = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Invitations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Roles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Roles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
|
||||
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RoleClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RoleClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RoleClaims_Roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "Roles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ModulePermissions",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ModuleName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Permission = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ModulePermissions", x => new { x.UserId, x.ModuleName, x.Permission });
|
||||
table.ForeignKey(
|
||||
name: "FK_ModulePermissions_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RefreshTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Token = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ExpiryDate = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
IsUsed = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsRevoked = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedByIp = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RefreshTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserClaims_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserLogins",
|
||||
columns: table => new
|
||||
{
|
||||
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserLogins_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserRoles",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserRoles_Roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "Roles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserRoles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserTokens",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invitations_Token",
|
||||
table: "Invitations",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RefreshTokens_Token",
|
||||
table: "RefreshTokens",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RefreshTokens_UserId",
|
||||
table: "RefreshTokens",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RoleClaims_RoleId",
|
||||
table: "RoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
table: "Roles",
|
||||
column: "NormalizedName",
|
||||
unique: true,
|
||||
filter: "[NormalizedName] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserClaims_UserId",
|
||||
table: "UserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserLogins_UserId",
|
||||
table: "UserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserRoles_RoleId",
|
||||
table: "UserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
table: "Users",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
table: "Users",
|
||||
column: "NormalizedUserName",
|
||||
unique: true,
|
||||
filter: "[NormalizedUserName] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AvailabilityState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Invitations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ModulePermissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RefreshTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RoleClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserLogins");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Roles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SlpModularCms.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("RoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("UserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("UserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex")
|
||||
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||
|
||||
b.ToTable("Roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex")
|
||||
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||
|
||||
b.ToTable("Users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.GlobalAvailabilityState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("LastUpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AvailabilityState", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.Invitation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiryDate")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsAccepted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Invitations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ModuleName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("UserId", "ModuleName", "Permission");
|
||||
|
||||
b.ToTable("ModulePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("CreatedByIp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiryDate")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User")
|
||||
.WithMany("ModulePermissions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("ModulePermissions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace SlpModularCms.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Interface voor een module binnen het SlpModularCms systeem.
|
||||
/// </summary>
|
||||
public interface IModule
|
||||
{
|
||||
/// <summary>
|
||||
/// De unieke naam van de module.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// De versie van de module.
|
||||
/// </summary>
|
||||
string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registreert services in de dependency injection container.
|
||||
/// </summary>
|
||||
void RegisterServices(IServiceCollection services);
|
||||
|
||||
/// <summary>
|
||||
/// Configureert de module in de HTTP request pipeline.
|
||||
/// </summary>
|
||||
void UseModule(IApplicationBuilder app);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SlpModularCms.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata over een geladen module.
|
||||
/// </summary>
|
||||
public record ModuleInfo(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Description = null,
|
||||
bool IsLoaded = false
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Tests;
|
||||
|
||||
public class AvailabilityMiddlewareTests
|
||||
{
|
||||
private readonly IAvailabilityService _service;
|
||||
private readonly AvailabilityMiddleware _middleware;
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public AvailabilityMiddlewareTests()
|
||||
{
|
||||
_service = Substitute.For<IAvailabilityService>();
|
||||
_next = Substitute.For<RequestDelegate>();
|
||||
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldAllowRequest_WhenSystemAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.Available);
|
||||
|
||||
// Act
|
||||
await _middleware.InvokeAsync(context, _service);
|
||||
|
||||
// Assert
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
// Act
|
||||
await _middleware.InvokeAsync(context, _service);
|
||||
|
||||
// Assert
|
||||
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldAllowBypass_ForStatusEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/availability/status";
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
// Act
|
||||
await _middleware.InvokeAsync(context, _service);
|
||||
|
||||
// Assert
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Tests;
|
||||
|
||||
public class PersistentAvailabilityServiceTests
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly PersistentAvailabilityService _service;
|
||||
|
||||
public PersistentAvailabilityServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new ApplicationDbContext(options);
|
||||
|
||||
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
|
||||
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
|
||||
|
||||
_service = new PersistentAvailabilityService(_context, availabilityOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailableAsync_ShouldReturnAvailable_WhenNoStateInDb()
|
||||
{
|
||||
// Act
|
||||
var status = await _service.IsAvailableAsync();
|
||||
|
||||
// Assert
|
||||
status.Should().Be(AvailabilityStatus.Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_ShouldPersistInDb()
|
||||
{
|
||||
// Act
|
||||
await _service.UpdateStatusAsync(AvailabilityStatus.Maintenance, "Maintenance mode", "Admin");
|
||||
|
||||
// Assert
|
||||
var status = await _service.IsAvailableAsync();
|
||||
status.Should().Be(AvailabilityStatus.Maintenance);
|
||||
|
||||
var dbState = await _context.AvailabilityStates.FirstAsync();
|
||||
dbState.Status.Should().Be(AvailabilityStatus.Maintenance);
|
||||
dbState.Message.Should().Be("Maintenance mode");
|
||||
dbState.UpdatedBy.Should().Be("Admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CircuitBreaker_ShouldReturnCachedStatus_AfterDatabaseError()
|
||||
{
|
||||
// Deze test is lastig met in-memory DB omdat deze zelden 'failt'.
|
||||
// Maar we kunnen de logica testen door de context te disposen (simulatie van DB error).
|
||||
|
||||
// Arrange
|
||||
await _service.UpdateStatusAsync(AvailabilityStatus.Available, "Reset", "System");
|
||||
_context.Dispose(); // Forceer error op volgende DB call
|
||||
|
||||
// Act
|
||||
var status1 = await _service.IsAvailableAsync(); // Deze zou moeten failen en circuit breaker activeren
|
||||
|
||||
// Assert
|
||||
status1.Should().Be(AvailabilityStatus.Available); // Fallback status
|
||||
|
||||
// Act 2: Circuit breaker is nu actief
|
||||
var status2 = await _service.IsAvailableAsync();
|
||||
status2.Should().Be(AvailabilityStatus.Available); // Direct uit cache
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Modules;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability;
|
||||
|
||||
public class AvailabilityModule : IModule
|
||||
{
|
||||
public string Name => "Availability";
|
||||
public string Version => "1.0.0";
|
||||
|
||||
public void RegisterServices(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IAvailabilityService, PersistentAvailabilityService>();
|
||||
services.AddScoped<PersistentAvailabilityService>();
|
||||
}
|
||||
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
{
|
||||
app.UseMiddleware<AvailabilityMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class AvailabilityController : ControllerBase
|
||||
{
|
||||
private readonly IAvailabilityService _availabilityService;
|
||||
|
||||
public AvailabilityController(IAvailabilityService availabilityService)
|
||||
{
|
||||
_availabilityService = availabilityService;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
var status = await _availabilityService.IsAvailableAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Status = status.ToString(),
|
||||
CheckedAt = DateTimeOffset.UtcNow,
|
||||
Message = status == AvailabilityStatus.Available
|
||||
? "System is running normally."
|
||||
: "System is in maintenance or unavailable."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("admin/status")]
|
||||
[Authorize(Policy = "OwnerOnly")]
|
||||
public async Task<IActionResult> UpdateStatus([FromBody] UpdateStatusRequest request)
|
||||
{
|
||||
if (_availabilityService is PersistentAvailabilityService persistentService)
|
||||
{
|
||||
await persistentService.UpdateStatusAsync(
|
||||
request.NewStatus,
|
||||
request.Reason,
|
||||
User.Identity?.Name);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest("Status update niet ondersteund door huidige service.");
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateStatusRequest(
|
||||
AvailabilityStatus NewStatus,
|
||||
string? Reason
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware die de beschikbaarheid van het systeem controleert en requests blokkeert indien nodig.
|
||||
/// </summary>
|
||||
public class AvailabilityMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<AvailabilityMiddleware> _logger;
|
||||
|
||||
public AvailabilityMiddleware(RequestDelegate next, ILogger<AvailabilityMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, IAvailabilityService availabilityService)
|
||||
{
|
||||
// Bypass voor status endpoint
|
||||
if (context.Request.Path.StartsWithSegments("/api/availability/status"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var status = await availabilityService.IsAvailableAsync();
|
||||
|
||||
if (status == AvailabilityStatus.Available)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check voor Beheerder Bypass (Owner of Administrator)
|
||||
if (IsAdminBypass(context))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
_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
|
||||
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
|
||||
: "De service is tijdelijk niet beschikbaar."
|
||||
);
|
||||
|
||||
await context.Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
|
||||
private bool IsAdminBypass(HttpContext context)
|
||||
{
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tokenString = authHeader.Substring("Bearer ".Length);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var token = handler.ReadJwtToken(tokenString);
|
||||
|
||||
var roles = token.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value);
|
||||
return roles.Any(r => r == "Owner" || r == "Administrator");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ongeldig token of parsing fout: geen bypass
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistente implementatie van de IAvailabilityService met Circuit Breaker.
|
||||
/// </summary>
|
||||
public class PersistentAvailabilityService : IAvailabilityService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly AvailabilityOptions _options;
|
||||
|
||||
// Circuit Breaker state
|
||||
private static DateTimeOffset _lastErrorTime = DateTimeOffset.MinValue;
|
||||
private static AvailabilityStatus _cachedStatus = AvailabilityStatus.Available;
|
||||
|
||||
public PersistentAvailabilityService(ApplicationDbContext context, IOptions<AvailabilityOptions> options)
|
||||
{
|
||||
_context = context;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<AvailabilityStatus> IsAvailableAsync()
|
||||
{
|
||||
// Check Circuit Breaker
|
||||
if (DateTimeOffset.UtcNow - _lastErrorTime < TimeSpan.FromSeconds(_options.CircuitBreakerSeconds))
|
||||
{
|
||||
return _cachedStatus;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
||||
|
||||
// Als er nog geen state is, maken we een default aan (Available)
|
||||
if (state == null)
|
||||
{
|
||||
return AvailabilityStatus.Available;
|
||||
}
|
||||
|
||||
_cachedStatus = state.Status;
|
||||
return state.Status;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Database fout: activeer Circuit Breaker
|
||||
_lastErrorTime = DateTimeOffset.UtcNow;
|
||||
_cachedStatus = AvailabilityStatus.Available; // Veilige fallback
|
||||
return _cachedStatus;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update de globale systeemstatus.
|
||||
/// </summary>
|
||||
public async Task UpdateStatusAsync(AvailabilityStatus newStatus, string? reason, string? updatedBy)
|
||||
{
|
||||
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
state = new GlobalAvailabilityState
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
_context.AvailabilityStates.Add(state);
|
||||
}
|
||||
|
||||
state.Status = newStatus;
|
||||
state.Message = reason;
|
||||
state.LastUpdatedAt = DateTimeOffset.UtcNow;
|
||||
state.UpdatedBy = updatedBy;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reset cache
|
||||
_cachedStatus = newStatus;
|
||||
_lastErrorTime = DateTimeOffset.MinValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Identity.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
|
||||
public AuthController(IAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var response = await _authService.AuthenticateAsync(request.Email, request.Password);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
var response = await _authService.RefreshTokenAsync(request.AccessToken, request.RefreshToken);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPost("revoke")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Revoke([FromBody] string refreshToken)
|
||||
{
|
||||
await _authService.RevokeTokenAsync(refreshToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Identity.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class SetupController : ControllerBase
|
||||
{
|
||||
private readonly ISetupService _setupService;
|
||||
|
||||
public SetupController(ISetupService setupService)
|
||||
{
|
||||
_setupService = setupService;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
var initialized = await _setupService.IsSystemInitializedAsync();
|
||||
return Ok(new { Initialized = initialized });
|
||||
}
|
||||
|
||||
[HttpPost("owner")]
|
||||
public async Task<IActionResult> CreateOwner([FromBody] CreateOwnerRequest request)
|
||||
{
|
||||
await _setupService.CreateInitialOwnerAsync(request.Email, request.Password);
|
||||
return Ok(new { Message = "Systeem succesvol geïnitialiseerd. De eerste eigenaar is aangemaakt." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Identity.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IInvitationService _invitationService;
|
||||
|
||||
public UsersController(IInvitationService invitationService)
|
||||
{
|
||||
_invitationService = invitationService;
|
||||
}
|
||||
|
||||
[HttpPost("invite")]
|
||||
[Authorize(Policy = "AdminOnly")]
|
||||
public async Task<IActionResult> Invite([FromBody] InviteUserRequest request)
|
||||
{
|
||||
var token = await _invitationService.CreateInvitationAsync(request.Email, request.Role);
|
||||
// In een echte app zou je hier een email sturen. Voor nu geven we de link terug.
|
||||
var inviteLink = $"/setup/complete?token={Uri.EscapeDataString(token)}";
|
||||
return Ok(new { InviteLink = inviteLink });
|
||||
}
|
||||
|
||||
[HttpPost("complete-setup")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> CompleteSetup([FromBody] CompleteSetupRequest request)
|
||||
{
|
||||
await _invitationService.CompleteInvitationAsync(request.Token, request.Password);
|
||||
return Ok(new { Message = "Account succesvol ingesteld. Je kunt nu inloggen." });
|
||||
}
|
||||
|
||||
[HttpGet("validate-invitation")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
|
||||
{
|
||||
var isValid = await _invitationService.ValidateInvitationAsync(token);
|
||||
return Ok(new { Valid = isValid });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlpModularCms.Core.Modules;
|
||||
|
||||
namespace SlpModularCms.Modules.Identity;
|
||||
|
||||
public class IdentityModule : IModule
|
||||
{
|
||||
public string Name => "Identity";
|
||||
public string Version => "1.0.0";
|
||||
|
||||
public void RegisterServices(IServiceCollection services)
|
||||
{
|
||||
// Specifieke services voor deze module (indien niet al in Core)
|
||||
}
|
||||
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user