From f48b421500025421c30418685ac76ec62a1962d2 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 1 Jun 2025 23:28:00 +0200 Subject: [PATCH] do sessions in memory and also fix glaring security hole --- .../Auth/SessionAuthenticationHandler.cs | 123 ++++++++++------- Femto.Api/Controllers/Auth/AuthController.cs | 91 ++++++------ .../Controllers/Auth/GetUserInfoResult.cs | 3 + .../Controllers/Auth/RefreshUserResult.cs | 3 - .../Sessions/HttpContextSessionExtensions.cs | 129 ++++++++++-------- Femto.Common/ICurrentUserContext.cs | 2 +- .../SaveChangesPipelineBehaviour.cs | 5 + Femto.Common/ScopeBinding.cs | 17 ++- .../20250529101346_SessionsRework.sql | 13 ++ Femto.Modules.Auth/Application/AuthStartup.cs | 31 ++++- .../GetUserInfo/GetUserInfoCommand.cs | 6 + .../GetUserInfo/GetUserInfoCommandHandler.cs | 27 ++++ .../Interface/Login/LoginCommand.cs | 6 - .../Interface/Login/LoginCommandHandler.cs | 31 ----- .../RefreshUserSessionCommand.cs | 7 - .../RefreshUserSessionCommandHandler.cs | 46 ------- .../Interface/Register/RegisterCommand.cs | 2 +- .../Register/RegisterCommandHandler.cs | 26 ++-- .../ValidateSession/ValidateSessionCommand.cs | 10 -- .../ValidateSessionCommandHandler.cs | 111 --------------- .../Application/{ => Services}/AuthModule.cs | 4 +- .../Application/Services/AuthService.cs | 79 +++++++++++ .../Application/{ => Services}/IAuthModule.cs | 2 +- .../Application/Services/IAuthService.cs | 14 ++ Femto.Modules.Auth/Data/AuthContext.cs | 1 - .../UserIdentityTypeConfiguration.cs | 2 - .../Infrastructure/SessionStorage.cs | 30 ++++ Femto.Modules.Auth/Models/Session.cs | 14 ++ Femto.Modules.Auth/Models/UserIdentity.cs | 5 - Femto.Modules.Auth/Models/UserSession.cs | 33 ----- Femto.Modules.Blog/Application/BlogStartup.cs | 8 +- 31 files changed, 441 insertions(+), 440 deletions(-) create mode 100644 Femto.Api/Controllers/Auth/GetUserInfoResult.cs delete mode 100644 Femto.Api/Controllers/Auth/RefreshUserResult.cs create mode 100644 Femto.Database/Migrations/20250529101346_SessionsRework.sql create mode 100644 Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs create mode 100644 Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs rename Femto.Modules.Auth/Application/{ => Services}/AuthModule.cs (83%) create mode 100644 Femto.Modules.Auth/Application/Services/AuthService.cs rename Femto.Modules.Auth/Application/{ => Services}/IAuthModule.cs (87%) create mode 100644 Femto.Modules.Auth/Application/Services/IAuthService.cs create mode 100644 Femto.Modules.Auth/Infrastructure/SessionStorage.cs create mode 100644 Femto.Modules.Auth/Models/Session.cs delete mode 100644 Femto.Modules.Auth/Models/UserSession.cs diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 539cdea..37939a4 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -1,15 +1,11 @@ using System.Security.Claims; using System.Text.Encodings.Web; -using System.Text.Json; using Femto.Api.Sessions; using Femto.Common; using Femto.Modules.Auth.Application; -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Application.Interface.ValidateSession; -using Femto.Modules.Auth.Errors; +using Femto.Modules.Auth.Application.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Extensions; namespace Femto.Api.Auth; @@ -17,61 +13,84 @@ internal class SessionAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - IAuthModule authModule, + IAuthService authService, CurrentUserContext currentUserContext ) : AuthenticationHandler(options, logger, encoder) { protected override async Task HandleAuthenticateAsync() { - var sessionId = this.Request.Cookies["session"]; - if (string.IsNullOrWhiteSpace(sessionId)) + Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier); + + var (sessionId, maybeUserId) = this.Context.GetSessionInfo(); + + + if (sessionId is null) + { + Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier); return AuthenticateResult.NoResult(); - - var userJson = this.Request.Cookies["user"]; - if (string.IsNullOrWhiteSpace(userJson)) - return AuthenticateResult.Fail("Invalid user"); - - var user = JsonSerializer.Deserialize(userJson); - - if (user is null) - return AuthenticateResult.Fail("Invalid user"); - - var rememberMe = this.Request.Cookies["rememberme"]; - - try - { - var result = await authModule.Command( - new ValidateSessionCommand(sessionId, user, rememberMe) - ); - - var claims = new List - { - new(ClaimTypes.Name, user.Username), - new("sub", user.Id.ToString()), - new("user_id", user.Id.ToString()), - }; - - claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))); - - var identity = new ClaimsIdentity(claims, this.Scheme.Name); - var principal = new ClaimsPrincipal(identity); - - this.Context.SetSession(result.SessionDto, user, Logger); - - currentUserContext.CurrentUser = new CurrentUser( - user.Id, - user.Username, - result.SessionDto.SessionId, - rememberMe - ); - - return AuthenticateResult.Success( - new AuthenticationTicket(principal, this.Scheme.Name) - ); } - catch (InvalidSessionError) + + var session = await authService.GetSession(sessionId); + + if (session is null) { - return AuthenticateResult.Fail("Invalid session"); + Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier); + return await FailAndDeleteSession(sessionId); } + + if (session.IsExpired) + { + Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier); + return await FailAndDeleteSession(sessionId); + } + + if (maybeUserId is not { } userId) + { + Logger.LogDebug("{TraceId} SessionId provided with no user", this.Context.TraceIdentifier); + return await FailAndDeleteSession(sessionId); + } + + if (session.UserId != userId) + { + Logger.LogDebug("{TraceId} SessionId provided with different user", this.Context.TraceIdentifier); + return await FailAndDeleteSession(sessionId); + } + + var user = await authService.GetUserWithId(userId); + + if (user is null) + { + await authService.DeleteSession(sessionId); + this.Context.DeleteSession(); + return AuthenticateResult.Fail("invalid session"); + } + + if (session.ExpiresSoon) + { + session = await authService.CreateWeakSession(userId); + this.Context.SetSession(session, user); + } + + var claims = new List + { + new(ClaimTypes.Name, user.Username), + new("sub", user.Id.ToString()), + new("user_id", user.Id.ToString()), + }; + + claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))); + + var identity = new ClaimsIdentity(claims, this.Scheme.Name); + var principal = new ClaimsPrincipal(identity); + currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username); + + return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)); + } + + private async Task FailAndDeleteSession(string sessionId) + { + await authService.DeleteSession(sessionId); + this.Context.DeleteSession(); + return AuthenticateResult.Fail("invalid session"); } } diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 7885038..3ca6203 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -1,13 +1,10 @@ using Femto.Api.Auth; using Femto.Api.Sessions; using Femto.Common; -using Femto.Modules.Auth.Application; -using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Interface.CreateSignupCode; using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; -using Femto.Modules.Auth.Application.Interface.Login; -using Femto.Modules.Auth.Application.Interface.RefreshUserSession; using Femto.Modules.Auth.Application.Interface.Register; +using Femto.Modules.Auth.Application.Services; using Femto.Modules.Auth.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,79 +18,85 @@ public class AuthController( IAuthModule authModule, IOptions cookieSettings, ICurrentUserContext currentUserContext, - ILogger logger + ILogger logger, + IAuthService authService ) : ControllerBase { [HttpPost("login")] - public async Task> Login([FromBody] LoginRequest request) + public async Task> Login( + [FromBody] LoginRequest request, + CancellationToken cancellationToken + ) { - var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); - - HttpContext.SetSession(result.SessionDto, result.User, logger); - - return new LoginResponse( - result.User.Id, - result.User.Username, - result.User.Roles.Any(r => r == Role.SuperUser) + var user = await authService.GetUserWithCredentials( + request.Username, + request.Password, + cancellationToken ); + + if (user is null) + return Forbid(); + + var session = await authService.CreateStrongSession(user.Id); + + HttpContext.SetSession(session, user); + + return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser)); } [HttpPost("register")] public async Task> Register([FromBody] RegisterRequest request) { - var result = await authModule.Command( + var user = await authModule.Command( new RegisterCommand(request.Username, request.Password, request.SignupCode) ); - HttpContext.SetSession(result.SessionDto, result.User, logger); + var session = await authService.CreateStrongSession(user.Id); + HttpContext.SetSession(session, user); return new RegisterResponse( - result.User.Id, - result.User.Username, - result.User.Roles.Any(r => r == Role.SuperUser) + user.Id, + user.Username, + user.Roles.Any(r => r == Role.SuperUser) ); } [HttpDelete("session")] public async Task DeleteSession() { - var currentUser = currentUserContext.CurrentUser; + var (sessionId, userId) = HttpContext.GetSessionInfo(); + + if (sessionId is not null) + { + await authService.DeleteSession(sessionId); + HttpContext.DeleteSession(); + } - if (currentUser != null) - await authModule.Command(new DeauthenticateCommand(currentUser.Id, currentUser.SessionId, currentUser.RememberMeToken)); - - HttpContext.DeleteSession(); - return Ok(new { }); } [HttpGet("user/{userId}")] [Authorize] - public async Task> RefreshUser( + public async Task> GetUserInfo( Guid userId, CancellationToken cancellationToken ) { - var currentUser = currentUserContext.CurrentUser!; + var currentUser = currentUserContext.CurrentUser; - try - { - var result = await authModule.Command( - new RefreshUserCommand(userId, currentUser), - cancellationToken - ); + if (currentUser is null || currentUser.Id != userId) + return this.BadRequest(); - return new RefreshUserResult( - result.User.Id, - result.User.Username, - result.User.Roles.Any(r => r == Role.SuperUser) - ); - } - catch (Exception) - { - HttpContext.DeleteSession(); - return this.Forbid(); - } + var user = await authService.GetUserWithId(userId, cancellationToken); + + if (user is null) + return this.BadRequest(); + + return new GetUserInfoResult( + user.Id, + user.Username, + user.Roles.Any(r => r == Role.SuperUser) + ); } [HttpPost("signup-codes")] diff --git a/Femto.Api/Controllers/Auth/GetUserInfoResult.cs b/Femto.Api/Controllers/Auth/GetUserInfoResult.cs new file mode 100644 index 0000000..0212f32 --- /dev/null +++ b/Femto.Api/Controllers/Auth/GetUserInfoResult.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Auth; + +public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/RefreshUserResult.cs b/Femto.Api/Controllers/Auth/RefreshUserResult.cs deleted file mode 100644 index 8dbdee8..0000000 --- a/Femto.Api/Controllers/Auth/RefreshUserResult.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Auth; - -public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser); \ No newline at end of file diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index bd95387..fcf2a1f 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -1,89 +1,102 @@ using System.Text.Json; -using System.Text.Json.Serialization; using Femto.Api.Auth; using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Models; using Microsoft.Extensions.Options; namespace Femto.Api.Sessions; +internal record SessionInfo(string? SessionId, Guid? UserId); + internal static class HttpContextSessionExtensions { - public static void SetSession(this HttpContext httpContext, SessionDto sessionDto, UserInfo user, ILogger logger) + private static readonly JsonSerializerOptions JsonOptions = new() { - var cookieSettings = httpContext.RequestServices.GetService>(); + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static SessionInfo GetSessionInfo(this HttpContext httpContext) + { + var sessionId = httpContext.Request.Cookies["sid"]; - var secure = cookieSettings?.Value.Secure ?? true; - var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict; - var domain = cookieSettings?.Value.Domain; - var expires = sessionDto.Expires; + var userJson = httpContext.Request.Cookies["user"]; + + UserInfo? user = null; + if (userJson is not null) + { + user = JsonSerializer.Deserialize(userJson, JsonOptions); + } - logger.LogInformation( - "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", - secure, - sameSite, - domain, - expires - ); + return new SessionInfo(sessionId, user?.Id); + } - httpContext.Response.Cookies.Append( - "session", - sessionDto.SessionId, + public static void SetSession(this HttpContext context, Session session, UserInfo user) + { + var cookieSettings = context.RequestServices.GetRequiredService< + IOptions + >(); + + context.Response.Cookies.Append( + "sid", + session.Id, new CookieOptions { + Path = "/", IsEssential = true, - Domain = domain, + Domain = cookieSettings.Value.Domain, HttpOnly = true, - Secure = secure, - SameSite = sameSite, - Expires = expires, + Secure = cookieSettings.Value.Secure, + SameSite = cookieSettings.Value.SameSite, + Expires = session.Expires, } ); - httpContext.Response.Cookies.Append( + context.Response.Cookies.Append( "user", - JsonSerializer.Serialize( - user, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() }, - } - ), + JsonSerializer.Serialize(user, JsonOptions), new CookieOptions { - Domain = domain, + Path = "/", + Domain = cookieSettings.Value.Domain, IsEssential = true, - Secure = secure, - SameSite = sameSite, - Expires = sessionDto.Expires, + Secure = cookieSettings.Value.Secure, + SameSite = cookieSettings.Value.SameSite, + Expires = session.Expires, } ); } - + public static void DeleteSession(this HttpContext httpContext) { - var cookieSettings = httpContext.RequestServices.GetService>(); - - var secure = cookieSettings?.Value.Secure ?? true; - var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; - var domain = cookieSettings?.Value.Domain; - - httpContext.Response.Cookies.Delete("session", new CookieOptions - { - HttpOnly = true, - Domain = domain, - IsEssential = true, - Secure = secure, - SameSite = sameSite, - Expires = DateTimeOffset.UtcNow.AddDays(-1), - }); - httpContext.Response.Cookies.Delete("user", new CookieOptions - { - Domain = domain, - IsEssential = true, - Secure = secure, - SameSite = sameSite, - Expires = DateTimeOffset.UtcNow.AddDays(-1), - }); + var cookieSettings = httpContext.RequestServices.GetRequiredService< + IOptions + >(); + + httpContext.Response.Cookies.Delete( + "sid", + new CookieOptions + { + Path = "/", + HttpOnly = true, + Domain = cookieSettings.Value.Domain, + IsEssential = true, + Secure = cookieSettings.Value.Secure, + SameSite = cookieSettings.Value.SameSite, + Expires = DateTimeOffset.UtcNow.AddDays(-1), + } + ); + + httpContext.Response.Cookies.Delete( + "user", + new CookieOptions + { + Path = "/", + Domain = cookieSettings.Value.Domain, + IsEssential = true, + Secure = cookieSettings.Value.Secure, + SameSite = cookieSettings.Value.SameSite, + Expires = DateTimeOffset.UtcNow.AddDays(-1), + } + ); } } diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index 4d2db53..3e7dae6 100644 --- a/Femto.Common/ICurrentUserContext.cs +++ b/Femto.Common/ICurrentUserContext.cs @@ -5,4 +5,4 @@ public interface ICurrentUserContext CurrentUser? CurrentUser { get; } } -public record CurrentUser(Guid Id, string Username, string SessionId, string? RememberMeToken); +public record CurrentUser(Guid Id, string Username); diff --git a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs index d9aaf03..b86a7e4 100644 --- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs +++ b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs @@ -18,7 +18,12 @@ public class SaveChangesPipelineBehaviour( CancellationToken cancellationToken ) { + logger.LogDebug("handling request {Type}", typeof(TRequest).Name); var response = await next(cancellationToken); + + var hasChanges = context.ChangeTracker.HasChanges(); + logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges); + if (context.ChangeTracker.HasChanges()) { await context.EmitDomainEvents(logger, publisher, cancellationToken); diff --git a/Femto.Common/ScopeBinding.cs b/Femto.Common/ScopeBinding.cs index c78408e..da58589 100644 --- a/Femto.Common/ScopeBinding.cs +++ b/Femto.Common/ScopeBinding.cs @@ -3,19 +3,24 @@ using Microsoft.Extensions.Logging; namespace Femto.Common; + /// /// We use this to bind a scope to the request scope in the composition root /// Any scoped services provided by this subcontainer should be accessed via a ScopeBinding injected in the host /// /// -public class ScopeBinding(IServiceScope scope) : IDisposable - where T : notnull +public class ScopeBinding(IServiceScope scope) : IDisposable { - public T GetService() { - return scope.ServiceProvider.GetRequiredService(); + private IServiceScope Scope { get; } = scope; + + public T GetService() + where T : notnull + { + return this.Scope.ServiceProvider.GetRequiredService(); } - public void Dispose() { - scope.Dispose(); + public virtual void Dispose() + { + this.Scope.Dispose(); } } diff --git a/Femto.Database/Migrations/20250529101346_SessionsRework.sql b/Femto.Database/Migrations/20250529101346_SessionsRework.sql new file mode 100644 index 0000000..11cb84e --- /dev/null +++ b/Femto.Database/Migrations/20250529101346_SessionsRework.sql @@ -0,0 +1,13 @@ +-- Migration: addLongTermSessions +-- Created at: 29/05/2025 10:13:46 + +DROP TABLE authn.user_session; + +CREATE TABLE authn.long_term_session +( + id serial PRIMARY KEY, + selector varchar(16) NOT NULL, + hashed_verifier bytea NOT NULL, + expires timestamptz not null, + user_id uuid REFERENCES authn.user_identity (id) +); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index b9e6132..c78e923 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -3,6 +3,7 @@ using Femto.Common.Infrastructure; using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.Outbox; using Femto.Common.Integration; +using Femto.Modules.Auth.Application.Services; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Infrastructure; using MediatR; @@ -24,16 +25,25 @@ public static class AuthStartup ) { var hostBuilder = Host.CreateDefaultBuilder(); + hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus, loggerFactory) ); + var host = hostBuilder.Build(); - rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); - rootContainer.AddScoped(services => - services.GetRequiredService>().GetService() + rootContainer.AddKeyedScoped( + "AuthServiceScope", + (s, o) => + { + var scope = host.Services.CreateScope(); + return new ScopeBinding(scope); + } ); + rootContainer.ExposeScopedService(); + rootContainer.ExposeScopedService(); + rootContainer.AddHostedService(services => new AuthApplication(host)); eventBus.Subscribe( (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) @@ -66,7 +76,7 @@ public static class AuthStartup { options.WaitForJobsToComplete = true; }); - + // #endif services.AddOutbox(); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); @@ -74,8 +84,10 @@ public static class AuthStartup services.ConfigureDomainServices(); services.AddSingleton(publisher); + services.AddSingleton(); services.AddScoped(); + services.AddScoped(); } private static async Task EventSubscriber( @@ -107,3 +119,14 @@ public static class AuthStartup } } } + +internal static class AuthServiceCollectionExtensions +{ + public static void ExposeScopedService(this IServiceCollection container) + where T : class + { + container.AddScoped(services => + services.GetRequiredKeyedService("AuthServiceScope").GetService() + ); + } +} diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs new file mode 100644 index 0000000..430b0d3 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs @@ -0,0 +1,6 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; + +namespace Femto.Modules.Auth.Application.Interface.GetUserInfo; + +public record GetUserInfoCommand(Guid ForUser) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs new file mode 100644 index 0000000..72c5f20 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs @@ -0,0 +1,27 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Interface.GetUserInfo; + +internal class GetUserInfoCommandHandler(AuthContext context) + : ICommandHandler +{ + public async Task Handle( + GetUserInfoCommand request, + CancellationToken cancellationToken + ) + { + + var user = await context.Users.SingleOrDefaultAsync( + u => u.Id == request.ForUser, + cancellationToken + ); + + if (user is null) + return null; + + return new UserInfo(user); + } +} diff --git a/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs deleted file mode 100644 index 8252e2e..0000000 --- a/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Femto.Common.Domain; -using Femto.Modules.Auth.Application.Dto; - -namespace Femto.Modules.Auth.Application.Interface.Login; - -public record LoginCommand(string Username, string Password) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs deleted file mode 100644 index 74094b5..0000000 --- a/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Femto.Common.Domain; -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Models; -using Microsoft.EntityFrameworkCore; - -namespace Femto.Modules.Auth.Application.Interface.Login; - -internal class LoginCommandHandler(AuthContext context) - : ICommandHandler -{ - public async Task Handle(LoginCommand request, CancellationToken cancellationToken) - { - var user = await context.Users.SingleOrDefaultAsync( - u => u.Username == request.Username, - cancellationToken - ); - - if (user is null) - throw new DomainError("invalid credentials"); - - if (!user.HasPassword(request.Password)) - throw new DomainError("invalid credentials"); - - var session = Session.Strong(user.Id); - - await context.AddAsync(session, cancellationToken); - - return new(new SessionDto(session), new UserInfo(user)); - } -} diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs deleted file mode 100644 index 1406d66..0000000 --- a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Femto.Common; -using Femto.Common.Domain; -using Femto.Modules.Auth.Application.Dto; - -namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession; - -public record RefreshUserCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs deleted file mode 100644 index ab1222a..0000000 --- a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Femto.Common.Domain; -using Femto.Common.Infrastructure.DbConnection; -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Errors; -using Femto.Modules.Auth.Models; -using Microsoft.EntityFrameworkCore; - -namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession; - -internal class RefreshUserSessionCommandHandler(AuthContext context) - : ICommandHandler -{ - public async Task Handle( - RefreshUserCommand request, - CancellationToken cancellationToken - ) - { - if (request.CurrentUser.Id != request.ForUser) - throw new DomainError("invalid request"); - - var user = await context.Users.SingleOrDefaultAsync( - u => u.Id == request.ForUser, - cancellationToken - ); - - if (user is null) - throw new DomainError("invalid request"); - - var session = await context.Sessions.SingleOrDefaultAsync( - s => s.Id == request.CurrentUser.SessionId && s.Expires > DateTimeOffset.UtcNow, - cancellationToken - ); - - if (session is null) - throw new InvalidSessionError(); - - if (session.ShouldRefresh) - { - session = Session.Weak(user.Id); - await context.AddAsync(session, cancellationToken); - } - - return new(new SessionDto(session), new UserInfo(user)); - } -} diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs index dd3c186..87332cb 100644 --- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs +++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs @@ -3,4 +3,4 @@ using Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Interface.Register; -public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand; \ No newline at end of file +public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs index 6ad2285..7bb17be 100644 --- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs @@ -7,16 +7,12 @@ using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Application.Interface.Register; internal class RegisterCommandHandler(AuthContext context) - : ICommandHandler + : ICommandHandler { - public async Task Handle( - RegisterCommand request, - CancellationToken cancellationToken - ) + public async Task Handle(RegisterCommand request, CancellationToken cancellationToken) { - var now = DateTimeOffset.UtcNow; - + var code = await context .SignupCodes.Where(c => c.Code == request.SignupCode) .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) @@ -26,18 +22,22 @@ internal class RegisterCommandHandler(AuthContext context) if (code is null) throw new DomainError("invalid signup code"); + var usernameTaken = await context.Users.AnyAsync( + u => u.Username == request.Username, + cancellationToken + ); + + if (usernameTaken) + throw new DomainError("username taken"); + var user = new UserIdentity(request.Username); await context.AddAsync(user, cancellationToken); - + user.SetPassword(request.Password); - var session = Session.Strong(user.Id); - - await context.AddAsync(session, cancellationToken); - code.Redeem(user.Id); - return new(new SessionDto(session), new UserInfo(user)); + return new UserInfo(user); } } diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs deleted file mode 100644 index 5e5fbb2..0000000 --- a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Femto.Common.Domain; -using Femto.Modules.Auth.Application.Dto; - -namespace Femto.Modules.Auth.Application.Interface.ValidateSession; - -/// -/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future -/// -/// -public record ValidateSessionCommand(string SessionId, UserInfo User, string? RememberMe) : ICommand; diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs deleted file mode 100644 index 34b7c72..0000000 --- a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Femto.Common.Domain; -using Femto.Common.Infrastructure.DbConnection; -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Errors; -using Femto.Modules.Auth.Models; -using Microsoft.EntityFrameworkCore; - -namespace Femto.Modules.Auth.Application.Interface.ValidateSession; - -internal class ValidateSessionCommandHandler(AuthContext context) - : ICommandHandler -{ - public async Task Handle( - ValidateSessionCommand request, - CancellationToken cancellationToken - ) - { - try - { - return new ValidateSessionResult(await DoSessionValidation(request, cancellationToken)); - } - finally - { - await context.SaveChangesAsync(cancellationToken); - } - } - - private async Task DoSessionValidation( - ValidateSessionCommand request, - CancellationToken cancellationToken - ) - { - var now = DateTimeOffset.UtcNow; - - var session = await context.Sessions.SingleOrDefaultAsync( - s => s.Id == request.SessionId, - cancellationToken - ); - - var rememberMe = request.RememberMe; - - if (session is null) - { - (session, rememberMe) = await this.TryAuthenticateWithRememberMeToken( - request.User, - request.RememberMe, - cancellationToken - ); - } - - if (session.UserId != request.User.Id) - { - context.Remove(session); - throw new InvalidSessionError(); - } - - if (session.Expires < now) - { - context.Remove(session); - throw new InvalidSessionError(); - } - - if (session.ShouldRefresh) - { - context.Remove(session); - session = Session.Weak(session.UserId); - await context.AddAsync(session, cancellationToken); - } - - return new SessionDto(session, rememberMe); - } - - private async Task<(Session, string)> TryAuthenticateWithRememberMeToken( - UserInfo user, - string? rememberMeToken, - CancellationToken cancellationToken - ) - { - if (rememberMeToken is null) - throw new InvalidSessionError(); - - var parts = rememberMeToken.Split('.'); - if (parts.Length != 2) - throw new InvalidSessionError(); - - var selector = parts[0]; - var verifier = parts[1]; - - var longTermSession = await context.LongTermSessions.SingleOrDefaultAsync( - s => s.Selector == selector, - cancellationToken - ); - - if (longTermSession is null) - throw new InvalidSessionError(); - - context.Remove(longTermSession); - - if (!longTermSession.Validate(verifier)) - throw new InvalidSessionError(); - - var session = Session.Weak(user.Id); - await context.AddAsync(session, cancellationToken); - - (longTermSession, rememberMeToken) = LongTermSession.Create(user.Id); - await context.AddAsync(longTermSession, cancellationToken); - - return (session, rememberMeToken); - } -} diff --git a/Femto.Modules.Auth/Application/AuthModule.cs b/Femto.Modules.Auth/Application/Services/AuthModule.cs similarity index 83% rename from Femto.Modules.Auth/Application/AuthModule.cs rename to Femto.Modules.Auth/Application/Services/AuthModule.cs index d289d9e..f64d78f 100644 --- a/Femto.Modules.Auth/Application/AuthModule.cs +++ b/Femto.Modules.Auth/Application/Services/AuthModule.cs @@ -1,9 +1,7 @@ using Femto.Common.Domain; using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -namespace Femto.Modules.Auth.Application; +namespace Femto.Modules.Auth.Application.Services; internal class AuthModule(IMediator mediator) : IAuthModule { diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs new file mode 100644 index 0000000..4fb9323 --- /dev/null +++ b/Femto.Modules.Auth/Application/Services/AuthService.cs @@ -0,0 +1,79 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Infrastructure; +using Femto.Modules.Auth.Models; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Services; + +internal class AuthService(AuthContext context, SessionStorage storage) : IAuthService +{ + public async Task GetUserWithCredentials( + string username, + string password, + CancellationToken cancellationToken = default + ) + { + return await context + .Users.Where(u => u.Username == username) + .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList())) + .SingleOrDefaultAsync(cancellationToken); + } + + public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken) + { + return context + .Users.Where(u => u.Id == userId) + .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList())) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task CreateStrongSession(Guid userId) + { + var session = new Session(userId, true); + + await storage.AddSession(session); + + return session; + } + + public async Task CreateWeakSession(Guid userId) + { + var session = new Session(userId, false); + + await storage.AddSession(session); + + return session; + } + + public Task GetSession(string sessionId) + { + return storage.GetSession(sessionId); + } + + public async Task DeleteSession(string sessionId) + { + await storage.DeleteSession(sessionId); + } + + public async Task CreateLongTermSession(Guid userId, bool isStrong) + { + throw new NotImplementedException(); + } + + public async Task DeleteLongTermSession(string sessionId) + { + throw new NotImplementedException(); + } + + public async Task RefreshLongTermSession(string sessionId) + { + throw new NotImplementedException(); + } + + public async Task ValidateLongTermSession(string sessionId) + { + throw new NotImplementedException(); + } +} diff --git a/Femto.Modules.Auth/Application/IAuthModule.cs b/Femto.Modules.Auth/Application/Services/IAuthModule.cs similarity index 87% rename from Femto.Modules.Auth/Application/IAuthModule.cs rename to Femto.Modules.Auth/Application/Services/IAuthModule.cs index 4559161..df34366 100644 --- a/Femto.Modules.Auth/Application/IAuthModule.cs +++ b/Femto.Modules.Auth/Application/Services/IAuthModule.cs @@ -1,6 +1,6 @@ using Femto.Common.Domain; -namespace Femto.Modules.Auth.Application; +namespace Femto.Modules.Auth.Application.Services; public interface IAuthModule { diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs new file mode 100644 index 0000000..56fd423 --- /dev/null +++ b/Femto.Modules.Auth/Application/Services/IAuthService.cs @@ -0,0 +1,14 @@ +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Models; + +namespace Femto.Modules.Auth.Application.Services; + +public interface IAuthService +{ + public Task GetUserWithCredentials(string username, string password, CancellationToken cancellationToken = default); + public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken = default); + public Task CreateStrongSession(Guid userId); + public Task CreateWeakSession(Guid userId); + public Task GetSession(string sessionId); + public Task DeleteSession(string sessionId); +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs index 1b18c61..e4488e4 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -7,7 +7,6 @@ namespace Femto.Modules.Auth.Data; internal class AuthContext(DbContextOptions options) : DbContext(options), IOutboxContext { public virtual DbSet Users { get; set; } - public virtual DbSet Sessions { get; set; } public virtual DbSet SignupCodes { get; set; } public virtual DbSet LongTermSessions { get; set; } public virtual DbSet Outbox { get; set; } diff --git a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs index 2e5086b..1921451 100644 --- a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs +++ b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs @@ -19,8 +19,6 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration u.Sessions).WithOwner().HasForeignKey("user_id"); - builder .OwnsMany(u => u.Roles, entity => { diff --git a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs new file mode 100644 index 0000000..0e1b3dd --- /dev/null +++ b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs @@ -0,0 +1,30 @@ +using Femto.Modules.Auth.Models; +using Microsoft.Extensions.Caching.Memory; + +namespace Femto.Modules.Auth.Infrastructure; + +internal class SessionStorage(MemoryCacheOptions? options = null) +{ + private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions()); + + public Task GetSession(string id) + { + return Task.FromResult(this._storage.Get(id)); + } + + public Task AddSession(Session session) + { + using var entry = this._storage.CreateEntry(session.Id); + entry.Value = session; + entry.SetAbsoluteExpiration(session.Expires); + + return Task.CompletedTask; + } + + public Task DeleteSession(string id) + { + this._storage.Remove(id); + + return Task.CompletedTask; + } +} diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs new file mode 100644 index 0000000..c142fb8 --- /dev/null +++ b/Femto.Modules.Auth/Models/Session.cs @@ -0,0 +1,14 @@ +using static System.Security.Cryptography.RandomNumberGenerator; + +namespace Femto.Modules.Auth.Models; + +public class Session(Guid userId, bool isStrong) +{ + public string Id { get; } = Convert.ToBase64String(GetBytes(32)); + public Guid UserId { get; } = userId; + public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(15); + + public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5); + public bool IsStronglyAuthenticated { get; } = isStrong; + public bool IsExpired => this.Expires < DateTimeOffset.UtcNow; +} diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index 12cf73e..a7e3ddd 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -1,9 +1,6 @@ -using System.Text; -using System.Text.Unicode; using Femto.Common.Domain; using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Models.Events; -using Geralt; namespace Femto.Modules.Auth.Models; @@ -15,8 +12,6 @@ internal class UserIdentity : Entity public Password? Password { get; private set; } - public ICollection Sessions { get; private set; } = []; - public ICollection Roles { get; private set; } = []; private UserIdentity() { } diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs deleted file mode 100644 index 72a59ea..0000000 --- a/Femto.Modules.Auth/Models/UserSession.cs +++ /dev/null @@ -1,33 +0,0 @@ -using static System.Security.Cryptography.RandomNumberGenerator; - -namespace Femto.Modules.Auth.Models; - -internal class Session -{ - private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30); - private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5); - public string Id { get; private set; } - public Guid UserId { get; private set; } - public DateTimeOffset Expires { get; private set; } - public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer; - - // true if this session was created with remember me token - // otherwise false - // required to be true to do things like change password etc. - public bool IsStronglyAuthenticated { get; private set; } - public bool ShouldRefresh => this.Expires < DateTimeOffset.UtcNow + ExpiryBuffer; - - private Session() { } - - public static Session Strong(Guid userId) => new(userId, true); - - public static Session Weak(Guid userId) => new(userId, false); - - private Session(Guid userId, bool isStrong) - { - this.Id = Convert.ToBase64String(GetBytes(32)); - this.UserId = userId; - this.Expires = DateTimeOffset.UtcNow + SessionTimeout; - this.IsStronglyAuthenticated = isStrong; - } -} diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs index b134f4c..afd18b5 100644 --- a/Femto.Modules.Blog/Application/BlogStartup.cs +++ b/Femto.Modules.Blog/Application/BlogStartup.cs @@ -35,9 +35,13 @@ public static class BlogStartup rootContainer.AddHostedService(_ => new BlogApplication(host)); - rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); + rootContainer.AddKeyedScoped( + "BlogService", + (_, o) => new ScopeBinding(host.Services.CreateScope()) + ); + rootContainer.AddScoped(services => - services.GetRequiredService>().GetService() + services.GetRequiredKeyedService("BlogService").GetService() ); bus.Subscribe(