From a4ef2b4a200f3414c2083a05884687961ceb7c30 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 16 May 2025 16:10:01 +0200 Subject: [PATCH] stuff --- .../Auth/SessionAuthenticationHandler.cs | 2 +- Femto.Api/Controllers/Auth/AuthController.cs | 8 +- .../Controllers/Media/MediaController.cs | 3 + .../Controllers/Posts/PostsController.cs | 3 +- Femto.Api/Femto.Api.csproj | 5 +- .../Middleware/ExceptionMapperMiddleware.cs | 79 +++++++++++++++++++ Femto.Api/Program.cs | 56 ++++++++++++- Femto.Common/Femto.Common.csproj | 1 + .../Infrastructure/DomainServiceExtensions.cs | 19 +++++ Femto.Common/Logs/LoggerExtensions.cs | 37 +++++++++ Femto.Database/Femto.Database.csproj | 1 + .../Migrations/20250425121459_Init.sql | 15 +++- Femto.Database/Seed/TestDataSeeder.cs | 44 +++++++++-- .../Application/AuthApplication.cs | 11 +++ ...{AuthenticationModule.cs => AuthModule.cs} | 2 +- ...uthenticationStartup.cs => AuthStartup.cs} | 43 +++++----- .../ValidateSessionCommandHandler.cs | 2 +- ...AuthenticationModule.cs => IAuthModule.cs} | 2 +- Femto.Modules.Auth/Data/AuthContext.cs | 4 +- .../UserIdentityTypeConfiguration.cs | 11 ++- .../Models/{UserPassword.cs => Password.cs} | 13 ++- Femto.Modules.Auth/Models/UserIdentity.cs | 14 +++- Femto.Modules.Auth/Models/UserSession.cs | 7 +- .../Application/BlogApplication.cs | 9 ++- Femto.Modules.Blog/Application/BlogStartup.cs | 7 +- .../{Startup.cs => MediaStartup.cs} | 11 +-- 26 files changed, 331 insertions(+), 78 deletions(-) create mode 100644 Femto.Api/Middleware/ExceptionMapperMiddleware.cs create mode 100644 Femto.Common/Infrastructure/DomainServiceExtensions.cs create mode 100644 Femto.Common/Logs/LoggerExtensions.cs create mode 100644 Femto.Modules.Auth/Application/AuthApplication.cs rename Femto.Modules.Auth/Application/{AuthenticationModule.cs => AuthModule.cs} (88%) rename Femto.Modules.Auth/Application/{AuthenticationStartup.cs => AuthStartup.cs} (51%) rename Femto.Modules.Auth/Application/{IAuthenticationModule.cs => IAuthModule.cs} (83%) rename Femto.Modules.Auth/Models/{UserPassword.cs => Password.cs} (86%) rename Femto.Modules.Media/Application/{Startup.cs => MediaStartup.cs} (82%) diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 1d33425..ce74176 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -14,7 +14,7 @@ internal class SessionAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - IAuthenticationModule authModule, + IAuthModule authModule, CurrentUserContext currentUserContext ) : AuthenticationHandler(options, logger, encoder) { diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 5e22096..8f3c52f 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -8,7 +8,7 @@ namespace Femto.Api.Controllers.Auth; [ApiController] [Route("auth")] -public class AuthController(IAuthenticationModule authModule) : ControllerBase +public class AuthController(IAuthModule authModule) : ControllerBase { [HttpPost("login")] public async Task> Login([FromBody] LoginRequest request) @@ -34,10 +34,10 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase return new RegisterResponse(result.UserId, result.Username); } - [HttpPost("delete-session")] - public async Task DeleteSession([FromBody] DeleteSessionRequest request) + [HttpDelete("session")] + public async Task DeleteSession() { - // TODO + HttpContext.Response.Cookies.Delete("session"); return Ok(new { }); } } diff --git a/Femto.Api/Controllers/Media/MediaController.cs b/Femto.Api/Controllers/Media/MediaController.cs index 2ad6515..7f0e371 100644 --- a/Femto.Api/Controllers/Media/MediaController.cs +++ b/Femto.Api/Controllers/Media/MediaController.cs @@ -4,6 +4,7 @@ using Femto.Modules.Media.Contracts; using Femto.Modules.Media.Contracts.LoadFile; using Femto.Modules.Media.Contracts.SaveFile; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Femto.Api.Controllers.Media; @@ -13,6 +14,7 @@ namespace Femto.Api.Controllers.Media; public class MediaController(IMediaModule mediaModule) : ControllerBase { [HttpPost] + [Authorize] public async Task> UploadMedia( IFormFile file, CancellationToken cancellationToken @@ -29,6 +31,7 @@ public class MediaController(IMediaModule mediaModule) : ControllerBase } [HttpGet("{id}")] + [Authorize] public async Task GetMedia(Guid id, CancellationToken cancellationToken) { var res = await mediaModule.PostQuery(new LoadFileQuery(id), cancellationToken); diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 7517d8d..0d8f4e8 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -1,9 +1,7 @@ using Femto.Api.Controllers.Posts.Dto; -using Femto.Modules.Blog; using Femto.Modules.Blog.Application; using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Queries.GetPosts; -using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -44,6 +42,7 @@ public class PostsController(IBlogModule blogModule) : ControllerBase } [HttpPost] + [Authorize] public async Task> Post( [FromBody] CreatePostRequest req, CancellationToken cancellationToken diff --git a/Femto.Api/Femto.Api.csproj b/Femto.Api/Femto.Api.csproj index c61ffdf..4625094 100644 --- a/Femto.Api/Femto.Api.csproj +++ b/Femto.Api/Femto.Api.csproj @@ -14,6 +14,7 @@ + @@ -28,8 +29,4 @@ - - - - diff --git a/Femto.Api/Middleware/ExceptionMapperMiddleware.cs b/Femto.Api/Middleware/ExceptionMapperMiddleware.cs new file mode 100644 index 0000000..154af78 --- /dev/null +++ b/Femto.Api/Middleware/ExceptionMapperMiddleware.cs @@ -0,0 +1,79 @@ +using Femto.Common.Domain; +using Femto.Common.Logs; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.WebUtilities; + +namespace Femto.Api.Middleware; + +public class ExceptionMapperMiddleware( + RequestDelegate next, + IWebHostEnvironment env, + ILogger logger +) +{ + public async Task Invoke(HttpContext context, ProblemDetailsFactory problemDetailsFactory) + { + try + { + await next(context); + + if (context.Response.StatusCode >= 400) + { + logger.LogFailedRequest( + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + context.TraceIdentifier, + ReasonPhrases.GetReasonPhrase(context.Response.StatusCode) + ); + } + } + catch (DomainError e) + { + context.Response.StatusCode = 400; + context.Response.ContentType = "application/json"; + + var problemDetails = problemDetailsFactory.CreateProblemDetails( + context, + statusCode: 400, + title: "client error", + detail: e.Message + ); + + logger.LogFailedRequest( + e, + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + context.TraceIdentifier, + e.Message + ); + + await context.Response.WriteAsJsonAsync(problemDetails); + } + catch (Exception e) + { + context.Response.StatusCode = 500; + context.Response.ContentType = "application/json"; + var problemDetails = problemDetailsFactory.CreateProblemDetails( + context, + statusCode: 500, + title: "server error error", + detail: env.IsDevelopment() ? e.Message : "Something went wrong" + ); + + logger.LogFailedRequest( + e, + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + context.TraceIdentifier, + e.Message + ); + + await context.Response.WriteAsJsonAsync(problemDetails); + } + finally { } + } +} diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 50f44b8..8975f47 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -2,11 +2,18 @@ using System.Text.Json; using System.Text.Json.Serialization; using Femto.Api; using Femto.Api.Auth; +using Femto.Api.Middleware; using Femto.Common; +using Femto.Common.Domain; using Femto.Modules.Auth.Application; using Femto.Modules.Blog.Application; using Femto.Modules.Media.Application; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.WebUtilities; +using Serilog; const string CorsPolicyName = "DefaultCorsPolicy"; @@ -22,6 +29,7 @@ var blobStorageRoot = builder.Configuration.GetValue("BlobStorageRoot"); if (blobStorageRoot is null) throw new Exception("no blob storage root found"); + builder.Services.InitializeBlogModule(connectionString); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); builder.Services.InitializeAuthenticationModule(connectionString); @@ -29,15 +37,16 @@ builder.Services.InitializeAuthenticationModule(connectionString); builder.Services.AddScoped(); builder.Services.AddScoped(s => s.GetRequiredService()); -builder.Services.AddControllers(); - builder.Services.AddCors(options => { options.AddPolicy( CorsPolicyName, b => { - b.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:5173"); + b.AllowAnyHeader() + .AllowAnyMethod() + .WithOrigins("http://localhost:5173") + .AllowCredentials(); } ); }); @@ -60,12 +69,51 @@ builder options => { } ); -builder.Services.AddAuthorization(); // if not already added +builder.Services.AddAuthorization(); var app = builder.Build(); app.UseCors(CorsPolicyName); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var exceptionHandlerFeature = context.Features.Get(); + var exception = exceptionHandlerFeature?.Error; + var problemDetailsFactory = + errorApp.ApplicationServices.GetRequiredService(); + var statusCode = exception switch + { + DomainError => 400, + _ => 500, + }; + + var message = exception switch + { + DomainError domainError => domainError.Message, + { } e => e.Message, + _ => ReasonPhrases.GetReasonPhrase(statusCode), + }; + + var problemDetails = problemDetailsFactory.CreateProblemDetails( + httpContext: context, + title: "An error occurred", + detail: message, + statusCode: statusCode + ); + + // problemDetails.Extensions["traceId"] = context.TraceIdentifier; + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(problemDetails); + }); +}); + +// app.UseMiddleware(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); diff --git a/Femto.Common/Femto.Common.csproj b/Femto.Common/Femto.Common.csproj index 269ff83..d8b3952 100644 --- a/Femto.Common/Femto.Common.csproj +++ b/Femto.Common/Femto.Common.csproj @@ -9,6 +9,7 @@ + diff --git a/Femto.Common/Infrastructure/DomainServiceExtensions.cs b/Femto.Common/Infrastructure/DomainServiceExtensions.cs new file mode 100644 index 0000000..e83469e --- /dev/null +++ b/Femto.Common/Infrastructure/DomainServiceExtensions.cs @@ -0,0 +1,19 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Femto.Common.Infrastructure; + +public static class DomainServiceExtensions +{ + public static void ConfigureDomainServices(this IServiceCollection services) + where TContext : DbContext + { + services.AddScoped(s => s.GetRequiredService()); + services.AddTransient( + typeof(IPipelineBehavior<,>), + typeof(SaveChangesPipelineBehaviour<,>) + ); + + } +} \ No newline at end of file diff --git a/Femto.Common/Logs/LoggerExtensions.cs b/Femto.Common/Logs/LoggerExtensions.cs new file mode 100644 index 0000000..5661dd2 --- /dev/null +++ b/Femto.Common/Logs/LoggerExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; + +namespace Femto.Common.Logs; + +public static partial class LoggerExtensions +{ + [LoggerMessage( + LogLevel.Error, + EventId = 1, + EventName = "FailedRequestWithException", + Message = "Request failed: {Method} {Path}, Status: {StatusCode}, TraceId: {TraceId}, Message: {Message}" + )] + public static partial void LogFailedRequest( + this ILogger logger, + Exception exception, + string method, + string path, + int statusCode, + string traceId, + string message + ); + + [LoggerMessage( + LogLevel.Error, + EventId = 2, + EventName = "FailedRequest", + Message = "Request failed: {Method} {Path}, Status: {StatusCode}, TraceId: {TraceId}, Message: {Message}" + )] + public static partial void LogFailedRequest( + this ILogger logger, + string method, + string path, + int statusCode, + string traceId, + string message + ); +} diff --git a/Femto.Database/Femto.Database.csproj b/Femto.Database/Femto.Database.csproj index 257e1a1..acb7f32 100644 --- a/Femto.Database/Femto.Database.csproj +++ b/Femto.Database/Femto.Database.csproj @@ -8,6 +8,7 @@ + diff --git a/Femto.Database/Migrations/20250425121459_Init.sql b/Femto.Database/Migrations/20250425121459_Init.sql index b608942..f42403f 100644 --- a/Femto.Database/Migrations/20250425121459_Init.sql +++ b/Femto.Database/Migrations/20250425121459_Init.sql @@ -59,5 +59,16 @@ CREATE SCHEMA authn; CREATE TABLE authn.user_identity ( - -); \ No newline at end of file + id uuid PRIMARY KEY, + username text NOT NULL UNIQUE, + + password_hash bytea, + password_salt bytea +); + +CREATE TABLE authn.user_session +( + id varchar(256) PRIMARY KEY, + user_id uuid NOT NULL REFERENCES authn.user_identity (id) ON DELETE CASCADE, + expires timestamptz NOT NULL +); diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index 649a1d5..7b0cb15 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -1,26 +1,46 @@ +using System.Text; +using Geralt; using Npgsql; namespace Femto.Database.Seed; public static class TestDataSeeder { + private const int Iterations = 3; + private const int MemorySize = 67108864; + public static async Task Seed(NpgsqlDataSource dataSource) { + var id = Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0"); + var username = "johnbotris"; + var salt = new byte[32]; + var password = "hunter2"u8; + var hashInput = new byte[password.Length + salt.Length]; + password.CopyTo(hashInput); + salt.CopyTo(hashInput, password.Length); + var passwordHash = new byte[128]; + Argon2id.ComputeHash( + passwordHash, + hashInput, + Iterations, + MemorySize + ); + await using var addToHistoryCommand = dataSource.CreateCommand( - """ + $""" INSERT INTO blog.author (id, username) VALUES - ('0196960c-6296-7532-ba66-8fabb38c6ae0', 'johnbotris') + (@id, @username) ; INSERT INTO blog.post (id, author_id, content) VALUES - ('019691a0-48ed-7eba-b8d3-608e25e07d4b', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'), - ('019691a0-4ace-7bb5-a8f3-e3362920eba0', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'), - ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', '0196960c-6296-7532-ba66-8fabb38c6ae0','Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'), - ('019691a0-4dd3-7e89-909e-94a6fd19a05e', '0196960c-6296-7532-ba66-8fabb38c6ae0','Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.') + ('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'), + ('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'), + ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id,'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'), + ('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id,'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.') ; INSERT INTO blog.post_media @@ -33,9 +53,19 @@ public static class TestDataSeeder ('019691b6-07cb-7353-8c33-68456188f462', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-2bxloyitgw7q1hfg.jpg', 1), ('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0) ; + + INSERT INTO authn.user_identity + (id, username, password_hash, password_salt) + VALUES + (@id, @username, @passwordHash, @salt); """ ); + + addToHistoryCommand.Parameters.AddWithValue("@id", id); + addToHistoryCommand.Parameters.AddWithValue("@username", username); + addToHistoryCommand.Parameters.AddWithValue("@passwordHash", passwordHash); + addToHistoryCommand.Parameters.AddWithValue("@salt", salt); await addToHistoryCommand.ExecuteNonQueryAsync(); } -} \ No newline at end of file +} diff --git a/Femto.Modules.Auth/Application/AuthApplication.cs b/Femto.Modules.Auth/Application/AuthApplication.cs new file mode 100644 index 0000000..4d08881 --- /dev/null +++ b/Femto.Modules.Auth/Application/AuthApplication.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Hosting; + +namespace Femto.Modules.Auth.Application; + +public class AuthApplication(IHost host) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await host.RunAsync(stoppingToken); + } +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/AuthenticationModule.cs b/Femto.Modules.Auth/Application/AuthModule.cs similarity index 88% rename from Femto.Modules.Auth/Application/AuthenticationModule.cs rename to Femto.Modules.Auth/Application/AuthModule.cs index 65dd713..1ac5fdb 100644 --- a/Femto.Modules.Auth/Application/AuthenticationModule.cs +++ b/Femto.Modules.Auth/Application/AuthModule.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Hosting; namespace Femto.Modules.Auth.Application; -internal class AuthenticationModule(IHost host) : IAuthenticationModule +internal class AuthModule(IHost host) : IAuthModule { public async Task PostCommand(ICommand command, CancellationToken cancellationToken = default) { diff --git a/Femto.Modules.Auth/Application/AuthenticationStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs similarity index 51% rename from Femto.Modules.Auth/Application/AuthenticationStartup.cs rename to Femto.Modules.Auth/Application/AuthStartup.cs index 283ccf5..90bf459 100644 --- a/Femto.Modules.Auth/Application/AuthenticationStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -4,47 +4,46 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Femto.Modules.Auth.Application; -public static class AuthenticationStartup +public static class AuthStartup { - public static void InitializeAuthenticationModule(this IServiceCollection rootContainer, string connectionString) + public static void InitializeAuthenticationModule( + this IServiceCollection rootContainer, + string connectionString + ) { var hostBuilder = Host.CreateDefaultBuilder(); hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString)); var host = hostBuilder.Build(); - rootContainer.AddScoped(_ => new AuthenticationModule(host)); + rootContainer.AddScoped(_ => new AuthModule(host)); + rootContainer.AddHostedService(services => new AuthApplication(host)); } private static void ConfigureServices(IServiceCollection services, string connectionString) { - services.AddDbContext( - builder => - { - builder.UseNpgsql(connectionString); - builder.UseSnakeCaseNamingConvention(); - }); - - services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly)); - + services.AddDbContext(builder => + { + builder.UseNpgsql(connectionString); + builder.UseSnakeCaseNamingConvention(); + }); + + services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); + services.AddDbContext(builder => { builder.UseNpgsql(); builder.UseSnakeCaseNamingConvention(); builder.EnableSensitiveDataLogging(); }); - - services.AddScoped(s => s.GetRequiredService()); - + + services.ConfigureDomainServices(); + services.AddMediatR(c => { - c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly); + c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly); }); - - services.AddTransient( - typeof(IPipelineBehavior<,>), - typeof(SaveChangesPipelineBehaviour<,>) - ); } -} \ No newline at end of file +} diff --git a/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs index f668831..129cf23 100644 --- a/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs @@ -24,7 +24,7 @@ internal class ValidateSessionCommandHandler(AuthContext context) if (user is null) throw new InvalidSessionError(); - var session = user.StartNewSession(); + var session = user.PossiblyRefreshSession(request.SessionId); return new ValidateSessionResult( new Session(session.Id, session.Expires), diff --git a/Femto.Modules.Auth/Application/IAuthenticationModule.cs b/Femto.Modules.Auth/Application/IAuthModule.cs similarity index 83% rename from Femto.Modules.Auth/Application/IAuthenticationModule.cs rename to Femto.Modules.Auth/Application/IAuthModule.cs index a3226c6..e9c2f4b 100644 --- a/Femto.Modules.Auth/Application/IAuthenticationModule.cs +++ b/Femto.Modules.Auth/Application/IAuthModule.cs @@ -2,7 +2,7 @@ using Femto.Common.Domain; namespace Femto.Modules.Auth.Application; -public interface IAuthenticationModule +public interface IAuthModule { Task PostCommand(ICommand command, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs index 03a9bce..7de1f2e 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -6,8 +6,8 @@ namespace Femto.Modules.Auth.Data; internal class AuthContext(DbContextOptions options) : DbContext(options), IOutboxContext { - public virtual DbSet Users { get; } - public virtual DbSet Outbox { get; } + public virtual DbSet Users { get; set; } + public virtual DbSet Outbox { get; set; } protected override void OnModelCreating(ModelBuilder builder) { diff --git a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs index 36c6324..2be1295 100644 --- a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs +++ b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs @@ -9,7 +9,16 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration builder) { builder.ToTable("user_identity"); - builder.OwnsOne(u => u.Password).WithOwner().HasForeignKey("user_id"); + builder.OwnsOne(u => u.Password, pw => + { + pw.Property(p => p.Hash) + .HasColumnName("password_hash") + .IsRequired(false); + + pw.Property(p => p.Salt) + .HasColumnName("password_salt") + .IsRequired(false); + }); builder.OwnsMany(u => u.Sessions).WithOwner().HasForeignKey("user_id"); } } diff --git a/Femto.Modules.Auth/Models/UserPassword.cs b/Femto.Modules.Auth/Models/Password.cs similarity index 86% rename from Femto.Modules.Auth/Models/UserPassword.cs rename to Femto.Modules.Auth/Models/Password.cs index 6d9bd2f..6800ff9 100644 --- a/Femto.Modules.Auth/Models/UserPassword.cs +++ b/Femto.Modules.Auth/Models/Password.cs @@ -4,23 +4,20 @@ using JetBrains.Annotations; namespace Femto.Modules.Auth.Models; -internal class UserPassword +internal class Password { private const int Iterations = 3; private const int MemorySize = 67108864; - public Guid Id { get; private set; } + public byte[] Hash { get; private set; } - private byte[] Hash { get; set; } - - private byte[] Salt { get; set; } + public byte[] Salt { get; private set; } [UsedImplicitly] - private UserPassword() {} + private Password() {} - public UserPassword(string password) + public Password(string password) { - this.Id = Guid.NewGuid(); this.Salt = ComputeSalt(); this.Hash = ComputePasswordHash(password, Salt); } diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index c4f9e47..871c7f3 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -12,7 +12,7 @@ internal class UserIdentity : Entity public string Username { get; private set; } - public UserPassword Password { get; private set; } + public Password? Password { get; private set; } public ICollection Sessions { get; private set; } = []; @@ -34,7 +34,7 @@ internal class UserIdentity : Entity public void SetPassword(string password) { - this.Password = new UserPassword(password); + this.Password = new Password(password); } public bool HasPassword(string requestPassword) @@ -47,6 +47,16 @@ internal class UserIdentity : Entity return this.Password.Check(requestPassword); } + public UserSession PossiblyRefreshSession(string sessionId) + { + var session = this.Sessions.Single(s => s.Id == sessionId); + + if (session.ExpiresSoon) + return this.StartNewSession(); + + return session; + } + public UserSession StartNewSession() { var session = UserSession.Create(); diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs index 1c82e61..b74a365 100644 --- a/Femto.Modules.Auth/Models/UserSession.cs +++ b/Femto.Modules.Auth/Models/UserSession.cs @@ -2,10 +2,13 @@ namespace Femto.Modules.Auth.Models; public class UserSession { - private static TimeSpan SessionTimeout = TimeSpan.FromMinutes(30); + private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30); + private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5); public string Id { get; private set; } public DateTimeOffset Expires { get; private set; } + public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer; + private UserSession() {} public static UserSession Create() @@ -13,7 +16,7 @@ public class UserSession return new() { Id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)), - Expires = DateTimeOffset.Now + SessionTimeout + Expires = DateTimeOffset.UtcNow + SessionTimeout }; } } \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/BlogApplication.cs b/Femto.Modules.Blog/Application/BlogApplication.cs index 70ed624..e5fad0c 100644 --- a/Femto.Modules.Blog/Application/BlogApplication.cs +++ b/Femto.Modules.Blog/Application/BlogApplication.cs @@ -6,6 +6,13 @@ public class BlogApplication(IHost host) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - await host.RunAsync(stoppingToken); + try + { + await host.RunAsync(stoppingToken); + } + catch (TaskCanceledException) + { + //ignore + } } } \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs index 0ef415b..fe30c48 100644 --- a/Femto.Modules.Blog/Application/BlogStartup.cs +++ b/Femto.Modules.Blog/Application/BlogStartup.cs @@ -52,11 +52,6 @@ public static class BlogStartup c.RegisterServicesFromAssembly(typeof(BlogStartup).Assembly); }); - services.AddScoped(s => s.GetRequiredService()); - - services.AddTransient( - typeof(IPipelineBehavior<,>), - typeof(SaveChangesPipelineBehaviour<,>) - ); + services.ConfigureDomainServices(); } } diff --git a/Femto.Modules.Media/Application/Startup.cs b/Femto.Modules.Media/Application/MediaStartup.cs similarity index 82% rename from Femto.Modules.Media/Application/Startup.cs rename to Femto.Modules.Media/Application/MediaStartup.cs index d9c5019..30fedf8 100644 --- a/Femto.Modules.Media/Application/Startup.cs +++ b/Femto.Modules.Media/Application/MediaStartup.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Hosting; namespace Femto.Modules.Media.Application; -public static class Startup +public static class MediaStartup { public static void InitializeMediaModule(this IServiceCollection rootContainer, string connectionString, string storageRoot) { @@ -22,16 +22,13 @@ public static class Startup builder.UseSnakeCaseNamingConvention(); }); services.AddTransient(s => new FilesystemStorageProvider(storageRoot)); - services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Startup).Assembly)); - - services.AddTransient( - typeof(IPipelineBehavior<,>), - typeof(SaveChangesPipelineBehaviour<,>) - ); + services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(MediaStartup).Assembly)); + services.ConfigureDomainServices(); }); var host = hostBuilder.Build(); rootContainer.AddTransient(_ => new MediaModule(host)); + rootContainer.AddHostedService(services => new MediaApplication(host)); } }