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