From 22e838849fbcf0ac0c154a17904cbb6cfed52d73 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 18 May 2025 23:03:25 +0200 Subject: [PATCH 01/66] create role for new users --- Femto.Modules.Auth/Models/UserIdentity.cs | 13 ++++++++----- Femto.Modules.Auth/Models/UserRole.cs | 7 +++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index 587859d..756be41 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Unicode; using Femto.Common.Domain; +using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Models.Events; using Geralt; @@ -15,7 +16,7 @@ internal class UserIdentity : Entity public Password? Password { get; private set; } public ICollection Sessions { get; private set; } = []; - + public ICollection Roles { get; private set; } = []; private UserIdentity() { } @@ -25,6 +26,8 @@ internal class UserIdentity : Entity this.Id = Guid.CreateVersion7(); this.Username = username; + this.Roles = [new UserRole(Role.User)]; + this.AddDomainEvent(new UserWasCreatedEvent(this)); } @@ -45,24 +48,24 @@ internal class UserIdentity : Entity { return false; } - + 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(); - + this.Sessions.Add(session); return session; diff --git a/Femto.Modules.Auth/Models/UserRole.cs b/Femto.Modules.Auth/Models/UserRole.cs index a00b52d..5a30d11 100644 --- a/Femto.Modules.Auth/Models/UserRole.cs +++ b/Femto.Modules.Auth/Models/UserRole.cs @@ -7,4 +7,11 @@ internal class UserRole public Guid UserId { get; set; } public Role Role { get; set; } + + private UserRole() {} + + public UserRole( Role role){ + Role = role; + + } } \ No newline at end of file From 4e24796a5d90c6908872badd9fdd7f63f7286758 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 18 May 2025 23:04:03 +0200 Subject: [PATCH 02/66] chore(release): v0.1.7 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index bca5080..b0e8f94 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.6 + 0.1.7 From 0d34774059f474d1354426665fc3c1fce5c93120 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 19 May 2025 09:23:20 +0200 Subject: [PATCH 03/66] refresh user --- .../Auth/SessionAuthenticationHandler.cs | 2 +- Femto.Api/Controllers/Auth/AuthController.cs | 57 +++++++++++++++++-- .../Controllers/Auth/RefreshUserResult.cs | 3 + .../Sessions/HttpContextSessionExtensions.cs | 6 ++ Femto.Common/ICurrentUserContext.cs | 2 +- Femto.Modules.Auth/Application/AuthModule.cs | 27 +++------ Femto.Modules.Auth/Application/AuthStartup.cs | 11 +++- .../Dto/RefreshUserSessionResult.cs | 3 + Femto.Modules.Auth/Application/Dto/Session.cs | 7 ++- .../RefreshUserSessionCommand.cs | 7 +++ .../RefreshUserSessionCommandHandler.cs | 32 +++++++++++ .../Application/ScopeBinding.cs | 16 ++++++ 12 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 Femto.Api/Controllers/Auth/RefreshUserResult.cs create mode 100644 Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs create mode 100644 Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs create mode 100644 Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs create mode 100644 Femto.Modules.Auth/Application/ScopeBinding.cs diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index ae282ff..c9c3685 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -46,7 +46,7 @@ internal class SessionAuthenticationHandler( var principal = new ClaimsPrincipal(identity); this.Context.SetSession(result.Session, cookieOptions.Value); - currentUserContext.CurrentUser = new CurrentUser(result.User.Id, result.User.Username); + currentUserContext.CurrentUser = new CurrentUser(result.User.Id, result.User.Username, result.Session.SessionId); return AuthenticateResult.Success( new AuthenticationTicket(principal, this.Scheme.Name) diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index faa178e..bb6af57 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -1,9 +1,12 @@ 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.Contracts; using Microsoft.AspNetCore.Authorization; @@ -14,8 +17,11 @@ namespace Femto.Api.Controllers.Auth; [ApiController] [Route("auth")] -public class AuthController(IAuthModule authModule, IOptions cookieSettings) - : ControllerBase +public class AuthController( + IAuthModule authModule, + IOptions cookieSettings, + ICurrentUserContext currentUserContext +) : ControllerBase { [HttpPost("login")] public async Task> Login([FromBody] LoginRequest request) @@ -24,7 +30,11 @@ public class AuthController(IAuthModule authModule, IOptions coo HttpContext.SetSession(result.Session, cookieSettings.Value); - return new LoginResponse(result.User.Id, result.User.Username, result.User.Roles.Any(r => r == Role.SuperUser)); + return new LoginResponse( + result.User.Id, + result.User.Username, + result.User.Roles.Any(r => r == Role.SuperUser) + ); } [HttpPost("register")] @@ -36,16 +46,49 @@ public class AuthController(IAuthModule authModule, IOptions coo HttpContext.SetSession(result.Session, cookieSettings.Value); - return new RegisterResponse(result.User.Id, result.User.Username, result.User.Roles.Any(r => r == Role.SuperUser)); + return new RegisterResponse( + result.User.Id, + result.User.Username, + result.User.Roles.Any(r => r == Role.SuperUser) + ); } [HttpDelete("session")] public async Task DeleteSession() { - HttpContext.Response.Cookies.Delete("session"); + HttpContext.DeleteSession(); return Ok(new { }); } + [HttpGet("user/{userId}")] + [Authorize] + public async Task> RefreshUser( + Guid userId, + CancellationToken cancellationToken + ) + { + var currentUser = currentUserContext.CurrentUser!; + + try + { + var result = await authModule.Command( + new RefreshUserSessionCommand(userId, currentUser), + cancellationToken + ); + + return new RefreshUserResult( + result.User.Id, + result.User.Username, + result.User.Roles.Any(r => r == Role.SuperUser) + ); + } + catch (Exception) + { + HttpContext.DeleteSession(); + return this.Forbid(); + } + } + [HttpPost("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task CreateSignupCode( @@ -63,7 +106,9 @@ public class AuthController(IAuthModule authModule, IOptions coo [HttpGet("signup-codes")] [Authorize(Roles = "SuperUser")] - public async Task> ListSignupCodes(CancellationToken cancellationToken) + public async Task> ListSignupCodes( + CancellationToken cancellationToken + ) { var codes = await authModule.Query(new GetSignupCodesQuery(), cancellationToken); diff --git a/Femto.Api/Controllers/Auth/RefreshUserResult.cs b/Femto.Api/Controllers/Auth/RefreshUserResult.cs new file mode 100644 index 0000000..8dbdee8 --- /dev/null +++ b/Femto.Api/Controllers/Auth/RefreshUserResult.cs @@ -0,0 +1,3 @@ +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 2b176ed..832fd93 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -34,4 +34,10 @@ internal static class HttpContextSessionExtensions } ); } + + public static void DeleteSession(this HttpContext httpContext) + { + httpContext.Response.Cookies.Delete("session"); + httpContext.Response.Cookies.Delete("hasSession"); + } } diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index 3e7dae6..a7233e0 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); +public record CurrentUser(Guid Id, string Username, string SessionId); diff --git a/Femto.Modules.Auth/Application/AuthModule.cs b/Femto.Modules.Auth/Application/AuthModule.cs index aa85984..d289d9e 100644 --- a/Femto.Modules.Auth/Application/AuthModule.cs +++ b/Femto.Modules.Auth/Application/AuthModule.cs @@ -5,31 +5,18 @@ using Microsoft.Extensions.Hosting; namespace Femto.Modules.Auth.Application; -internal class AuthModule(IHost host) : IAuthModule +internal class AuthModule(IMediator mediator) : IAuthModule { - public async Task Command(ICommand command, CancellationToken cancellationToken = default) - { - using var scope = host.Services.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); + public async Task Command(ICommand command, CancellationToken cancellationToken = default) => await mediator.Send(command, cancellationToken); - } public async Task Command( ICommand command, CancellationToken cancellationToken = default - ) - { - using var scope = host.Services.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - var response = await mediator.Send(command, cancellationToken); - return response; - } + ) => await mediator.Send(command, cancellationToken); - public async Task Query(IQuery query, CancellationToken cancellationToken = default) - { - using var scope = host.Services.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - var response = await mediator.Send(query, cancellationToken); - return response; - } + public async Task Query( + IQuery query, + CancellationToken cancellationToken = default + ) => await mediator.Send(query, cancellationToken); } diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 48bf182..c80004d 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -21,7 +21,12 @@ public static class AuthStartup var hostBuilder = Host.CreateDefaultBuilder(); hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus)); var host = hostBuilder.Build(); - rootContainer.AddScoped(_ => new AuthModule(host)); + + rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); + + rootContainer.AddScoped(services => + services.GetRequiredService().GetService()); + rootContainer.AddHostedService(services => new AuthApplication(host)); eventBus.Subscribe((evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)); } @@ -50,11 +55,11 @@ public static class AuthStartup services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); - - services.ConfigureDomainServices(); services.AddSingleton(publisher); + + services.AddScoped(); } private static async Task EventSubscriber( diff --git a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs new file mode 100644 index 0000000..ac1bbc3 --- /dev/null +++ b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs @@ -0,0 +1,3 @@ +namespace Femto.Modules.Auth.Application.Dto; + +public record RefreshUserSessionResult(Session Session, UserInfo User); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Dto/Session.cs b/Femto.Modules.Auth/Application/Dto/Session.cs index 297b4d8..9e87ca8 100644 --- a/Femto.Modules.Auth/Application/Dto/Session.cs +++ b/Femto.Modules.Auth/Application/Dto/Session.cs @@ -2,4 +2,9 @@ using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application.Dto; -public record Session(string SessionId, DateTimeOffset Expires); \ No newline at end of file +public record Session(string SessionId, DateTimeOffset Expires) +{ + internal Session(UserSession session) : this(session.Id, session.Expires) + { + } +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs new file mode 100644 index 0000000..f04fa82 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs @@ -0,0 +1,7 @@ +using Femto.Common; +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; + +namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession; + +public record RefreshUserSessionCommand(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 new file mode 100644 index 0000000..f0c6dc1 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs @@ -0,0 +1,32 @@ +using Femto.Common.Domain; +using Femto.Common.Infrastructure.DbConnection; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession; + +internal class RefreshUserSessionCommandHandler(AuthContext context) + : ICommandHandler +{ + public async Task Handle( + RefreshUserSessionCommand 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 = user.PossiblyRefreshSession(request.CurrentUser.SessionId); + + return new(new Session(session), new UserInfo(user)); + } +} diff --git a/Femto.Modules.Auth/Application/ScopeBinding.cs b/Femto.Modules.Auth/Application/ScopeBinding.cs new file mode 100644 index 0000000..4a6419f --- /dev/null +++ b/Femto.Modules.Auth/Application/ScopeBinding.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Femto.Modules.Auth.Application; + + +/// +/// 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 +{ + public T GetService() where T : notnull => scope.ServiceProvider.GetRequiredService(); + + public void Dispose() => scope.Dispose(); +} \ No newline at end of file From a6fef1929c3fe851810d2df0865e0cb7bfd65424 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 09:20:50 +0200 Subject: [PATCH 04/66] add username and userid cookies --- .../Auth/SessionAuthenticationHandler.cs | 11 +++++---- Femto.Api/Controllers/Auth/AuthController.cs | 4 ++-- .../Sessions/HttpContextSessionExtensions.cs | 23 ++++++++++++++++--- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index c9c3685..01ec20e 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -38,15 +38,18 @@ internal class SessionAuthenticationHandler( }; claims.AddRange( - result.User.Roles - .Select(role => new Claim(ClaimTypes.Role, role.ToString())) + result.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.Session, cookieOptions.Value); - currentUserContext.CurrentUser = new CurrentUser(result.User.Id, result.User.Username, result.Session.SessionId); + this.Context.SetSession(result.Session, result.User, cookieOptions.Value); + currentUserContext.CurrentUser = new CurrentUser( + result.User.Id, + result.User.Username, + result.Session.SessionId + ); return AuthenticateResult.Success( new AuthenticationTicket(principal, this.Scheme.Name) diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index bb6af57..953926d 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -28,7 +28,7 @@ public class AuthController( { var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); - HttpContext.SetSession(result.Session, cookieSettings.Value); + HttpContext.SetSession(result.Session, result.User, cookieSettings.Value); return new LoginResponse( result.User.Id, @@ -44,7 +44,7 @@ public class AuthController( new RegisterCommand(request.Username, request.Password, request.SignupCode) ); - HttpContext.SetSession(result.Session, cookieSettings.Value); + HttpContext.SetSession(result.Session, result.User, cookieSettings.Value); return new RegisterResponse( result.User.Id, diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index 832fd93..4de3e92 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -8,24 +8,41 @@ internal static class HttpContextSessionExtensions public static void SetSession( this HttpContext httpContext, Session session, + UserInfo user, CookieSettings cookieSettings ) { + + var secure = cookieSettings.Secure; + var sameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified; + var expires = session.Expires; + httpContext.Response.Cookies.Append( "session", session.SessionId, new CookieOptions { HttpOnly = true, + Secure = secure, + SameSite = sameSite, + Expires = expires, + } + ); + + httpContext.Response.Cookies.Append( + "uid", + user.Id.ToString(), + new CookieOptions + { Secure = cookieSettings.Secure, SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified, Expires = session.Expires, } ); - + httpContext.Response.Cookies.Append( - "hasSession", - "true", + "uname", + user.Username, new CookieOptions { Secure = cookieSettings.Secure, From a30811cb84a40617502b54d599db6a69e25a3bca Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 09:21:34 +0200 Subject: [PATCH 05/66] chore(release): v0.1.8 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b0e8f94..f164902 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.7 + 0.1.8 From 71584d91bef074c3867927e85d96ab7b856d4c6f Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 10:06:56 +0200 Subject: [PATCH 06/66] store whole user in session actually --- .../Sessions/HttpContextSessionExtensions.cs | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index 4de3e92..865467e 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using Femto.Api.Auth; using Femto.Modules.Auth.Application.Dto; @@ -12,11 +14,10 @@ internal static class HttpContextSessionExtensions CookieSettings cookieSettings ) { - var secure = cookieSettings.Secure; var sameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified; var expires = session.Expires; - + httpContext.Response.Cookies.Append( "session", session.SessionId, @@ -28,21 +29,17 @@ internal static class HttpContextSessionExtensions Expires = expires, } ); - + httpContext.Response.Cookies.Append( - "uid", - user.Id.ToString(), - new CookieOptions - { - Secure = cookieSettings.Secure, - SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified, - Expires = session.Expires, - } - ); - - httpContext.Response.Cookies.Append( - "uname", - user.Username, + "user", + JsonSerializer.Serialize( + user, + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + } + ), new CookieOptions { Secure = cookieSettings.Secure, @@ -55,6 +52,6 @@ internal static class HttpContextSessionExtensions public static void DeleteSession(this HttpContext httpContext) { httpContext.Response.Cookies.Delete("session"); - httpContext.Response.Cookies.Delete("hasSession"); + httpContext.Response.Cookies.Delete("user"); } } From 435055cdb4bcc8340818955d4aac614316b71155 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 10:07:27 +0200 Subject: [PATCH 07/66] chore(release): v0.1.9 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index f164902..0feedef 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.8 + 0.1.9 From edc41639b556a1ea356f35750c0702a63397bca6 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 10:11:32 +0200 Subject: [PATCH 08/66] suppress authcontext logs --- Femto.Modules.Auth/Application/AuthStartup.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index c80004d..195aa23 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -39,8 +39,8 @@ public static class AuthStartup { builder.UseNpgsql(connectionString); builder.UseSnakeCaseNamingConvention(); - // var loggerFactory = LoggerFactory.Create(b => { }); - // builder.UseLoggerFactory(loggerFactory); + var loggerFactory = LoggerFactory.Create(b => { }); + builder.UseLoggerFactory(loggerFactory); // #if DEBUG // builder.EnableSensitiveDataLogging(); // #endif From 007d53c9e69fcdccab7775c1d3b1364a98c6910b Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 10:12:01 +0200 Subject: [PATCH 09/66] chore(release): v0.1.10 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0feedef..e9b2585 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.9 + 0.1.10 From 6dfa49bd0170d8532c28b223d1e2943dfd7eb4c7 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 10:15:30 +0200 Subject: [PATCH 10/66] chore(release): v0.1.11 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e9b2585..9340429 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.10 + 0.1.11 From 88b8aa7429377bf2aee1b2df08a3ba470544c2fd Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:02:54 +0200 Subject: [PATCH 11/66] secure cookies fix --- Femto.Api/Auth/CookieSettings.cs | 1 - Femto.Api/Auth/SessionAuthenticationHandler.cs | 2 +- Femto.Api/Controllers/Auth/AuthController.cs | 4 ++-- Femto.Api/Sessions/HttpContextSessionExtensions.cs | 13 +++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Femto.Api/Auth/CookieSettings.cs b/Femto.Api/Auth/CookieSettings.cs index 449a2cf..79f32a3 100644 --- a/Femto.Api/Auth/CookieSettings.cs +++ b/Femto.Api/Auth/CookieSettings.cs @@ -2,6 +2,5 @@ namespace Femto.Api.Auth; public class CookieSettings { - public bool SameSite { get; set; } public bool Secure { get; set; } } \ No newline at end of file diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 01ec20e..d019808 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -44,7 +44,7 @@ internal class SessionAuthenticationHandler( var identity = new ClaimsIdentity(claims, this.Scheme.Name); var principal = new ClaimsPrincipal(identity); - this.Context.SetSession(result.Session, result.User, cookieOptions.Value); + this.Context.SetSession(result.Session, result.User); currentUserContext.CurrentUser = new CurrentUser( result.User.Id, result.User.Username, diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 953926d..cf17500 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -28,7 +28,7 @@ public class AuthController( { var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); - HttpContext.SetSession(result.Session, result.User, cookieSettings.Value); + HttpContext.SetSession(result.Session, result.User); return new LoginResponse( result.User.Id, @@ -44,7 +44,7 @@ public class AuthController( new RegisterCommand(request.Username, request.Password, request.SignupCode) ); - HttpContext.SetSession(result.Session, result.User, cookieSettings.Value); + HttpContext.SetSession(result.Session, result.User); return new RegisterResponse( result.User.Id, diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index 865467e..b0a9b6e 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Femto.Api.Auth; using Femto.Modules.Auth.Application.Dto; +using Microsoft.Extensions.Options; namespace Femto.Api.Sessions; @@ -10,12 +11,12 @@ internal static class HttpContextSessionExtensions public static void SetSession( this HttpContext httpContext, Session session, - UserInfo user, - CookieSettings cookieSettings + UserInfo user ) { - var secure = cookieSettings.Secure; - var sameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified; + var cookieSettings = httpContext.RequestServices.GetService>(); + var secure = cookieSettings?.Value.Secure ?? true; + var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; var expires = session.Expires; httpContext.Response.Cookies.Append( @@ -42,8 +43,8 @@ internal static class HttpContextSessionExtensions ), new CookieOptions { - Secure = cookieSettings.Secure, - SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified, + Secure = secure, + SameSite = sameSite, Expires = session.Expires, } ); From 3aa88e848387c20bc198b6e1c552b099ceae5ad7 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:10:35 +0200 Subject: [PATCH 12/66] chore(release): v0.1.12 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9340429..e547ff1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.11 + 0.1.12 From 6853e013ea9addbcba86ebbfa8938fc49f62ca53 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:21:36 +0200 Subject: [PATCH 13/66] add more params --- Femto.Api/Auth/CookieSettings.cs | 1 + Femto.Api/Sessions/HttpContextSessionExtensions.cs | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Femto.Api/Auth/CookieSettings.cs b/Femto.Api/Auth/CookieSettings.cs index 79f32a3..fedfd27 100644 --- a/Femto.Api/Auth/CookieSettings.cs +++ b/Femto.Api/Auth/CookieSettings.cs @@ -3,4 +3,5 @@ namespace Femto.Api.Auth; public class CookieSettings { public bool Secure { get; set; } + public string? Domain { get; set; } } \ No newline at end of file diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index b0a9b6e..713f7da 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -8,15 +8,12 @@ namespace Femto.Api.Sessions; internal static class HttpContextSessionExtensions { - public static void SetSession( - this HttpContext httpContext, - Session session, - UserInfo user - ) + public static void SetSession(this HttpContext httpContext, Session session, UserInfo user) { var cookieSettings = httpContext.RequestServices.GetService>(); var secure = cookieSettings?.Value.Secure ?? true; var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; + var domain = cookieSettings?.Value.Domain; var expires = session.Expires; httpContext.Response.Cookies.Append( @@ -24,6 +21,8 @@ internal static class HttpContextSessionExtensions session.SessionId, new CookieOptions { + IsEssential = true, + Domain = domain, HttpOnly = true, Secure = secure, SameSite = sameSite, @@ -43,6 +42,8 @@ internal static class HttpContextSessionExtensions ), new CookieOptions { + Domain = domain, + IsEssential = true, Secure = secure, SameSite = sameSite, Expires = session.Expires, From 271eb114fe7563a533a5b560b1f7601f578bb8af Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:22:03 +0200 Subject: [PATCH 14/66] chore(release): v0.1.13 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e547ff1..64d179e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.12 + 0.1.13 From c675aeac7bcd62ec2bb894f99e2e2d36a642998d Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:37:31 +0200 Subject: [PATCH 15/66] debug log cookies --- Femto.Api/Sessions/HttpContextSessionExtensions.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index 713f7da..e017441 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -11,11 +11,21 @@ internal static class HttpContextSessionExtensions public static void SetSession(this HttpContext httpContext, Session session, UserInfo user) { var cookieSettings = httpContext.RequestServices.GetService>(); + var logger = httpContext.RequestServices.GetRequiredService>(); + var secure = cookieSettings?.Value.Secure ?? true; var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; var domain = cookieSettings?.Value.Domain; var expires = session.Expires; + logger.LogDebug( + "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", + secure, + sameSite, + domain, + expires + ); + httpContext.Response.Cookies.Append( "session", session.SessionId, From dc001b114cc9a4bc85317a269678dc003c8859f7 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:39:33 +0200 Subject: [PATCH 16/66] chore(release): v0.1.14 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 64d179e..7543294 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.13 + 0.1.14 From 55e35e36723f8f2fc3ec311fbafad331777dd087 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:50:59 +0200 Subject: [PATCH 17/66] update version commit message --- scripts/bump-build-push.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bump-build-push.sh b/scripts/bump-build-push.sh index e0e0b87..3a3e71a 100755 --- a/scripts/bump-build-push.sh +++ b/scripts/bump-build-push.sh @@ -52,7 +52,7 @@ docker push "$FULL_IMAGE:latest" # Step 5: Commit version bump & tag echo "✅ Committing and tagging version bump..." git add "$PROPS_FILE" -git commit -m "chore(release): v$NEW_VERSION" +git commit -m "v$NEW_VERSION" git tag "v$NEW_VERSION" git push origin main git push origin "v$NEW_VERSION" From a4170f0c94e6f31c55a3987f6aa3c37148e476a2 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:54:27 +0200 Subject: [PATCH 18/66] fix log --- Femto.Api/Sessions/HttpContextSessionExtensions.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index e017441..ae6d6f0 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -10,9 +10,11 @@ internal static class HttpContextSessionExtensions { public static void SetSession(this HttpContext httpContext, Session session, UserInfo user) { + var loggerFactory = httpContext.RequestServices.GetRequiredService(); + var logger = loggerFactory.CreateLogger("Femto"); + var cookieSettings = httpContext.RequestServices.GetService>(); - var logger = httpContext.RequestServices.GetRequiredService>(); - + var secure = cookieSettings?.Value.Secure ?? true; var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; var domain = cookieSettings?.Value.Domain; From 8a0834aaa5bdf0a149b4b72b813e6e3e5612cf82 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:55:27 +0200 Subject: [PATCH 19/66] v0.1.15 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7543294..129b7c7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.14 + 0.1.15 From 2e4a55f15484b3f70fa7c23db1e40fd61d65cbaa Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:58:18 +0200 Subject: [PATCH 20/66] inject logger --- Femto.Api/Auth/SessionAuthenticationHandler.cs | 5 ++--- Femto.Api/Controllers/Auth/AuthController.cs | 7 ++++--- Femto.Api/Sessions/HttpContextSessionExtensions.cs | 5 +---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index d019808..e71481d 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -16,8 +16,7 @@ internal class SessionAuthenticationHandler( ILoggerFactory logger, UrlEncoder encoder, IAuthModule authModule, - CurrentUserContext currentUserContext, - IOptions cookieOptions + CurrentUserContext currentUserContext ) : AuthenticationHandler(options, logger, encoder) { protected override async Task HandleAuthenticateAsync() @@ -44,7 +43,7 @@ internal class SessionAuthenticationHandler( var identity = new ClaimsIdentity(claims, this.Scheme.Name); var principal = new ClaimsPrincipal(identity); - this.Context.SetSession(result.Session, result.User); + this.Context.SetSession(result.Session, result.User, Logger); currentUserContext.CurrentUser = new CurrentUser( result.User.Id, result.User.Username, diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index cf17500..b567d1d 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -20,7 +20,8 @@ namespace Femto.Api.Controllers.Auth; public class AuthController( IAuthModule authModule, IOptions cookieSettings, - ICurrentUserContext currentUserContext + ICurrentUserContext currentUserContext, + ILogger logger ) : ControllerBase { [HttpPost("login")] @@ -28,7 +29,7 @@ public class AuthController( { var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); - HttpContext.SetSession(result.Session, result.User); + HttpContext.SetSession(result.Session, result.User, logger); return new LoginResponse( result.User.Id, @@ -44,7 +45,7 @@ public class AuthController( new RegisterCommand(request.Username, request.Password, request.SignupCode) ); - HttpContext.SetSession(result.Session, result.User); + HttpContext.SetSession(result.Session, result.User, logger); return new RegisterResponse( result.User.Id, diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index ae6d6f0..fb7c3af 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -8,11 +8,8 @@ namespace Femto.Api.Sessions; internal static class HttpContextSessionExtensions { - public static void SetSession(this HttpContext httpContext, Session session, UserInfo user) + public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger) { - var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger("Femto"); - var cookieSettings = httpContext.RequestServices.GetService>(); var secure = cookieSettings?.Value.Secure ?? true; From b2f34089daa2fb64c17f88ac65c9fa171b7547d5 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 11:58:44 +0200 Subject: [PATCH 21/66] v0.1.16 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 129b7c7..e99aeeb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.15 + 0.1.16 From a7c6192c17b9c983a409c63bfd8559d9bd7221ed Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 12:02:41 +0200 Subject: [PATCH 22/66] add logging :facepalm: --- Femto.Api/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 29b75e4..0d30d12 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -21,6 +21,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); +builder.Services.AddLogging(); +builder.Logging.AddConsole(); +builder.Services.AddHttpLogging(); + var connectionString = builder.Configuration.GetConnectionString("Database"); if (connectionString is null) throw new Exception("no database connection string found"); From c8665949783c9622fa9a00829bf821e0019a595f Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 12:03:12 +0200 Subject: [PATCH 23/66] v0.1.17 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e99aeeb..c389ad7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.16 + 0.1.17 From 061c75cda1f9ae05ef743bedf92926041f7df389 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 12:10:24 +0200 Subject: [PATCH 24/66] losing my mind --- Femto.Api/Sessions/HttpContextSessionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index fb7c3af..ec41df2 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -17,7 +17,7 @@ internal static class HttpContextSessionExtensions var domain = cookieSettings?.Value.Domain; var expires = session.Expires; - logger.LogDebug( + logger.LogInformation( "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", secure, sameSite, From ca6d5dbd27655b5cacdef4af8f3265b22a2aabf6 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 12:11:50 +0200 Subject: [PATCH 25/66] v0.1.18 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index c389ad7..79d64c7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.17 + 0.1.18 From a3bff3f042de0f1523ed3ee3260771ef845f00e8 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 12:15:46 +0200 Subject: [PATCH 26/66] maybe --- Femto.Api/Program.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 0d30d12..469d3e0 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -21,9 +21,11 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); -builder.Services.AddLogging(); +builder.Logging.ClearProviders(); builder.Logging.AddConsole(); -builder.Services.AddHttpLogging(); +builder.Logging.AddDebug(); +builder.Logging.SetMinimumLevel(LogLevel.Information); + var connectionString = builder.Configuration.GetConnectionString("Database"); if (connectionString is null) From 693004bcb94175ad53053ffe10a22b561f8de6a9 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 12:16:12 +0200 Subject: [PATCH 27/66] v0.1.19 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 79d64c7..725845b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.18 + 0.1.19 From 0235a4c52b54d5c8fa12cf0d3b91c9e15b47dd75 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 23:36:14 +0200 Subject: [PATCH 28/66] set cookie options when deleting a cookie? --- .../Sessions/HttpContextSessionExtensions.cs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index ec41df2..f8f93b9 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -62,7 +62,28 @@ internal static class HttpContextSessionExtensions public static void DeleteSession(this HttpContext httpContext) { - httpContext.Response.Cookies.Delete("session"); - httpContext.Response.Cookies.Delete("user"); + 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), + }); } } From b93115d787f4ec75955a2c3735ed85df424924fd Mon Sep 17 00:00:00 2001 From: john Date: Tue, 20 May 2025 23:36:46 +0200 Subject: [PATCH 29/66] v0.1.20 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 725845b..8248e8b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.19 + 0.1.20 From cd078ca6437caad55dd6ab7aa91c67509eb346f0 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 00:19:49 +0200 Subject: [PATCH 30/66] fix injectionses --- .../Controllers/Posts/PostsController.cs | 12 ++++- Femto.Api/CurrentUserContext.cs | 22 ++++++++- Femto.Api/Program.cs | 17 ++++--- Femto.Api/appsettings.json | 4 +- Femto.Common/ICurrentUserContext.cs | 5 +- .../ScopeBinding.cs | 17 ++++--- Femto.Modules.Auth/Application/AuthStartup.cs | 46 +++++++++++++------ Femto.Modules.Blog/Application/BlogModule.cs | 16 ++----- Femto.Modules.Blog/Application/BlogStartup.cs | 24 +++++++--- .../Commands/DeletePost/DeletePostCommand.cs | 5 ++ Femto.Modules.Blog/Application/IBlogModule.cs | 6 +-- 11 files changed, 119 insertions(+), 55 deletions(-) rename {Femto.Modules.Auth/Application => Femto.Common}/ScopeBinding.cs (50%) create mode 100644 Femto.Modules.Blog/Application/Commands/DeletePost/DeletePostCommand.cs diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 9eeeafc..ce2d75f 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -2,6 +2,7 @@ using Femto.Api.Controllers.Posts.Dto; using Femto.Common; using Femto.Modules.Blog.Application; using Femto.Modules.Blog.Application.Commands.CreatePost; +using Femto.Modules.Blog.Application.Commands.DeletePost; using Femto.Modules.Blog.Application.Queries.GetPosts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,7 +19,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current CancellationToken cancellationToken ) { - var res = await blogModule.PostQuery( + var res = await blogModule.Query( new GetPostsQuery(currentUserContext.CurrentUser?.Id) { From = searchParams.From, @@ -48,7 +49,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current CancellationToken cancellationToken ) { - var guid = await blogModule.PostCommand( + var guid = await blogModule.Command( new CreatePostCommand( req.AuthorId, req.Content, @@ -70,4 +71,11 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current return new CreatePostResponse(guid); } + + [HttpDelete("{postId}")] + [Authorize] + public async Task DeletePost(Guid postId, CancellationToken cancellationToken) + { + await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser.Id), cancellationToken); + } } diff --git a/Femto.Api/CurrentUserContext.cs b/Femto.Api/CurrentUserContext.cs index 1754dbe..01d0d5c 100644 --- a/Femto.Api/CurrentUserContext.cs +++ b/Femto.Api/CurrentUserContext.cs @@ -4,5 +4,23 @@ namespace Femto.Api; internal class CurrentUserContext : ICurrentUserContext { - public CurrentUser? CurrentUser { get; set; } -} \ No newline at end of file + private CurrentUser? _currentUser; + + public CurrentUser? tryGetUserCurrentUser() => this._currentUser; + + public bool HasUser => this._currentUser is not null; + + public CurrentUser CurrentUser + { + get + { + if (_currentUser is null) + throw new InvalidOperationException( + "don't access current user if not authenticated" + ); + + return _currentUser; + } + set => _currentUser = value; + } +} diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 469d3e0..08a40b8 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -21,11 +21,16 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); -builder.Logging.AddDebug(); -builder.Logging.SetMinimumLevel(LogLevel.Information); +var loggerFactory = LoggerFactory.Create(b => +{ + b.SetMinimumLevel(LogLevel.Information) + .AddConfiguration(builder.Configuration.GetSection("Logging")) + .AddConsole() + .AddDebug(); +}); +builder.Services.AddSingleton(loggerFactory); +builder.Services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)); var connectionString = builder.Configuration.GetConnectionString("Database"); if (connectionString is null) @@ -39,9 +44,9 @@ if (blobStorageRoot is null) var eventBus = new EventBus(Channel.CreateUnbounded()); builder.Services.AddHostedService(_ => eventBus); -builder.Services.InitializeBlogModule(connectionString, eventBus); +builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); -builder.Services.InitializeAuthenticationModule(connectionString, eventBus); +builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory); builder.Services.AddScoped(); builder.Services.AddScoped(s => s.GetRequiredService()); diff --git a/Femto.Api/appsettings.json b/Femto.Api/appsettings.json index 51120b2..5c949c0 100644 --- a/Femto.Api/appsettings.json +++ b/Femto.Api/appsettings.json @@ -3,7 +3,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Femto": "Debug", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "AllowedHosts": "*" diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index a7233e0..0cb0207 100644 --- a/Femto.Common/ICurrentUserContext.cs +++ b/Femto.Common/ICurrentUserContext.cs @@ -2,7 +2,10 @@ namespace Femto.Common; public interface ICurrentUserContext { - CurrentUser? CurrentUser { get; } + CurrentUser? tryGetUserCurrentUser(); + + bool HasUser { get; } + CurrentUser CurrentUser { get; } } public record CurrentUser(Guid Id, string Username, string SessionId); diff --git a/Femto.Modules.Auth/Application/ScopeBinding.cs b/Femto.Common/ScopeBinding.cs similarity index 50% rename from Femto.Modules.Auth/Application/ScopeBinding.cs rename to Femto.Common/ScopeBinding.cs index 4a6419f..c78408e 100644 --- a/Femto.Modules.Auth/Application/ScopeBinding.cs +++ b/Femto.Common/ScopeBinding.cs @@ -1,16 +1,21 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; -namespace Femto.Modules.Auth.Application; - +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 +public class ScopeBinding(IServiceScope scope) : IDisposable + where T : notnull { - public T GetService() where T : notnull => scope.ServiceProvider.GetRequiredService(); + public T GetService() { + return scope.ServiceProvider.GetRequiredService(); + } - public void Dispose() => scope.Dispose(); -} \ No newline at end of file + public void Dispose() { + scope.Dispose(); + } +} diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 195aa23..b9e6132 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -1,3 +1,4 @@ +using Femto.Common; using Femto.Common.Infrastructure; using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.Outbox; @@ -15,37 +16,52 @@ namespace Femto.Modules.Auth.Application; public static class AuthStartup { - public static void InitializeAuthenticationModule(this IServiceCollection rootContainer, - string connectionString, IEventBus eventBus) + public static void InitializeAuthenticationModule( + this IServiceCollection rootContainer, + string connectionString, + IEventBus eventBus, + ILoggerFactory loggerFactory + ) { var hostBuilder = Host.CreateDefaultBuilder(); - hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus)); + 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.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); + rootContainer.AddScoped(services => + services.GetRequiredService>().GetService() + ); + rootContainer.AddHostedService(services => new AuthApplication(host)); - eventBus.Subscribe((evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)); + eventBus.Subscribe( + (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) + ); } - private static void ConfigureServices(IServiceCollection services, string connectionString, IEventPublisher publisher) + private static void ConfigureServices( + IServiceCollection services, + string connectionString, + IEventPublisher publisher, + ILoggerFactory loggerFactory + ) { services.AddTransient(_ => new DbConnectionFactory(connectionString)); - + services.AddDbContext(builder => { builder.UseNpgsql(connectionString); builder.UseSnakeCaseNamingConvention(); - var loggerFactory = LoggerFactory.Create(b => { }); builder.UseLoggerFactory(loggerFactory); // #if DEBUG // builder.EnableSensitiveDataLogging(); // #endif }); + services.AddSingleton(loggerFactory); + services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)); + services.AddQuartzHostedService(options => { options.WaitForJobsToComplete = true; @@ -58,10 +74,10 @@ public static class AuthStartup services.ConfigureDomainServices(); services.AddSingleton(publisher); - + services.AddScoped(); } - + private static async Task EventSubscriber( IEvent evt, IServiceProvider provider, diff --git a/Femto.Modules.Blog/Application/BlogModule.cs b/Femto.Modules.Blog/Application/BlogModule.cs index d96228c..c293ceb 100644 --- a/Femto.Modules.Blog/Application/BlogModule.cs +++ b/Femto.Modules.Blog/Application/BlogModule.cs @@ -1,37 +1,29 @@ using Femto.Common.Domain; using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; namespace Femto.Modules.Blog.Application; -internal class BlogModule(IHost host) : IBlogModule +internal class BlogModule(IMediator mediator) : IBlogModule { - public async Task PostCommand(ICommand command, CancellationToken cancellationToken = default) + public async Task Command(ICommand command, CancellationToken cancellationToken = default) { - using var scope = host.Services.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); await mediator.Send(command, cancellationToken); } - public async Task PostCommand( + public async Task Command( ICommand command, CancellationToken cancellationToken = default ) { - using var scope = host.Services.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); var response = await mediator.Send(command, cancellationToken); return response; } - public async Task PostQuery( + public async Task Query( IQuery query, CancellationToken cancellationToken = default ) { - using var scope = host.Services.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); var response = await mediator.Send(query, cancellationToken); return response; } diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs index 000e9bd..b134f4c 100644 --- a/Femto.Modules.Blog/Application/BlogStartup.cs +++ b/Femto.Modules.Blog/Application/BlogStartup.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using Femto.Common; using Femto.Common.Infrastructure; using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.Outbox; @@ -20,20 +21,24 @@ public static class BlogStartup public static void InitializeBlogModule( this IServiceCollection rootContainer, string connectionString, - IEventBus bus + IEventBus bus, + ILoggerFactory loggerFactory ) { var hostBuilder = Host.CreateDefaultBuilder(); hostBuilder.ConfigureServices(services => - ConfigureServices(services, connectionString, bus) + ConfigureServices(services, connectionString, bus, loggerFactory) ); var host = hostBuilder.Build(); - rootContainer.AddHostedService(services => new BlogApplication(host)); + rootContainer.AddHostedService(_ => new BlogApplication(host)); - rootContainer.AddScoped(_ => new BlogModule(host)); + rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); + rootContainer.AddScoped(services => + services.GetRequiredService>().GetService() + ); bus.Subscribe( (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) @@ -43,7 +48,8 @@ public static class BlogStartup private static void ConfigureServices( this IServiceCollection services, string connectionString, - IEventPublisher publisher + IEventPublisher publisher, + ILoggerFactory loggerFactory ) { services.AddTransient(_ => new DbConnectionFactory(connectionString)); @@ -58,10 +64,12 @@ public static class BlogStartup } ); builder.UseSnakeCaseNamingConvention(); - var loggerFactory = LoggerFactory.Create(b => { }); builder.UseLoggerFactory(loggerFactory); }); - + + services.AddSingleton(loggerFactory); + services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)); + services.AddOutbox(); services.AddMediatR(c => @@ -71,6 +79,8 @@ public static class BlogStartup services.ConfigureDomainServices(); + services.AddScoped(); + services.AddSingleton(publisher); } diff --git a/Femto.Modules.Blog/Application/Commands/DeletePost/DeletePostCommand.cs b/Femto.Modules.Blog/Application/Commands/DeletePost/DeletePostCommand.cs new file mode 100644 index 0000000..099ed49 --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/DeletePost/DeletePostCommand.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Application.Commands.DeletePost; + +public record DeletePostCommand(Guid PostId, Guid InitiatingUserId) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/IBlogModule.cs b/Femto.Modules.Blog/Application/IBlogModule.cs index 941e1e2..083b184 100644 --- a/Femto.Modules.Blog/Application/IBlogModule.cs +++ b/Femto.Modules.Blog/Application/IBlogModule.cs @@ -4,14 +4,14 @@ namespace Femto.Modules.Blog.Application; public interface IBlogModule { - Task PostCommand(ICommand command, CancellationToken cancellationToken = default); + Task Command(ICommand command, CancellationToken cancellationToken = default); - Task PostCommand( + Task Command( ICommand command, CancellationToken cancellationToken = default ); - Task PostQuery( + Task Query( IQuery query, CancellationToken cancellationToken = default ); From 3f1c6280ba4d5fe80d29785cc91c9a93d011735d Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 00:20:23 +0200 Subject: [PATCH 31/66] v0.1.21 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 8248e8b..6bd076c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.20 + 0.1.21 From dc999fd7cc56d0a861a8f9619abb24d37a3382b0 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:52:12 +0200 Subject: [PATCH 32/66] fix loading public posts --- Femto.Api/Controllers/Posts/PostsController.cs | 2 +- Femto.Api/CurrentUserContext.cs | 2 +- Femto.Common/ICurrentUserContext.cs | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index ce2d75f..2b1e830 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -76,6 +76,6 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current [Authorize] public async Task DeletePost(Guid postId, CancellationToken cancellationToken) { - await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser.Id), cancellationToken); + await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), cancellationToken); } } diff --git a/Femto.Api/CurrentUserContext.cs b/Femto.Api/CurrentUserContext.cs index 01d0d5c..1164eb0 100644 --- a/Femto.Api/CurrentUserContext.cs +++ b/Femto.Api/CurrentUserContext.cs @@ -6,7 +6,7 @@ internal class CurrentUserContext : ICurrentUserContext { private CurrentUser? _currentUser; - public CurrentUser? tryGetUserCurrentUser() => this._currentUser; + public CurrentUser? TryGetUserCurrentUser() => this._currentUser; public bool HasUser => this._currentUser is not null; diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index 0cb0207..f09e417 100644 --- a/Femto.Common/ICurrentUserContext.cs +++ b/Femto.Common/ICurrentUserContext.cs @@ -2,10 +2,8 @@ namespace Femto.Common; public interface ICurrentUserContext { - CurrentUser? tryGetUserCurrentUser(); - bool HasUser { get; } - CurrentUser CurrentUser { get; } + CurrentUser? CurrentUser { get; } } public record CurrentUser(Guid Id, string Username, string SessionId); From e15399a85b48939365ef2f1853c794ac8dd0cb68 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:52:49 +0200 Subject: [PATCH 33/66] v0.1.22 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6bd076c..a6a5912 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.21 + 0.1.22 From d31998b8effd77289a861b97efd0c934d21a8249 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:55:04 +0200 Subject: [PATCH 34/66] fix --- Femto.Api/CurrentUserContext.cs | 20 +------------------- Femto.Common/ICurrentUserContext.cs | 1 - 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/Femto.Api/CurrentUserContext.cs b/Femto.Api/CurrentUserContext.cs index 1164eb0..1455406 100644 --- a/Femto.Api/CurrentUserContext.cs +++ b/Femto.Api/CurrentUserContext.cs @@ -4,23 +4,5 @@ namespace Femto.Api; internal class CurrentUserContext : ICurrentUserContext { - private CurrentUser? _currentUser; - - public CurrentUser? TryGetUserCurrentUser() => this._currentUser; - - public bool HasUser => this._currentUser is not null; - - public CurrentUser CurrentUser - { - get - { - if (_currentUser is null) - throw new InvalidOperationException( - "don't access current user if not authenticated" - ); - - return _currentUser; - } - set => _currentUser = value; - } + public CurrentUser? CurrentUser { get; } } diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index f09e417..a7233e0 100644 --- a/Femto.Common/ICurrentUserContext.cs +++ b/Femto.Common/ICurrentUserContext.cs @@ -2,7 +2,6 @@ namespace Femto.Common; public interface ICurrentUserContext { - bool HasUser { get; } CurrentUser? CurrentUser { get; } } From 104fbe7e6d187571d13be94fe887cd415d961e93 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:55:55 +0200 Subject: [PATCH 35/66] fix --- Femto.Api/CurrentUserContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Femto.Api/CurrentUserContext.cs b/Femto.Api/CurrentUserContext.cs index 1455406..6608a50 100644 --- a/Femto.Api/CurrentUserContext.cs +++ b/Femto.Api/CurrentUserContext.cs @@ -4,5 +4,5 @@ namespace Femto.Api; internal class CurrentUserContext : ICurrentUserContext { - public CurrentUser? CurrentUser { get; } + public CurrentUser? CurrentUser { get; set; } } From cc0d697c9cf27ee6703cac82bb77939abe585367 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:56:23 +0200 Subject: [PATCH 36/66] v0.1.23 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index a6a5912..3f77b94 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.22 + 0.1.23 From 5d7cc522016dea8a033f9d949bf7450e2511dbfb Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 20:37:07 +0200 Subject: [PATCH 37/66] change test user password --- Femto.Database/Seed/TestDataSeeder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index f180b8f..47d22aa 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -14,7 +14,7 @@ public static class TestDataSeeder var id = Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0"); var username = "johnbotris"; var salt = new byte[32]; - var password = "hunter2"u8; + var password = "password"u8; var hashInput = new byte[password.Length + salt.Length]; password.CopyTo(hashInput); salt.CopyTo(hashInput, password.Length); From c0dc2fdcb92b6eefba22618b8129ec70398140db Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:06:49 +0200 Subject: [PATCH 38/66] update session stuff --- Femto.Api/Auth/CookieSettings.cs | 1 + Femto.Api/Properties/launchSettings.json | 2 +- Femto.Api/Sessions/HttpContextSessionExtensions.cs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Femto.Api/Auth/CookieSettings.cs b/Femto.Api/Auth/CookieSettings.cs index fedfd27..a98a5cb 100644 --- a/Femto.Api/Auth/CookieSettings.cs +++ b/Femto.Api/Auth/CookieSettings.cs @@ -2,6 +2,7 @@ namespace Femto.Api.Auth; public class CookieSettings { + public SameSiteMode SameSite { get; set; } public bool Secure { get; set; } public string? Domain { get; set; } } \ No newline at end of file diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json index 237dc27..9a9026a 100644 --- a/Femto.Api/Properties/launchSettings.json +++ b/Femto.Api/Properties/launchSettings.json @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://0.0.0.0:7269;http://0.0.0.0:5181", + "applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index f8f93b9..f5e5d25 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -13,7 +13,7 @@ internal static class HttpContextSessionExtensions var cookieSettings = httpContext.RequestServices.GetService>(); var secure = cookieSettings?.Value.Secure ?? true; - var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; + var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict; var domain = cookieSettings?.Value.Domain; var expires = session.Expires; From def7767b94d8ad057a2610324f077c3ec6a57f02 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:12:08 +0200 Subject: [PATCH 39/66] v0.1.24 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 3f77b94..0e31fcf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.23 + 0.1.24 From 8ad4302ec8324b985cacc2179423de50f09f751e Mon Sep 17 00:00:00 2001 From: john Date: Tue, 27 May 2025 00:28:10 +0200 Subject: [PATCH 40/66] wip --- .../Posts/Dto/GetAllPublicPostsResponse.cs | 2 +- Femto.Api/Controllers/Posts/Dto/PostDto.cs | 1 + .../Controllers/Posts/Dto/PostReactionDto.cs | 3 + .../Controllers/Posts/PostsController.cs | 5 +- .../20250526220032_AddReactions.sql | 12 + .../Configurations/PostConfiguration.cs | 1 + .../Queries/GetPosts/Dto/PostDto.cs | 8 +- .../Queries/GetPosts/Dto/PostReactionDto.cs | 3 + Femto.Modules.Blog/Domain/Posts/Post.cs | 9 + .../Domain/Posts/PostReaction.cs | 17 + Femto.Modules.Blog/Emoji/GetRandomEmoji.cs | 18 + Femto.Modules.Blog/Emoji/ListOfEmoji.cs | 3789 +++++++++++++++++ scripts/generate-emojis.js | 38 + 13 files changed, 3902 insertions(+), 4 deletions(-) create mode 100644 Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs create mode 100644 Femto.Database/Migrations/20250526220032_AddReactions.sql create mode 100644 Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs create mode 100644 Femto.Modules.Blog/Domain/Posts/PostReaction.cs create mode 100644 Femto.Modules.Blog/Emoji/GetRandomEmoji.cs create mode 100644 Femto.Modules.Blog/Emoji/ListOfEmoji.cs create mode 100644 scripts/generate-emojis.js diff --git a/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs index ededad1..7efdeee 100644 --- a/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs +++ b/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs @@ -3,4 +3,4 @@ using JetBrains.Annotations; namespace Femto.Api.Controllers.Posts.Dto; [PublicAPI] -public record GetAllPublicPostsResponse(IEnumerable Posts, Guid? Next); \ No newline at end of file +public record LoadPostsResponse(IEnumerable Posts, Guid? Next); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index c48014a..75ec296 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -8,5 +8,6 @@ public record PostDto( Guid PostId, string Content, IEnumerable Media, + IEnumerable Reactions, DateTimeOffset CreatedAt ); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs new file mode 100644 index 0000000..98a19b1 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record PostReactionDto(Guid ReactionId, string Emoji, int Count, bool DidReact); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 2b1e830..99da1ad 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -14,7 +14,7 @@ namespace Femto.Api.Controllers.Posts; public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase { [HttpGet] - public async Task> LoadPosts( + public async Task> LoadPosts( [FromQuery] GetPublicPostsSearchParams searchParams, CancellationToken cancellationToken ) @@ -30,12 +30,13 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); - return new GetAllPublicPostsResponse( + return new LoadPostsResponse( res.Posts.Select(p => new PostDto( new PostAuthorDto(p.Author.AuthorId, p.Author.Username), p.PostId, p.Text, p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), + p.Reactions?.Select(r => new PostReactionDto(r.ReactionId, r.Emoji, r.Count, r.DidReact)), p.CreatedAt )), res.Next diff --git a/Femto.Database/Migrations/20250526220032_AddReactions.sql b/Femto.Database/Migrations/20250526220032_AddReactions.sql new file mode 100644 index 0000000..8c18e2e --- /dev/null +++ b/Femto.Database/Migrations/20250526220032_AddReactions.sql @@ -0,0 +1,12 @@ +-- Migration: AddReactions +-- Created at: 26/05/2025 22:00:32 + +ALTER TABLE blog.post +ADD COLUMN possible_reactions TEXT; + +CREATE TABLE blog.post_reaction +( + post_id uuid REFERENCES blog.post(id), + author_id uuid REFERENCES blog.author(id), + emoji text not null +); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index 8cb2a64..936c10e 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -10,5 +10,6 @@ internal class PostConfiguration : IEntityTypeConfiguration { table.ToTable("post"); table.OwnsMany(post => post.Media).WithOwner(); + table.OwnsMany(post => post.Reactions).WithOwner(); } } diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs index 584d1f5..3f20873 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -1,3 +1,9 @@ namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; -public record PostDto(Guid PostId, string Text, IList Media, DateTimeOffset CreatedAt, PostAuthorDto Author); \ No newline at end of file +public record PostDto( + Guid PostId, + string Text, + IList Media, + DateTimeOffset CreatedAt, + PostAuthorDto Author, + IList Reactions); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs new file mode 100644 index 0000000..9ea33dd --- /dev/null +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs @@ -0,0 +1,3 @@ +namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; + +public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index 65f90d6..0279dcb 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -1,5 +1,6 @@ using Femto.Common.Domain; using Femto.Modules.Blog.Domain.Posts.Events; +using Femto.Modules.Blog.Emoji; namespace Femto.Modules.Blog.Domain.Posts; @@ -9,6 +10,8 @@ internal class Post : Entity public Guid AuthorId { get; private set; } public string Content { get; private set; } = null!; public IList Media { get; private set; } + + public IList Reactions { get; private set; } = []; public bool IsPublic { get; set; } private Post() { } @@ -19,7 +22,13 @@ internal class Post : Entity this.AuthorId = authorId; this.Content = content; this.Media = media; + + this.Reactions = AllEmoji + .GetRandomEmoji(5) + .Select(emoji => new PostReaction(emoji, 0)) + .ToList(); this.AddDomainEvent(new PostCreated(this)); } + } diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs new file mode 100644 index 0000000..beb24b8 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs @@ -0,0 +1,17 @@ +namespace Femto.Modules.Blog.Domain.Posts; + +public class PostReaction +{ + public Guid Id { get; private set; } + public string Emoji { get; private set; } = null!; + public int Count { get; private set; } + + public PostReaction(string emoji, int count) + { + this.Id = Guid.CreateVersion7(); + this.Emoji = emoji; + this.Count = count; + } + + private PostReaction() { } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Emoji/GetRandomEmoji.cs b/Femto.Modules.Blog/Emoji/GetRandomEmoji.cs new file mode 100644 index 0000000..8fa7928 --- /dev/null +++ b/Femto.Modules.Blog/Emoji/GetRandomEmoji.cs @@ -0,0 +1,18 @@ +namespace Femto.Modules.Blog.Emoji; + +internal static partial class AllEmoji +{ + public static IList GetRandomEmoji(int count) => new Random().TakeRandomly(Emojis).Distinct().Take(count).ToList(); +} + +internal static class RandomExtensions +{ + public static IEnumerable TakeRandomly(this Random rand, ICollection collection) + { + while (true) + { + var idx = rand.Next(collection.Count); + yield return collection.ElementAt(idx); + } + } +} diff --git a/Femto.Modules.Blog/Emoji/ListOfEmoji.cs b/Femto.Modules.Blog/Emoji/ListOfEmoji.cs new file mode 100644 index 0000000..f3ad8b5 --- /dev/null +++ b/Femto.Modules.Blog/Emoji/ListOfEmoji.cs @@ -0,0 +1,3789 @@ +namespace Femto.Modules.Blog.Emoji; + +internal static partial class AllEmoji +{ + public static readonly string[] Emojis = + [ + "😀", + "😃", + "😄", + "😁", + "😆", + "😅", + "🤣", + "😂", + "🙂", + "🙃", + "🫠", + "😉", + "😊", + "😇", + "🥰", + "😍", + "🤩", + "😘", + "😗", + "☺️", + "😚", + "😙", + "🥲", + "😋", + "😛", + "😜", + "🤪", + "😝", + "🤑", + "🤗", + "🤭", + "🫢", + "🫣", + "🤫", + "🤔", + "🫡", + "🤐", + "🤨", + "😐", + "😑", + "😶", + "🫥", + "😶‍🌫️", + "😏", + "😒", + "🙄", + "😬", + "😮‍💨", + "🤥", + "🫨", + "🙂‍↔️", + "🙂‍↕️", + "😌", + "😔", + "😪", + "🤤", + "😴", + "🫩", + "😷", + "🤒", + "🤕", + "🤢", + "🤮", + "🤧", + "🥵", + "🥶", + "🥴", + "😵", + "😵‍💫", + "🤯", + "🤠", + "🥳", + "🥸", + "😎", + "🤓", + "🧐", + "😕", + "🫤", + "😟", + "🙁", + "☹️", + "😮", + "😯", + "😲", + "😳", + "🥺", + "🥹", + "😦", + "😧", + "😨", + "😰", + "😥", + "😢", + "😭", + "😱", + "😖", + "😣", + "😞", + "😓", + "😩", + "😫", + "🥱", + "😤", + "😡", + "😠", + "🤬", + "😈", + "👿", + "💀", + "☠️", + "💩", + "🤡", + "👹", + "👺", + "👻", + "👽", + "👾", + "🤖", + "😺", + "😸", + "😹", + "😻", + "😼", + "😽", + "🙀", + "😿", + "😾", + "🙈", + "🙉", + "🙊", + "💌", + "💘", + "💝", + "💖", + "💗", + "💓", + "💞", + "💕", + "💟", + "❣️", + "💔", + "❤️‍🔥", + "❤️‍🩹", + "❤️", + "🩷", + "🧡", + "💛", + "💚", + "💙", + "🩵", + "💜", + "🤎", + "🖤", + "🩶", + "🤍", + "💋", + "💯", + "💢", + "💥", + "💫", + "💦", + "💨", + "🕳️", + "💬", + "👁️‍🗨️", + "🗨️", + "🗯️", + "💭", + "💤", + "👋", + "👋🏻", + "👋🏼", + "👋🏽", + "👋🏾", + "👋🏿", + "🤚", + "🤚🏻", + "🤚🏼", + "🤚🏽", + "🤚🏾", + "🤚🏿", + "🖐️", + "🖐🏻", + "🖐🏼", + "🖐🏽", + "🖐🏾", + "🖐🏿", + "✋", + "✋🏻", + "✋🏼", + "✋🏽", + "✋🏾", + "✋🏿", + "🖖", + "🖖🏻", + "🖖🏼", + "🖖🏽", + "🖖🏾", + "🖖🏿", + "🫱", + "🫱🏻", + "🫱🏼", + "🫱🏽", + "🫱🏾", + "🫱🏿", + "🫲", + "🫲🏻", + "🫲🏼", + "🫲🏽", + "🫲🏾", + "🫲🏿", + "🫳", + "🫳🏻", + "🫳🏼", + "🫳🏽", + "🫳🏾", + "🫳🏿", + "🫴", + "🫴🏻", + "🫴🏼", + "🫴🏽", + "🫴🏾", + "🫴🏿", + "🫷", + "🫷🏻", + "🫷🏼", + "🫷🏽", + "🫷🏾", + "🫷🏿", + "🫸", + "🫸🏻", + "🫸🏼", + "🫸🏽", + "🫸🏾", + "🫸🏿", + "👌", + "👌🏻", + "👌🏼", + "👌🏽", + "👌🏾", + "👌🏿", + "🤌", + "🤌🏻", + "🤌🏼", + "🤌🏽", + "🤌🏾", + "🤌🏿", + "🤏", + "🤏🏻", + "🤏🏼", + "🤏🏽", + "🤏🏾", + "🤏🏿", + "✌️", + "✌🏻", + "✌🏼", + "✌🏽", + "✌🏾", + "✌🏿", + "🤞", + "🤞🏻", + "🤞🏼", + "🤞🏽", + "🤞🏾", + "🤞🏿", + "🫰", + "🫰🏻", + "🫰🏼", + "🫰🏽", + "🫰🏾", + "🫰🏿", + "🤟", + "🤟🏻", + "🤟🏼", + "🤟🏽", + "🤟🏾", + "🤟🏿", + "🤘", + "🤘🏻", + "🤘🏼", + "🤘🏽", + "🤘🏾", + "🤘🏿", + "🤙", + "🤙🏻", + "🤙🏼", + "🤙🏽", + "🤙🏾", + "🤙🏿", + "👈", + "👈🏻", + "👈🏼", + "👈🏽", + "👈🏾", + "👈🏿", + "👉", + "👉🏻", + "👉🏼", + "👉🏽", + "👉🏾", + "👉🏿", + "👆", + "👆🏻", + "👆🏼", + "👆🏽", + "👆🏾", + "👆🏿", + "🖕", + "🖕🏻", + "🖕🏼", + "🖕🏽", + "🖕🏾", + "🖕🏿", + "👇", + "👇🏻", + "👇🏼", + "👇🏽", + "👇🏾", + "👇🏿", + "☝️", + "☝🏻", + "☝🏼", + "☝🏽", + "☝🏾", + "☝🏿", + "🫵", + "🫵🏻", + "🫵🏼", + "🫵🏽", + "🫵🏾", + "🫵🏿", + "👍", + "👍🏻", + "👍🏼", + "👍🏽", + "👍🏾", + "👍🏿", + "👎", + "👎🏻", + "👎🏼", + "👎🏽", + "👎🏾", + "👎🏿", + "✊", + "✊🏻", + "✊🏼", + "✊🏽", + "✊🏾", + "✊🏿", + "👊", + "👊🏻", + "👊🏼", + "👊🏽", + "👊🏾", + "👊🏿", + "🤛", + "🤛🏻", + "🤛🏼", + "🤛🏽", + "🤛🏾", + "🤛🏿", + "🤜", + "🤜🏻", + "🤜🏼", + "🤜🏽", + "🤜🏾", + "🤜🏿", + "👏", + "👏🏻", + "👏🏼", + "👏🏽", + "👏🏾", + "👏🏿", + "🙌", + "🙌🏻", + "🙌🏼", + "🙌🏽", + "🙌🏾", + "🙌🏿", + "🫶", + "🫶🏻", + "🫶🏼", + "🫶🏽", + "🫶🏾", + "🫶🏿", + "👐", + "👐🏻", + "👐🏼", + "👐🏽", + "👐🏾", + "👐🏿", + "🤲", + "🤲🏻", + "🤲🏼", + "🤲🏽", + "🤲🏾", + "🤲🏿", + "🤝", + "🤝🏻", + "🤝🏼", + "🤝🏽", + "🤝🏾", + "🤝🏿", + "🫱🏻‍🫲🏼", + "🫱🏻‍🫲🏽", + "🫱🏻‍🫲🏾", + "🫱🏻‍🫲🏿", + "🫱🏼‍🫲🏻", + "🫱🏼‍🫲🏽", + "🫱🏼‍🫲🏾", + "🫱🏼‍🫲🏿", + "🫱🏽‍🫲🏻", + "🫱🏽‍🫲🏼", + "🫱🏽‍🫲🏾", + "🫱🏽‍🫲🏿", + "🫱🏾‍🫲🏻", + "🫱🏾‍🫲🏼", + "🫱🏾‍🫲🏽", + "🫱🏾‍🫲🏿", + "🫱🏿‍🫲🏻", + "🫱🏿‍🫲🏼", + "🫱🏿‍🫲🏽", + "🫱🏿‍🫲🏾", + "🙏", + "🙏🏻", + "🙏🏼", + "🙏🏽", + "🙏🏾", + "🙏🏿", + "✍️", + "✍🏻", + "✍🏼", + "✍🏽", + "✍🏾", + "✍🏿", + "💅", + "💅🏻", + "💅🏼", + "💅🏽", + "💅🏾", + "💅🏿", + "🤳", + "🤳🏻", + "🤳🏼", + "🤳🏽", + "🤳🏾", + "🤳🏿", + "💪", + "💪🏻", + "💪🏼", + "💪🏽", + "💪🏾", + "💪🏿", + "🦾", + "🦿", + "🦵", + "🦵🏻", + "🦵🏼", + "🦵🏽", + "🦵🏾", + "🦵🏿", + "🦶", + "🦶🏻", + "🦶🏼", + "🦶🏽", + "🦶🏾", + "🦶🏿", + "👂", + "👂🏻", + "👂🏼", + "👂🏽", + "👂🏾", + "👂🏿", + "🦻", + "🦻🏻", + "🦻🏼", + "🦻🏽", + "🦻🏾", + "🦻🏿", + "👃", + "👃🏻", + "👃🏼", + "👃🏽", + "👃🏾", + "👃🏿", + "🧠", + "🫀", + "🫁", + "🦷", + "🦴", + "👀", + "👁️", + "👅", + "👄", + "🫦", + "👶", + "👶🏻", + "👶🏼", + "👶🏽", + "👶🏾", + "👶🏿", + "🧒", + "🧒🏻", + "🧒🏼", + "🧒🏽", + "🧒🏾", + "🧒🏿", + "👦", + "👦🏻", + "👦🏼", + "👦🏽", + "👦🏾", + "👦🏿", + "👧", + "👧🏻", + "👧🏼", + "👧🏽", + "👧🏾", + "👧🏿", + "🧑", + "🧑🏻", + "🧑🏼", + "🧑🏽", + "🧑🏾", + "🧑🏿", + "👱", + "👱🏻", + "👱🏼", + "👱🏽", + "👱🏾", + "👱🏿", + "👨", + "👨🏻", + "👨🏼", + "👨🏽", + "👨🏾", + "👨🏿", + "🧔", + "🧔🏻", + "🧔🏼", + "🧔🏽", + "🧔🏾", + "🧔🏿", + "🧔‍♂️", + "🧔🏻‍♂️", + "🧔🏼‍♂️", + "🧔🏽‍♂️", + "🧔🏾‍♂️", + "🧔🏿‍♂️", + "🧔‍♀️", + "🧔🏻‍♀️", + "🧔🏼‍♀️", + "🧔🏽‍♀️", + "🧔🏾‍♀️", + "🧔🏿‍♀️", + "👨‍🦰", + "👨🏻‍🦰", + "👨🏼‍🦰", + "👨🏽‍🦰", + "👨🏾‍🦰", + "👨🏿‍🦰", + "👨‍🦱", + "👨🏻‍🦱", + "👨🏼‍🦱", + "👨🏽‍🦱", + "👨🏾‍🦱", + "👨🏿‍🦱", + "👨‍🦳", + "👨🏻‍🦳", + "👨🏼‍🦳", + "👨🏽‍🦳", + "👨🏾‍🦳", + "👨🏿‍🦳", + "👨‍🦲", + "👨🏻‍🦲", + "👨🏼‍🦲", + "👨🏽‍🦲", + "👨🏾‍🦲", + "👨🏿‍🦲", + "👩", + "👩🏻", + "👩🏼", + "👩🏽", + "👩🏾", + "👩🏿", + "👩‍🦰", + "👩🏻‍🦰", + "👩🏼‍🦰", + "👩🏽‍🦰", + "👩🏾‍🦰", + "👩🏿‍🦰", + "🧑‍🦰", + "🧑🏻‍🦰", + "🧑🏼‍🦰", + "🧑🏽‍🦰", + "🧑🏾‍🦰", + "🧑🏿‍🦰", + "👩‍🦱", + "👩🏻‍🦱", + "👩🏼‍🦱", + "👩🏽‍🦱", + "👩🏾‍🦱", + "👩🏿‍🦱", + "🧑‍🦱", + "🧑🏻‍🦱", + "🧑🏼‍🦱", + "🧑🏽‍🦱", + "🧑🏾‍🦱", + "🧑🏿‍🦱", + "👩‍🦳", + "👩🏻‍🦳", + "👩🏼‍🦳", + "👩🏽‍🦳", + "👩🏾‍🦳", + "👩🏿‍🦳", + "🧑‍🦳", + "🧑🏻‍🦳", + "🧑🏼‍🦳", + "🧑🏽‍🦳", + "🧑🏾‍🦳", + "🧑🏿‍🦳", + "👩‍🦲", + "👩🏻‍🦲", + "👩🏼‍🦲", + "👩🏽‍🦲", + "👩🏾‍🦲", + "👩🏿‍🦲", + "🧑‍🦲", + "🧑🏻‍🦲", + "🧑🏼‍🦲", + "🧑🏽‍🦲", + "🧑🏾‍🦲", + "🧑🏿‍🦲", + "👱‍♀️", + "👱🏻‍♀️", + "👱🏼‍♀️", + "👱🏽‍♀️", + "👱🏾‍♀️", + "👱🏿‍♀️", + "👱‍♂️", + "👱🏻‍♂️", + "👱🏼‍♂️", + "👱🏽‍♂️", + "👱🏾‍♂️", + "👱🏿‍♂️", + "🧓", + "🧓🏻", + "🧓🏼", + "🧓🏽", + "🧓🏾", + "🧓🏿", + "👴", + "👴🏻", + "👴🏼", + "👴🏽", + "👴🏾", + "👴🏿", + "👵", + "👵🏻", + "👵🏼", + "👵🏽", + "👵🏾", + "👵🏿", + "🙍", + "🙍🏻", + "🙍🏼", + "🙍🏽", + "🙍🏾", + "🙍🏿", + "🙍‍♂️", + "🙍🏻‍♂️", + "🙍🏼‍♂️", + "🙍🏽‍♂️", + "🙍🏾‍♂️", + "🙍🏿‍♂️", + "🙍‍♀️", + "🙍🏻‍♀️", + "🙍🏼‍♀️", + "🙍🏽‍♀️", + "🙍🏾‍♀️", + "🙍🏿‍♀️", + "🙎", + "🙎🏻", + "🙎🏼", + "🙎🏽", + "🙎🏾", + "🙎🏿", + "🙎‍♂️", + "🙎🏻‍♂️", + "🙎🏼‍♂️", + "🙎🏽‍♂️", + "🙎🏾‍♂️", + "🙎🏿‍♂️", + "🙎‍♀️", + "🙎🏻‍♀️", + "🙎🏼‍♀️", + "🙎🏽‍♀️", + "🙎🏾‍♀️", + "🙎🏿‍♀️", + "🙅", + "🙅🏻", + "🙅🏼", + "🙅🏽", + "🙅🏾", + "🙅🏿", + "🙅‍♂️", + "🙅🏻‍♂️", + "🙅🏼‍♂️", + "🙅🏽‍♂️", + "🙅🏾‍♂️", + "🙅🏿‍♂️", + "🙅‍♀️", + "🙅🏻‍♀️", + "🙅🏼‍♀️", + "🙅🏽‍♀️", + "🙅🏾‍♀️", + "🙅🏿‍♀️", + "🙆", + "🙆🏻", + "🙆🏼", + "🙆🏽", + "🙆🏾", + "🙆🏿", + "🙆‍♂️", + "🙆🏻‍♂️", + "🙆🏼‍♂️", + "🙆🏽‍♂️", + "🙆🏾‍♂️", + "🙆🏿‍♂️", + "🙆‍♀️", + "🙆🏻‍♀️", + "🙆🏼‍♀️", + "🙆🏽‍♀️", + "🙆🏾‍♀️", + "🙆🏿‍♀️", + "💁", + "💁🏻", + "💁🏼", + "💁🏽", + "💁🏾", + "💁🏿", + "💁‍♂️", + "💁🏻‍♂️", + "💁🏼‍♂️", + "💁🏽‍♂️", + "💁🏾‍♂️", + "💁🏿‍♂️", + "💁‍♀️", + "💁🏻‍♀️", + "💁🏼‍♀️", + "💁🏽‍♀️", + "💁🏾‍♀️", + "💁🏿‍♀️", + "🙋", + "🙋🏻", + "🙋🏼", + "🙋🏽", + "🙋🏾", + "🙋🏿", + "🙋‍♂️", + "🙋🏻‍♂️", + "🙋🏼‍♂️", + "🙋🏽‍♂️", + "🙋🏾‍♂️", + "🙋🏿‍♂️", + "🙋‍♀️", + "🙋🏻‍♀️", + "🙋🏼‍♀️", + "🙋🏽‍♀️", + "🙋🏾‍♀️", + "🙋🏿‍♀️", + "🧏", + "🧏🏻", + "🧏🏼", + "🧏🏽", + "🧏🏾", + "🧏🏿", + "🧏‍♂️", + "🧏🏻‍♂️", + "🧏🏼‍♂️", + "🧏🏽‍♂️", + "🧏🏾‍♂️", + "🧏🏿‍♂️", + "🧏‍♀️", + "🧏🏻‍♀️", + "🧏🏼‍♀️", + "🧏🏽‍♀️", + "🧏🏾‍♀️", + "🧏🏿‍♀️", + "🙇", + "🙇🏻", + "🙇🏼", + "🙇🏽", + "🙇🏾", + "🙇🏿", + "🙇‍♂️", + "🙇🏻‍♂️", + "🙇🏼‍♂️", + "🙇🏽‍♂️", + "🙇🏾‍♂️", + "🙇🏿‍♂️", + "🙇‍♀️", + "🙇🏻‍♀️", + "🙇🏼‍♀️", + "🙇🏽‍♀️", + "🙇🏾‍♀️", + "🙇🏿‍♀️", + "🤦", + "🤦🏻", + "🤦🏼", + "🤦🏽", + "🤦🏾", + "🤦🏿", + "🤦‍♂️", + "🤦🏻‍♂️", + "🤦🏼‍♂️", + "🤦🏽‍♂️", + "🤦🏾‍♂️", + "🤦🏿‍♂️", + "🤦‍♀️", + "🤦🏻‍♀️", + "🤦🏼‍♀️", + "🤦🏽‍♀️", + "🤦🏾‍♀️", + "🤦🏿‍♀️", + "🤷", + "🤷🏻", + "🤷🏼", + "🤷🏽", + "🤷🏾", + "🤷🏿", + "🤷‍♂️", + "🤷🏻‍♂️", + "🤷🏼‍♂️", + "🤷🏽‍♂️", + "🤷🏾‍♂️", + "🤷🏿‍♂️", + "🤷‍♀️", + "🤷🏻‍♀️", + "🤷🏼‍♀️", + "🤷🏽‍♀️", + "🤷🏾‍♀️", + "🤷🏿‍♀️", + "🧑‍⚕️", + "🧑🏻‍⚕️", + "🧑🏼‍⚕️", + "🧑🏽‍⚕️", + "🧑🏾‍⚕️", + "🧑🏿‍⚕️", + "👨‍⚕️", + "👨🏻‍⚕️", + "👨🏼‍⚕️", + "👨🏽‍⚕️", + "👨🏾‍⚕️", + "👨🏿‍⚕️", + "👩‍⚕️", + "👩🏻‍⚕️", + "👩🏼‍⚕️", + "👩🏽‍⚕️", + "👩🏾‍⚕️", + "👩🏿‍⚕️", + "🧑‍🎓", + "🧑🏻‍🎓", + "🧑🏼‍🎓", + "🧑🏽‍🎓", + "🧑🏾‍🎓", + "🧑🏿‍🎓", + "👨‍🎓", + "👨🏻‍🎓", + "👨🏼‍🎓", + "👨🏽‍🎓", + "👨🏾‍🎓", + "👨🏿‍🎓", + "👩‍🎓", + "👩🏻‍🎓", + "👩🏼‍🎓", + "👩🏽‍🎓", + "👩🏾‍🎓", + "👩🏿‍🎓", + "🧑‍🏫", + "🧑🏻‍🏫", + "🧑🏼‍🏫", + "🧑🏽‍🏫", + "🧑🏾‍🏫", + "🧑🏿‍🏫", + "👨‍🏫", + "👨🏻‍🏫", + "👨🏼‍🏫", + "👨🏽‍🏫", + "👨🏾‍🏫", + "👨🏿‍🏫", + "👩‍🏫", + "👩🏻‍🏫", + "👩🏼‍🏫", + "👩🏽‍🏫", + "👩🏾‍🏫", + "👩🏿‍🏫", + "🧑‍⚖️", + "🧑🏻‍⚖️", + "🧑🏼‍⚖️", + "🧑🏽‍⚖️", + "🧑🏾‍⚖️", + "🧑🏿‍⚖️", + "👨‍⚖️", + "👨🏻‍⚖️", + "👨🏼‍⚖️", + "👨🏽‍⚖️", + "👨🏾‍⚖️", + "👨🏿‍⚖️", + "👩‍⚖️", + "👩🏻‍⚖️", + "👩🏼‍⚖️", + "👩🏽‍⚖️", + "👩🏾‍⚖️", + "👩🏿‍⚖️", + "🧑‍🌾", + "🧑🏻‍🌾", + "🧑🏼‍🌾", + "🧑🏽‍🌾", + "🧑🏾‍🌾", + "🧑🏿‍🌾", + "👨‍🌾", + "👨🏻‍🌾", + "👨🏼‍🌾", + "👨🏽‍🌾", + "👨🏾‍🌾", + "👨🏿‍🌾", + "👩‍🌾", + "👩🏻‍🌾", + "👩🏼‍🌾", + "👩🏽‍🌾", + "👩🏾‍🌾", + "👩🏿‍🌾", + "🧑‍🍳", + "🧑🏻‍🍳", + "🧑🏼‍🍳", + "🧑🏽‍🍳", + "🧑🏾‍🍳", + "🧑🏿‍🍳", + "👨‍🍳", + "👨🏻‍🍳", + "👨🏼‍🍳", + "👨🏽‍🍳", + "👨🏾‍🍳", + "👨🏿‍🍳", + "👩‍🍳", + "👩🏻‍🍳", + "👩🏼‍🍳", + "👩🏽‍🍳", + "👩🏾‍🍳", + "👩🏿‍🍳", + "🧑‍🔧", + "🧑🏻‍🔧", + "🧑🏼‍🔧", + "🧑🏽‍🔧", + "🧑🏾‍🔧", + "🧑🏿‍🔧", + "👨‍🔧", + "👨🏻‍🔧", + "👨🏼‍🔧", + "👨🏽‍🔧", + "👨🏾‍🔧", + "👨🏿‍🔧", + "👩‍🔧", + "👩🏻‍🔧", + "👩🏼‍🔧", + "👩🏽‍🔧", + "👩🏾‍🔧", + "👩🏿‍🔧", + "🧑‍🏭", + "🧑🏻‍🏭", + "🧑🏼‍🏭", + "🧑🏽‍🏭", + "🧑🏾‍🏭", + "🧑🏿‍🏭", + "👨‍🏭", + "👨🏻‍🏭", + "👨🏼‍🏭", + "👨🏽‍🏭", + "👨🏾‍🏭", + "👨🏿‍🏭", + "👩‍🏭", + "👩🏻‍🏭", + "👩🏼‍🏭", + "👩🏽‍🏭", + "👩🏾‍🏭", + "👩🏿‍🏭", + "🧑‍💼", + "🧑🏻‍💼", + "🧑🏼‍💼", + "🧑🏽‍💼", + "🧑🏾‍💼", + "🧑🏿‍💼", + "👨‍💼", + "👨🏻‍💼", + "👨🏼‍💼", + "👨🏽‍💼", + "👨🏾‍💼", + "👨🏿‍💼", + "👩‍💼", + "👩🏻‍💼", + "👩🏼‍💼", + "👩🏽‍💼", + "👩🏾‍💼", + "👩🏿‍💼", + "🧑‍🔬", + "🧑🏻‍🔬", + "🧑🏼‍🔬", + "🧑🏽‍🔬", + "🧑🏾‍🔬", + "🧑🏿‍🔬", + "👨‍🔬", + "👨🏻‍🔬", + "👨🏼‍🔬", + "👨🏽‍🔬", + "👨🏾‍🔬", + "👨🏿‍🔬", + "👩‍🔬", + "👩🏻‍🔬", + "👩🏼‍🔬", + "👩🏽‍🔬", + "👩🏾‍🔬", + "👩🏿‍🔬", + "🧑‍💻", + "🧑🏻‍💻", + "🧑🏼‍💻", + "🧑🏽‍💻", + "🧑🏾‍💻", + "🧑🏿‍💻", + "👨‍💻", + "👨🏻‍💻", + "👨🏼‍💻", + "👨🏽‍💻", + "👨🏾‍💻", + "👨🏿‍💻", + "👩‍💻", + "👩🏻‍💻", + "👩🏼‍💻", + "👩🏽‍💻", + "👩🏾‍💻", + "👩🏿‍💻", + "🧑‍🎤", + "🧑🏻‍🎤", + "🧑🏼‍🎤", + "🧑🏽‍🎤", + "🧑🏾‍🎤", + "🧑🏿‍🎤", + "👨‍🎤", + "👨🏻‍🎤", + "👨🏼‍🎤", + "👨🏽‍🎤", + "👨🏾‍🎤", + "👨🏿‍🎤", + "👩‍🎤", + "👩🏻‍🎤", + "👩🏼‍🎤", + "👩🏽‍🎤", + "👩🏾‍🎤", + "👩🏿‍🎤", + "🧑‍🎨", + "🧑🏻‍🎨", + "🧑🏼‍🎨", + "🧑🏽‍🎨", + "🧑🏾‍🎨", + "🧑🏿‍🎨", + "👨‍🎨", + "👨🏻‍🎨", + "👨🏼‍🎨", + "👨🏽‍🎨", + "👨🏾‍🎨", + "👨🏿‍🎨", + "👩‍🎨", + "👩🏻‍🎨", + "👩🏼‍🎨", + "👩🏽‍🎨", + "👩🏾‍🎨", + "👩🏿‍🎨", + "🧑‍✈️", + "🧑🏻‍✈️", + "🧑🏼‍✈️", + "🧑🏽‍✈️", + "🧑🏾‍✈️", + "🧑🏿‍✈️", + "👨‍✈️", + "👨🏻‍✈️", + "👨🏼‍✈️", + "👨🏽‍✈️", + "👨🏾‍✈️", + "👨🏿‍✈️", + "👩‍✈️", + "👩🏻‍✈️", + "👩🏼‍✈️", + "👩🏽‍✈️", + "👩🏾‍✈️", + "👩🏿‍✈️", + "🧑‍🚀", + "🧑🏻‍🚀", + "🧑🏼‍🚀", + "🧑🏽‍🚀", + "🧑🏾‍🚀", + "🧑🏿‍🚀", + "👨‍🚀", + "👨🏻‍🚀", + "👨🏼‍🚀", + "👨🏽‍🚀", + "👨🏾‍🚀", + "👨🏿‍🚀", + "👩‍🚀", + "👩🏻‍🚀", + "👩🏼‍🚀", + "👩🏽‍🚀", + "👩🏾‍🚀", + "👩🏿‍🚀", + "🧑‍🚒", + "🧑🏻‍🚒", + "🧑🏼‍🚒", + "🧑🏽‍🚒", + "🧑🏾‍🚒", + "🧑🏿‍🚒", + "👨‍🚒", + "👨🏻‍🚒", + "👨🏼‍🚒", + "👨🏽‍🚒", + "👨🏾‍🚒", + "👨🏿‍🚒", + "👩‍🚒", + "👩🏻‍🚒", + "👩🏼‍🚒", + "👩🏽‍🚒", + "👩🏾‍🚒", + "👩🏿‍🚒", + "👮", + "👮🏻", + "👮🏼", + "👮🏽", + "👮🏾", + "👮🏿", + "👮‍♂️", + "👮🏻‍♂️", + "👮🏼‍♂️", + "👮🏽‍♂️", + "👮🏾‍♂️", + "👮🏿‍♂️", + "👮‍♀️", + "👮🏻‍♀️", + "👮🏼‍♀️", + "👮🏽‍♀️", + "👮🏾‍♀️", + "👮🏿‍♀️", + "🕵️", + "🕵🏻", + "🕵🏼", + "🕵🏽", + "🕵🏾", + "🕵🏿", + "🕵️‍♂️", + "🕵🏻‍♂️", + "🕵🏼‍♂️", + "🕵🏽‍♂️", + "🕵🏾‍♂️", + "🕵🏿‍♂️", + "🕵️‍♀️", + "🕵🏻‍♀️", + "🕵🏼‍♀️", + "🕵🏽‍♀️", + "🕵🏾‍♀️", + "🕵🏿‍♀️", + "💂", + "💂🏻", + "💂🏼", + "💂🏽", + "💂🏾", + "💂🏿", + "💂‍♂️", + "💂🏻‍♂️", + "💂🏼‍♂️", + "💂🏽‍♂️", + "💂🏾‍♂️", + "💂🏿‍♂️", + "💂‍♀️", + "💂🏻‍♀️", + "💂🏼‍♀️", + "💂🏽‍♀️", + "💂🏾‍♀️", + "💂🏿‍♀️", + "🥷", + "🥷🏻", + "🥷🏼", + "🥷🏽", + "🥷🏾", + "🥷🏿", + "👷", + "👷🏻", + "👷🏼", + "👷🏽", + "👷🏾", + "👷🏿", + "👷‍♂️", + "👷🏻‍♂️", + "👷🏼‍♂️", + "👷🏽‍♂️", + "👷🏾‍♂️", + "👷🏿‍♂️", + "👷‍♀️", + "👷🏻‍♀️", + "👷🏼‍♀️", + "👷🏽‍♀️", + "👷🏾‍♀️", + "👷🏿‍♀️", + "🫅", + "🫅🏻", + "🫅🏼", + "🫅🏽", + "🫅🏾", + "🫅🏿", + "🤴", + "🤴🏻", + "🤴🏼", + "🤴🏽", + "🤴🏾", + "🤴🏿", + "👸", + "👸🏻", + "👸🏼", + "👸🏽", + "👸🏾", + "👸🏿", + "👳", + "👳🏻", + "👳🏼", + "👳🏽", + "👳🏾", + "👳🏿", + "👳‍♂️", + "👳🏻‍♂️", + "👳🏼‍♂️", + "👳🏽‍♂️", + "👳🏾‍♂️", + "👳🏿‍♂️", + "👳‍♀️", + "👳🏻‍♀️", + "👳🏼‍♀️", + "👳🏽‍♀️", + "👳🏾‍♀️", + "👳🏿‍♀️", + "👲", + "👲🏻", + "👲🏼", + "👲🏽", + "👲🏾", + "👲🏿", + "🧕", + "🧕🏻", + "🧕🏼", + "🧕🏽", + "🧕🏾", + "🧕🏿", + "🤵", + "🤵🏻", + "🤵🏼", + "🤵🏽", + "🤵🏾", + "🤵🏿", + "🤵‍♂️", + "🤵🏻‍♂️", + "🤵🏼‍♂️", + "🤵🏽‍♂️", + "🤵🏾‍♂️", + "🤵🏿‍♂️", + "🤵‍♀️", + "🤵🏻‍♀️", + "🤵🏼‍♀️", + "🤵🏽‍♀️", + "🤵🏾‍♀️", + "🤵🏿‍♀️", + "👰", + "👰🏻", + "👰🏼", + "👰🏽", + "👰🏾", + "👰🏿", + "👰‍♂️", + "👰🏻‍♂️", + "👰🏼‍♂️", + "👰🏽‍♂️", + "👰🏾‍♂️", + "👰🏿‍♂️", + "👰‍♀️", + "👰🏻‍♀️", + "👰🏼‍♀️", + "👰🏽‍♀️", + "👰🏾‍♀️", + "👰🏿‍♀️", + "🤰", + "🤰🏻", + "🤰🏼", + "🤰🏽", + "🤰🏾", + "🤰🏿", + "🫃", + "🫃🏻", + "🫃🏼", + "🫃🏽", + "🫃🏾", + "🫃🏿", + "🫄", + "🫄🏻", + "🫄🏼", + "🫄🏽", + "🫄🏾", + "🫄🏿", + "🤱", + "🤱🏻", + "🤱🏼", + "🤱🏽", + "🤱🏾", + "🤱🏿", + "👩‍🍼", + "👩🏻‍🍼", + "👩🏼‍🍼", + "👩🏽‍🍼", + "👩🏾‍🍼", + "👩🏿‍🍼", + "👨‍🍼", + "👨🏻‍🍼", + "👨🏼‍🍼", + "👨🏽‍🍼", + "👨🏾‍🍼", + "👨🏿‍🍼", + "🧑‍🍼", + "🧑🏻‍🍼", + "🧑🏼‍🍼", + "🧑🏽‍🍼", + "🧑🏾‍🍼", + "🧑🏿‍🍼", + "👼", + "👼🏻", + "👼🏼", + "👼🏽", + "👼🏾", + "👼🏿", + "🎅", + "🎅🏻", + "🎅🏼", + "🎅🏽", + "🎅🏾", + "🎅🏿", + "🤶", + "🤶🏻", + "🤶🏼", + "🤶🏽", + "🤶🏾", + "🤶🏿", + "🧑‍🎄", + "🧑🏻‍🎄", + "🧑🏼‍🎄", + "🧑🏽‍🎄", + "🧑🏾‍🎄", + "🧑🏿‍🎄", + "🦸", + "🦸🏻", + "🦸🏼", + "🦸🏽", + "🦸🏾", + "🦸🏿", + "🦸‍♂️", + "🦸🏻‍♂️", + "🦸🏼‍♂️", + "🦸🏽‍♂️", + "🦸🏾‍♂️", + "🦸🏿‍♂️", + "🦸‍♀️", + "🦸🏻‍♀️", + "🦸🏼‍♀️", + "🦸🏽‍♀️", + "🦸🏾‍♀️", + "🦸🏿‍♀️", + "🦹", + "🦹🏻", + "🦹🏼", + "🦹🏽", + "🦹🏾", + "🦹🏿", + "🦹‍♂️", + "🦹🏻‍♂️", + "🦹🏼‍♂️", + "🦹🏽‍♂️", + "🦹🏾‍♂️", + "🦹🏿‍♂️", + "🦹‍♀️", + "🦹🏻‍♀️", + "🦹🏼‍♀️", + "🦹🏽‍♀️", + "🦹🏾‍♀️", + "🦹🏿‍♀️", + "🧙", + "🧙🏻", + "🧙🏼", + "🧙🏽", + "🧙🏾", + "🧙🏿", + "🧙‍♂️", + "🧙🏻‍♂️", + "🧙🏼‍♂️", + "🧙🏽‍♂️", + "🧙🏾‍♂️", + "🧙🏿‍♂️", + "🧙‍♀️", + "🧙🏻‍♀️", + "🧙🏼‍♀️", + "🧙🏽‍♀️", + "🧙🏾‍♀️", + "🧙🏿‍♀️", + "🧚", + "🧚🏻", + "🧚🏼", + "🧚🏽", + "🧚🏾", + "🧚🏿", + "🧚‍♂️", + "🧚🏻‍♂️", + "🧚🏼‍♂️", + "🧚🏽‍♂️", + "🧚🏾‍♂️", + "🧚🏿‍♂️", + "🧚‍♀️", + "🧚🏻‍♀️", + "🧚🏼‍♀️", + "🧚🏽‍♀️", + "🧚🏾‍♀️", + "🧚🏿‍♀️", + "🧛", + "🧛🏻", + "🧛🏼", + "🧛🏽", + "🧛🏾", + "🧛🏿", + "🧛‍♂️", + "🧛🏻‍♂️", + "🧛🏼‍♂️", + "🧛🏽‍♂️", + "🧛🏾‍♂️", + "🧛🏿‍♂️", + "🧛‍♀️", + "🧛🏻‍♀️", + "🧛🏼‍♀️", + "🧛🏽‍♀️", + "🧛🏾‍♀️", + "🧛🏿‍♀️", + "🧜", + "🧜🏻", + "🧜🏼", + "🧜🏽", + "🧜🏾", + "🧜🏿", + "🧜‍♂️", + "🧜🏻‍♂️", + "🧜🏼‍♂️", + "🧜🏽‍♂️", + "🧜🏾‍♂️", + "🧜🏿‍♂️", + "🧜‍♀️", + "🧜🏻‍♀️", + "🧜🏼‍♀️", + "🧜🏽‍♀️", + "🧜🏾‍♀️", + "🧜🏿‍♀️", + "🧝", + "🧝🏻", + "🧝🏼", + "🧝🏽", + "🧝🏾", + "🧝🏿", + "🧝‍♂️", + "🧝🏻‍♂️", + "🧝🏼‍♂️", + "🧝🏽‍♂️", + "🧝🏾‍♂️", + "🧝🏿‍♂️", + "🧝‍♀️", + "🧝🏻‍♀️", + "🧝🏼‍♀️", + "🧝🏽‍♀️", + "🧝🏾‍♀️", + "🧝🏿‍♀️", + "🧞", + "🧞‍♂️", + "🧞‍♀️", + "🧟", + "🧟‍♂️", + "🧟‍♀️", + "🧌", + "💆", + "💆🏻", + "💆🏼", + "💆🏽", + "💆🏾", + "💆🏿", + "💆‍♂️", + "💆🏻‍♂️", + "💆🏼‍♂️", + "💆🏽‍♂️", + "💆🏾‍♂️", + "💆🏿‍♂️", + "💆‍♀️", + "💆🏻‍♀️", + "💆🏼‍♀️", + "💆🏽‍♀️", + "💆🏾‍♀️", + "💆🏿‍♀️", + "💇", + "💇🏻", + "💇🏼", + "💇🏽", + "💇🏾", + "💇🏿", + "💇‍♂️", + "💇🏻‍♂️", + "💇🏼‍♂️", + "💇🏽‍♂️", + "💇🏾‍♂️", + "💇🏿‍♂️", + "💇‍♀️", + "💇🏻‍♀️", + "💇🏼‍♀️", + "💇🏽‍♀️", + "💇🏾‍♀️", + "💇🏿‍♀️", + "🚶", + "🚶🏻", + "🚶🏼", + "🚶🏽", + "🚶🏾", + "🚶🏿", + "🚶‍♂️", + "🚶🏻‍♂️", + "🚶🏼‍♂️", + "🚶🏽‍♂️", + "🚶🏾‍♂️", + "🚶🏿‍♂️", + "🚶‍♀️", + "🚶🏻‍♀️", + "🚶🏼‍♀️", + "🚶🏽‍♀️", + "🚶🏾‍♀️", + "🚶🏿‍♀️", + "🚶‍➡️", + "🚶🏻‍➡️", + "🚶🏼‍➡️", + "🚶🏽‍➡️", + "🚶🏾‍➡️", + "🚶🏿‍➡️", + "🚶‍♀️‍➡️", + "🚶🏻‍♀️‍➡️", + "🚶🏼‍♀️‍➡️", + "🚶🏽‍♀️‍➡️", + "🚶🏾‍♀️‍➡️", + "🚶🏿‍♀️‍➡️", + "🚶‍♂️‍➡️", + "🚶🏻‍♂️‍➡️", + "🚶🏼‍♂️‍➡️", + "🚶🏽‍♂️‍➡️", + "🚶🏾‍♂️‍➡️", + "🚶🏿‍♂️‍➡️", + "🧍", + "🧍🏻", + "🧍🏼", + "🧍🏽", + "🧍🏾", + "🧍🏿", + "🧍‍♂️", + "🧍🏻‍♂️", + "🧍🏼‍♂️", + "🧍🏽‍♂️", + "🧍🏾‍♂️", + "🧍🏿‍♂️", + "🧍‍♀️", + "🧍🏻‍♀️", + "🧍🏼‍♀️", + "🧍🏽‍♀️", + "🧍🏾‍♀️", + "🧍🏿‍♀️", + "🧎", + "🧎🏻", + "🧎🏼", + "🧎🏽", + "🧎🏾", + "🧎🏿", + "🧎‍♂️", + "🧎🏻‍♂️", + "🧎🏼‍♂️", + "🧎🏽‍♂️", + "🧎🏾‍♂️", + "🧎🏿‍♂️", + "🧎‍♀️", + "🧎🏻‍♀️", + "🧎🏼‍♀️", + "🧎🏽‍♀️", + "🧎🏾‍♀️", + "🧎🏿‍♀️", + "🧎‍➡️", + "🧎🏻‍➡️", + "🧎🏼‍➡️", + "🧎🏽‍➡️", + "🧎🏾‍➡️", + "🧎🏿‍➡️", + "🧎‍♀️‍➡️", + "🧎🏻‍♀️‍➡️", + "🧎🏼‍♀️‍➡️", + "🧎🏽‍♀️‍➡️", + "🧎🏾‍♀️‍➡️", + "🧎🏿‍♀️‍➡️", + "🧎‍♂️‍➡️", + "🧎🏻‍♂️‍➡️", + "🧎🏼‍♂️‍➡️", + "🧎🏽‍♂️‍➡️", + "🧎🏾‍♂️‍➡️", + "🧎🏿‍♂️‍➡️", + "🧑‍🦯", + "🧑🏻‍🦯", + "🧑🏼‍🦯", + "🧑🏽‍🦯", + "🧑🏾‍🦯", + "🧑🏿‍🦯", + "🧑‍🦯‍➡️", + "🧑🏻‍🦯‍➡️", + "🧑🏼‍🦯‍➡️", + "🧑🏽‍🦯‍➡️", + "🧑🏾‍🦯‍➡️", + "🧑🏿‍🦯‍➡️", + "👨‍🦯", + "👨🏻‍🦯", + "👨🏼‍🦯", + "👨🏽‍🦯", + "👨🏾‍🦯", + "👨🏿‍🦯", + "👨‍🦯‍➡️", + "👨🏻‍🦯‍➡️", + "👨🏼‍🦯‍➡️", + "👨🏽‍🦯‍➡️", + "👨🏾‍🦯‍➡️", + "👨🏿‍🦯‍➡️", + "👩‍🦯", + "👩🏻‍🦯", + "👩🏼‍🦯", + "👩🏽‍🦯", + "👩🏾‍🦯", + "👩🏿‍🦯", + "👩‍🦯‍➡️", + "👩🏻‍🦯‍➡️", + "👩🏼‍🦯‍➡️", + "👩🏽‍🦯‍➡️", + "👩🏾‍🦯‍➡️", + "👩🏿‍🦯‍➡️", + "🧑‍🦼", + "🧑🏻‍🦼", + "🧑🏼‍🦼", + "🧑🏽‍🦼", + "🧑🏾‍🦼", + "🧑🏿‍🦼", + "🧑‍🦼‍➡️", + "🧑🏻‍🦼‍➡️", + "🧑🏼‍🦼‍➡️", + "🧑🏽‍🦼‍➡️", + "🧑🏾‍🦼‍➡️", + "🧑🏿‍🦼‍➡️", + "👨‍🦼", + "👨🏻‍🦼", + "👨🏼‍🦼", + "👨🏽‍🦼", + "👨🏾‍🦼", + "👨🏿‍🦼", + "👨‍🦼‍➡️", + "👨🏻‍🦼‍➡️", + "👨🏼‍🦼‍➡️", + "👨🏽‍🦼‍➡️", + "👨🏾‍🦼‍➡️", + "👨🏿‍🦼‍➡️", + "👩‍🦼", + "👩🏻‍🦼", + "👩🏼‍🦼", + "👩🏽‍🦼", + "👩🏾‍🦼", + "👩🏿‍🦼", + "👩‍🦼‍➡️", + "👩🏻‍🦼‍➡️", + "👩🏼‍🦼‍➡️", + "👩🏽‍🦼‍➡️", + "👩🏾‍🦼‍➡️", + "👩🏿‍🦼‍➡️", + "🧑‍🦽", + "🧑🏻‍🦽", + "🧑🏼‍🦽", + "🧑🏽‍🦽", + "🧑🏾‍🦽", + "🧑🏿‍🦽", + "🧑‍🦽‍➡️", + "🧑🏻‍🦽‍➡️", + "🧑🏼‍🦽‍➡️", + "🧑🏽‍🦽‍➡️", + "🧑🏾‍🦽‍➡️", + "🧑🏿‍🦽‍➡️", + "👨‍🦽", + "👨🏻‍🦽", + "👨🏼‍🦽", + "👨🏽‍🦽", + "👨🏾‍🦽", + "👨🏿‍🦽", + "👨‍🦽‍➡️", + "👨🏻‍🦽‍➡️", + "👨🏼‍🦽‍➡️", + "👨🏽‍🦽‍➡️", + "👨🏾‍🦽‍➡️", + "👨🏿‍🦽‍➡️", + "👩‍🦽", + "👩🏻‍🦽", + "👩🏼‍🦽", + "👩🏽‍🦽", + "👩🏾‍🦽", + "👩🏿‍🦽", + "👩‍🦽‍➡️", + "👩🏻‍🦽‍➡️", + "👩🏼‍🦽‍➡️", + "👩🏽‍🦽‍➡️", + "👩🏾‍🦽‍➡️", + "👩🏿‍🦽‍➡️", + "🏃", + "🏃🏻", + "🏃🏼", + "🏃🏽", + "🏃🏾", + "🏃🏿", + "🏃‍♂️", + "🏃🏻‍♂️", + "🏃🏼‍♂️", + "🏃🏽‍♂️", + "🏃🏾‍♂️", + "🏃🏿‍♂️", + "🏃‍♀️", + "🏃🏻‍♀️", + "🏃🏼‍♀️", + "🏃🏽‍♀️", + "🏃🏾‍♀️", + "🏃🏿‍♀️", + "🏃‍➡️", + "🏃🏻‍➡️", + "🏃🏼‍➡️", + "🏃🏽‍➡️", + "🏃🏾‍➡️", + "🏃🏿‍➡️", + "🏃‍♀️‍➡️", + "🏃🏻‍♀️‍➡️", + "🏃🏼‍♀️‍➡️", + "🏃🏽‍♀️‍➡️", + "🏃🏾‍♀️‍➡️", + "🏃🏿‍♀️‍➡️", + "🏃‍♂️‍➡️", + "🏃🏻‍♂️‍➡️", + "🏃🏼‍♂️‍➡️", + "🏃🏽‍♂️‍➡️", + "🏃🏾‍♂️‍➡️", + "🏃🏿‍♂️‍➡️", + "💃", + "💃🏻", + "💃🏼", + "💃🏽", + "💃🏾", + "💃🏿", + "🕺", + "🕺🏻", + "🕺🏼", + "🕺🏽", + "🕺🏾", + "🕺🏿", + "🕴️", + "🕴🏻", + "🕴🏼", + "🕴🏽", + "🕴🏾", + "🕴🏿", + "👯", + "👯‍♂️", + "👯‍♀️", + "🧖", + "🧖🏻", + "🧖🏼", + "🧖🏽", + "🧖🏾", + "🧖🏿", + "🧖‍♂️", + "🧖🏻‍♂️", + "🧖🏼‍♂️", + "🧖🏽‍♂️", + "🧖🏾‍♂️", + "🧖🏿‍♂️", + "🧖‍♀️", + "🧖🏻‍♀️", + "🧖🏼‍♀️", + "🧖🏽‍♀️", + "🧖🏾‍♀️", + "🧖🏿‍♀️", + "🧗", + "🧗🏻", + "🧗🏼", + "🧗🏽", + "🧗🏾", + "🧗🏿", + "🧗‍♂️", + "🧗🏻‍♂️", + "🧗🏼‍♂️", + "🧗🏽‍♂️", + "🧗🏾‍♂️", + "🧗🏿‍♂️", + "🧗‍♀️", + "🧗🏻‍♀️", + "🧗🏼‍♀️", + "🧗🏽‍♀️", + "🧗🏾‍♀️", + "🧗🏿‍♀️", + "🤺", + "🏇", + "🏇🏻", + "🏇🏼", + "🏇🏽", + "🏇🏾", + "🏇🏿", + "⛷️", + "🏂", + "🏂🏻", + "🏂🏼", + "🏂🏽", + "🏂🏾", + "🏂🏿", + "🏌️", + "🏌🏻", + "🏌🏼", + "🏌🏽", + "🏌🏾", + "🏌🏿", + "🏌️‍♂️", + "🏌🏻‍♂️", + "🏌🏼‍♂️", + "🏌🏽‍♂️", + "🏌🏾‍♂️", + "🏌🏿‍♂️", + "🏌️‍♀️", + "🏌🏻‍♀️", + "🏌🏼‍♀️", + "🏌🏽‍♀️", + "🏌🏾‍♀️", + "🏌🏿‍♀️", + "🏄", + "🏄🏻", + "🏄🏼", + "🏄🏽", + "🏄🏾", + "🏄🏿", + "🏄‍♂️", + "🏄🏻‍♂️", + "🏄🏼‍♂️", + "🏄🏽‍♂️", + "🏄🏾‍♂️", + "🏄🏿‍♂️", + "🏄‍♀️", + "🏄🏻‍♀️", + "🏄🏼‍♀️", + "🏄🏽‍♀️", + "🏄🏾‍♀️", + "🏄🏿‍♀️", + "🚣", + "🚣🏻", + "🚣🏼", + "🚣🏽", + "🚣🏾", + "🚣🏿", + "🚣‍♂️", + "🚣🏻‍♂️", + "🚣🏼‍♂️", + "🚣🏽‍♂️", + "🚣🏾‍♂️", + "🚣🏿‍♂️", + "🚣‍♀️", + "🚣🏻‍♀️", + "🚣🏼‍♀️", + "🚣🏽‍♀️", + "🚣🏾‍♀️", + "🚣🏿‍♀️", + "🏊", + "🏊🏻", + "🏊🏼", + "🏊🏽", + "🏊🏾", + "🏊🏿", + "🏊‍♂️", + "🏊🏻‍♂️", + "🏊🏼‍♂️", + "🏊🏽‍♂️", + "🏊🏾‍♂️", + "🏊🏿‍♂️", + "🏊‍♀️", + "🏊🏻‍♀️", + "🏊🏼‍♀️", + "🏊🏽‍♀️", + "🏊🏾‍♀️", + "🏊🏿‍♀️", + "⛹️", + "⛹🏻", + "⛹🏼", + "⛹🏽", + "⛹🏾", + "⛹🏿", + "⛹️‍♂️", + "⛹🏻‍♂️", + "⛹🏼‍♂️", + "⛹🏽‍♂️", + "⛹🏾‍♂️", + "⛹🏿‍♂️", + "⛹️‍♀️", + "⛹🏻‍♀️", + "⛹🏼‍♀️", + "⛹🏽‍♀️", + "⛹🏾‍♀️", + "⛹🏿‍♀️", + "🏋️", + "🏋🏻", + "🏋🏼", + "🏋🏽", + "🏋🏾", + "🏋🏿", + "🏋️‍♂️", + "🏋🏻‍♂️", + "🏋🏼‍♂️", + "🏋🏽‍♂️", + "🏋🏾‍♂️", + "🏋🏿‍♂️", + "🏋️‍♀️", + "🏋🏻‍♀️", + "🏋🏼‍♀️", + "🏋🏽‍♀️", + "🏋🏾‍♀️", + "🏋🏿‍♀️", + "🚴", + "🚴🏻", + "🚴🏼", + "🚴🏽", + "🚴🏾", + "🚴🏿", + "🚴‍♂️", + "🚴🏻‍♂️", + "🚴🏼‍♂️", + "🚴🏽‍♂️", + "🚴🏾‍♂️", + "🚴🏿‍♂️", + "🚴‍♀️", + "🚴🏻‍♀️", + "🚴🏼‍♀️", + "🚴🏽‍♀️", + "🚴🏾‍♀️", + "🚴🏿‍♀️", + "🚵", + "🚵🏻", + "🚵🏼", + "🚵🏽", + "🚵🏾", + "🚵🏿", + "🚵‍♂️", + "🚵🏻‍♂️", + "🚵🏼‍♂️", + "🚵🏽‍♂️", + "🚵🏾‍♂️", + "🚵🏿‍♂️", + "🚵‍♀️", + "🚵🏻‍♀️", + "🚵🏼‍♀️", + "🚵🏽‍♀️", + "🚵🏾‍♀️", + "🚵🏿‍♀️", + "🤸", + "🤸🏻", + "🤸🏼", + "🤸🏽", + "🤸🏾", + "🤸🏿", + "🤸‍♂️", + "🤸🏻‍♂️", + "🤸🏼‍♂️", + "🤸🏽‍♂️", + "🤸🏾‍♂️", + "🤸🏿‍♂️", + "🤸‍♀️", + "🤸🏻‍♀️", + "🤸🏼‍♀️", + "🤸🏽‍♀️", + "🤸🏾‍♀️", + "🤸🏿‍♀️", + "🤼", + "🤼‍♂️", + "🤼‍♀️", + "🤽", + "🤽🏻", + "🤽🏼", + "🤽🏽", + "🤽🏾", + "🤽🏿", + "🤽‍♂️", + "🤽🏻‍♂️", + "🤽🏼‍♂️", + "🤽🏽‍♂️", + "🤽🏾‍♂️", + "🤽🏿‍♂️", + "🤽‍♀️", + "🤽🏻‍♀️", + "🤽🏼‍♀️", + "🤽🏽‍♀️", + "🤽🏾‍♀️", + "🤽🏿‍♀️", + "🤾", + "🤾🏻", + "🤾🏼", + "🤾🏽", + "🤾🏾", + "🤾🏿", + "🤾‍♂️", + "🤾🏻‍♂️", + "🤾🏼‍♂️", + "🤾🏽‍♂️", + "🤾🏾‍♂️", + "🤾🏿‍♂️", + "🤾‍♀️", + "🤾🏻‍♀️", + "🤾🏼‍♀️", + "🤾🏽‍♀️", + "🤾🏾‍♀️", + "🤾🏿‍♀️", + "🤹", + "🤹🏻", + "🤹🏼", + "🤹🏽", + "🤹🏾", + "🤹🏿", + "🤹‍♂️", + "🤹🏻‍♂️", + "🤹🏼‍♂️", + "🤹🏽‍♂️", + "🤹🏾‍♂️", + "🤹🏿‍♂️", + "🤹‍♀️", + "🤹🏻‍♀️", + "🤹🏼‍♀️", + "🤹🏽‍♀️", + "🤹🏾‍♀️", + "🤹🏿‍♀️", + "🧘", + "🧘🏻", + "🧘🏼", + "🧘🏽", + "🧘🏾", + "🧘🏿", + "🧘‍♂️", + "🧘🏻‍♂️", + "🧘🏼‍♂️", + "🧘🏽‍♂️", + "🧘🏾‍♂️", + "🧘🏿‍♂️", + "🧘‍♀️", + "🧘🏻‍♀️", + "🧘🏼‍♀️", + "🧘🏽‍♀️", + "🧘🏾‍♀️", + "🧘🏿‍♀️", + "🛀", + "🛀🏻", + "🛀🏼", + "🛀🏽", + "🛀🏾", + "🛀🏿", + "🛌", + "🛌🏻", + "🛌🏼", + "🛌🏽", + "🛌🏾", + "🛌🏿", + "🧑‍🤝‍🧑", + "🧑🏻‍🤝‍🧑🏻", + "🧑🏻‍🤝‍🧑🏼", + "🧑🏻‍🤝‍🧑🏽", + "🧑🏻‍🤝‍🧑🏾", + "🧑🏻‍🤝‍🧑🏿", + "🧑🏼‍🤝‍🧑🏻", + "🧑🏼‍🤝‍🧑🏼", + "🧑🏼‍🤝‍🧑🏽", + "🧑🏼‍🤝‍🧑🏾", + "🧑🏼‍🤝‍🧑🏿", + "🧑🏽‍🤝‍🧑🏻", + "🧑🏽‍🤝‍🧑🏼", + "🧑🏽‍🤝‍🧑🏽", + "🧑🏽‍🤝‍🧑🏾", + "🧑🏽‍🤝‍🧑🏿", + "🧑🏾‍🤝‍🧑🏻", + "🧑🏾‍🤝‍🧑🏼", + "🧑🏾‍🤝‍🧑🏽", + "🧑🏾‍🤝‍🧑🏾", + "🧑🏾‍🤝‍🧑🏿", + "🧑🏿‍🤝‍🧑🏻", + "🧑🏿‍🤝‍🧑🏼", + "🧑🏿‍🤝‍🧑🏽", + "🧑🏿‍🤝‍🧑🏾", + "🧑🏿‍🤝‍🧑🏿", + "👭", + "👭🏻", + "👩🏻‍🤝‍👩🏼", + "👩🏻‍🤝‍👩🏽", + "👩🏻‍🤝‍👩🏾", + "👩🏻‍🤝‍👩🏿", + "👩🏼‍🤝‍👩🏻", + "👭🏼", + "👩🏼‍🤝‍👩🏽", + "👩🏼‍🤝‍👩🏾", + "👩🏼‍🤝‍👩🏿", + "👩🏽‍🤝‍👩🏻", + "👩🏽‍🤝‍👩🏼", + "👭🏽", + "👩🏽‍🤝‍👩🏾", + "👩🏽‍🤝‍👩🏿", + "👩🏾‍🤝‍👩🏻", + "👩🏾‍🤝‍👩🏼", + "👩🏾‍🤝‍👩🏽", + "👭🏾", + "👩🏾‍🤝‍👩🏿", + "👩🏿‍🤝‍👩🏻", + "👩🏿‍🤝‍👩🏼", + "👩🏿‍🤝‍👩🏽", + "👩🏿‍🤝‍👩🏾", + "👭🏿", + "👫", + "👫🏻", + "👩🏻‍🤝‍👨🏼", + "👩🏻‍🤝‍👨🏽", + "👩🏻‍🤝‍👨🏾", + "👩🏻‍🤝‍👨🏿", + "👩🏼‍🤝‍👨🏻", + "👫🏼", + "👩🏼‍🤝‍👨🏽", + "👩🏼‍🤝‍👨🏾", + "👩🏼‍🤝‍👨🏿", + "👩🏽‍🤝‍👨🏻", + "👩🏽‍🤝‍👨🏼", + "👫🏽", + "👩🏽‍🤝‍👨🏾", + "👩🏽‍🤝‍👨🏿", + "👩🏾‍🤝‍👨🏻", + "👩🏾‍🤝‍👨🏼", + "👩🏾‍🤝‍👨🏽", + "👫🏾", + "👩🏾‍🤝‍👨🏿", + "👩🏿‍🤝‍👨🏻", + "👩🏿‍🤝‍👨🏼", + "👩🏿‍🤝‍👨🏽", + "👩🏿‍🤝‍👨🏾", + "👫🏿", + "👬", + "👬🏻", + "👨🏻‍🤝‍👨🏼", + "👨🏻‍🤝‍👨🏽", + "👨🏻‍🤝‍👨🏾", + "👨🏻‍🤝‍👨🏿", + "👨🏼‍🤝‍👨🏻", + "👬🏼", + "👨🏼‍🤝‍👨🏽", + "👨🏼‍🤝‍👨🏾", + "👨🏼‍🤝‍👨🏿", + "👨🏽‍🤝‍👨🏻", + "👨🏽‍🤝‍👨🏼", + "👬🏽", + "👨🏽‍🤝‍👨🏾", + "👨🏽‍🤝‍👨🏿", + "👨🏾‍🤝‍👨🏻", + "👨🏾‍🤝‍👨🏼", + "👨🏾‍🤝‍👨🏽", + "👬🏾", + "👨🏾‍🤝‍👨🏿", + "👨🏿‍🤝‍👨🏻", + "👨🏿‍🤝‍👨🏼", + "👨🏿‍🤝‍👨🏽", + "👨🏿‍🤝‍👨🏾", + "👬🏿", + "💏", + "💏🏻", + "💏🏼", + "💏🏽", + "💏🏾", + "💏🏿", + "🧑🏻‍❤️‍💋‍🧑🏼", + "🧑🏻‍❤️‍💋‍🧑🏽", + "🧑🏻‍❤️‍💋‍🧑🏾", + "🧑🏻‍❤️‍💋‍🧑🏿", + "🧑🏼‍❤️‍💋‍🧑🏻", + "🧑🏼‍❤️‍💋‍🧑🏽", + "🧑🏼‍❤️‍💋‍🧑🏾", + "🧑🏼‍❤️‍💋‍🧑🏿", + "🧑🏽‍❤️‍💋‍🧑🏻", + "🧑🏽‍❤️‍💋‍🧑🏼", + "🧑🏽‍❤️‍💋‍🧑🏾", + "🧑🏽‍❤️‍💋‍🧑🏿", + "🧑🏾‍❤️‍💋‍🧑🏻", + "🧑🏾‍❤️‍💋‍🧑🏼", + "🧑🏾‍❤️‍💋‍🧑🏽", + "🧑🏾‍❤️‍💋‍🧑🏿", + "🧑🏿‍❤️‍💋‍🧑🏻", + "🧑🏿‍❤️‍💋‍🧑🏼", + "🧑🏿‍❤️‍💋‍🧑🏽", + "🧑🏿‍❤️‍💋‍🧑🏾", + "👩‍❤️‍💋‍👨", + "👩🏻‍❤️‍💋‍👨🏻", + "👩🏻‍❤️‍💋‍👨🏼", + "👩🏻‍❤️‍💋‍👨🏽", + "👩🏻‍❤️‍💋‍👨🏾", + "👩🏻‍❤️‍💋‍👨🏿", + "👩🏼‍❤️‍💋‍👨🏻", + "👩🏼‍❤️‍💋‍👨🏼", + "👩🏼‍❤️‍💋‍👨🏽", + "👩🏼‍❤️‍💋‍👨🏾", + "👩🏼‍❤️‍💋‍👨🏿", + "👩🏽‍❤️‍💋‍👨🏻", + "👩🏽‍❤️‍💋‍👨🏼", + "👩🏽‍❤️‍💋‍👨🏽", + "👩🏽‍❤️‍💋‍👨🏾", + "👩🏽‍❤️‍💋‍👨🏿", + "👩🏾‍❤️‍💋‍👨🏻", + "👩🏾‍❤️‍💋‍👨🏼", + "👩🏾‍❤️‍💋‍👨🏽", + "👩🏾‍❤️‍💋‍👨🏾", + "👩🏾‍❤️‍💋‍👨🏿", + "👩🏿‍❤️‍💋‍👨🏻", + "👩🏿‍❤️‍💋‍👨🏼", + "👩🏿‍❤️‍💋‍👨🏽", + "👩🏿‍❤️‍💋‍👨🏾", + "👩🏿‍❤️‍💋‍👨🏿", + "👨‍❤️‍💋‍👨", + "👨🏻‍❤️‍💋‍👨🏻", + "👨🏻‍❤️‍💋‍👨🏼", + "👨🏻‍❤️‍💋‍👨🏽", + "👨🏻‍❤️‍💋‍👨🏾", + "👨🏻‍❤️‍💋‍👨🏿", + "👨🏼‍❤️‍💋‍👨🏻", + "👨🏼‍❤️‍💋‍👨🏼", + "👨🏼‍❤️‍💋‍👨🏽", + "👨🏼‍❤️‍💋‍👨🏾", + "👨🏼‍❤️‍💋‍👨🏿", + "👨🏽‍❤️‍💋‍👨🏻", + "👨🏽‍❤️‍💋‍👨🏼", + "👨🏽‍❤️‍💋‍👨🏽", + "👨🏽‍❤️‍💋‍👨🏾", + "👨🏽‍❤️‍💋‍👨🏿", + "👨🏾‍❤️‍💋‍👨🏻", + "👨🏾‍❤️‍💋‍👨🏼", + "👨🏾‍❤️‍💋‍👨🏽", + "👨🏾‍❤️‍💋‍👨🏾", + "👨🏾‍❤️‍💋‍👨🏿", + "👨🏿‍❤️‍💋‍👨🏻", + "👨🏿‍❤️‍💋‍👨🏼", + "👨🏿‍❤️‍💋‍👨🏽", + "👨🏿‍❤️‍💋‍👨🏾", + "👨🏿‍❤️‍💋‍👨🏿", + "👩‍❤️‍💋‍👩", + "👩🏻‍❤️‍💋‍👩🏻", + "👩🏻‍❤️‍💋‍👩🏼", + "👩🏻‍❤️‍💋‍👩🏽", + "👩🏻‍❤️‍💋‍👩🏾", + "👩🏻‍❤️‍💋‍👩🏿", + "👩🏼‍❤️‍💋‍👩🏻", + "👩🏼‍❤️‍💋‍👩🏼", + "👩🏼‍❤️‍💋‍👩🏽", + "👩🏼‍❤️‍💋‍👩🏾", + "👩🏼‍❤️‍💋‍👩🏿", + "👩🏽‍❤️‍💋‍👩🏻", + "👩🏽‍❤️‍💋‍👩🏼", + "👩🏽‍❤️‍💋‍👩🏽", + "👩🏽‍❤️‍💋‍👩🏾", + "👩🏽‍❤️‍💋‍👩🏿", + "👩🏾‍❤️‍💋‍👩🏻", + "👩🏾‍❤️‍💋‍👩🏼", + "👩🏾‍❤️‍💋‍👩🏽", + "👩🏾‍❤️‍💋‍👩🏾", + "👩🏾‍❤️‍💋‍👩🏿", + "👩🏿‍❤️‍💋‍👩🏻", + "👩🏿‍❤️‍💋‍👩🏼", + "👩🏿‍❤️‍💋‍👩🏽", + "👩🏿‍❤️‍💋‍👩🏾", + "👩🏿‍❤️‍💋‍👩🏿", + "💑", + "💑🏻", + "💑🏼", + "💑🏽", + "💑🏾", + "💑🏿", + "🧑🏻‍❤️‍🧑🏼", + "🧑🏻‍❤️‍🧑🏽", + "🧑🏻‍❤️‍🧑🏾", + "🧑🏻‍❤️‍🧑🏿", + "🧑🏼‍❤️‍🧑🏻", + "🧑🏼‍❤️‍🧑🏽", + "🧑🏼‍❤️‍🧑🏾", + "🧑🏼‍❤️‍🧑🏿", + "🧑🏽‍❤️‍🧑🏻", + "🧑🏽‍❤️‍🧑🏼", + "🧑🏽‍❤️‍🧑🏾", + "🧑🏽‍❤️‍🧑🏿", + "🧑🏾‍❤️‍🧑🏻", + "🧑🏾‍❤️‍🧑🏼", + "🧑🏾‍❤️‍🧑🏽", + "🧑🏾‍❤️‍🧑🏿", + "🧑🏿‍❤️‍🧑🏻", + "🧑🏿‍❤️‍🧑🏼", + "🧑🏿‍❤️‍🧑🏽", + "🧑🏿‍❤️‍🧑🏾", + "👩‍❤️‍👨", + "👩🏻‍❤️‍👨🏻", + "👩🏻‍❤️‍👨🏼", + "👩🏻‍❤️‍👨🏽", + "👩🏻‍❤️‍👨🏾", + "👩🏻‍❤️‍👨🏿", + "👩🏼‍❤️‍👨🏻", + "👩🏼‍❤️‍👨🏼", + "👩🏼‍❤️‍👨🏽", + "👩🏼‍❤️‍👨🏾", + "👩🏼‍❤️‍👨🏿", + "👩🏽‍❤️‍👨🏻", + "👩🏽‍❤️‍👨🏼", + "👩🏽‍❤️‍👨🏽", + "👩🏽‍❤️‍👨🏾", + "👩🏽‍❤️‍👨🏿", + "👩🏾‍❤️‍👨🏻", + "👩🏾‍❤️‍👨🏼", + "👩🏾‍❤️‍👨🏽", + "👩🏾‍❤️‍👨🏾", + "👩🏾‍❤️‍👨🏿", + "👩🏿‍❤️‍👨🏻", + "👩🏿‍❤️‍👨🏼", + "👩🏿‍❤️‍👨🏽", + "👩🏿‍❤️‍👨🏾", + "👩🏿‍❤️‍👨🏿", + "👨‍❤️‍👨", + "👨🏻‍❤️‍👨🏻", + "👨🏻‍❤️‍👨🏼", + "👨🏻‍❤️‍👨🏽", + "👨🏻‍❤️‍👨🏾", + "👨🏻‍❤️‍👨🏿", + "👨🏼‍❤️‍👨🏻", + "👨🏼‍❤️‍👨🏼", + "👨🏼‍❤️‍👨🏽", + "👨🏼‍❤️‍👨🏾", + "👨🏼‍❤️‍👨🏿", + "👨🏽‍❤️‍👨🏻", + "👨🏽‍❤️‍👨🏼", + "👨🏽‍❤️‍👨🏽", + "👨🏽‍❤️‍👨🏾", + "👨🏽‍❤️‍👨🏿", + "👨🏾‍❤️‍👨🏻", + "👨🏾‍❤️‍👨🏼", + "👨🏾‍❤️‍👨🏽", + "👨🏾‍❤️‍👨🏾", + "👨🏾‍❤️‍👨🏿", + "👨🏿‍❤️‍👨🏻", + "👨🏿‍❤️‍👨🏼", + "👨🏿‍❤️‍👨🏽", + "👨🏿‍❤️‍👨🏾", + "👨🏿‍❤️‍👨🏿", + "👩‍❤️‍👩", + "👩🏻‍❤️‍👩🏻", + "👩🏻‍❤️‍👩🏼", + "👩🏻‍❤️‍👩🏽", + "👩🏻‍❤️‍👩🏾", + "👩🏻‍❤️‍👩🏿", + "👩🏼‍❤️‍👩🏻", + "👩🏼‍❤️‍👩🏼", + "👩🏼‍❤️‍👩🏽", + "👩🏼‍❤️‍👩🏾", + "👩🏼‍❤️‍👩🏿", + "👩🏽‍❤️‍👩🏻", + "👩🏽‍❤️‍👩🏼", + "👩🏽‍❤️‍👩🏽", + "👩🏽‍❤️‍👩🏾", + "👩🏽‍❤️‍👩🏿", + "👩🏾‍❤️‍👩🏻", + "👩🏾‍❤️‍👩🏼", + "👩🏾‍❤️‍👩🏽", + "👩🏾‍❤️‍👩🏾", + "👩🏾‍❤️‍👩🏿", + "👩🏿‍❤️‍👩🏻", + "👩🏿‍❤️‍👩🏼", + "👩🏿‍❤️‍👩🏽", + "👩🏿‍❤️‍👩🏾", + "👩🏿‍❤️‍👩🏿", + "👨‍👩‍👦", + "👨‍👩‍👧", + "👨‍👩‍👧‍👦", + "👨‍👩‍👦‍👦", + "👨‍👩‍👧‍👧", + "👨‍👨‍👦", + "👨‍👨‍👧", + "👨‍👨‍👧‍👦", + "👨‍👨‍👦‍👦", + "👨‍👨‍👧‍👧", + "👩‍👩‍👦", + "👩‍👩‍👧", + "👩‍👩‍👧‍👦", + "👩‍👩‍👦‍👦", + "👩‍👩‍👧‍👧", + "👨‍👦", + "👨‍👦‍👦", + "👨‍👧", + "👨‍👧‍👦", + "👨‍👧‍👧", + "👩‍👦", + "👩‍👦‍👦", + "👩‍👧", + "👩‍👧‍👦", + "👩‍👧‍👧", + "🗣️", + "👤", + "👥", + "🫂", + "👪", + "🧑‍🧑‍🧒", + "🧑‍🧑‍🧒‍🧒", + "🧑‍🧒", + "🧑‍🧒‍🧒", + "👣", + "🫆", + "🐵", + "🐒", + "🦍", + "🦧", + "🐶", + "🐕", + "🦮", + "🐕‍🦺", + "🐩", + "🐺", + "🦊", + "🦝", + "🐱", + "🐈", + "🐈‍⬛", + "🦁", + "🐯", + "🐅", + "🐆", + "🐴", + "🫎", + "🫏", + "🐎", + "🦄", + "🦓", + "🦌", + "🦬", + "🐮", + "🐂", + "🐃", + "🐄", + "🐷", + "🐖", + "🐗", + "🐽", + "🐏", + "🐑", + "🐐", + "🐪", + "🐫", + "🦙", + "🦒", + "🐘", + "🦣", + "🦏", + "🦛", + "🐭", + "🐁", + "🐀", + "🐹", + "🐰", + "🐇", + "🐿️", + "🦫", + "🦔", + "🦇", + "🐻", + "🐻‍❄️", + "🐨", + "🐼", + "🦥", + "🦦", + "🦨", + "🦘", + "🦡", + "🐾", + "🦃", + "🐔", + "🐓", + "🐣", + "🐤", + "🐥", + "🐦", + "🐧", + "🕊️", + "🦅", + "🦆", + "🦢", + "🦉", + "🦤", + "🪶", + "🦩", + "🦚", + "🦜", + "🪽", + "🐦‍⬛", + "🪿", + "🐦‍🔥", + "🐸", + "🐊", + "🐢", + "🦎", + "🐍", + "🐲", + "🐉", + "🦕", + "🦖", + "🐳", + "🐋", + "🐬", + "🦭", + "🐟", + "🐠", + "🐡", + "🦈", + "🐙", + "🐚", + "🪸", + "🪼", + "🦀", + "🦞", + "🦐", + "🦑", + "🦪", + "🐌", + "🦋", + "🐛", + "🐜", + "🐝", + "🪲", + "🐞", + "🦗", + "🪳", + "🕷️", + "🕸️", + "🦂", + "🦟", + "🪰", + "🪱", + "🦠", + "💐", + "🌸", + "💮", + "🪷", + "🏵️", + "🌹", + "🥀", + "🌺", + "🌻", + "🌼", + "🌷", + "🪻", + "🌱", + "🪴", + "🌲", + "🌳", + "🌴", + "🌵", + "🌾", + "🌿", + "☘️", + "🍀", + "🍁", + "🍂", + "🍃", + "🪹", + "🪺", + "🍄", + "🪾", + "🍇", + "🍈", + "🍉", + "🍊", + "🍋", + "🍋‍🟩", + "🍌", + "🍍", + "🥭", + "🍎", + "🍏", + "🍐", + "🍑", + "🍒", + "🍓", + "🫐", + "🥝", + "🍅", + "🫒", + "🥥", + "🥑", + "🍆", + "🥔", + "🥕", + "🌽", + "🌶️", + "🫑", + "🥒", + "🥬", + "🥦", + "🧄", + "🧅", + "🥜", + "🫘", + "🌰", + "🫚", + "🫛", + "🍄‍🟫", + "🫜", + "🍞", + "🥐", + "🥖", + "🫓", + "🥨", + "🥯", + "🥞", + "🧇", + "🧀", + "🍖", + "🍗", + "🥩", + "🥓", + "🍔", + "🍟", + "🍕", + "🌭", + "🥪", + "🌮", + "🌯", + "🫔", + "🥙", + "🧆", + "🥚", + "🍳", + "🥘", + "🍲", + "🫕", + "🥣", + "🥗", + "🍿", + "🧈", + "🧂", + "🥫", + "🍱", + "🍘", + "🍙", + "🍚", + "🍛", + "🍜", + "🍝", + "🍠", + "🍢", + "🍣", + "🍤", + "🍥", + "🥮", + "🍡", + "🥟", + "🥠", + "🥡", + "🍦", + "🍧", + "🍨", + "🍩", + "🍪", + "🎂", + "🍰", + "🧁", + "🥧", + "🍫", + "🍬", + "🍭", + "🍮", + "🍯", + "🍼", + "🥛", + "☕", + "🫖", + "🍵", + "🍶", + "🍾", + "🍷", + "🍸", + "🍹", + "🍺", + "🍻", + "🥂", + "🥃", + "🫗", + "🥤", + "🧋", + "🧃", + "🧉", + "🧊", + "🥢", + "🍽️", + "🍴", + "🥄", + "🔪", + "🫙", + "🏺", + "🌍", + "🌎", + "🌏", + "🌐", + "🗺️", + "🗾", + "🧭", + "🏔️", + "⛰️", + "🌋", + "🗻", + "🏕️", + "🏖️", + "🏜️", + "🏝️", + "🏞️", + "🏟️", + "🏛️", + "🏗️", + "🧱", + "🪨", + "🪵", + "🛖", + "🏘️", + "🏚️", + "🏠", + "🏡", + "🏢", + "🏣", + "🏤", + "🏥", + "🏦", + "🏨", + "🏩", + "🏪", + "🏫", + "🏬", + "🏭", + "🏯", + "🏰", + "💒", + "🗼", + "🗽", + "⛪", + "🕌", + "🛕", + "🕍", + "⛩️", + "🕋", + "⛲", + "⛺", + "🌁", + "🌃", + "🏙️", + "🌄", + "🌅", + "🌆", + "🌇", + "🌉", + "♨️", + "🎠", + "🛝", + "🎡", + "🎢", + "💈", + "🎪", + "🚂", + "🚃", + "🚄", + "🚅", + "🚆", + "🚇", + "🚈", + "🚉", + "🚊", + "🚝", + "🚞", + "🚋", + "🚌", + "🚍", + "🚎", + "🚐", + "🚑", + "🚒", + "🚓", + "🚔", + "🚕", + "🚖", + "🚗", + "🚘", + "🚙", + "🛻", + "🚚", + "🚛", + "🚜", + "🏎️", + "🏍️", + "🛵", + "🦽", + "🦼", + "🛺", + "🚲", + "🛴", + "🛹", + "🛼", + "🚏", + "🛣️", + "🛤️", + "🛢️", + "⛽", + "🛞", + "🚨", + "🚥", + "🚦", + "🛑", + "🚧", + "⚓", + "🛟", + "⛵", + "🛶", + "🚤", + "🛳️", + "⛴️", + "🛥️", + "🚢", + "✈️", + "🛩️", + "🛫", + "🛬", + "🪂", + "💺", + "🚁", + "🚟", + "🚠", + "🚡", + "🛰️", + "🚀", + "🛸", + "🛎️", + "🧳", + "⌛", + "⏳", + "⌚", + "⏰", + "⏱️", + "⏲️", + "🕰️", + "🕛", + "🕧", + "🕐", + "🕜", + "🕑", + "🕝", + "🕒", + "🕞", + "🕓", + "🕟", + "🕔", + "🕠", + "🕕", + "🕡", + "🕖", + "🕢", + "🕗", + "🕣", + "🕘", + "🕤", + "🕙", + "🕥", + "🕚", + "🕦", + "🌑", + "🌒", + "🌓", + "🌔", + "🌕", + "🌖", + "🌗", + "🌘", + "🌙", + "🌚", + "🌛", + "🌜", + "🌡️", + "☀️", + "🌝", + "🌞", + "🪐", + "⭐", + "🌟", + "🌠", + "🌌", + "☁️", + "⛅", + "⛈️", + "🌤️", + "🌥️", + "🌦️", + "🌧️", + "🌨️", + "🌩️", + "🌪️", + "🌫️", + "🌬️", + "🌀", + "🌈", + "🌂", + "☂️", + "☔", + "⛱️", + "⚡", + "❄️", + "☃️", + "⛄", + "☄️", + "🔥", + "💧", + "🌊", + "🎃", + "🎄", + "🎆", + "🎇", + "🧨", + "✨", + "🎈", + "🎉", + "🎊", + "🎋", + "🎍", + "🎎", + "🎏", + "🎐", + "🎑", + "🧧", + "🎀", + "🎁", + "🎗️", + "🎟️", + "🎫", + "🎖️", + "🏆", + "🏅", + "🥇", + "🥈", + "🥉", + "⚽", + "⚾", + "🥎", + "🏀", + "🏐", + "🏈", + "🏉", + "🎾", + "🥏", + "🎳", + "🏏", + "🏑", + "🏒", + "🥍", + "🏓", + "🏸", + "🥊", + "🥋", + "🥅", + "⛳", + "⛸️", + "🎣", + "🤿", + "🎽", + "🎿", + "🛷", + "🥌", + "🎯", + "🪀", + "🪁", + "🔫", + "🎱", + "🔮", + "🪄", + "🎮", + "🕹️", + "🎰", + "🎲", + "🧩", + "🧸", + "🪅", + "🪩", + "🪆", + "♠️", + "♥️", + "♦️", + "♣️", + "♟️", + "🃏", + "🀄", + "🎴", + "🎭", + "🖼️", + "🎨", + "🧵", + "🪡", + "🧶", + "🪢", + "👓", + "🕶️", + "🥽", + "🥼", + "🦺", + "👔", + "👕", + "👖", + "🧣", + "🧤", + "🧥", + "🧦", + "👗", + "👘", + "🥻", + "🩱", + "🩲", + "🩳", + "👙", + "👚", + "🪭", + "👛", + "👜", + "👝", + "🛍️", + "🎒", + "🩴", + "👞", + "👟", + "🥾", + "🥿", + "👠", + "👡", + "🩰", + "👢", + "🪮", + "👑", + "👒", + "🎩", + "🎓", + "🧢", + "🪖", + "⛑️", + "📿", + "💄", + "💍", + "💎", + "🔇", + "🔈", + "🔉", + "🔊", + "📢", + "📣", + "📯", + "🔔", + "🔕", + "🎼", + "🎵", + "🎶", + "🎙️", + "🎚️", + "🎛️", + "🎤", + "🎧", + "📻", + "🎷", + "🪗", + "🎸", + "🎹", + "🎺", + "🎻", + "🪕", + "🥁", + "🪘", + "🪇", + "🪈", + "🪉", + "📱", + "📲", + "☎️", + "📞", + "📟", + "📠", + "🔋", + "🪫", + "🔌", + "💻", + "🖥️", + "🖨️", + "⌨️", + "🖱️", + "🖲️", + "💽", + "💾", + "💿", + "📀", + "🧮", + "🎥", + "🎞️", + "📽️", + "🎬", + "📺", + "📷", + "📸", + "📹", + "📼", + "🔍", + "🔎", + "🕯️", + "💡", + "🔦", + "🏮", + "🪔", + "📔", + "📕", + "📖", + "📗", + "📘", + "📙", + "📚", + "📓", + "📒", + "📃", + "📜", + "📄", + "📰", + "🗞️", + "📑", + "🔖", + "🏷️", + "💰", + "🪙", + "💴", + "💵", + "💶", + "💷", + "💸", + "💳", + "🧾", + "💹", + "✉️", + "📧", + "📨", + "📩", + "📤", + "📥", + "📦", + "📫", + "📪", + "📬", + "📭", + "📮", + "🗳️", + "✏️", + "✒️", + "🖋️", + "🖊️", + "🖌️", + "🖍️", + "📝", + "💼", + "📁", + "📂", + "🗂️", + "📅", + "📆", + "🗒️", + "🗓️", + "📇", + "📈", + "📉", + "📊", + "📋", + "📌", + "📍", + "📎", + "🖇️", + "📏", + "📐", + "✂️", + "🗃️", + "🗄️", + "🗑️", + "🔒", + "🔓", + "🔏", + "🔐", + "🔑", + "🗝️", + "🔨", + "🪓", + "⛏️", + "⚒️", + "🛠️", + "🗡️", + "⚔️", + "💣", + "🪃", + "🏹", + "🛡️", + "🪚", + "🔧", + "🪛", + "🔩", + "⚙️", + "🗜️", + "⚖️", + "🦯", + "🔗", + "⛓️‍💥", + "⛓️", + "🪝", + "🧰", + "🧲", + "🪜", + "🪏", + "⚗️", + "🧪", + "🧫", + "🧬", + "🔬", + "🔭", + "📡", + "💉", + "🩸", + "💊", + "🩹", + "🩼", + "🩺", + "🩻", + "🚪", + "🛗", + "🪞", + "🪟", + "🛏️", + "🛋️", + "🪑", + "🚽", + "🪠", + "🚿", + "🛁", + "🪤", + "🪒", + "🧴", + "🧷", + "🧹", + "🧺", + "🧻", + "🪣", + "🧼", + "🫧", + "🪥", + "🧽", + "🧯", + "🛒", + "🚬", + "⚰️", + "🪦", + "⚱️", + "🧿", + "🪬", + "🗿", + "🪧", + "🪪", + "🏧", + "🚮", + "🚰", + "♿", + "🚹", + "🚺", + "🚻", + "🚼", + "🚾", + "🛂", + "🛃", + "🛄", + "🛅", + "⚠️", + "🚸", + "⛔", + "🚫", + "🚳", + "🚭", + "🚯", + "🚱", + "🚷", + "📵", + "🔞", + "☢️", + "☣️", + "⬆️", + "↗️", + "➡️", + "↘️", + "⬇️", + "↙️", + "⬅️", + "↖️", + "↕️", + "↔️", + "↩️", + "↪️", + "⤴️", + "⤵️", + "🔃", + "🔄", + "🔙", + "🔚", + "🔛", + "🔜", + "🔝", + "🛐", + "⚛️", + "🕉️", + "✡️", + "☸️", + "☯️", + "✝️", + "☦️", + "☪️", + "☮️", + "🕎", + "🔯", + "🪯", + "♈", + "♉", + "♊", + "♋", + "♌", + "♍", + "♎", + "♏", + "♐", + "♑", + "♒", + "♓", + "⛎", + "🔀", + "🔁", + "🔂", + "▶️", + "⏩", + "⏭️", + "⏯️", + "◀️", + "⏪", + "⏮️", + "🔼", + "⏫", + "🔽", + "⏬", + "⏸️", + "⏹️", + "⏺️", + "⏏️", + "🎦", + "🔅", + "🔆", + "📶", + "🛜", + "📳", + "📴", + "♀️", + "♂️", + "⚧️", + "✖️", + "➕", + "➖", + "➗", + "🟰", + "♾️", + "‼️", + "⁉️", + "❓", + "❔", + "❕", + "❗", + "〰️", + "💱", + "💲", + "⚕️", + "♻️", + "⚜️", + "🔱", + "📛", + "🔰", + "⭕", + "✅", + "☑️", + "✔️", + "❌", + "❎", + "➰", + "➿", + "〽️", + "✳️", + "✴️", + "❇️", + "©️", + "®️", + "™️", + "🫟", + "#️⃣", + "*️⃣", + "0️⃣", + "1️⃣", + "2️⃣", + "3️⃣", + "4️⃣", + "5️⃣", + "6️⃣", + "7️⃣", + "8️⃣", + "9️⃣", + "🔟", + "🔠", + "🔡", + "🔢", + "🔣", + "🔤", + "🅰️", + "🆎", + "🅱️", + "🆑", + "🆒", + "🆓", + "ℹ️", + "🆔", + "Ⓜ️", + "🆕", + "🆖", + "🅾️", + "🆗", + "🅿️", + "🆘", + "🆙", + "🆚", + "🈁", + "🈂️", + "🈷️", + "🈶", + "🈯", + "🉐", + "🈹", + "🈚", + "🈲", + "🉑", + "🈸", + "🈴", + "🈳", + "㊗️", + "㊙️", + "🈺", + "🈵", + "🔴", + "🟠", + "🟡", + "🟢", + "🔵", + "🟣", + "🟤", + "⚫", + "⚪", + "🟥", + "🟧", + "🟨", + "🟩", + "🟦", + "🟪", + "🟫", + "⬛", + "⬜", + "◼️", + "◻️", + "◾", + "◽", + "▪️", + "▫️", + "🔶", + "🔷", + "🔸", + "🔹", + "🔺", + "🔻", + "💠", + "🔘", + "🔳", + "🔲", + "🏁", + "🚩", + "🎌", + "🏴", + "🏳️", + "🏳️‍🌈", + "🏳️‍⚧️", + "🏴‍☠️", + "🇦🇨", + "🇦🇩", + "🇦🇪", + "🇦🇫", + "🇦🇬", + "🇦🇮", + "🇦🇱", + "🇦🇲", + "🇦🇴", + "🇦🇶", + "🇦🇷", + "🇦🇸", + "🇦🇹", + "🇦🇺", + "🇦🇼", + "🇦🇽", + "🇦🇿", + "🇧🇦", + "🇧🇧", + "🇧🇩", + "🇧🇪", + "🇧🇫", + "🇧🇬", + "🇧🇭", + "🇧🇮", + "🇧🇯", + "🇧🇱", + "🇧🇲", + "🇧🇳", + "🇧🇴", + "🇧🇶", + "🇧🇷", + "🇧🇸", + "🇧🇹", + "🇧🇻", + "🇧🇼", + "🇧🇾", + "🇧🇿", + "🇨🇦", + "🇨🇨", + "🇨🇩", + "🇨🇫", + "🇨🇬", + "🇨🇭", + "🇨🇮", + "🇨🇰", + "🇨🇱", + "🇨🇲", + "🇨🇳", + "🇨🇴", + "🇨🇵", + "🇨🇶", + "🇨🇷", + "🇨🇺", + "🇨🇻", + "🇨🇼", + "🇨🇽", + "🇨🇾", + "🇨🇿", + "🇩🇪", + "🇩🇬", + "🇩🇯", + "🇩🇰", + "🇩🇲", + "🇩🇴", + "🇩🇿", + "🇪🇦", + "🇪🇨", + "🇪🇪", + "🇪🇬", + "🇪🇭", + "🇪🇷", + "🇪🇸", + "🇪🇹", + "🇪🇺", + "🇫🇮", + "🇫🇯", + "🇫🇰", + "🇫🇲", + "🇫🇴", + "🇫🇷", + "🇬🇦", + "🇬🇧", + "🇬🇩", + "🇬🇪", + "🇬🇫", + "🇬🇬", + "🇬🇭", + "🇬🇮", + "🇬🇱", + "🇬🇲", + "🇬🇳", + "🇬🇵", + "🇬🇶", + "🇬🇷", + "🇬🇸", + "🇬🇹", + "🇬🇺", + "🇬🇼", + "🇬🇾", + "🇭🇰", + "🇭🇲", + "🇭🇳", + "🇭🇷", + "🇭🇹", + "🇭🇺", + "🇮🇨", + "🇮🇩", + "🇮🇪", + "🇮🇱", + "🇮🇲", + "🇮🇳", + "🇮🇴", + "🇮🇶", + "🇮🇷", + "🇮🇸", + "🇮🇹", + "🇯🇪", + "🇯🇲", + "🇯🇴", + "🇯🇵", + "🇰🇪", + "🇰🇬", + "🇰🇭", + "🇰🇮", + "🇰🇲", + "🇰🇳", + "🇰🇵", + "🇰🇷", + "🇰🇼", + "🇰🇾", + "🇰🇿", + "🇱🇦", + "🇱🇧", + "🇱🇨", + "🇱🇮", + "🇱🇰", + "🇱🇷", + "🇱🇸", + "🇱🇹", + "🇱🇺", + "🇱🇻", + "🇱🇾", + "🇲🇦", + "🇲🇨", + "🇲🇩", + "🇲🇪", + "🇲🇫", + "🇲🇬", + "🇲🇭", + "🇲🇰", + "🇲🇱", + "🇲🇲", + "🇲🇳", + "🇲🇴", + "🇲🇵", + "🇲🇶", + "🇲🇷", + "🇲🇸", + "🇲🇹", + "🇲🇺", + "🇲🇻", + "🇲🇼", + "🇲🇽", + "🇲🇾", + "🇲🇿", + "🇳🇦", + "🇳🇨", + "🇳🇪", + "🇳🇫", + "🇳🇬", + "🇳🇮", + "🇳🇱", + "🇳🇴", + "🇳🇵", + "🇳🇷", + "🇳🇺", + "🇳🇿", + "🇴🇲", + "🇵🇦", + "🇵🇪", + "🇵🇫", + "🇵🇬", + "🇵🇭", + "🇵🇰", + "🇵🇱", + "🇵🇲", + "🇵🇳", + "🇵🇷", + "🇵🇸", + "🇵🇹", + "🇵🇼", + "🇵🇾", + "🇶🇦", + "🇷🇪", + "🇷🇴", + "🇷🇸", + "🇷🇺", + "🇷🇼", + "🇸🇦", + "🇸🇧", + "🇸🇨", + "🇸🇩", + "🇸🇪", + "🇸🇬", + "🇸🇭", + "🇸🇮", + "🇸🇯", + "🇸🇰", + "🇸🇱", + "🇸🇲", + "🇸🇳", + "🇸🇴", + "🇸🇷", + "🇸🇸", + "🇸🇹", + "🇸🇻", + "🇸🇽", + "🇸🇾", + "🇸🇿", + "🇹🇦", + "🇹🇨", + "🇹🇩", + "🇹🇫", + "🇹🇬", + "🇹🇭", + "🇹🇯", + "🇹🇰", + "🇹🇱", + "🇹🇲", + "🇹🇳", + "🇹🇴", + "🇹🇷", + "🇹🇹", + "🇹🇻", + "🇹🇼", + "🇹🇿", + "🇺🇦", + "🇺🇬", + "🇺🇲", + "🇺🇳", + "🇺🇸", + "🇺🇾", + "🇺🇿", + "🇻🇦", + "🇻🇨", + "🇻🇪", + "🇻🇬", + "🇻🇮", + "🇻🇳", + "🇻🇺", + "🇼🇫", + "🇼🇸", + "🇽🇰", + "🇾🇪", + "🇾🇹", + "🇿🇦", + "🇿🇲", + "🇿🇼", + "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "🏴󠁧󠁢󠁷󠁬󠁳󠁿" + ]; +} \ No newline at end of file diff --git a/scripts/generate-emojis.js b/scripts/generate-emojis.js new file mode 100644 index 0000000..ba3a3b6 --- /dev/null +++ b/scripts/generate-emojis.js @@ -0,0 +1,38 @@ +try { + console.error('Downloading emoji-test.txt...'); + const response = await fetch('https://unicode.org/Public/emoji/latest/emoji-test.txt'); + const text = await response.text(); + const emojis = extractEmojis(text); + console.error(`Extracted ${emojis.length} fully-qualified emojis.`); + console.log(` + namespace Femto.Modules.Blog.Emoji; + + internal static partial class AllEmoji + { + public static readonly string[] Emojis = [\n${emojis.map(e => `"${e}"`).join(',\n')}\n]; + } + `) +} catch (err) { + console.error('Error:', err); +} + + +function extractEmojis(text) { + const lines = text.split('\n'); + const emojis = []; + + for (const line of lines) { + if (line.startsWith('#') || line.trim() === '') continue; + + const [codePart, descPart] = line.split(';'); + if (!descPart || !descPart.includes('fully-qualified')) continue; + + const match = line.match(/#\s+(.+?)\s+E\d+\.\d+/); + if (match) { + emojis.push(match[1]); + } + } + + return emojis; +} + From d1687f276bb7202e19493d6226945ec80592978c Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 20:05:00 +0200 Subject: [PATCH 41/66] return postdto from post create --- .../Posts/Dto/CreatePostResponse.cs | 2 +- ...cPostsResponse.cs => LoadPostsResponse.cs} | 0 Femto.Api/Controllers/Posts/Dto/PostDto.cs | 3 +- .../Controllers/Posts/Dto/PostReactionDto.cs | 2 +- .../Controllers/Posts/PostsController.cs | 30 ++- .../Migrations/20250518193113_AddUserRole.sql | 2 +- .../20250526220032_AddReactions.sql | 3 +- Femto.Database/Seed/TestDataSeeder.cs | 19 +- .../Commands/CreatePost/CreatePostCommand.cs | 20 +- .../CreatePost/CreatePostCommandHandler.cs | 20 +- .../Configurations/PostConfiguration.cs | 5 + .../Queries/GetPosts/Dto/PostDto.cs | 4 +- .../Queries/GetPosts/GetPostsQueryHandler.cs | 197 +++++++++++------- Femto.Modules.Blog/Domain/Posts/Post.cs | 19 +- .../Domain/Posts/PostReaction.cs | 11 +- 15 files changed, 226 insertions(+), 111 deletions(-) rename Femto.Api/Controllers/Posts/Dto/{GetAllPublicPostsResponse.cs => LoadPostsResponse.cs} (100%) diff --git a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs index a03dd93..1cec414 100644 --- a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs +++ b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Posts.Dto; -public record CreatePostResponse(Guid PostId); \ No newline at end of file +public record CreatePostResponse(PostDto Post); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs similarity index 100% rename from Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs rename to Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index 75ec296..a00c8c1 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -9,5 +9,6 @@ public record PostDto( string Content, IEnumerable Media, IEnumerable Reactions, - DateTimeOffset CreatedAt + DateTimeOffset CreatedAt, + IEnumerable PossibleReactions ); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs index 98a19b1..81e3a95 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Posts.Dto; -public record PostReactionDto(Guid ReactionId, string Emoji, int Count, bool DidReact); \ No newline at end of file +public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 99da1ad..b24fc39 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -11,7 +11,8 @@ namespace Femto.Api.Controllers.Posts; [ApiController] [Route("posts")] -public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase +public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) + : ControllerBase { [HttpGet] public async Task> LoadPosts( @@ -36,8 +37,9 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current p.PostId, p.Text, p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), - p.Reactions?.Select(r => new PostReactionDto(r.ReactionId, r.Emoji, r.Count, r.DidReact)), - p.CreatedAt + p.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)), + p.CreatedAt, + p.PossibleReactions )), res.Next ); @@ -50,7 +52,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current CancellationToken cancellationToken ) { - var guid = await blogModule.Command( + var post = await blogModule.Command( new CreatePostCommand( req.AuthorId, req.Content, @@ -65,18 +67,32 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current media.Height ) ), - req.IsPublic + req.IsPublic, + currentUserContext.CurrentUser! ), cancellationToken ); - return new CreatePostResponse(guid); + return new CreatePostResponse( + new PostDto( + new PostAuthorDto(post.Author.AuthorId, post.Author.Username), + post.PostId, + post.Text, + post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), + post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(), + post.CreatedAt, + post.PossibleReactions + ) + ); } [HttpDelete("{postId}")] [Authorize] public async Task DeletePost(Guid postId, CancellationToken cancellationToken) { - await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), cancellationToken); + await blogModule.Command( + new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), + cancellationToken + ); } } diff --git a/Femto.Database/Migrations/20250518193113_AddUserRole.sql b/Femto.Database/Migrations/20250518193113_AddUserRole.sql index 8199a10..9febe66 100644 --- a/Femto.Database/Migrations/20250518193113_AddUserRole.sql +++ b/Femto.Database/Migrations/20250518193113_AddUserRole.sql @@ -6,4 +6,4 @@ CREATE TABLE authn.user_role user_id uuid REFERENCES authn.user_identity(id), role int, primary key (user_id, role) -); \ No newline at end of file +) \ No newline at end of file diff --git a/Femto.Database/Migrations/20250526220032_AddReactions.sql b/Femto.Database/Migrations/20250526220032_AddReactions.sql index 8c18e2e..a99bbae 100644 --- a/Femto.Database/Migrations/20250526220032_AddReactions.sql +++ b/Femto.Database/Migrations/20250526220032_AddReactions.sql @@ -8,5 +8,6 @@ CREATE TABLE blog.post_reaction ( post_id uuid REFERENCES blog.post(id), author_id uuid REFERENCES blog.author(id), - emoji text not null + emoji text not null, + primary key (post_id, author_id, emoji) ); \ No newline at end of file diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index 47d22aa..433f73c 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -35,12 +35,12 @@ public static class TestDataSeeder ; INSERT INTO blog.post - (id, author_id, content) + (id, author_id, possible_reactions, content) VALUES - ('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.') + ('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 @@ -54,6 +54,15 @@ public static class TestDataSeeder ('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 blog.post_reaction + (post_id, author_id, emoji) + VALUES + ('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '🍆'), + ('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '🍆'), + ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🧑🏾‍'), + ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗') + ; + INSERT INTO authn.user_identity (id, username, password_hash, password_salt) VALUES diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs index d10c496..30cc602 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs @@ -1,8 +1,22 @@ +using Femto.Common; using Femto.Common.Domain; +using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Commands.CreatePost; -public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable Media, bool? IsPublic) - : ICommand; +public record CreatePostCommand( + Guid AuthorId, + string Content, + IEnumerable Media, + bool? IsPublic, + CurrentUser CurrentUser +) : ICommand; -public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height); +public record CreatePostMedia( + Guid MediaId, + Uri Url, + string? Type, + int Order, + int? Width, + int? Height +); diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs index cda4b2d..bfa6d31 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -1,12 +1,16 @@ +using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; using Femto.Modules.Blog.Domain.Posts; using MediatR; namespace Femto.Modules.Blog.Application.Commands.CreatePost; internal class CreatePostCommandHandler(BlogContext context) - : IRequestHandler + : IRequestHandler { - public async Task Handle(CreatePostCommand request, CancellationToken cancellationToken) + public async Task Handle( + CreatePostCommand request, + CancellationToken cancellationToken + ) { var post = new Post( request.AuthorId, @@ -22,11 +26,19 @@ internal class CreatePostCommandHandler(BlogContext context) )) .ToList() ); - + post.IsPublic = request.IsPublic is true; await context.AddAsync(post, cancellationToken); - return post.Id; + return new PostDto( + post.Id, + post.Content, + post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)).ToList(), + post.PostedOn, + new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), + [], + post.PossibleReactions + ); } } diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index 936c10e..40fbdbf 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -11,5 +11,10 @@ internal class PostConfiguration : IEntityTypeConfiguration table.ToTable("post"); table.OwnsMany(post => post.Media).WithOwner(); table.OwnsMany(post => post.Reactions).WithOwner(); + + table.Property("PossibleReactionsJson") + .HasColumnName("possible_reactions"); + + table.Ignore(e => e.PossibleReactions); } } diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs index 3f20873..b8b6a3d 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -6,4 +6,6 @@ public record PostDto( IList Media, DateTimeOffset CreatedAt, PostAuthorDto Author, - IList Reactions); \ No newline at end of file + IList Reactions, + IEnumerable PossibleReactions +); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index 7ace8a6..26ae43a 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Dapper; using Femto.Common.Infrastructure.DbConnection; using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; @@ -15,42 +16,29 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) { using var conn = connectionFactory.GetConnection(); - var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc"; - var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">="; var username = query.Author; var authorGuid = query.AuthorId; var cursor = query.From; var showPrivate = query.CurrentUserId is not null; - // lang=sql - var sql = $$""" - with page as ( - select blog.post.*, blog.author.username as Username, blog.author.id as AuthorId - from blog.post - inner join blog.author on blog.author.id = blog.post.author_id - where (@username is null or blog.author.username = @username) - and (@showPrivate or blog.post.is_public = true) - and (@authorGuid is null or blog.author.id = @authorGuid) - and (@cursor is null or blog.post.id {{pageFilter}} @cursor) - order by blog.post.id {{orderBy}} - limit @amount - ) - select - page.id as PostId, - page.content as Content, - blog.post_media.url as MediaUrl, - blog.post_media.width as MediaWidth, - blog.post_media.height as MediaHeight, - page.posted_on as PostedOn, - page.Username, - page.AuthorId - from page - left join blog.post_media on blog.post_media.post_id = page.id - order by page.id {{orderBy}} - """; - - var result = await conn.QueryAsync( - sql, + var loadPostsResult = await conn.QueryAsync( + """ + select + blog.post.id as PostId, + blog.post.content as Content, + blog.post.posted_on as PostedOn, + blog.author.username as Username, + blog.author.id as AuthorId, + blog.post.possible_reactions as PossibleReactions + from blog.post + inner join blog.author on blog.author.id = blog.post.author_id + where (@username is null or blog.author.username = @username) + and (@showPrivate or blog.post.is_public = true) + and (@authorGuid is null or blog.author.id = @authorGuid) + and (@cursor is null or blog.post.id <= @cursor) + order by blog.post.id desc + limit @amount + """, new { username, @@ -62,54 +50,115 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) } ); - var rows = result.ToList(); + var loadedPosts = loadPostsResult.ToList(); + var posts = loadedPosts.Take(query.Amount).ToList(); + var next = loadedPosts.LastOrDefault()?.PostId; - var posts = rows.GroupBy(row => row.PostId) - .Select(group => - { - var postId = group.Key; - var post = group.First(); - var media = group - .Select(row => - { - if (row.MediaUrl is not null) - { - return new PostMediaDto( - new Uri(row.MediaUrl), - row.MediaHeight, - row.MediaHeight - ); - } - else - return null; - }) - .OfType() - .ToList(); - return new PostDto( - postId, - post.Content, - media, - post.PostedOn, - new PostAuthorDto(post.AuthorId, post.Username) - ); - }) - .ToList(); + var postIds = posts.Select(p => p.PostId).ToList(); - var next = rows.Count >= query.Amount ? rows.LastOrDefault()?.PostId : null; + var loadMediaResult = await conn.QueryAsync( + """ + select + pm.url as MediaUrl, + pm.type as MediaType, + pm.width as MediaWidth, + pm.height as MediaHeight, + pm.post_id as PostId + from blog.post_media pm where pm.post_id = ANY (@postIds) + order by pm.ordering + """, + new { postIds } + ); - return new GetPostsQueryResult(posts, next); + var loadReactionsResult = await conn.QueryAsync( + """ + select + pr.post_id as PostId, + pr.author_id as AuthorId, + pr.emoji as Emoji + from blog.post_reaction pr + where pr.post_id = ANY (@postIds) + """, + new { postIds } + ); + + var reactionsByPostId = loadReactionsResult + .GroupBy(r => r.PostId) + .ToDictionary( + group => group.Key, + group => + group + .GroupBy( + r => r.Emoji, + (key, g) => + { + var reactions = g.ToList(); + return new PostReactionDto( + key, + reactions.Count, + reactions.Any(r => r.AuthorId == query.CurrentUserId) + ); + } + ) + .ToList() + ); + + var mediaByPostId = loadMediaResult + .GroupBy(m => m.PostId) + .ToDictionary( + g => g.Key, + g => + g.Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )) + .ToList() + ); + + return new GetPostsQueryResult( + posts + .Select(p => new PostDto( + p.PostId, + p.Content, + mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [], + p.PostedOn, + new PostAuthorDto(p.AuthorId, p.Username), + reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos) + ? reactionDtos.ToList() + : [], + !string.IsNullOrEmpty(p.PossibleReactions) + ? JsonSerializer.Deserialize>(p.PossibleReactions)! + : [] + )) + .ToList(), + next + ); } - internal class QueryResult + internal record LoadPostRow { - public Guid PostId { get; set; } - public string Content { get; set; } - public string? MediaUrl { get; set; } - public string? MediaType { get; set; } - public int? MediaWidth { get; set; } - public int? MediaHeight { get; set; } - public DateTimeOffset PostedOn { get; set; } - public Guid AuthorId { get; set; } - public string Username { get; set; } + public Guid PostId { get; init; } + public string Content { get; init; } + public DateTimeOffset PostedOn { get; init; } + public string Username { get; init; } + public Guid AuthorId { get; init; } + public string? PossibleReactions { get; init; } + } + + internal record LoadMediaRow + { + public string MediaUrl { get; init; } + public string? MediaType { get; init; } + public int? MediaWidth { get; init; } + public int? MediaHeight { get; init; } + public Guid PostId { get; init; } + } + + internal record LoadReactionRow + { + public Guid PostId { get; init; } + public Guid AuthorId { get; init; } + public string Emoji { get; init; } } } diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index 0279dcb..2244cca 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Femto.Common.Domain; using Femto.Modules.Blog.Domain.Posts.Events; using Femto.Modules.Blog.Emoji; @@ -13,6 +14,16 @@ internal class Post : Entity public IList Reactions { get; private set; } = []; public bool IsPublic { get; set; } + + public DateTimeOffset PostedOn { get; private set; } + + private string PossibleReactionsJson { get; set; } = null!; + + public IEnumerable PossibleReactions + { + get => JsonSerializer.Deserialize>(this.PossibleReactionsJson)!; + init => PossibleReactionsJson = JsonSerializer.Serialize(value); + } private Post() { } @@ -22,13 +33,9 @@ internal class Post : Entity this.AuthorId = authorId; this.Content = content; this.Media = media; - - this.Reactions = AllEmoji - .GetRandomEmoji(5) - .Select(emoji => new PostReaction(emoji, 0)) - .ToList(); + this.PossibleReactions = AllEmoji.GetRandomEmoji(5); + this.PostedOn = DateTimeOffset.UtcNow; this.AddDomainEvent(new PostCreated(this)); } - } diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs index beb24b8..ea8ab16 100644 --- a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs +++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs @@ -2,15 +2,14 @@ namespace Femto.Modules.Blog.Domain.Posts; public class PostReaction { - public Guid Id { get; private set; } + public Guid AuthorId { get; private set; } + public Guid PostId { get; private set; } public string Emoji { get; private set; } = null!; - public int Count { get; private set; } - - public PostReaction(string emoji, int count) + public PostReaction(Guid authorId, Guid postId, string emoji) { - this.Id = Guid.CreateVersion7(); + this.AuthorId = authorId; + this.PostId = postId; this.Emoji = emoji; - this.Count = count; } private PostReaction() { } From 8e8e4e22581ff4f88441286688070cd30f2e6183 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:05:20 +0200 Subject: [PATCH 42/66] post reactions --- .../Posts/Dto/AddPostReactionRequest.cs | 3 +++ .../Posts/Dto/DeletePostReactionRequest.cs | 3 +++ .../Controllers/Posts/PostsController.cs | 25 +++++++++++++++++++ .../AddPostReaction/AddPostReactionCommand.cs | 6 +++++ .../AddPostReactionCommandHandler.cs | 21 ++++++++++++++++ .../ClearPostReactionCommand.cs | 6 +++++ .../ClearPostReactionCommandHandler.cs | 22 ++++++++++++++++ .../CreatePost/CreatePostCommandHandler.cs | 7 +++--- .../Configurations/PostConfiguration.cs | 19 +++++++++++--- Femto.Modules.Blog/Domain/Posts/Post.cs | 20 ++++++++++++++- 10 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs create mode 100644 Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs create mode 100644 Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs create mode 100644 Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs create mode 100644 Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs create mode 100644 Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs new file mode 100644 index 0000000..36330cf --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record AddPostReactionRequest(string Emoji); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs b/Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs new file mode 100644 index 0000000..cb39e0e --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record DeletePostReactionRequest(string Emoji); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index b24fc39..6036767 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -1,6 +1,8 @@ using Femto.Api.Controllers.Posts.Dto; using Femto.Common; using Femto.Modules.Blog.Application; +using Femto.Modules.Blog.Application.Commands.AddPostReaction; +using Femto.Modules.Blog.Application.Commands.ClearPostReaction; using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Commands.DeletePost; using Femto.Modules.Blog.Application.Queries.GetPosts; @@ -95,4 +97,27 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); } + + [HttpPost("{postId}/reactions")] + [Authorize] + public async Task AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken) + { + var currentUser = currentUserContext.CurrentUser!; + + await blogModule.Command(new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken); + + return this.Ok(); + } + + [HttpDelete("{postId}/reactions")] + [Authorize] + public async Task DeletePostReaction(Guid postId, [FromBody] DeletePostReactionRequest request, CancellationToken cancellationToken) + { + var currentUser = currentUserContext.CurrentUser!; + + await blogModule.Command(new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken); + + return this.Ok(); + } + } diff --git a/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs new file mode 100644 index 0000000..9096687 --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs @@ -0,0 +1,6 @@ +using Femto.Common; +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Application.Commands.AddPostReaction; + +public record AddPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs new file mode 100644 index 0000000..e2c3f8a --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs @@ -0,0 +1,21 @@ +using Femto.Common.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Blog.Application.Commands.AddPostReaction; + +internal class AddPostReactionCommandHandler(BlogContext context) + : ICommandHandler +{ + public async Task Handle(AddPostReactionCommand request, CancellationToken cancellationToken) + { + var post = await context.Posts.SingleOrDefaultAsync( + p => p.Id == request.PostId, + cancellationToken + ); + + if (post is null) + return; + + post.AddReaction(request.ReactorId, request.Emoji); + } +} diff --git a/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs new file mode 100644 index 0000000..618305f --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs @@ -0,0 +1,6 @@ +using Femto.Common; +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction; + +public record ClearPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId): ICommand; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs new file mode 100644 index 0000000..e4d344e --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs @@ -0,0 +1,22 @@ +using Femto.Common.Domain; +using Femto.Modules.Blog.Application.Commands.AddPostReaction; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction; + +internal class ClearPostReactionCommandHandler(BlogContext context) + : ICommandHandler +{ + public async Task Handle(ClearPostReactionCommand request, CancellationToken cancellationToken) + { + var post = await context.Posts.SingleOrDefaultAsync( + p => p.Id == request.PostId, + cancellationToken + ); + + if (post is null) + return; + + post.RemoveReaction(request.ReactorId, request.Emoji); + } +} diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs index bfa6d31..2d9c713 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -25,9 +25,10 @@ internal class CreatePostCommandHandler(BlogContext context) media.Height )) .ToList() - ); - - post.IsPublic = request.IsPublic is true; + ) + { + IsPublic = request.IsPublic is true + }; await context.AddAsync(post, cancellationToken); diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index 40fbdbf..b1defec 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -10,10 +10,21 @@ internal class PostConfiguration : IEntityTypeConfiguration { table.ToTable("post"); table.OwnsMany(post => post.Media).WithOwner(); - table.OwnsMany(post => post.Reactions).WithOwner(); - - table.Property("PossibleReactionsJson") - .HasColumnName("possible_reactions"); + table.OwnsMany( + post => post.Reactions, + reactions => + { + reactions.WithOwner().HasForeignKey(r => r.PostId); + reactions.HasKey(r => new + { + r.PostId, + r.AuthorId, + r.Emoji, + }); + } + ); + + table.Property("PossibleReactionsJson").HasColumnName("possible_reactions"); table.Ignore(e => e.PossibleReactions); } diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index 2244cca..dc4d937 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -14,7 +14,7 @@ internal class Post : Entity public IList Reactions { get; private set; } = []; public bool IsPublic { get; set; } - + public DateTimeOffset PostedOn { get; private set; } private string PossibleReactionsJson { get; set; } = null!; @@ -38,4 +38,22 @@ internal class Post : Entity this.AddDomainEvent(new PostCreated(this)); } + + public void AddReaction(Guid reactorId, string emoji) + { + if (!this.PossibleReactions.Contains(emoji)) + return; + + if (this.Reactions.Any(r => r.AuthorId == reactorId && r.Emoji == emoji)) + return; + + this.Reactions.Add(new PostReaction(reactorId, this.Id, emoji)); + } + + public void RemoveReaction(Guid reactorId, string emoji) + { + this.Reactions = this + .Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji) + .ToList(); + } } From aa4394fd21f350ea391eb8b1b3c6decaea69e685 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:08:59 +0200 Subject: [PATCH 43/66] v0.1.25 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0e31fcf..1afa3d5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.24 + 0.1.25 From 7b6c155a737847c56f5db210b9a365edf243822b Mon Sep 17 00:00:00 2001 From: john Date: Thu, 29 May 2025 00:39:40 +0200 Subject: [PATCH 44/66] wip session auth --- .../Auth/SessionAuthenticationHandler.cs | 37 +++++--- Femto.Api/Controllers/Auth/AuthController.cs | 12 ++- .../Sessions/HttpContextSessionExtensions.cs | 8 +- Femto.Common/ICurrentUserContext.cs | 2 +- Femto.Docs/Design/Auth/RememberMe.md | 27 ++++++ .../Design/Auth/strong_vs_weak_session.md | 16 ++++ .../Application/Dto/LoginResult.cs | 2 +- .../Dto/RefreshUserSessionResult.cs | 2 +- .../Application/Dto/RegisterResult.cs | 2 +- Femto.Modules.Auth/Application/Dto/Session.cs | 17 +++- .../Application/Dto/ValidateSessionResult.cs | 2 +- .../Deauthenticate/DeauthenticateCommand.cs | 6 ++ .../DeauthenticateCommandHandler.cs | 12 +++ .../Interface/Login/LoginCommandHandler.cs | 9 +- .../RefreshUserSessionCommand.cs | 2 +- .../RefreshUserSessionCommandHandler.cs | 22 ++++- .../Register/RegisterCommandHandler.cs | 28 ++++-- .../ValidateSession/ValidateSessionCommand.cs | 2 +- .../ValidateSessionCommandHandler.cs | 91 +++++++++++++++++-- Femto.Modules.Auth/Data/AuthContext.cs | 2 + Femto.Modules.Auth/Models/LongTermSession.cs | 51 +++++++++++ Femto.Modules.Auth/Models/UserIdentity.cs | 27 +----- Femto.Modules.Auth/Models/UserSession.cs | 32 +++++-- 23 files changed, 321 insertions(+), 90 deletions(-) create mode 100644 Femto.Docs/Design/Auth/RememberMe.md create mode 100644 Femto.Docs/Design/Auth/strong_vs_weak_session.md create mode 100644 Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs create mode 100644 Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs create mode 100644 Femto.Modules.Auth/Models/LongTermSession.cs diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index e71481d..539cdea 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -1,8 +1,10 @@ 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 Microsoft.AspNetCore.Authentication; @@ -25,29 +27,42 @@ internal class SessionAuthenticationHandler( if (string.IsNullOrWhiteSpace(sessionId)) 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)); + var result = await authModule.Command( + new ValidateSessionCommand(sessionId, user, rememberMe) + ); var claims = new List { - new(ClaimTypes.Name, result.User.Username), - new("sub", result.User.Id.ToString()), - new("user_id", result.User.Id.ToString()), + new(ClaimTypes.Name, user.Username), + new("sub", user.Id.ToString()), + new("user_id", user.Id.ToString()), }; - claims.AddRange( - result.User.Roles.Select(role => new Claim(ClaimTypes.Role, role.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.Session, result.User, Logger); + this.Context.SetSession(result.SessionDto, user, Logger); + currentUserContext.CurrentUser = new CurrentUser( - result.User.Id, - result.User.Username, - result.Session.SessionId + user.Id, + user.Username, + result.SessionDto.SessionId, + rememberMe ); return AuthenticateResult.Success( diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index b567d1d..7885038 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -29,7 +29,7 @@ public class AuthController( { var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); - HttpContext.SetSession(result.Session, result.User, logger); + HttpContext.SetSession(result.SessionDto, result.User, logger); return new LoginResponse( result.User.Id, @@ -45,7 +45,7 @@ public class AuthController( new RegisterCommand(request.Username, request.Password, request.SignupCode) ); - HttpContext.SetSession(result.Session, result.User, logger); + HttpContext.SetSession(result.SessionDto, result.User, logger); return new RegisterResponse( result.User.Id, @@ -57,7 +57,13 @@ public class AuthController( [HttpDelete("session")] public async Task DeleteSession() { + var currentUser = currentUserContext.CurrentUser; + + if (currentUser != null) + await authModule.Command(new DeauthenticateCommand(currentUser.Id, currentUser.SessionId, currentUser.RememberMeToken)); + HttpContext.DeleteSession(); + return Ok(new { }); } @@ -73,7 +79,7 @@ public class AuthController( try { var result = await authModule.Command( - new RefreshUserSessionCommand(userId, currentUser), + new RefreshUserCommand(userId, currentUser), cancellationToken ); diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index f5e5d25..bd95387 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -8,14 +8,14 @@ namespace Femto.Api.Sessions; internal static class HttpContextSessionExtensions { - public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger) + public static void SetSession(this HttpContext httpContext, SessionDto sessionDto, UserInfo user, ILogger logger) { var cookieSettings = httpContext.RequestServices.GetService>(); var secure = cookieSettings?.Value.Secure ?? true; var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict; var domain = cookieSettings?.Value.Domain; - var expires = session.Expires; + var expires = sessionDto.Expires; logger.LogInformation( "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", @@ -27,7 +27,7 @@ internal static class HttpContextSessionExtensions httpContext.Response.Cookies.Append( "session", - session.SessionId, + sessionDto.SessionId, new CookieOptions { IsEssential = true, @@ -55,7 +55,7 @@ internal static class HttpContextSessionExtensions IsEssential = true, Secure = secure, SameSite = sameSite, - Expires = session.Expires, + Expires = sessionDto.Expires, } ); } diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index a7233e0..4d2db53 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); +public record CurrentUser(Guid Id, string Username, string SessionId, string? RememberMeToken); diff --git a/Femto.Docs/Design/Auth/RememberMe.md b/Femto.Docs/Design/Auth/RememberMe.md new file mode 100644 index 0000000..0ff9ec2 --- /dev/null +++ b/Femto.Docs/Design/Auth/RememberMe.md @@ -0,0 +1,27 @@ +# Remember me + +We want to implement long lived sessions + +we will do this with a remember me cookie + +this should be implemented as so: + + +logging or registering and including a "rememberMe" flag with the request will generate a new remember me token, which can be stored as a cookie . + +the remember me token should live until: +* the user changes password anywhere +* the user logs out on that device +* the user logs in with an expired session, in which case the remember me token will be used to refresh the session, and then it will be swapped out for a new one + +that means we need to implement three spots: +- [ ] login +- [ ] register +- [ ] validate session + +we will implement it as described [here](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence) + +we will only check the remember me token in "validate session". + +"refresh session" is only called with valid sessions so we do not need to check it here, as the session should already have been validated + diff --git a/Femto.Docs/Design/Auth/strong_vs_weak_session.md b/Femto.Docs/Design/Auth/strong_vs_weak_session.md new file mode 100644 index 0000000..5a45a7d --- /dev/null +++ b/Femto.Docs/Design/Auth/strong_vs_weak_session.md @@ -0,0 +1,16 @@ +# Strong vs weak sessions + +a **strong** session is one that should have the power to do account level admin tasks like change password + + +a **weak** session has strictly fewer privileges than a strong session + +## where to get a strong session + +a strong session is created when a user provides a username and a password. a session remains strong until it is refreshed, at which point it becomes weak. + +## where to get a weak session + +A weak session is any session that has not been directly created by user credentials, i.e.: +* short-term session refresh +* long-term session refresh \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Dto/LoginResult.cs b/Femto.Modules.Auth/Application/Dto/LoginResult.cs index 1405a28..c9048ad 100644 --- a/Femto.Modules.Auth/Application/Dto/LoginResult.cs +++ b/Femto.Modules.Auth/Application/Dto/LoginResult.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Auth.Application.Dto; -public record LoginResult(Session Session, UserInfo User); \ No newline at end of file +public record LoginResult(SessionDto SessionDto, UserInfo User); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs index ac1bbc3..19f1d17 100644 --- a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs +++ b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Auth.Application.Dto; -public record RefreshUserSessionResult(Session Session, UserInfo User); \ No newline at end of file +public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Dto/RegisterResult.cs b/Femto.Modules.Auth/Application/Dto/RegisterResult.cs index 13e1d12..e0a1243 100644 --- a/Femto.Modules.Auth/Application/Dto/RegisterResult.cs +++ b/Femto.Modules.Auth/Application/Dto/RegisterResult.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Auth.Application.Dto; -public record RegisterResult(Session Session, UserInfo User); \ No newline at end of file +public record RegisterResult(SessionDto SessionDto, UserInfo User); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Dto/Session.cs b/Femto.Modules.Auth/Application/Dto/Session.cs index 9e87ca8..7f422eb 100644 --- a/Femto.Modules.Auth/Application/Dto/Session.cs +++ b/Femto.Modules.Auth/Application/Dto/Session.cs @@ -2,9 +2,16 @@ using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application.Dto; -public record Session(string SessionId, DateTimeOffset Expires) +public record SessionDto( + string SessionId, + DateTimeOffset Expires, + bool Weak, + string? RememberMe = null +) { - internal Session(UserSession session) : this(session.Id, session.Expires) - { - } -} \ No newline at end of file + internal SessionDto(Session session) + : this(session.Id, session.Expires, !session.IsStronglyAuthenticated) { } + + internal SessionDto(Session session, string? rememberMe) + : this(session.Id, session.Expires, !session.IsStronglyAuthenticated, rememberMe) { } +} diff --git a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs index 7fb022f..e29c84a 100644 --- a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs +++ b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Auth.Application.Dto; -public record ValidateSessionResult(Session Session, UserInfo User); \ No newline at end of file +public record ValidateSessionResult(SessionDto SessionDto); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs new file mode 100644 index 0000000..44c346f --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs @@ -0,0 +1,6 @@ + +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Application.Interface.Deauthenticate; + +public record DeauthenticateCommand(Guid UserId, string SessionId, string? RememberMeToken) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs new file mode 100644 index 0000000..435718c --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs @@ -0,0 +1,12 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Data; + +namespace Femto.Modules.Auth.Application.Interface.Deauthenticate; + +internal class DeauthenticateCommandHandler(AuthContext context) : ICommandHandler +{ + public async Task Handle(DeauthenticateCommand request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} \ 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 index 45b1ae4..74094b5 100644 --- a/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs @@ -1,6 +1,7 @@ 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; @@ -21,8 +22,10 @@ internal class LoginCommandHandler(AuthContext context) if (!user.HasPassword(request.Password)) throw new DomainError("invalid credentials"); - var session = user.StartNewSession(); - - return new(new Session(session.Id, session.Expires), new UserInfo(user)); + 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 index f04fa82..1406d66 100644 --- a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs +++ b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs @@ -4,4 +4,4 @@ using Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession; -public record RefreshUserSessionCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand; \ No newline at end of file +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 index f0c6dc1..ab1222a 100644 --- a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs @@ -2,15 +2,17 @@ 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 + : ICommandHandler { public async Task Handle( - RefreshUserSessionCommand request, + RefreshUserCommand request, CancellationToken cancellationToken ) { @@ -25,8 +27,20 @@ internal class RefreshUserSessionCommandHandler(AuthContext context) if (user is null) throw new DomainError("invalid request"); - var session = user.PossiblyRefreshSession(request.CurrentUser.SessionId); + var session = await context.Sessions.SingleOrDefaultAsync( + s => s.Id == request.CurrentUser.SessionId && s.Expires > DateTimeOffset.UtcNow, + cancellationToken + ); - return new(new Session(session), new UserInfo(user)); + 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/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs index 9e29be6..6ad2285 100644 --- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs @@ -6,30 +6,38 @@ using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Application.Interface.Register; -internal class RegisterCommandHandler(AuthContext context) : ICommandHandler +internal class RegisterCommandHandler(AuthContext context) + : 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) + + var code = await context + .SignupCodes.Where(c => c.Code == request.SignupCode) .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) .Where(c => c.RedeemingUserId == null) .SingleOrDefaultAsync(cancellationToken); if (code is null) throw new DomainError("invalid signup code"); - + var user = new UserIdentity(request.Username); + + await context.AddAsync(user, cancellationToken); user.SetPassword(request.Password); - var session = user.StartNewSession(); + var session = Session.Strong(user.Id); - await context.AddAsync(user, cancellationToken); + await context.AddAsync(session, cancellationToken); code.Redeem(user.Id); - - return new(new Session(session.Id, session.Expires), new UserInfo(user)); + + return new(new SessionDto(session), new UserInfo(user)); } -} \ No newline at end of file +} diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs index 40d5417..5e5fbb2 100644 --- a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs +++ b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs @@ -7,4 +7,4 @@ 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) : ICommand; +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 index f79552c..34b7c72 100644 --- a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs @@ -1,7 +1,9 @@ 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; @@ -13,22 +15,97 @@ internal class ValidateSessionCommandHandler(AuthContext context) 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 user = await context.Users.SingleOrDefaultAsync( - u => u.Sessions.Any(s => s.Id == request.SessionId && s.Expires > now), + var session = await context.Sessions.SingleOrDefaultAsync( + s => s.Id == request.SessionId, cancellationToken ); - if (user is null) + 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 session = user.PossiblyRefreshSession(request.SessionId); + var parts = rememberMeToken.Split('.'); + if (parts.Length != 2) + throw new InvalidSessionError(); - return new ValidateSessionResult( - new Session(session.Id, session.Expires), - new UserInfo(user) + 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/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs index e850eb8..1b18c61 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -7,7 +7,9 @@ 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; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/Femto.Modules.Auth/Models/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs new file mode 100644 index 0000000..06bd19d --- /dev/null +++ b/Femto.Modules.Auth/Models/LongTermSession.cs @@ -0,0 +1,51 @@ +using System.Text; +using static System.Security.Cryptography.RandomNumberGenerator; + +namespace Femto.Modules.Auth.Models; + +public class LongTermSession +{ + private static TimeSpan TokenTimeout { get; } = TimeSpan.FromDays(90); + + public int Id { get; private set; } + + public string Selector { get; private set; } + + public byte[] HashedVerifier { get; private set; } + + public DateTimeOffset Expires { get; private set; } + + public Guid UserId { get; private set; } + + private LongTermSession() {} + + public static (LongTermSession, string) Create(Guid userId) + { + var selector = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 12); + var verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32); + + using var sha256 = System.Security.Cryptography.SHA256.Create(); + + var longTermSession = new LongTermSession + { + Selector = selector, + HashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)), + UserId = userId, + Expires = DateTimeOffset.UtcNow + TokenTimeout + }; + + var rememberMeToken = $"{selector}.{verifier}"; + + return (longTermSession, rememberMeToken); + } + + public bool Validate(string verifier) + { + if (this.Expires < DateTimeOffset.UtcNow) + return false; + + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); + return hashedVerifier.SequenceEqual(this.HashedVerifier); + } +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index 756be41..12cf73e 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -15,7 +15,7 @@ internal class UserIdentity : Entity public Password? Password { get; private set; } - public ICollection Sessions { get; private set; } = []; + public ICollection Sessions { get; private set; } = []; public ICollection Roles { get; private set; } = []; @@ -31,12 +31,6 @@ internal class UserIdentity : Entity this.AddDomainEvent(new UserWasCreatedEvent(this)); } - public UserIdentity WithPassword(string password) - { - this.SetPassword(password); - return this; - } - public void SetPassword(string password) { this.Password = new Password(password); @@ -51,25 +45,6 @@ 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(); - - this.Sessions.Add(session); - - return session; - } } public class SetPasswordError(string message, Exception inner) : DomainError(message, inner); diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs index 7deb251..72a59ea 100644 --- a/Femto.Modules.Auth/Models/UserSession.cs +++ b/Femto.Modules.Auth/Models/UserSession.cs @@ -1,21 +1,33 @@ +using static System.Security.Cryptography.RandomNumberGenerator; + namespace Femto.Modules.Auth.Models; -internal class UserSession +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; - - private UserSession() {} - public static UserSession Create() + // 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) { - return new() - { - Id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)), - Expires = DateTimeOffset.UtcNow + SessionTimeout - }; + this.Id = Convert.ToBase64String(GetBytes(32)); + this.UserId = userId; + this.Expires = DateTimeOffset.UtcNow + SessionTimeout; + this.IsStronglyAuthenticated = isStrong; } -} \ No newline at end of file +} From f48b421500025421c30418685ac76ec62a1962d2 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 1 Jun 2025 23:28:00 +0200 Subject: [PATCH 45/66] 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( From c2846aed4df5852d2c181f4620ca4009dcad703e Mon Sep 17 00:00:00 2001 From: john Date: Sun, 1 Jun 2025 23:35:33 +0200 Subject: [PATCH 46/66] dont use user from user cookie whatsoever!!! --- Femto.Api/Auth/SessionAuthenticationHandler.cs | 17 +++-------------- Femto.Api/Controllers/Auth/AuthController.cs | 2 +- .../Sessions/HttpContextSessionExtensions.cs | 12 ++---------- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 37939a4..2559988 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -21,7 +21,7 @@ internal class SessionAuthenticationHandler( { Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier); - var (sessionId, maybeUserId) = this.Context.GetSessionInfo(); + var sessionId = this.Context.GetSessionId(); if (sessionId is null) @@ -44,19 +44,8 @@ internal class SessionAuthenticationHandler( 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); + var user = await authService.GetUserWithId(session.UserId); if (user is null) { @@ -67,7 +56,7 @@ internal class SessionAuthenticationHandler( if (session.ExpiresSoon) { - session = await authService.CreateWeakSession(userId); + session = await authService.CreateWeakSession(session.UserId); this.Context.SetSession(session, user); } diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 3ca6203..5322493 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -64,7 +64,7 @@ public class AuthController( [HttpDelete("session")] public async Task DeleteSession() { - var (sessionId, userId) = HttpContext.GetSessionInfo(); + var sessionId = HttpContext.GetSessionId(); if (sessionId is not null) { diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index fcf2a1f..e693180 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -15,19 +15,11 @@ internal static class HttpContextSessionExtensions PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - public static SessionInfo GetSessionInfo(this HttpContext httpContext) + public static string? GetSessionId(this HttpContext httpContext) { var sessionId = httpContext.Request.Cookies["sid"]; - - var userJson = httpContext.Request.Cookies["user"]; - - UserInfo? user = null; - if (userJson is not null) - { - user = JsonSerializer.Deserialize(userJson, JsonOptions); - } - return new SessionInfo(sessionId, user?.Id); + return sessionId; } public static void SetSession(this HttpContext context, Session session, UserInfo user) From 120adf806ddb3c72e416fae388435530318d2a12 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 1 Jun 2025 23:35:44 +0200 Subject: [PATCH 47/66] ocd --- Femto.Api/Auth/SessionAuthenticationHandler.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 2559988..aa549d9 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -23,7 +23,6 @@ internal class SessionAuthenticationHandler( var sessionId = this.Context.GetSessionId(); - if (sessionId is null) { Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier); @@ -44,7 +43,6 @@ internal class SessionAuthenticationHandler( return await FailAndDeleteSession(sessionId); } - var user = await authService.GetUserWithId(session.UserId); if (user is null) From 8798c8999d186919fe4b2f6a4e4a681a6af5ead5 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 1 Jun 2025 23:38:09 +0200 Subject: [PATCH 48/66] change something --- Femto.Api/Auth/SessionAuthenticationHandler.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index aa549d9..f98ae8d 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -45,11 +45,9 @@ internal class SessionAuthenticationHandler( var user = await authService.GetUserWithId(session.UserId); - if (user is null) + if (user is null) { - await authService.DeleteSession(sessionId); - this.Context.DeleteSession(); - return AuthenticateResult.Fail("invalid session"); + return await FailAndDeleteSession(sessionId); } if (session.ExpiresSoon) From 8137a33e2461ac28861d02480eb6ad129466c521 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 11 Jun 2025 22:51:20 +0200 Subject: [PATCH 49/66] v0.1.26 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 1afa3d5..42a6bcb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.25 + 0.1.26 From cb75412d199dc77284323fbff6cd89cae0d81dca Mon Sep 17 00:00:00 2001 From: john Date: Wed, 11 Jun 2025 23:36:30 +0200 Subject: [PATCH 50/66] write some comment --- Femto.Modules.Auth/Application/Services/IAuthService.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs index 56fd423..2858053 100644 --- a/Femto.Modules.Auth/Application/Services/IAuthService.cs +++ b/Femto.Modules.Auth/Application/Services/IAuthService.cs @@ -3,6 +3,12 @@ using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application.Services; +/// +/// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling, +/// particularly in regards to session management. I may or may not bother to move the commands and queries here also, +/// but for controller actions I do quite like having the abstraction, and there is less drive within me to bother. +/// It just seems redundant to expose them both, and it's a bit confusin' +/// public interface IAuthService { public Task GetUserWithCredentials(string username, string password, CancellationToken cancellationToken = default); From a57515c33eb177e5e8a2dc1a3856199610936d2c Mon Sep 17 00:00:00 2001 From: john Date: Sun, 15 Jun 2025 19:12:34 +0200 Subject: [PATCH 51/66] oops --- .../Application/Services/AuthService.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs index 4fb9323..1a9f868 100644 --- a/Femto.Modules.Auth/Application/Services/AuthService.cs +++ b/Femto.Modules.Auth/Application/Services/AuthService.cs @@ -15,10 +15,17 @@ internal class AuthService(AuthContext context, SessionStorage storage) : IAuthS CancellationToken cancellationToken = default ) { - return await context + var user = 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); + + if (user is null) + return null; + + if (!user.HasPassword(password)) + return null; + + return new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()); } public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken) From 65ba3a64350b1cbca66c7d7ee6b3b1ad6ab68dcb Mon Sep 17 00:00:00 2001 From: john Date: Sun, 15 Jun 2025 19:14:49 +0200 Subject: [PATCH 52/66] change login failure status code --- Femto.Api/Controllers/Auth/AuthController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 5322493..e45e73c 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -35,7 +35,7 @@ public class AuthController( ); if (user is null) - return Forbid(); + return this.BadRequest(); var session = await authService.CreateStrongSession(user.Id); From e282e2ece31a2f974f3ca988e2cece1d570e4d6a Mon Sep 17 00:00:00 2001 From: john Date: Sun, 15 Jun 2025 19:30:12 +0200 Subject: [PATCH 53/66] v0.1.27 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 42a6bcb..60ef2bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.26 + 0.1.27 From 84457413b2dc525e5fac94149b7e80c2f60cf959 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 16 Jun 2025 21:11:40 +0200 Subject: [PATCH 54/66] refactor --- Femto.Api/Controllers/Auth/AuthController.cs | 30 ++--- ...neBehaviour.cs => DDDPipelineBehaviour.cs} | 4 +- .../Infrastructure/DomainServiceExtensions.cs | 2 +- Femto.Modules.Auth/Application/AuthStartup.cs | 7 +- .../CreateSignupCodeCommand.cs | 5 - .../CreateSignupCodeCommandHandler.cs | 15 --- .../Deauthenticate/DeauthenticateCommand.cs | 6 - .../DeauthenticateCommandHandler.cs | 12 -- .../GetSignupCodesQuery.cs | 6 - .../GetSignupCodesQueryHandler.cs | 55 -------- .../GetUserInfo/GetUserInfoCommand.cs | 6 - .../GetUserInfo/GetUserInfoCommandHandler.cs | 27 ---- .../Interface/Register/RegisterCommand.cs | 6 - .../Register/RegisterCommandHandler.cs | 43 ------ .../Application/Services/AuthModule.cs | 20 --- .../Application/Services/AuthService.cs | 124 +++++++++++++++++- .../Application/Services/IAuthModule.cs | 10 -- .../Application/Services/IAuthService.cs | 26 +++- Femto.Modules.Auth/Data/AuthContext.cs | 43 ++++++ .../SaveChangesPipelineBehaviour.cs | 23 ++++ 20 files changed, 224 insertions(+), 246 deletions(-) rename Femto.Common/Infrastructure/{SaveChangesPipelineBehaviour.cs => DDDPipelineBehaviour.cs} (88%) delete mode 100644 Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs delete mode 100644 Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs delete mode 100644 Femto.Modules.Auth/Application/Services/AuthModule.cs delete mode 100644 Femto.Modules.Auth/Application/Services/IAuthModule.cs create mode 100644 Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index e45e73c..a91d906 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -1,24 +1,16 @@ -using Femto.Api.Auth; using Femto.Api.Sessions; using Femto.Common; -using Femto.Modules.Auth.Application.Interface.CreateSignupCode; -using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; -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; -using Microsoft.Extensions.Options; namespace Femto.Api.Controllers.Auth; [ApiController] [Route("auth")] public class AuthController( - IAuthModule authModule, - IOptions cookieSettings, ICurrentUserContext currentUserContext, - ILogger logger, IAuthService authService ) : ControllerBase { @@ -28,17 +20,17 @@ public class AuthController( CancellationToken cancellationToken ) { - var user = await authService.GetUserWithCredentials( + var result = await authService.GetUserWithCredentials( request.Username, request.Password, cancellationToken ); - if (user is null) + if (result is null) return this.BadRequest(); - var session = await authService.CreateStrongSession(user.Id); - + var (user, session) = result; + HttpContext.SetSession(session, user); return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser)); @@ -47,13 +39,10 @@ public class AuthController( [HttpPost("register")] public async Task> Register([FromBody] RegisterRequest request) { - var user = await authModule.Command( - new RegisterCommand(request.Username, request.Password, request.SignupCode) - ); - - var session = await authService.CreateStrongSession(user.Id); + var (user, session) = await authService.CreateUserWithCredentials(request.Username, request.Password, request.SignupCode); HttpContext.SetSession(session, user); + return new RegisterResponse( user.Id, user.Username, @@ -106,10 +95,7 @@ public class AuthController( CancellationToken cancellationToken ) { - await authModule.Command( - new CreateSignupCodeCommand(request.Code, request.Email, request.Name), - cancellationToken - ); + await authService.AddSignupCode(request.Code, request.Name, cancellationToken); return Ok(new { }); } @@ -120,7 +106,7 @@ public class AuthController( CancellationToken cancellationToken ) { - var codes = await authModule.Query(new GetSignupCodesQuery(), cancellationToken); + var codes = await authService.GetSignupCodes(cancellationToken); return new ListSignupCodesResult( codes.Select(c => new SignupCodeDto( diff --git a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs similarity index 88% rename from Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs rename to Femto.Common/Infrastructure/DDDPipelineBehaviour.cs index b86a7e4..e5f338f 100644 --- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs +++ b/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs @@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging; namespace Femto.Common.Infrastructure; -public class SaveChangesPipelineBehaviour( +public class DDDPipelineBehaviour( DbContext context, IPublisher publisher, - ILogger> logger + ILogger> logger ) : IPipelineBehavior where TRequest : notnull { diff --git a/Femto.Common/Infrastructure/DomainServiceExtensions.cs b/Femto.Common/Infrastructure/DomainServiceExtensions.cs index e83469e..9812c93 100644 --- a/Femto.Common/Infrastructure/DomainServiceExtensions.cs +++ b/Femto.Common/Infrastructure/DomainServiceExtensions.cs @@ -12,7 +12,7 @@ public static class DomainServiceExtensions services.AddScoped(s => s.GetRequiredService()); services.AddTransient( typeof(IPipelineBehavior<,>), - typeof(SaveChangesPipelineBehaviour<,>) + typeof(DDDPipelineBehaviour<,>) ); } diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index c78e923..4fb7b22 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -41,7 +41,6 @@ public static class AuthStartup } ); - rootContainer.ExposeScopedService(); rootContainer.ExposeScopedService(); rootContainer.AddHostedService(services => new AuthApplication(host)); @@ -85,8 +84,12 @@ public static class AuthStartup services.AddSingleton(publisher); services.AddSingleton(); + + services.AddScoped( + typeof(IPipelineBehavior<,>), + typeof(SaveChangesPipelineBehaviour<,>) + ); - services.AddScoped(); services.AddScoped(); } diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs deleted file mode 100644 index be24aa9..0000000 --- a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Femto.Common.Domain; - -namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode; - -public record CreateSignupCodeCommand(string Code, string RecipientEmail, string RecipientName): ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs deleted file mode 100644 index cfbb44a..0000000 --- a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Femto.Common.Domain; -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Models; - -namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode; - -internal class CreateSignupCodeCommandHandler(AuthContext context) : ICommandHandler -{ - public async Task Handle(CreateSignupCodeCommand command, CancellationToken cancellationToken) - { - var code = new SignupCode(command.RecipientEmail, command.RecipientName, command.Code); - - await context.SignupCodes.AddAsync(code, cancellationToken); - } -} diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs deleted file mode 100644 index 44c346f..0000000 --- a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ - -using Femto.Common.Domain; - -namespace Femto.Modules.Auth.Application.Interface.Deauthenticate; - -public record DeauthenticateCommand(Guid UserId, string SessionId, string? RememberMeToken) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs deleted file mode 100644 index 435718c..0000000 --- a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Femto.Common.Domain; -using Femto.Modules.Auth.Data; - -namespace Femto.Modules.Auth.Application.Interface.Deauthenticate; - -internal class DeauthenticateCommandHandler(AuthContext context) : ICommandHandler -{ - public async Task Handle(DeauthenticateCommand request, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs deleted file mode 100644 index 422a09d..0000000 --- a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Femto.Common.Domain; -using Femto.Modules.Auth.Application.Dto; - -namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; - -public record GetSignupCodesQuery: IQuery>; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs deleted file mode 100644 index 201fdce..0000000 --- a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Dapper; -using Femto.Common.Domain; -using Femto.Common.Infrastructure.DbConnection; -using Femto.Modules.Auth.Application.Dto; - -namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; - -public class GetSignupCodesQueryHandler(IDbConnectionFactory connectionFactory) - : IQueryHandler> -{ - public async Task> Handle( - GetSignupCodesQuery request, - CancellationToken cancellationToken - ) - { - using var conn = connectionFactory.GetConnection(); - - // lang=sql - const string sql = """ - SELECT - sc.code as Code, - sc.recipient_email as Email, - sc.recipient_name as Name, - sc.redeeming_user_id as RedeemedByUserId, - u.username as RedeemedByUsername, - sc.expires_at as ExpiresOn - FROM authn.signup_code sc - LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id - ORDER BY sc.created_at DESC - """; - - var result = await conn.QueryAsync(sql); - - return result - .Select(row => new SignupCodeDto( - row.Code, - row.Email, - row.Name, - row.RedeemedByUserId, - row.RedeemedByUsername, - row.ExpiresOn - )) - .ToList(); - } - - private class QueryResultRow - { - public string Code { get; set; } - public string Email { get; set; } - public string Name { get; set; } - public Guid? RedeemedByUserId { get; set; } - public string? RedeemedByUsername { get; set; } - public DateTimeOffset? ExpiresOn { get; set; } - } -} diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs deleted file mode 100644 index 430b0d3..0000000 --- a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 72c5f20..0000000 --- a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs +++ /dev/null @@ -1,27 +0,0 @@ -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/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs deleted file mode 100644 index 87332cb..0000000 --- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Femto.Common.Domain; -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 diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs deleted file mode 100644 index 7bb17be..0000000 --- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs +++ /dev/null @@ -1,43 +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.Register; - -internal class RegisterCommandHandler(AuthContext context) - : ICommandHandler -{ - 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) - .Where(c => c.RedeemingUserId == null) - .SingleOrDefaultAsync(cancellationToken); - - 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); - - code.Redeem(user.Id); - - return new UserInfo(user); - } -} diff --git a/Femto.Modules.Auth/Application/Services/AuthModule.cs b/Femto.Modules.Auth/Application/Services/AuthModule.cs deleted file mode 100644 index f64d78f..0000000 --- a/Femto.Modules.Auth/Application/Services/AuthModule.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Femto.Common.Domain; -using MediatR; - -namespace Femto.Modules.Auth.Application.Services; - -internal class AuthModule(IMediator mediator) : IAuthModule -{ - public async Task Command(ICommand command, CancellationToken cancellationToken = default) => - await mediator.Send(command, cancellationToken); - - public async Task Command( - ICommand command, - CancellationToken cancellationToken = default - ) => await mediator.Send(command, cancellationToken); - - public async Task Query( - IQuery query, - CancellationToken cancellationToken = default - ) => await mediator.Send(query, cancellationToken); -} diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs index 1a9f868..0a73d60 100644 --- a/Femto.Modules.Auth/Application/Services/AuthService.cs +++ b/Femto.Modules.Auth/Application/Services/AuthService.cs @@ -1,4 +1,6 @@ +using Dapper; using Femto.Common.Domain; +using Femto.Common.Infrastructure.DbConnection; using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Infrastructure; @@ -7,9 +9,13 @@ using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Application.Services; -internal class AuthService(AuthContext context, SessionStorage storage) : IAuthService +internal class AuthService( + AuthContext context, + SessionStorage storage, + IDbConnectionFactory connectionFactory +) : IAuthService { - public async Task GetUserWithCredentials( + public async Task GetUserWithCredentials( string username, string password, CancellationToken cancellationToken = default @@ -18,14 +24,21 @@ internal class AuthService(AuthContext context, SessionStorage storage) : IAuthS var user = await context .Users.Where(u => u.Username == username) .SingleOrDefaultAsync(cancellationToken); - + if (user is null) return null; if (!user.HasPassword(password)) return null; - - return new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()); + + var session = new Session(user.Id, true); + + await storage.AddSession(session); + + return new( + new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()), + session + ); } public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken) @@ -64,6 +77,97 @@ internal class AuthService(AuthContext context, SessionStorage storage) : IAuthS await storage.DeleteSession(sessionId); } + public async Task CreateUserWithCredentials( + string username, + string password, + string signupCode, + CancellationToken cancellationToken = default + ) + { + var now = DateTimeOffset.UtcNow; + + var code = await context + .SignupCodes.Where(c => c.Code == signupCode) + .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) + .Where(c => c.RedeemingUserId == null) + .SingleOrDefaultAsync(cancellationToken); + + if (code is null) + throw new DomainError("invalid signup code"); + + var usernameTaken = await context.Users.AnyAsync( + u => u.Username == username, + cancellationToken + ); + + if (usernameTaken) + throw new DomainError("username taken"); + + var user = new UserIdentity(username); + + await context.AddAsync(user, cancellationToken); + + user.SetPassword(password); + + code.Redeem(user.Id); + + var session = new Session(user.Id, true); + + await storage.AddSession(session); + + await context.SaveChangesAsync(cancellationToken); + + return new(new UserInfo(user), session); + } + + public async Task AddSignupCode( + string code, + string recipientName, + CancellationToken cancellationToken + ) + { + await context.SignupCodes.AddAsync( + new SignupCode("", recipientName, code), + cancellationToken + ); + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task> GetSignupCodes( + CancellationToken cancellationToken = default + ) + { + using var conn = connectionFactory.GetConnection(); + + // lang=sql + const string sql = """ + SELECT + sc.code as Code, + sc.recipient_email as Email, + sc.recipient_name as Name, + sc.redeeming_user_id as RedeemedByUserId, + u.username as RedeemedByUsername, + sc.expires_at as ExpiresOn + FROM authn.signup_code sc + LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id + ORDER BY sc.created_at DESC + """; + + var result = await conn.QueryAsync(sql, cancellationToken); + + return result + .Select(row => new SignupCodeDto( + row.Code, + row.Email, + row.Name, + row.RedeemedByUserId, + row.RedeemedByUsername, + row.ExpiresOn + )) + .ToList(); + } + public async Task CreateLongTermSession(Guid userId, bool isStrong) { throw new NotImplementedException(); @@ -83,4 +187,14 @@ internal class AuthService(AuthContext context, SessionStorage storage) : IAuthS { throw new NotImplementedException(); } + + private class GetSignupCodesQueryResultRow + { + public string Code { get; set; } + public string Email { get; set; } + public string Name { get; set; } + public Guid? RedeemedByUserId { get; set; } + public string? RedeemedByUsername { get; set; } + public DateTimeOffset? ExpiresOn { get; set; } + } } diff --git a/Femto.Modules.Auth/Application/Services/IAuthModule.cs b/Femto.Modules.Auth/Application/Services/IAuthModule.cs deleted file mode 100644 index df34366..0000000 --- a/Femto.Modules.Auth/Application/Services/IAuthModule.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Femto.Common.Domain; - -namespace Femto.Modules.Auth.Application.Services; - -public interface IAuthModule -{ - Task Command(ICommand command, CancellationToken cancellationToken = default); - Task Command(ICommand command, CancellationToken cancellationToken = default); - Task Query(IQuery query, CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs index 2858053..f07939e 100644 --- a/Femto.Modules.Auth/Application/Services/IAuthService.cs +++ b/Femto.Modules.Auth/Application/Services/IAuthService.cs @@ -11,10 +11,30 @@ 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 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 + + public Task CreateUserWithCredentials( + string username, + string password, + string signupCode, + CancellationToken cancellationToken = default + ); + + public Task AddSignupCode(string code, string recipientName, CancellationToken cancellationToken = default); + + public Task> GetSignupCodes(CancellationToken cancellationToken = default); +} + +public record UserAndSession(UserInfo User, Session Session); diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs index e4488e4..ac395ba 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -1,6 +1,10 @@ +using Femto.Common.Domain; using Femto.Common.Infrastructure.Outbox; using Femto.Modules.Auth.Models; +using MediatR; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; namespace Femto.Modules.Auth.Data; @@ -17,4 +21,43 @@ internal class AuthContext(DbContextOptions options) : DbContext(op builder.HasDefaultSchema("authn"); builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly); } + + public override int SaveChanges() + { + throw new InvalidOperationException("Use SaveChangesAsync instead"); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await EmitDomainEvents(cancellationToken); + + return await base.SaveChangesAsync(cancellationToken); + } + + private async Task EmitDomainEvents(CancellationToken cancellationToken) + { + var logger = this.GetService>(); + var publisher = this.GetService(); + var domainEvents = this + .ChangeTracker.Entries() + .SelectMany(e => + { + var events = e.Entity.DomainEvents; + e.Entity.ClearDomainEvents(); + return events; + }) + .ToList(); + + logger.LogTrace("loaded {Count} domain events", domainEvents.Count); + + foreach (var domainEvent in domainEvents) + { + logger.LogTrace( + "publishing {Type} domain event {Id}", + domainEvent.GetType().Name, + domainEvent.EventId + ); + await publisher.Publish(domainEvent, cancellationToken); + } + } } \ No newline at end of file diff --git a/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs new file mode 100644 index 0000000..cc4f983 --- /dev/null +++ b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs @@ -0,0 +1,23 @@ +using Femto.Modules.Auth.Data; +using MediatR; + +namespace Femto.Modules.Auth.Infrastructure; + +internal class SaveChangesPipelineBehaviour(AuthContext context) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + var response = await next(cancellationToken); + + if (context.ChangeTracker.HasChanges()) + await context.SaveChangesAsync(cancellationToken); + + return response; + } +} From dac3acfecfff94fa1aef08cf009b815ddd809e0b Mon Sep 17 00:00:00 2001 From: john Date: Mon, 16 Jun 2025 21:24:37 +0200 Subject: [PATCH 55/66] add remember me to API --- Femto.Api/Controllers/Auth/AuthController.cs | 22 +++++++++++-------- Femto.Api/Controllers/Auth/LoginRequest.cs | 2 +- Femto.Api/Controllers/Auth/RegisterRequest.cs | 2 +- .../Application/Services/AuthService.cs | 14 +++++------- .../Application/Services/IAuthService.cs | 16 +++++++++----- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index a91d906..b3ad33f 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -9,10 +9,8 @@ namespace Femto.Api.Controllers.Auth; [ApiController] [Route("auth")] -public class AuthController( - ICurrentUserContext currentUserContext, - IAuthService authService -) : ControllerBase +public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService) + : ControllerBase { [HttpPost("login")] public async Task> Login( @@ -23,14 +21,15 @@ public class AuthController( var result = await authService.GetUserWithCredentials( request.Username, request.Password, + request.RememberMe, cancellationToken ); - + if (result is null) return this.BadRequest(); - + var (user, session) = result; - + HttpContext.SetSession(session, user); return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser)); @@ -39,10 +38,15 @@ public class AuthController( [HttpPost("register")] public async Task> Register([FromBody] RegisterRequest request) { - var (user, session) = await authService.CreateUserWithCredentials(request.Username, request.Password, request.SignupCode); + var (user, session) = await authService.CreateUserWithCredentials( + request.Username, + request.Password, + request.SignupCode, + request.RememberMe + ); HttpContext.SetSession(session, user); - + return new RegisterResponse( user.Id, user.Username, diff --git a/Femto.Api/Controllers/Auth/LoginRequest.cs b/Femto.Api/Controllers/Auth/LoginRequest.cs index 8366d14..6c09e64 100644 --- a/Femto.Api/Controllers/Auth/LoginRequest.cs +++ b/Femto.Api/Controllers/Auth/LoginRequest.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Auth; -public record LoginRequest(string Username, string Password); \ No newline at end of file +public record LoginRequest(string Username, string Password, bool RememberMe); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/RegisterRequest.cs b/Femto.Api/Controllers/Auth/RegisterRequest.cs index f386198..ee21297 100644 --- a/Femto.Api/Controllers/Auth/RegisterRequest.cs +++ b/Femto.Api/Controllers/Auth/RegisterRequest.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Auth; -public record RegisterRequest(string Username, string Password, string SignupCode, string? Email); \ No newline at end of file +public record RegisterRequest(string Username, string Password, string SignupCode, bool RememberMe); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs index 0a73d60..4c741fc 100644 --- a/Femto.Modules.Auth/Application/Services/AuthService.cs +++ b/Femto.Modules.Auth/Application/Services/AuthService.cs @@ -15,11 +15,10 @@ internal class AuthService( IDbConnectionFactory connectionFactory ) : IAuthService { - public async Task GetUserWithCredentials( - string username, + public async Task GetUserWithCredentials(string username, string password, - CancellationToken cancellationToken = default - ) + bool createLongTermSession, + CancellationToken cancellationToken = default) { var user = await context .Users.Where(u => u.Username == username) @@ -77,12 +76,11 @@ internal class AuthService( await storage.DeleteSession(sessionId); } - public async Task CreateUserWithCredentials( - string username, + public async Task CreateUserWithCredentials(string username, string password, string signupCode, - CancellationToken cancellationToken = default - ) + bool createLongTermSession, + CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs index f07939e..c8c252d 100644 --- a/Femto.Modules.Auth/Application/Services/IAuthService.cs +++ b/Femto.Modules.Auth/Application/Services/IAuthService.cs @@ -14,6 +14,7 @@ public interface IAuthService public Task GetUserWithCredentials( string username, string password, + bool createLongTermSession, CancellationToken cancellationToken = default ); public Task GetUserWithId( @@ -25,16 +26,21 @@ public interface IAuthService public Task GetSession(string sessionId); public Task DeleteSession(string sessionId); - public Task CreateUserWithCredentials( - string username, + public Task CreateUserWithCredentials(string username, string password, string signupCode, + bool createLongTermSession, + CancellationToken cancellationToken = default); + + public Task AddSignupCode( + string code, + string recipientName, CancellationToken cancellationToken = default ); - public Task AddSignupCode(string code, string recipientName, CancellationToken cancellationToken = default); - - public Task> GetSignupCodes(CancellationToken cancellationToken = default); + public Task> GetSignupCodes( + CancellationToken cancellationToken = default + ); } public record UserAndSession(UserInfo User, Session Session); From 8629883f8896d0fa9120c76148866d7ceec68c8f Mon Sep 17 00:00:00 2001 From: john Date: Sat, 21 Jun 2025 11:41:53 +0200 Subject: [PATCH 56/66] remember me --- .../Auth/SessionAuthenticationHandler.cs | 113 ++++++++++++------ Femto.Api/Controllers/Auth/AuthController.cs | 31 ++++- .../Sessions/HttpContextSessionExtensions.cs | 57 +++++++-- .../Application/{Services => }/AuthService.cs | 75 +++++++++--- Femto.Modules.Auth/Application/AuthStartup.cs | 1 - .../Application/Dto/RememberMeToken.cs | 18 +++ .../{Services => }/IAuthService.cs | 14 ++- .../LongTermSessionConfiguration.cs | 13 ++ Femto.Modules.Auth/Models/LongTermSession.cs | 46 ++++--- Femto.Modules.Auth/Models/Session.cs | 6 +- 10 files changed, 278 insertions(+), 96 deletions(-) rename Femto.Modules.Auth/Application/{Services => }/AuthService.cs (70%) create mode 100644 Femto.Modules.Auth/Application/Dto/RememberMeToken.cs rename Femto.Modules.Auth/Application/{Services => }/IAuthService.cs (75%) create mode 100644 Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index f98ae8d..efe0eed 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -3,7 +3,8 @@ using System.Text.Encodings.Web; using Femto.Api.Sessions; using Femto.Common; using Femto.Modules.Auth.Application; -using Femto.Modules.Auth.Application.Services; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; @@ -20,41 +21,14 @@ internal class SessionAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier); - - var sessionId = this.Context.GetSessionId(); - - if (sessionId is null) - { - Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier); - return AuthenticateResult.NoResult(); - } - - var session = await authService.GetSession(sessionId); - if (session is null) - { - Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier); - return await FailAndDeleteSession(sessionId); - } + var user = await this.TryAuthenticateWithSession(); - if (session.IsExpired) - { - Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier); - return await FailAndDeleteSession(sessionId); - } - - var user = await authService.GetUserWithId(session.UserId); + if (user is null) + user = await this.TryAuthenticateWithRememberMeToken(); if (user is null) - { - return await FailAndDeleteSession(sessionId); - } - - if (session.ExpiresSoon) - { - session = await authService.CreateWeakSession(session.UserId); - this.Context.SetSession(session, user); - } + return AuthenticateResult.NoResult(); var claims = new List { @@ -72,10 +46,77 @@ internal class SessionAuthenticationHandler( return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)); } - private async Task FailAndDeleteSession(string sessionId) + private async Task TryAuthenticateWithSession() { - await authService.DeleteSession(sessionId); - this.Context.DeleteSession(); - return AuthenticateResult.Fail("invalid session"); + var sessionId = this.Context.GetSessionId(); + + if (sessionId is null) + { + Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier); + return null; + } + + var session = await authService.GetSession(sessionId); + + if (session is null) + { + Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier); + return null; + } + + if (session.IsExpired) + { + Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier); + await authService.DeleteSession(sessionId); + this.Context.DeleteSession(); + return null; + } + + var user = await authService.GetUserWithId(session.UserId); + + if (user is null) + { + await authService.DeleteSession(sessionId); + this.Context.DeleteSession(); + return null; + } + + if (session.ExpiresSoon) + { + session = await authService.CreateWeakSession(session.UserId); + this.Context.SetSession(session, user); + } + + return user; + } + + private async Task TryAuthenticateWithRememberMeToken() + { + /* + * load remember me from token + * if it is null, return null + * if it exists, validate it + * if it is valid, create a new weak session, return the user + * if it is almost expired, refresh it + */ + + var rememberMeToken = this.Context.GetRememberMeToken(); + + if (rememberMeToken is null) + return null; + + var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(rememberMeToken); + + if (user is null) + return null; + + var session = await authService.CreateWeakSession(user.Id); + + this.Context.SetSession(session, user); + + if (newRememberMeToken is not null) + this.Context.SetRememberMeToken(newRememberMeToken); + + return user; } } diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index b3ad33f..9fe4b85 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -1,6 +1,6 @@ using Femto.Api.Sessions; using Femto.Common; -using Femto.Modules.Auth.Application.Services; +using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,10 +18,9 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService CancellationToken cancellationToken ) { - var result = await authService.GetUserWithCredentials( + var result = await authService.AuthenticateUserCredentials( request.Username, request.Password, - request.RememberMe, cancellationToken ); @@ -29,23 +28,35 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService return this.BadRequest(); var (user, session) = result; - + HttpContext.SetSession(session, user); + + if (request.RememberMe) + { + var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); + HttpContext.SetRememberMeToken(newRememberMeToken); + } return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser)); } [HttpPost("register")] - public async Task> Register([FromBody] RegisterRequest request) + public async Task> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) { var (user, session) = await authService.CreateUserWithCredentials( request.Username, request.Password, request.SignupCode, - request.RememberMe + cancellationToken ); HttpContext.SetSession(session, user); + + if (request.RememberMe) + { + var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); + HttpContext.SetRememberMeToken(newRememberMeToken); + } return new RegisterResponse( user.Id, @@ -65,6 +76,14 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService HttpContext.DeleteSession(); } + var rememberMeToken = HttpContext.GetRememberMeToken(); + + if (rememberMeToken is not null) + { + await authService.DeleteRememberMeToken(rememberMeToken); + HttpContext.DeleteRememberMeToken(); + } + return Ok(new { }); } diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index e693180..2b8ee96 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -15,18 +15,12 @@ internal static class HttpContextSessionExtensions PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - public static string? GetSessionId(this HttpContext httpContext) - { - var sessionId = httpContext.Request.Cookies["sid"]; - - return sessionId; - } + public static string? GetSessionId(this HttpContext httpContext) => + httpContext.Request.Cookies["sid"]; public static void SetSession(this HttpContext context, Session session, UserInfo user) { - var cookieSettings = context.RequestServices.GetRequiredService< - IOptions - >(); + var cookieSettings = context.RequestServices.GetRequiredService>(); context.Response.Cookies.Append( "sid", @@ -57,7 +51,7 @@ internal static class HttpContextSessionExtensions } ); } - + public static void DeleteSession(this HttpContext httpContext) { var cookieSettings = httpContext.RequestServices.GetRequiredService< @@ -91,4 +85,47 @@ internal static class HttpContextSessionExtensions } ); } + + + public static RememberMeToken? GetRememberMeToken(this HttpContext httpContext) => + httpContext.Request.Cookies["rid"] is { } code ? RememberMeToken.FromCode(code) : null; + + public static void SetRememberMeToken(this HttpContext context, NewRememberMeToken token) + { + var cookieSettings = context.RequestServices.GetRequiredService>(); + + context.Response.Cookies.Append( + "rid", + token.Code, + new CookieOptions + { + Path = "/", + IsEssential = true, + Domain = cookieSettings.Value.Domain, + HttpOnly = true, + Secure = cookieSettings.Value.Secure, + SameSite = cookieSettings.Value.SameSite, + Expires = token.Expires, + } + ); + } + + public static void DeleteRememberMeToken(this HttpContext context) + { + var cookieSettings = context.RequestServices.GetRequiredService>(); + + context.Response.Cookies.Delete( + "rid", + new CookieOptions + { + Path = "/", + HttpOnly = true, + Domain = cookieSettings.Value.Domain, + IsEssential = true, + Secure = cookieSettings.Value.Secure, + SameSite = cookieSettings.Value.SameSite, + Expires = DateTimeOffset.UtcNow.AddDays(-1), + } + ); + } } diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/AuthService.cs similarity index 70% rename from Femto.Modules.Auth/Application/Services/AuthService.cs rename to Femto.Modules.Auth/Application/AuthService.cs index 4c741fc..d81eb42 100644 --- a/Femto.Modules.Auth/Application/Services/AuthService.cs +++ b/Femto.Modules.Auth/Application/AuthService.cs @@ -7,7 +7,7 @@ using Femto.Modules.Auth.Infrastructure; using Femto.Modules.Auth.Models; using Microsoft.EntityFrameworkCore; -namespace Femto.Modules.Auth.Application.Services; +namespace Femto.Modules.Auth.Application; internal class AuthService( AuthContext context, @@ -15,10 +15,11 @@ internal class AuthService( IDbConnectionFactory connectionFactory ) : IAuthService { - public async Task GetUserWithCredentials(string username, + public async Task AuthenticateUserCredentials( + string username, string password, - bool createLongTermSession, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var user = await context .Users.Where(u => u.Username == username) @@ -48,7 +49,7 @@ internal class AuthService( .SingleOrDefaultAsync(cancellationToken); } - public async Task CreateStrongSession(Guid userId) + public async Task CreateNewSession(Guid userId) { var session = new Session(userId, true); @@ -76,11 +77,12 @@ internal class AuthService( await storage.DeleteSession(sessionId); } - public async Task CreateUserWithCredentials(string username, + public async Task CreateUserWithCredentials( + string username, string password, string signupCode, - bool createLongTermSession, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var now = DateTimeOffset.UtcNow; @@ -166,24 +168,61 @@ internal class AuthService( .ToList(); } - public async Task CreateLongTermSession(Guid userId, bool isStrong) + public async Task CreateRememberMeToken(Guid userId) { - throw new NotImplementedException(); + var (rememberMeToken, verifier) = LongTermSession.Create(userId); + + await context.AddAsync(rememberMeToken); + await context.SaveChangesAsync(); + + return new(rememberMeToken.Selector, verifier, rememberMeToken.Expires); } - public async Task DeleteLongTermSession(string sessionId) + public async Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken( + RememberMeToken rememberMeToken + ) { - throw new NotImplementedException(); + var token = await context.LongTermSessions.SingleOrDefaultAsync(t => + t.Selector == rememberMeToken.Selector + ); + + if (token is null) + return (null, null); + + if (!token.Validate(rememberMeToken.Verifier)) + return (null, null); + + var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId); + + if (user is null) + return (null, null); + + if (token.ExpiresSoon) + { + var (newToken, verifier) = LongTermSession.Create(user.Id); + await context.AddAsync(newToken); + await context.SaveChangesAsync(); + + return (new(user), new(newToken.Selector, verifier, newToken.Expires)); + } + + return (new(user), null); } - public async Task RefreshLongTermSession(string sessionId) + public async Task DeleteRememberMeToken(RememberMeToken rememberMeToken) { - throw new NotImplementedException(); - } + var session = await context.LongTermSessions.SingleOrDefaultAsync(s => + s.Selector == rememberMeToken.Selector + ); - public async Task ValidateLongTermSession(string sessionId) - { - throw new NotImplementedException(); + if (session is null) + return; + + if (!session.Validate(rememberMeToken.Verifier)) + return; + + context.Remove(session); + await context.SaveChangesAsync(); } private class GetSignupCodesQueryResultRow diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 4fb7b22..1d20668 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -3,7 +3,6 @@ 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; diff --git a/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs new file mode 100644 index 0000000..5750b8d --- /dev/null +++ b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs @@ -0,0 +1,18 @@ +using Femto.Modules.Auth.Models; + +namespace Femto.Modules.Auth.Application.Dto; + +public record RememberMeToken(string Selector, string Verifier) +{ + public static RememberMeToken FromCode(string code) + { + var parts = code.Split('.'); + return new RememberMeToken(parts[0], parts[1]); + } + +}; + +public record NewRememberMeToken(string Selector, string Verifier, DateTimeOffset Expires) +{ + public string Code => $"{Selector}.{Verifier}"; +} diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/IAuthService.cs similarity index 75% rename from Femto.Modules.Auth/Application/Services/IAuthService.cs rename to Femto.Modules.Auth/Application/IAuthService.cs index c8c252d..9fd517d 100644 --- a/Femto.Modules.Auth/Application/Services/IAuthService.cs +++ b/Femto.Modules.Auth/Application/IAuthService.cs @@ -1,7 +1,7 @@ using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Models; -namespace Femto.Modules.Auth.Application.Services; +namespace Femto.Modules.Auth.Application; /// /// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling, @@ -11,17 +11,16 @@ namespace Femto.Modules.Auth.Application.Services; /// public interface IAuthService { - public Task GetUserWithCredentials( + public Task AuthenticateUserCredentials( string username, string password, - bool createLongTermSession, CancellationToken cancellationToken = default ); public Task GetUserWithId( Guid? userId, CancellationToken cancellationToken = default ); - public Task CreateStrongSession(Guid userId); + public Task CreateNewSession(Guid userId); public Task CreateWeakSession(Guid userId); public Task GetSession(string sessionId); public Task DeleteSession(string sessionId); @@ -29,7 +28,6 @@ public interface IAuthService public Task CreateUserWithCredentials(string username, string password, string signupCode, - bool createLongTermSession, CancellationToken cancellationToken = default); public Task AddSignupCode( @@ -41,6 +39,10 @@ public interface IAuthService public Task> GetSignupCodes( CancellationToken cancellationToken = default ); + + Task CreateRememberMeToken(Guid userId); + Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken); + Task DeleteRememberMeToken(RememberMeToken rememberMeToken); } -public record UserAndSession(UserInfo User, Session Session); +public record UserAndSession(UserInfo User, Session Session); \ No newline at end of file diff --git a/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs new file mode 100644 index 0000000..00f2a13 --- /dev/null +++ b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs @@ -0,0 +1,13 @@ +using Femto.Modules.Auth.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Femto.Modules.Auth.Data.Configurations; + +public class LongTermSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("long_term_session"); + } +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Models/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs index 06bd19d..113a3eb 100644 --- a/Femto.Modules.Auth/Models/LongTermSession.cs +++ b/Femto.Modules.Auth/Models/LongTermSession.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using System.Text; using static System.Security.Cryptography.RandomNumberGenerator; @@ -6,22 +7,30 @@ namespace Femto.Modules.Auth.Models; public class LongTermSession { private static TimeSpan TokenTimeout { get; } = TimeSpan.FromDays(90); - + private static TimeSpan RefreshBuffer { get; } = TimeSpan.FromDays(5); + public int Id { get; private set; } - + public string Selector { get; private set; } - + public byte[] HashedVerifier { get; private set; } - + public DateTimeOffset Expires { get; private set; } - + public Guid UserId { get; private set; } - - private LongTermSession() {} - + + [NotMapped] + public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer; + + private LongTermSession() { } + public static (LongTermSession, string) Create(Guid userId) { - var selector = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 12); + var selector = GetString( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + 12 + ); + var verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32); using var sha256 = System.Security.Cryptography.SHA256.Create(); @@ -29,23 +38,26 @@ public class LongTermSession var longTermSession = new LongTermSession { Selector = selector, - HashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)), + HashedVerifier = ComputeHash(verifier), UserId = userId, - Expires = DateTimeOffset.UtcNow + TokenTimeout + Expires = DateTimeOffset.UtcNow + TokenTimeout, }; - - var rememberMeToken = $"{selector}.{verifier}"; - return (longTermSession, rememberMeToken); + return (longTermSession, verifier); } public bool Validate(string verifier) { if (this.Expires < DateTimeOffset.UtcNow) return false; - + + return ComputeHash(verifier).SequenceEqual(this.HashedVerifier); + } + + private static byte[] ComputeHash(string verifier) + { using var sha256 = System.Security.Cryptography.SHA256.Create(); var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); - return hashedVerifier.SequenceEqual(this.HashedVerifier); + return hashedVerifier; } -} \ No newline at end of file +} diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs index c142fb8..a1cf4da 100644 --- a/Femto.Modules.Auth/Models/Session.cs +++ b/Femto.Modules.Auth/Models/Session.cs @@ -4,11 +4,13 @@ namespace Femto.Modules.Auth.Models; public class Session(Guid userId, bool isStrong) { + private static readonly TimeSpan ValidityPeriod = TimeSpan.FromSeconds(5); + private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(0); public string Id { get; } = Convert.ToBase64String(GetBytes(32)); public Guid UserId { get; } = userId; - public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(15); + public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + ValidityPeriod; - public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5); + public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer; public bool IsStronglyAuthenticated { get; } = isStrong; public bool IsExpired => this.Expires < DateTimeOffset.UtcNow; } From 6746a023980ab59d320dc21722a2d5e726455530 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 21 Jun 2025 11:42:21 +0200 Subject: [PATCH 57/66] fix session lifespan --- Femto.Modules.Auth/Models/Session.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs index a1cf4da..c4f8a60 100644 --- a/Femto.Modules.Auth/Models/Session.cs +++ b/Femto.Modules.Auth/Models/Session.cs @@ -4,8 +4,8 @@ namespace Femto.Modules.Auth.Models; public class Session(Guid userId, bool isStrong) { - private static readonly TimeSpan ValidityPeriod = TimeSpan.FromSeconds(5); - private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(0); + private static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15); + private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5); public string Id { get; } = Convert.ToBase64String(GetBytes(32)); public Guid UserId { get; } = userId; public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + ValidityPeriod; From 36d8cc9a4d944bb9051e4cac3fbe7e29b461f2ef Mon Sep 17 00:00:00 2001 From: john Date: Sat, 19 Jul 2025 12:32:46 +0200 Subject: [PATCH 58/66] v0.1.28 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 60ef2bc..727ce06 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.27 + 0.1.28 From 2519fc77d228e53e478c4783663eeddf9ef8c9d8 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 19 Jul 2025 14:10:01 +0200 Subject: [PATCH 59/66] deleting password --- .../Auth/SessionAuthenticationHandler.cs | 23 +++-- Femto.Api/Controllers/Auth/AuthController.cs | 99 ++++++++++++++++++- .../Auth/ChangePasswordRequestBody.cs | 3 + Femto.Api/Program.cs | 2 +- Femto.Common/ICurrentUserContext.cs | 2 +- ...9104200_AddInvalidateToLongTermSession.sql | 5 + Femto.Modules.Auth/Application/AuthService.cs | 39 ++++++-- Femto.Modules.Auth/Application/AuthStartup.cs | 17 ++-- .../Application/IAuthService.cs | 9 +- .../Infrastructure/SessionStorage.cs | 42 ++++++-- .../UserPasswordChangedHandler.cs | 22 +++++ .../Events/UserPasswordChangedDomainEvent.cs | 5 + Femto.Modules.Auth/Models/LongTermSession.cs | 12 ++- Femto.Modules.Auth/Models/Session.cs | 2 +- Femto.Modules.Auth/Models/UserIdentity.cs | 2 + 15 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs create mode 100644 Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql create mode 100644 Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs create mode 100644 Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index efe0eed..9c44b04 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -4,6 +4,7 @@ using Femto.Api.Sessions; using Femto.Common; using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; @@ -26,10 +27,10 @@ internal class SessionAuthenticationHandler( if (user is null) user = await this.TryAuthenticateWithRememberMeToken(); - + if (user is null) return AuthenticateResult.NoResult(); - + var claims = new List { new(ClaimTypes.Name, user.Username), @@ -41,7 +42,11 @@ internal class SessionAuthenticationHandler( var identity = new ClaimsIdentity(claims, this.Scheme.Name); var principal = new ClaimsPrincipal(identity); - currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username); + currentUserContext.CurrentUser = new CurrentUser( + user.Id, + user.Username, + user.Roles.Contains(Role.SuperUser) + ); return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)); } @@ -99,21 +104,23 @@ internal class SessionAuthenticationHandler( * if it is valid, create a new weak session, return the user * if it is almost expired, refresh it */ - + var rememberMeToken = this.Context.GetRememberMeToken(); if (rememberMeToken is null) return null; - - var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(rememberMeToken); + + var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken( + rememberMeToken + ); if (user is null) return null; - + var session = await authService.CreateWeakSession(user.Id); this.Context.SetSession(session, user); - + if (newRememberMeToken is not null) this.Context.SetRememberMeToken(newRememberMeToken); diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 9fe4b85..c9108da 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -28,9 +28,9 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService return this.BadRequest(); var (user, session) = result; - + HttpContext.SetSession(session, user); - + if (request.RememberMe) { var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); @@ -41,7 +41,10 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService } [HttpPost("register")] - public async Task> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) + public async Task> Register( + [FromBody] RegisterRequest request, + CancellationToken cancellationToken + ) { var (user, session) = await authService.CreateUserWithCredentials( request.Username, @@ -51,7 +54,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); HttpContext.SetSession(session, user); - + if (request.RememberMe) { var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); @@ -65,6 +68,60 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); } + [HttpPost("change-password")] + public async Task ChangePassword( + [FromBody] ChangePasswordRequestBody req, + CancellationToken cancellationToken + ) + { + if (currentUserContext.CurrentUser is not {} user) + return this.BadRequest(); + + // superuser do what superuser want + if (!user.IsSuperUser) + { + if (user.Id != req.UserId) + return this.BadRequest(); + + var session = await authService.GetSession(this.HttpContext.GetSessionId()!); + + // require strong authentication to change password + // the user can re-enter their password + if (session is null || !session.IsStronglyAuthenticated) + return this.BadRequest(); + } + + await authService.ChangePassword(req.UserId, req.NewPassword, cancellationToken); + + // TODO would be better do handle this from inside the auth service. maybe just have it happen in a post-save event handler? + await authService.InvalidateUserSessions(req.UserId, cancellationToken); + + return this.Ok(new {}); + } + + [HttpPost("delete-current-session")] + public async Task DeleteSessionV2() + { + var sessionId = HttpContext.GetSessionId(); + + if (sessionId is not null) + { + await authService.DeleteSession(sessionId); + HttpContext.DeleteSession(); + } + + var rememberMeToken = HttpContext.GetRememberMeToken(); + + if (rememberMeToken is not null) + { + await authService.DeleteRememberMeToken(rememberMeToken); + HttpContext.DeleteRememberMeToken(); + } + + return Ok(new { }); + } + + [Obsolete("use POST /auth/delete-current-session")] [HttpDelete("session")] public async Task DeleteSession() { @@ -111,6 +168,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); } + [Obsolete("use POST /auth/create-signup-code")] [HttpPost("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task CreateSignupCode( @@ -123,6 +181,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService return Ok(new { }); } + [Obsolete("use GET /auth/list-signup-codes")] [HttpGet("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task> ListSignupCodes( @@ -142,4 +201,36 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService )) ); } + + [HttpPost("create-signup-code")] + [Authorize(Roles = "SuperUser")] + public async Task CreateSignupCodeV2( + [FromBody] CreateSignupCodeRequest request, + CancellationToken cancellationToken + ) + { + await authService.AddSignupCode(request.Code, request.Name, cancellationToken); + + return Ok(new { }); + } + + [HttpGet("list-signup-codes")] + [Authorize(Roles = "SuperUser")] + public async Task> ListSignupCodesV2( + CancellationToken cancellationToken + ) + { + var codes = await authService.GetSignupCodes(cancellationToken); + + return new ListSignupCodesResult( + codes.Select(c => new SignupCodeDto( + c.Code, + c.Email, + c.Name, + c.RedeemedByUserId, + c.RedeemedByUsername, + c.ExpiresOn + )) + ); + } } diff --git a/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs new file mode 100644 index 0000000..77f1dcd --- /dev/null +++ b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Auth; + +public record ChangePasswordRequestBody(Guid UserId, string NewPassword); \ No newline at end of file diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 08a40b8..ebb81fd 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus); builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); -builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory); +builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(s => s.GetRequiredService()); diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index 3e7dae6..629b2d2 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); +public record CurrentUser(Guid Id, string Username, bool IsSuperUser); \ No newline at end of file diff --git a/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql new file mode 100644 index 0000000..15d0323 --- /dev/null +++ b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql @@ -0,0 +1,5 @@ +-- Migration: AddInvalidateToLongTermSession +-- Created at: 19/07/2025 10:42:00 + +ALTER TABLE authn.long_term_session +ADD COLUMN is_invalidated BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/AuthService.cs b/Femto.Modules.Auth/Application/AuthService.cs index d81eb42..eb6215f 100644 --- a/Femto.Modules.Auth/Application/AuthService.cs +++ b/Femto.Modules.Auth/Application/AuthService.cs @@ -11,7 +11,7 @@ namespace Femto.Modules.Auth.Application; internal class AuthService( AuthContext context, - SessionStorage storage, + SessionStorage sessionStorage, IDbConnectionFactory connectionFactory ) : IAuthService { @@ -33,7 +33,7 @@ internal class AuthService( var session = new Session(user.Id, true); - await storage.AddSession(session); + await sessionStorage.AddSession(session); return new( new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()), @@ -53,7 +53,7 @@ internal class AuthService( { var session = new Session(userId, true); - await storage.AddSession(session); + await sessionStorage.AddSession(session); return session; } @@ -62,19 +62,19 @@ internal class AuthService( { var session = new Session(userId, false); - await storage.AddSession(session); + await sessionStorage.AddSession(session); return session; } public Task GetSession(string sessionId) { - return storage.GetSession(sessionId); + return sessionStorage.GetSession(sessionId); } public async Task DeleteSession(string sessionId) { - await storage.DeleteSession(sessionId); + await sessionStorage.DeleteSession(sessionId); } public async Task CreateUserWithCredentials( @@ -113,7 +113,7 @@ internal class AuthService( var session = new Session(user.Id, true); - await storage.AddSession(session); + await sessionStorage.AddSession(session); await context.SaveChangesAsync(cancellationToken); @@ -189,7 +189,7 @@ internal class AuthService( if (token is null) return (null, null); - if (!token.Validate(rememberMeToken.Verifier)) + if (!token.CheckVerifier(rememberMeToken.Verifier)) return (null, null); var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId); @@ -218,13 +218,34 @@ internal class AuthService( if (session is null) return; - if (!session.Validate(rememberMeToken.Verifier)) + if (!session.CheckVerifier(rememberMeToken.Verifier)) return; context.Remove(session); await context.SaveChangesAsync(); } + public async Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken) + { + // change the password + // invalidate long term sessions + // invalidate sessions + + var user = await context.Users.SingleOrDefaultAsync(u => u.Id == userId,cancellationToken); + + if (user is null) + throw new DomainError("invalid user"); + + user.SetPassword(password); + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken) + { + await sessionStorage.InvalidateUserSessions(userId); + } + private class GetSignupCodesQueryResultRow { public string Code { get; set; } diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 1d20668..362e600 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -20,13 +20,14 @@ public static class AuthStartup this IServiceCollection rootContainer, string connectionString, IEventBus eventBus, - ILoggerFactory loggerFactory + ILoggerFactory loggerFactory, + TimeProvider timeProvider ) { var hostBuilder = Host.CreateDefaultBuilder(); hostBuilder.ConfigureServices(services => - ConfigureServices(services, connectionString, eventBus, loggerFactory) + ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider) ); var host = hostBuilder.Build(); @@ -52,9 +53,12 @@ public static class AuthStartup IServiceCollection services, string connectionString, IEventPublisher publisher, - ILoggerFactory loggerFactory + ILoggerFactory loggerFactory, + TimeProvider timeProvider ) { + services.AddSingleton(timeProvider); + services.AddTransient(_ => new DbConnectionFactory(connectionString)); services.AddDbContext(builder => @@ -83,11 +87,8 @@ public static class AuthStartup services.AddSingleton(publisher); services.AddSingleton(); - - services.AddScoped( - typeof(IPipelineBehavior<,>), - typeof(SaveChangesPipelineBehaviour<,>) - ); + + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>)); services.AddScoped(); } diff --git a/Femto.Modules.Auth/Application/IAuthService.cs b/Femto.Modules.Auth/Application/IAuthService.cs index 9fd517d..f656d95 100644 --- a/Femto.Modules.Auth/Application/IAuthService.cs +++ b/Femto.Modules.Auth/Application/IAuthService.cs @@ -3,12 +3,6 @@ using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application; -/// -/// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling, -/// particularly in regards to session management. I may or may not bother to move the commands and queries here also, -/// but for controller actions I do quite like having the abstraction, and there is less drive within me to bother. -/// It just seems redundant to expose them both, and it's a bit confusin' -/// public interface IAuthService { public Task AuthenticateUserCredentials( @@ -43,6 +37,9 @@ public interface IAuthService Task CreateRememberMeToken(Guid userId); Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken); Task DeleteRememberMeToken(RememberMeToken rememberMeToken); + + Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken = default); + Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken = default); } public record UserAndSession(UserInfo User, Session Session); \ No newline at end of file diff --git a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs index 0e1b3dd..e331396 100644 --- a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs +++ b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs @@ -1,29 +1,55 @@ +using System.Collections; +using System.Collections.Concurrent; using Femto.Modules.Auth.Models; using Microsoft.Extensions.Caching.Memory; namespace Femto.Modules.Auth.Infrastructure; -internal class SessionStorage(MemoryCacheOptions? options = null) +internal class SessionStorage(TimeProvider timeProvider) { - private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions()); + private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions()); - public Task GetSession(string id) + public async Task GetSession(string id) { - return Task.FromResult(this._storage.Get(id)); + var session = this._storage.Get($"session:{id}"); + + if (session is null) + return null; + + var invalidUntil = this._storage.Get( + $"user:invalid_until:{session.UserId}" + ); + + if (invalidUntil is not null && invalidUntil > session.Expires) + return null; + + return session; } public Task AddSession(Session session) { - using var entry = this._storage.CreateEntry(session.Id); - entry.Value = session; - entry.SetAbsoluteExpiration(session.Expires); + using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}"); + sessionEntry.Value = session; + sessionEntry.SetAbsoluteExpiration(session.Expires); return Task.CompletedTask; } public Task DeleteSession(string id) { - this._storage.Remove(id); + this._storage.Remove($"session:{id}"); + + return Task.CompletedTask; + } + + public Task InvalidateUserSessions(Guid userId) + { + var invalidUntil = timeProvider.GetUtcNow() + Session.ValidityPeriod; + + // invalidate sessions who are currently valid + // any sessions created after this will have a validity period that extends past invalid_until + // this cache entry doesn't need to live longer than that point in time + this._storage.Set($"user:invalid_until:{userId}", invalidUntil, invalidUntil); return Task.CompletedTask; } diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs new file mode 100644 index 0000000..e09c66e --- /dev/null +++ b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs @@ -0,0 +1,22 @@ +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Models.Events; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Models.DomainEventHandlers; + +internal class UserPasswordChangedHandler(AuthContext context) + : INotificationHandler +{ + public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken) + { + var longTermSessions = await context + .LongTermSessions.Where(s => s.UserId == notification.User.Id) + .ToListAsync(cancellationToken); + + foreach (var session in longTermSessions) + { + session.Invalidate(); + } + } +} diff --git a/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs new file mode 100644 index 0000000..70c9a73 --- /dev/null +++ b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Models.Events; + +internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent; \ No newline at end of file diff --git a/Femto.Modules.Auth/Models/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs index 113a3eb..eba3d9d 100644 --- a/Femto.Modules.Auth/Models/LongTermSession.cs +++ b/Femto.Modules.Auth/Models/LongTermSession.cs @@ -18,6 +18,8 @@ public class LongTermSession public DateTimeOffset Expires { get; private set; } public Guid UserId { get; private set; } + + public bool IsInvalidated { get; private set; } [NotMapped] public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer; @@ -46,8 +48,11 @@ public class LongTermSession return (longTermSession, verifier); } - public bool Validate(string verifier) + public bool CheckVerifier(string verifier) { + if (this.IsInvalidated) + return false; + if (this.Expires < DateTimeOffset.UtcNow) return false; @@ -60,4 +65,9 @@ public class LongTermSession var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); return hashedVerifier; } + + public void Invalidate() + { + this.IsInvalidated = true; + } } diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs index c4f8a60..e641a61 100644 --- a/Femto.Modules.Auth/Models/Session.cs +++ b/Femto.Modules.Auth/Models/Session.cs @@ -4,7 +4,7 @@ namespace Femto.Modules.Auth.Models; public class Session(Guid userId, bool isStrong) { - private static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15); + public static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15); private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5); public string Id { get; } = Convert.ToBase64String(GetBytes(32)); public Guid UserId { get; } = userId; diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index a7e3ddd..bd0288f 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -28,6 +28,8 @@ internal class UserIdentity : Entity public void SetPassword(string password) { + if (this.Password is not null) + this.AddDomainEvent(new UserPasswordChangedDomainEvent(this)); this.Password = new Password(password); } From 5379d29c5fd89b9a4b8199eafa999a45908ff188 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 18:12:16 +0200 Subject: [PATCH 60/66] refactor post reactions --- .../Controllers/Posts/Dto/GetPostResponse.cs | 3 + .../Posts/Dto/LoadPostsResponse.cs | 2 +- Femto.Api/Controllers/Posts/Dto/PostDto.cs | 14 +++- .../Controllers/Posts/Dto/PostReactionDto.cs | 2 +- .../Controllers/Posts/PostsController.cs | 69 +++++++++-------- Femto.Api/Properties/launchSettings.json | 2 +- .../20250810152132_AddTimestampToReaction.sql | 4 + .../GetPosts/Dto/GetPostsQueryResult.cs | 2 +- .../Queries/GetPosts/Dto/PostReactionDto.cs | 2 +- .../Queries/GetPosts/GetPostsQuery.cs | 31 ++++---- .../Queries/GetPosts/GetPostsQueryHandler.cs | 74 +++++++------------ .../Domain/Posts/PostReaction.cs | 2 + scripts/push-db.bash | 19 +++++ 13 files changed, 129 insertions(+), 97 deletions(-) create mode 100644 Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs create mode 100644 Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql create mode 100644 scripts/push-db.bash diff --git a/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs new file mode 100644 index 0000000..b44740c --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record GetPostResponse(PostDto Post); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs index 7efdeee..54b9df7 100644 --- a/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs +++ b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs @@ -3,4 +3,4 @@ using JetBrains.Annotations; namespace Femto.Api.Controllers.Posts.Dto; [PublicAPI] -public record LoadPostsResponse(IEnumerable Posts, Guid? Next); \ No newline at end of file +public record LoadPostsResponse(IEnumerable Posts); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index a00c8c1..2e6e827 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -11,4 +11,16 @@ public record PostDto( IEnumerable Reactions, DateTimeOffset CreatedAt, IEnumerable PossibleReactions -); \ No newline at end of file +) +{ + public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) => + new( + new PostAuthorDto(post.Author.AuthorId, post.Author.Username), + post.PostId, + post.Text, + post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), + post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)), + post.CreatedAt, + post.PossibleReactions + ); +} diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs index 81e3a95..f9934c6 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Posts.Dto; -public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file +public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 6036767..aa59a2f 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -25,7 +25,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current var res = await blogModule.Query( new GetPostsQuery(currentUserContext.CurrentUser?.Id) { - From = searchParams.From, + After = searchParams.From, Amount = searchParams.Amount ?? 20, AuthorId = searchParams.AuthorId, Author = searchParams.Author, @@ -33,18 +33,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); - return new LoadPostsResponse( - res.Posts.Select(p => new PostDto( - new PostAuthorDto(p.Author.AuthorId, p.Author.Username), - p.PostId, - p.Text, - p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), - p.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)), - p.CreatedAt, - p.PossibleReactions - )), - res.Next - ); + return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel)); } [HttpPost] @@ -75,17 +64,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); - return new CreatePostResponse( - new PostDto( - new PostAuthorDto(post.Author.AuthorId, post.Author.Username), - post.PostId, - post.Text, - post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), - post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(), - post.CreatedAt, - post.PossibleReactions - ) + return new CreatePostResponse(PostDto.FromModel(post)); + } + + [HttpGet("{postId}")] + public async Task> GetPost( + Guid postId, + CancellationToken cancellationToken + ) + { + var result = await blogModule.Query( + new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id), + cancellationToken ); + + var post = result.Posts.SingleOrDefault(); + + if (post is null) + return NotFound(); + + return new GetPostResponse(PostDto.FromModel(post)); } [HttpDelete("{postId}")] @@ -100,24 +98,37 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current [HttpPost("{postId}/reactions")] [Authorize] - public async Task AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken) + public async Task AddPostReaction( + Guid postId, + [FromBody] AddPostReactionRequest request, + CancellationToken cancellationToken + ) { var currentUser = currentUserContext.CurrentUser!; - await blogModule.Command(new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken); + await blogModule.Command( + new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), + cancellationToken + ); return this.Ok(); } - + [HttpDelete("{postId}/reactions")] [Authorize] - public async Task DeletePostReaction(Guid postId, [FromBody] DeletePostReactionRequest request, CancellationToken cancellationToken) + public async Task DeletePostReaction( + Guid postId, + [FromBody] DeletePostReactionRequest request, + CancellationToken cancellationToken + ) { var currentUser = currentUserContext.CurrentUser!; - await blogModule.Command(new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken); + await blogModule.Command( + new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), + cancellationToken + ); return this.Ok(); } - } diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json index 9a9026a..b024278 100644 --- a/Femto.Api/Properties/launchSettings.json +++ b/Femto.Api/Properties/launchSettings.json @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181", + "applicationUrl": "https://localhost:7269", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql new file mode 100644 index 0000000..4557156 --- /dev/null +++ b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql @@ -0,0 +1,4 @@ +-- Migration: AddTimestampToReaction +-- Created at: 10/08/2025 15:21:32 +alter table blog.post_reaction +add column created_at timestamptz; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs index be8157a..8b75d6e 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; -public record GetPostsQueryResult(IList Posts, Guid? Next); \ No newline at end of file +public record GetPostsQueryResult(IList Posts); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs index 9ea33dd..60349b9 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; -public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file +public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs index f8af9d2..1bb1d4c 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs @@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Queries.GetPosts; +/// +/// Get posts in reverse chronological order +/// +/// public record GetPostsQuery(Guid? CurrentUserId) : IQuery { - public Guid? From { get; init; } + /// + /// Id of the specific post to load. If specified, After and Amount are ignored + /// + public Guid? PostId { get; } + + /// + /// If specified, loads posts from after the given Id. Used for paging + /// + public Guid? After { get; init; } public int Amount { get; init; } = 20; public Guid? AuthorId { get; init; } public string? Author { get; init; } - /// - /// Default is to load in reverse chronological order - /// TODO this is not exposed on the client as it probably wouldn't work that well - /// - public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward; -} - -public enum GetPostsDirection -{ - Forward, - Backward, -} + public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId) + { + this.PostId = postId; + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index 26ae43a..0af48ee 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs @@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) var username = query.Author; var authorGuid = query.AuthorId; - var cursor = query.From; + var cursor = query.After; var showPrivate = query.CurrentUserId is not null; var loadPostsResult = await conn.QueryAsync( @@ -33,9 +33,10 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) from blog.post inner join blog.author on blog.author.id = blog.post.author_id where (@username is null or blog.author.username = @username) + and (@postId is null or blog.post.id = @postId) and (@showPrivate or blog.post.is_public = true) and (@authorGuid is null or blog.author.id = @authorGuid) - and (@cursor is null or blog.post.id <= @cursor) + and (@cursor is null or blog.post.id < @cursor) order by blog.post.id desc limit @amount """, @@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) username, authorGuid, cursor, - // load an extra one to take for the cursor - amount = query.Amount + 1, + amount = query.PostId is not null ? 1 : query.Amount, showPrivate, + postId = query.PostId, } ); - var loadedPosts = loadPostsResult.ToList(); - var posts = loadedPosts.Take(query.Amount).ToList(); - var next = loadedPosts.LastOrDefault()?.PostId; + var posts = loadPostsResult.ToList(); var postIds = posts.Select(p => p.PostId).ToList(); @@ -69,70 +68,46 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) """, new { postIds } ); + + var media = loadMediaResult.ToList(); var loadReactionsResult = await conn.QueryAsync( """ select pr.post_id as PostId, - pr.author_id as AuthorId, - pr.emoji as Emoji + a.username as AuthorName, + pr.emoji as Emoji, + pr.created_at as CreatedOn from blog.post_reaction pr + join blog.author a on a.id = pr.author_id where pr.post_id = ANY (@postIds) """, new { postIds } ); - var reactionsByPostId = loadReactionsResult - .GroupBy(r => r.PostId) - .ToDictionary( - group => group.Key, - group => - group - .GroupBy( - r => r.Emoji, - (key, g) => - { - var reactions = g.ToList(); - return new PostReactionDto( - key, - reactions.Count, - reactions.Any(r => r.AuthorId == query.CurrentUserId) - ); - } - ) - .ToList() - ); - - var mediaByPostId = loadMediaResult - .GroupBy(m => m.PostId) - .ToDictionary( - g => g.Key, - g => - g.Select(m => new PostMediaDto( - new Uri(m.MediaUrl), - m.MediaWidth, - m.MediaHeight - )) - .ToList() - ); + var reactions = loadReactionsResult.ToList(); return new GetPostsQueryResult( posts .Select(p => new PostDto( p.PostId, p.Content, - mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [], + media.Where(m => m.PostId == p.PostId).Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )).ToList(), p.PostedOn, new PostAuthorDto(p.AuthorId, p.Username), - reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos) - ? reactionDtos.ToList() - : [], + reactions + .Where(r => r.PostId == p.PostId) + .Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt)) + .ToList(), !string.IsNullOrEmpty(p.PossibleReactions) ? JsonSerializer.Deserialize>(p.PossibleReactions)! : [] )) - .ToList(), - next + .ToList() ); } @@ -158,7 +133,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) internal record LoadReactionRow { public Guid PostId { get; init; } - public Guid AuthorId { get; init; } + public string AuthorName { get; init; } public string Emoji { get; init; } + public DateTimeOffset CreatedAt { get; init; } } } diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs index ea8ab16..38e33b8 100644 --- a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs +++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs @@ -5,11 +5,13 @@ public class PostReaction public Guid AuthorId { get; private set; } public Guid PostId { get; private set; } public string Emoji { get; private set; } = null!; + public DateTimeOffset CreatedAt { get; private set; } public PostReaction(Guid authorId, Guid postId, string emoji) { this.AuthorId = authorId; this.PostId = postId; this.Emoji = emoji; + this.CreatedAt = TimeProvider.System.GetUtcNow(); } private PostReaction() { } diff --git a/scripts/push-db.bash b/scripts/push-db.bash new file mode 100644 index 0000000..f48d885 --- /dev/null +++ b/scripts/push-db.bash @@ -0,0 +1,19 @@ + +# Check if connection string is provided +if [ $# -lt 1 ]; then + echo "Usage: $0 " + echo "Example: $0 'Host=localhost;Database=mydb;Username=user;Password=pass'" + exit 1 +fi + +# Get the connection string from the first argument +CONNECTION_STRING="$1" + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Navigate to the parent directory (assuming migrator is built there) +cd "$SCRIPT_DIR/.." + +# Run the migrator with the 'up' command and provided connection string +dotnet run --project . -- up --connection-string "$CONNECTION_STRING" \ No newline at end of file From e8d5d50ae511abfa9c8fad4be0e6b1301c1f41e0 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 18:14:27 +0200 Subject: [PATCH 61/66] v0.1.29 --- Directory.Build.props | 2 +- scripts/push-db.bash | 19 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 scripts/push-db.bash diff --git a/Directory.Build.props b/Directory.Build.props index 727ce06..0f29b4c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.28 + 0.1.29 diff --git a/scripts/push-db.bash b/scripts/push-db.bash deleted file mode 100644 index f48d885..0000000 --- a/scripts/push-db.bash +++ /dev/null @@ -1,19 +0,0 @@ - -# Check if connection string is provided -if [ $# -lt 1 ]; then - echo "Usage: $0 " - echo "Example: $0 'Host=localhost;Database=mydb;Username=user;Password=pass'" - exit 1 -fi - -# Get the connection string from the first argument -CONNECTION_STRING="$1" - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Navigate to the parent directory (assuming migrator is built there) -cd "$SCRIPT_DIR/.." - -# Run the migrator with the 'up' command and provided connection string -dotnet run --project . -- up --connection-string "$CONNECTION_STRING" \ No newline at end of file From c49267e6c48ba18e30e53a79a261ba2a7b7399d5 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 18:20:44 +0200 Subject: [PATCH 62/66] fix search param name --- Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs | 2 +- Femto.Api/Controllers/Posts/PostsController.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs index e5155f6..3705456 100644 --- a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs +++ b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs @@ -3,4 +3,4 @@ using JetBrains.Annotations; namespace Femto.Api.Controllers.Posts.Dto; [PublicAPI] -public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author); \ No newline at end of file +public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index aa59a2f..bb4bca7 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -25,7 +25,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current var res = await blogModule.Query( new GetPostsQuery(currentUserContext.CurrentUser?.Id) { - After = searchParams.From, + After = searchParams.After, Amount = searchParams.Amount ?? 20, AuthorId = searchParams.AuthorId, Author = searchParams.Author, From 31d3de7bf3290e1e0d711a0a020a0cc712485122 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 18:21:15 +0200 Subject: [PATCH 63/66] v0.1.30 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0f29b4c..e16080e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.29 + 0.1.30 From cbf67bf5f18215c6b5b45fadcc93a3c4ca452e18 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 18:49:57 +0200 Subject: [PATCH 64/66] remove leftover directories --- Femto.Modules.Blog.Data/Class1.cs | 5 ---- .../Femto.Modules.Blog.Data.csproj | 9 ------- .../Femto.Modules.Blog.Domain.csproj | 24 ------------------- Femto.Modules.Files/Domain/Files/File.cs | 8 ------- .../Femto.Modules.Files.csproj | 9 ------- 5 files changed, 55 deletions(-) delete mode 100644 Femto.Modules.Blog.Data/Class1.cs delete mode 100644 Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj delete mode 100644 Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj delete mode 100644 Femto.Modules.Files/Domain/Files/File.cs delete mode 100644 Femto.Modules.Files/Femto.Modules.Files.csproj diff --git a/Femto.Modules.Blog.Data/Class1.cs b/Femto.Modules.Blog.Data/Class1.cs deleted file mode 100644 index 3be8b2a..0000000 --- a/Femto.Modules.Blog.Data/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Femto.Modules.Blog.Data; - -public class Class1 -{ -} \ No newline at end of file diff --git a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj deleted file mode 100644 index 17b910f..0000000 --- a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net9.0 - enable - enable - - - diff --git a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj deleted file mode 100644 index 6ae6742..0000000 --- a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - - - ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll - - - - diff --git a/Femto.Modules.Files/Domain/Files/File.cs b/Femto.Modules.Files/Domain/Files/File.cs deleted file mode 100644 index 9600ceb..0000000 --- a/Femto.Modules.Files/Domain/Files/File.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Femto.Modules.Files.Domain.Files; - -public class File -{ - Guid Id { get; set; } - - -} \ No newline at end of file diff --git a/Femto.Modules.Files/Femto.Modules.Files.csproj b/Femto.Modules.Files/Femto.Modules.Files.csproj deleted file mode 100644 index 17b910f..0000000 --- a/Femto.Modules.Files/Femto.Modules.Files.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net9.0 - enable - enable - - - From ce3888f1ab2a4d66baf707b2d72e8819fc07c6f5 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 19:57:47 +0200 Subject: [PATCH 65/66] comments --- .../Posts/Dto/AddPostCommentRequest.cs | 3 ++ .../Controllers/Posts/Dto/PostCommentDto.cs | 3 ++ Femto.Api/Controllers/Posts/Dto/PostDto.cs | 8 ++-- .../Controllers/Posts/PostsController.cs | 22 ++++++++- .../20250810172242_AddCommentToPost.sql | 11 +++++ Femto.Database/Seed/TestDataSeeder.cs | 7 +++ .../AddPostComment/AddPostCommentCommand.cs | 5 ++ .../AddPostCommentCommandHandler.cs | 20 ++++++++ .../CreatePost/CreatePostCommandHandler.cs | 11 ++--- .../Configurations/PostConfiguration.cs | 2 + .../Queries/GetPosts/Dto/PostCommentDto.cs | 3 ++ .../Queries/GetPosts/Dto/PostDto.cs | 5 +- .../Queries/GetPosts/GetPostsQueryHandler.cs | 47 ++++++++++++++++--- Femto.Modules.Blog/Domain/Posts/Post.cs | 17 ++++++- .../Domain/Posts/PostComment.cs | 19 ++++++++ 15 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs create mode 100644 Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs create mode 100644 Femto.Database/Migrations/20250810172242_AddCommentToPost.sql create mode 100644 Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs create mode 100644 Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs create mode 100644 Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs create mode 100644 Femto.Modules.Blog/Domain/Posts/PostComment.cs diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs new file mode 100644 index 0000000..7546af0 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record AddPostCommentRequest(Guid AuthorId, string Content); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs new file mode 100644 index 0000000..04e180a --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Posts.Dto; + +public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index 2e6e827..c9af7c6 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -10,7 +10,8 @@ public record PostDto( IEnumerable Media, IEnumerable Reactions, DateTimeOffset CreatedAt, - IEnumerable PossibleReactions + IEnumerable PossibleReactions, + IEnumerable Comments ) { public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) => @@ -21,6 +22,7 @@ public record PostDto( post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)), post.CreatedAt, - post.PossibleReactions + post.PossibleReactions, + post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn)) ); -} +} \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index bb4bca7..ed882f7 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -1,6 +1,7 @@ using Femto.Api.Controllers.Posts.Dto; using Femto.Common; using Femto.Modules.Blog.Application; +using Femto.Modules.Blog.Application.Commands.AddPostComment; using Femto.Modules.Blog.Application.Commands.AddPostReaction; using Femto.Modules.Blog.Application.Commands.ClearPostReaction; using Femto.Modules.Blog.Application.Commands.CreatePost; @@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts; [ApiController] [Route("posts")] -public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) +public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth) : ControllerBase { [HttpGet] @@ -131,4 +132,23 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current return this.Ok(); } + + [HttpPost("{postId}/comments")] + [Authorize] + public async Task AddPostComment( + Guid postId, + [FromBody] AddPostCommentRequest request, + CancellationToken cancellationToken + ) + { + if (currentUserContext.CurrentUser?.Id != request.AuthorId) + return this.BadRequest(); + + await blogModule.Command( + new AddPostCommentCommand(postId, request.AuthorId, request.Content), + cancellationToken + ); + + return this.Ok(); + } } diff --git a/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql new file mode 100644 index 0000000..44e0086 --- /dev/null +++ b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql @@ -0,0 +1,11 @@ +-- Migration: AddCommentToPost +-- Created at: 10/08/2025 17:22:42 + +CREATE TABLE blog.post_comment +( + id uuid PRIMARY KEY, + post_id uuid REFERENCES blog.post(id), + author_id uuid REFERENCES blog.author(id), + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) \ No newline at end of file diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index 433f73c..2c8efcc 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -43,6 +43,7 @@ public static class TestDataSeeder ('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 (id, post_id, url, ordering) VALUES @@ -63,6 +64,12 @@ public static class TestDataSeeder ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗') ; + INSERT INTO blog.post_comment + (id, post_id, author_id, content) + VALUES + ('9116da05-49eb-4053-9199-57f54f92e73a', '019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'this is a comment!') + ; + INSERT INTO authn.user_identity (id, username, password_hash, password_salt) VALUES diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs new file mode 100644 index 0000000..445c59e --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Application.Commands.AddPostComment; + +public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs new file mode 100644 index 0000000..6e52877 --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs @@ -0,0 +1,20 @@ +using Femto.Common.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Blog.Application.Commands.AddPostComment; + +internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler +{ + public async Task Handle(AddPostCommentCommand request, CancellationToken cancellationToken) + { + var post = await context.Posts.SingleOrDefaultAsync( + p => p.Id == request.PostId, + cancellationToken + ); + + if (post is null) + return; + + post.AddComment(request.AuthorId, request.Content); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs index 2d9c713..25bab45 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context) media.Width, media.Height )) - .ToList() - ) - { - IsPublic = request.IsPublic is true - }; + .ToList(), + request.IsPublic is true + ); await context.AddAsync(post, cancellationToken); @@ -39,7 +37,8 @@ internal class CreatePostCommandHandler(BlogContext context) post.PostedOn, new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), [], - post.PossibleReactions + post.PossibleReactions, + [] ); } } diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index b1defec..630cbe2 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -24,6 +24,8 @@ internal class PostConfiguration : IEntityTypeConfiguration } ); + table.OwnsMany(p => p.Comments).WithOwner(); + table.Property("PossibleReactionsJson").HasColumnName("possible_reactions"); table.Ignore(e => e.PossibleReactions); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs new file mode 100644 index 0000000..55ea5e8 --- /dev/null +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs @@ -0,0 +1,3 @@ +namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; + +public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs index b8b6a3d..63efede 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -7,5 +7,6 @@ public record PostDto( DateTimeOffset CreatedAt, PostAuthorDto Author, IList Reactions, - IEnumerable PossibleReactions -); + IEnumerable PossibleReactions, + IList Comments +); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index 0af48ee..c57627f 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs @@ -68,7 +68,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) """, new { postIds } ); - + var media = loadMediaResult.ToList(); var loadReactionsResult = await conn.QueryAsync( @@ -87,16 +87,36 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) var reactions = loadReactionsResult.ToList(); + var loadCommentsResult = await conn.QueryAsync( + """ + select + pc.id as CommentId, + pc.post_id as PostId, + pc.content as Content, + pc.created_at as PostedOn, + a.username as AuthorName + from blog.post_comment pc + join blog.author a on pc.author_id = a.id + where pc.post_id = ANY (@postIds) + """, + new { postIds } + ); + + var comments = loadCommentsResult.ToList(); + return new GetPostsQueryResult( posts .Select(p => new PostDto( p.PostId, p.Content, - media.Where(m => m.PostId == p.PostId).Select(m => new PostMediaDto( - new Uri(m.MediaUrl), - m.MediaWidth, - m.MediaHeight - )).ToList(), + media + .Where(m => m.PostId == p.PostId) + .Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )) + .ToList(), p.PostedOn, new PostAuthorDto(p.AuthorId, p.Username), reactions @@ -105,7 +125,11 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) .ToList(), !string.IsNullOrEmpty(p.PossibleReactions) ? JsonSerializer.Deserialize>(p.PossibleReactions)! - : [] + : [], + comments + .Where(c => c.PostId == p.PostId) + .Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn)) + .ToList() )) .ToList() ); @@ -137,4 +161,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) public string Emoji { get; init; } public DateTimeOffset CreatedAt { get; init; } } + + internal record LoadCommentRow + { + public Guid CommentId { get; init; } + public Guid PostId { get; init; } + public string Content { get; init; } + public DateTimeOffset PostedOn { get; init; } + public string AuthorName { get; init; } + } } diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index dc4d937..b5a9b2d 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -13,7 +13,9 @@ internal class Post : Entity public IList Media { get; private set; } public IList Reactions { get; private set; } = []; - public bool IsPublic { get; set; } + + public IList Comments { get; private set; } = []; + public bool IsPublic { get; private set; } public DateTimeOffset PostedOn { get; private set; } @@ -27,7 +29,7 @@ internal class Post : Entity private Post() { } - public Post(Guid authorId, string content, IList media) + public Post(Guid authorId, string content, IList media, bool isPublic) { this.Id = Guid.CreateVersion7(); this.AuthorId = authorId; @@ -35,6 +37,7 @@ internal class Post : Entity this.Media = media; this.PossibleReactions = AllEmoji.GetRandomEmoji(5); this.PostedOn = DateTimeOffset.UtcNow; + this.IsPublic = isPublic; this.AddDomainEvent(new PostCreated(this)); } @@ -56,4 +59,14 @@ internal class Post : Entity .Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji) .ToList(); } + + public void AddComment(Guid authorId, string content) + { + // XXX just ignore empty comments for now. we may want to upgrade this to an error + // but it is probably suitable to just consider it a no-op + if (string.IsNullOrWhiteSpace(content)) + return; + + this.Comments.Add(new PostComment(authorId, content)); + } } diff --git a/Femto.Modules.Blog/Domain/Posts/PostComment.cs b/Femto.Modules.Blog/Domain/Posts/PostComment.cs new file mode 100644 index 0000000..6f658a8 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/PostComment.cs @@ -0,0 +1,19 @@ +namespace Femto.Modules.Blog.Domain.Posts; + +internal class PostComment +{ + public Guid Id { get; private set; } + public Guid AuthorId { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public string Content { get; private set; } + + private PostComment() {} + + public PostComment(Guid authorId, string content) + { + this.Id = Guid.CreateVersion7(); + this.AuthorId = authorId; + this.Content = content; + this.CreatedAt = TimeProvider.System.GetUtcNow(); + } +} \ No newline at end of file From 3de79728a8b82500ad0e59d400e14d19398af14d Mon Sep 17 00:00:00 2001 From: john Date: Sun, 10 Aug 2025 21:22:27 +0200 Subject: [PATCH 66/66] v0.1.31 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e16080e..0751e1d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.30 + 0.1.31