diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 37939a4..e71481d 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -3,9 +3,11 @@ 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.Interface.ValidateSession; +using Femto.Modules.Auth.Errors; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Extensions; namespace Femto.Api.Auth; @@ -13,84 +15,48 @@ internal class SessionAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - IAuthService authService, + IAuthModule authModule, CurrentUserContext currentUserContext ) : AuthenticationHandler(options, logger, encoder) { protected override async Task HandleAuthenticateAsync() { - 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); + var sessionId = this.Request.Cookies["session"]; + if (string.IsNullOrWhiteSpace(sessionId)) return AuthenticateResult.NoResult(); - } - - var session = await authService.GetSession(sessionId); - if (session is null) + try { - Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier); - return await FailAndDeleteSession(sessionId); + var result = await authModule.Command(new ValidateSessionCommand(sessionId)); + + var claims = new List + { + new(ClaimTypes.Name, result.User.Username), + new("sub", result.User.Id.ToString()), + new("user_id", result.User.Id.ToString()), + }; + + claims.AddRange( + 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, result.User, Logger); + currentUserContext.CurrentUser = new CurrentUser( + result.User.Id, + result.User.Username, + result.Session.SessionId + ); + + return AuthenticateResult.Success( + new AuthenticationTicket(principal, this.Scheme.Name) + ); } - - if (session.IsExpired) + catch (InvalidSessionError) { - Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier); - return await FailAndDeleteSession(sessionId); + return AuthenticateResult.Fail("Invalid session"); } - - 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 3ca6203..b567d1d 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -1,10 +1,13 @@ 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; @@ -18,85 +21,73 @@ public class AuthController( IAuthModule authModule, IOptions cookieSettings, ICurrentUserContext currentUserContext, - ILogger logger, - IAuthService authService + ILogger logger ) : ControllerBase { [HttpPost("login")] - public async Task> Login( - [FromBody] LoginRequest request, - CancellationToken cancellationToken - ) + public async Task> Login([FromBody] LoginRequest request) { - var user = await authService.GetUserWithCredentials( - request.Username, - request.Password, - cancellationToken + var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); + + HttpContext.SetSession(result.Session, result.User, logger); + + return new LoginResponse( + result.User.Id, + result.User.Username, + result.User.Roles.Any(r => r == Role.SuperUser) ); - - 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 user = await authModule.Command( + var result = await authModule.Command( new RegisterCommand(request.Username, request.Password, request.SignupCode) ); - var session = await authService.CreateStrongSession(user.Id); + HttpContext.SetSession(result.Session, result.User, logger); - HttpContext.SetSession(session, user); return new RegisterResponse( - user.Id, - user.Username, - user.Roles.Any(r => r == Role.SuperUser) + result.User.Id, + result.User.Username, + result.User.Roles.Any(r => r == Role.SuperUser) ); } [HttpDelete("session")] public async Task DeleteSession() { - var (sessionId, userId) = HttpContext.GetSessionInfo(); - - if (sessionId is not null) - { - await authService.DeleteSession(sessionId); - HttpContext.DeleteSession(); - } - + HttpContext.DeleteSession(); return Ok(new { }); } [HttpGet("user/{userId}")] [Authorize] - public async Task> GetUserInfo( + public async Task> RefreshUser( Guid userId, CancellationToken cancellationToken ) { - var currentUser = currentUserContext.CurrentUser; + var currentUser = currentUserContext.CurrentUser!; - if (currentUser is null || currentUser.Id != userId) - return this.BadRequest(); + try + { + var result = await authModule.Command( + new RefreshUserSessionCommand(userId, currentUser), + cancellationToken + ); - 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) - ); + 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")] diff --git a/Femto.Api/Controllers/Auth/GetUserInfoResult.cs b/Femto.Api/Controllers/Auth/GetUserInfoResult.cs deleted file mode 100644 index 0212f32..0000000 --- a/Femto.Api/Controllers/Auth/GetUserInfoResult.cs +++ /dev/null @@ -1,3 +0,0 @@ -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 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 fcf2a1f..f5e5d25 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -1,102 +1,89 @@ 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 { - private static readonly JsonSerializerOptions JsonOptions = new() + public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger) { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - public static SessionInfo GetSessionInfo(this HttpContext httpContext) - { - var sessionId = httpContext.Request.Cookies["sid"]; + var cookieSettings = httpContext.RequestServices.GetService>(); - var userJson = httpContext.Request.Cookies["user"]; - - UserInfo? user = null; - if (userJson is not null) - { - user = JsonSerializer.Deserialize(userJson, JsonOptions); - } + var secure = cookieSettings?.Value.Secure ?? true; + var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict; + var domain = cookieSettings?.Value.Domain; + var expires = session.Expires; - return new SessionInfo(sessionId, user?.Id); - } + logger.LogInformation( + "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", + secure, + sameSite, + domain, + expires + ); - public static void SetSession(this HttpContext context, Session session, UserInfo user) - { - var cookieSettings = context.RequestServices.GetRequiredService< - IOptions - >(); - - context.Response.Cookies.Append( - "sid", - session.Id, + httpContext.Response.Cookies.Append( + "session", + session.SessionId, new CookieOptions { - Path = "/", IsEssential = true, - Domain = cookieSettings.Value.Domain, + Domain = domain, HttpOnly = true, - Secure = cookieSettings.Value.Secure, - SameSite = cookieSettings.Value.SameSite, - Expires = session.Expires, + Secure = secure, + SameSite = sameSite, + Expires = expires, } ); - context.Response.Cookies.Append( + httpContext.Response.Cookies.Append( "user", - JsonSerializer.Serialize(user, JsonOptions), + JsonSerializer.Serialize( + user, + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + } + ), new CookieOptions { - Path = "/", - Domain = cookieSettings.Value.Domain, + Domain = domain, IsEssential = true, - Secure = cookieSettings.Value.Secure, - SameSite = cookieSettings.Value.SameSite, + Secure = secure, + SameSite = sameSite, Expires = session.Expires, } ); } - + public static void DeleteSession(this HttpContext httpContext) { - 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), - } - ); + 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), + }); } } 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.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs index b86a7e4..d9aaf03 100644 --- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs +++ b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs @@ -18,12 +18,7 @@ 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 da58589..c78408e 100644 --- a/Femto.Common/ScopeBinding.cs +++ b/Femto.Common/ScopeBinding.cs @@ -3,24 +3,19 @@ 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 +public class ScopeBinding(IServiceScope scope) : IDisposable + where T : notnull { - private IServiceScope Scope { get; } = scope; - - public T GetService() - where T : notnull - { - return this.Scope.ServiceProvider.GetRequiredService(); + public T GetService() { + return scope.ServiceProvider.GetRequiredService(); } - public virtual void Dispose() - { - this.Scope.Dispose(); + public void Dispose() { + scope.Dispose(); } } diff --git a/Femto.Database/Migrations/20250529101346_SessionsRework.sql b/Femto.Database/Migrations/20250529101346_SessionsRework.sql deleted file mode 100644 index 11cb84e..0000000 --- a/Femto.Database/Migrations/20250529101346_SessionsRework.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 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.Docs/Design/Auth/RememberMe.md b/Femto.Docs/Design/Auth/RememberMe.md deleted file mode 100644 index 0ff9ec2..0000000 --- a/Femto.Docs/Design/Auth/RememberMe.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index 5a45a7d..0000000 --- a/Femto.Docs/Design/Auth/strong_vs_weak_session.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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/Services/AuthModule.cs b/Femto.Modules.Auth/Application/AuthModule.cs similarity index 83% rename from Femto.Modules.Auth/Application/Services/AuthModule.cs rename to Femto.Modules.Auth/Application/AuthModule.cs index f64d78f..d289d9e 100644 --- a/Femto.Modules.Auth/Application/Services/AuthModule.cs +++ b/Femto.Modules.Auth/Application/AuthModule.cs @@ -1,7 +1,9 @@ using Femto.Common.Domain; using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; -namespace Femto.Modules.Auth.Application.Services; +namespace Femto.Modules.Auth.Application; internal class AuthModule(IMediator mediator) : IAuthModule { diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index c78e923..b9e6132 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; @@ -25,25 +24,16 @@ public static class AuthStartup ) { var hostBuilder = Host.CreateDefaultBuilder(); - hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus, loggerFactory) ); - var host = hostBuilder.Build(); - rootContainer.AddKeyedScoped( - "AuthServiceScope", - (s, o) => - { - var scope = host.Services.CreateScope(); - return new ScopeBinding(scope); - } + rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); + rootContainer.AddScoped(services => + services.GetRequiredService>().GetService() ); - rootContainer.ExposeScopedService(); - rootContainer.ExposeScopedService(); - rootContainer.AddHostedService(services => new AuthApplication(host)); eventBus.Subscribe( (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) @@ -76,7 +66,7 @@ public static class AuthStartup { options.WaitForJobsToComplete = true; }); - // #endif + services.AddOutbox(); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); @@ -84,10 +74,8 @@ public static class AuthStartup services.ConfigureDomainServices(); services.AddSingleton(publisher); - services.AddSingleton(); services.AddScoped(); - services.AddScoped(); } private static async Task EventSubscriber( @@ -119,14 +107,3 @@ 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/Dto/LoginResult.cs b/Femto.Modules.Auth/Application/Dto/LoginResult.cs index c9048ad..1405a28 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(SessionDto SessionDto, UserInfo User); \ No newline at end of file +public record LoginResult(Session Session, 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 19f1d17..ac1bbc3 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(SessionDto SessionDto, UserInfo User); \ No newline at end of file +public record RefreshUserSessionResult(Session Session, 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 e0a1243..13e1d12 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(SessionDto SessionDto, UserInfo User); \ No newline at end of file +public record RegisterResult(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 7f422eb..9e87ca8 100644 --- a/Femto.Modules.Auth/Application/Dto/Session.cs +++ b/Femto.Modules.Auth/Application/Dto/Session.cs @@ -2,16 +2,9 @@ using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application.Dto; -public record SessionDto( - string SessionId, - DateTimeOffset Expires, - bool Weak, - string? RememberMe = null -) +public record Session(string SessionId, DateTimeOffset Expires) { - 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) { } -} + internal Session(UserSession session) : this(session.Id, session.Expires) + { + } +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs index e29c84a..7fb022f 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(SessionDto SessionDto); \ No newline at end of file +public record ValidateSessionResult(Session Session, UserInfo User); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Services/IAuthModule.cs b/Femto.Modules.Auth/Application/IAuthModule.cs similarity index 87% rename from Femto.Modules.Auth/Application/Services/IAuthModule.cs rename to Femto.Modules.Auth/Application/IAuthModule.cs index df34366..4559161 100644 --- a/Femto.Modules.Auth/Application/Services/IAuthModule.cs +++ b/Femto.Modules.Auth/Application/IAuthModule.cs @@ -1,6 +1,6 @@ using Femto.Common.Domain; -namespace Femto.Modules.Auth.Application.Services; +namespace Femto.Modules.Auth.Application; public interface IAuthModule { 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/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/Login/LoginCommand.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs new file mode 100644 index 0000000..8252e2e --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..45b1ae4 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs @@ -0,0 +1,28 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +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 = user.StartNewSession(); + + return new(new Session(session.Id, session.Expires), new UserInfo(user)); + } +} 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/Interface/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs index 87332cb..dd3c186 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 7bb17be..9e29be6 100644 --- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs @@ -6,38 +6,30 @@ 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 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 = user.StartNewSession(); + + await context.AddAsync(user, cancellationToken); + code.Redeem(user.Id); - - return new UserInfo(user); + + return new(new Session(session.Id, session.Expires), 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 new file mode 100644 index 0000000..40d5417 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs @@ -0,0 +1,10 @@ +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) : ICommand; diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs new file mode 100644 index 0000000..f79552c --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs @@ -0,0 +1,34 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Errors; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Interface.ValidateSession; + +internal class ValidateSessionCommandHandler(AuthContext context) + : ICommandHandler +{ + public async Task Handle( + 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), + cancellationToken + ); + + if (user is null) + throw new InvalidSessionError(); + + var session = user.PossiblyRefreshSession(request.SessionId); + + return new ValidateSessionResult( + new Session(session.Id, session.Expires), + new UserInfo(user) + ); + } +} diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs deleted file mode 100644 index 4fb9323..0000000 --- a/Femto.Modules.Auth/Application/Services/AuthService.cs +++ /dev/null @@ -1,79 +0,0 @@ -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/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs deleted file mode 100644 index 56fd423..0000000 --- a/Femto.Modules.Auth/Application/Services/IAuthService.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 e4488e4..e850eb8 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -8,7 +8,6 @@ internal class AuthContext(DbContextOptions options) : DbContext(op { public virtual DbSet Users { 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/Data/Configurations/UserIdentityTypeConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs index 1921451..2e5086b 100644 --- a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs +++ b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs @@ -19,6 +19,8 @@ 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 deleted file mode 100644 index 0e1b3dd..0000000 --- a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs +++ /dev/null @@ -1,30 +0,0 @@ -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/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs deleted file mode 100644 index 06bd19d..0000000 --- a/Femto.Modules.Auth/Models/LongTermSession.cs +++ /dev/null @@ -1,51 +0,0 @@ -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/Session.cs b/Femto.Modules.Auth/Models/Session.cs deleted file mode 100644 index c142fb8..0000000 --- a/Femto.Modules.Auth/Models/Session.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 a7e3ddd..756be41 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -1,6 +1,9 @@ +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; @@ -12,6 +15,8 @@ internal class UserIdentity : Entity public Password? Password { get; private set; } + public ICollection Sessions { get; private set; } = []; + public ICollection Roles { get; private set; } = []; private UserIdentity() { } @@ -26,6 +31,12 @@ 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); @@ -40,6 +51,25 @@ 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 new file mode 100644 index 0000000..7deb251 --- /dev/null +++ b/Femto.Modules.Auth/Models/UserSession.cs @@ -0,0 +1,21 @@ +namespace Femto.Modules.Auth.Models; + +internal class UserSession +{ + private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30); + private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5); + public string Id { get; private set; } + public DateTimeOffset Expires { get; private set; } + public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer; + + private UserSession() {} + + public static UserSession Create() + { + return new() + { + Id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)), + Expires = DateTimeOffset.UtcNow + SessionTimeout + }; + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs index afd18b5..b134f4c 100644 --- a/Femto.Modules.Blog/Application/BlogStartup.cs +++ b/Femto.Modules.Blog/Application/BlogStartup.cs @@ -35,13 +35,9 @@ public static class BlogStartup rootContainer.AddHostedService(_ => new BlogApplication(host)); - rootContainer.AddKeyedScoped( - "BlogService", - (_, o) => new ScopeBinding(host.Services.CreateScope()) - ); - + rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope())); rootContainer.AddScoped(services => - services.GetRequiredKeyedService("BlogService").GetService() + services.GetRequiredService>().GetService() ); bus.Subscribe(