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; }