From 7b6c155a737847c56f5db210b9a365edf243822b Mon Sep 17 00:00:00 2001 From: john Date: Thu, 29 May 2025 00:39:40 +0200 Subject: [PATCH] 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 +}