Compare commits

...

2 commits

Author SHA1 Message Date
f48b421500 do sessions in memory and also fix glaring security hole 2025-06-01 23:28:00 +02:00
7b6c155a73 wip session auth 2025-05-29 00:39:40 +02:00
41 changed files with 577 additions and 345 deletions

View file

@ -3,11 +3,9 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions; using Femto.Api.Sessions;
using Femto.Common; using Femto.Common;
using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Interface.ValidateSession; using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Errors;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Extensions;
namespace Femto.Api.Auth; namespace Femto.Api.Auth;
@ -15,48 +13,84 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
IAuthModule authModule, IAuthService authService,
CurrentUserContext currentUserContext CurrentUserContext currentUserContext
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) ) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
var sessionId = this.Request.Cookies["session"]; Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
if (string.IsNullOrWhiteSpace(sessionId))
var (sessionId, maybeUserId) = this.Context.GetSessionInfo();
if (sessionId is null)
{
Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
return AuthenticateResult.NoResult(); return AuthenticateResult.NoResult();
try
{
var result = await authModule.Command(new ValidateSessionCommand(sessionId));
var claims = new List<Claim>
{
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)
);
} }
catch (InvalidSessionError)
var session = await authService.GetSession(sessionId);
if (session is null)
{ {
return AuthenticateResult.Fail("Invalid session"); Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
return await FailAndDeleteSession(sessionId);
} }
if (session.IsExpired)
{
Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier);
return await FailAndDeleteSession(sessionId);
}
if (maybeUserId is not { } userId)
{
Logger.LogDebug("{TraceId} SessionId provided with no user", this.Context.TraceIdentifier);
return await FailAndDeleteSession(sessionId);
}
if (session.UserId != userId)
{
Logger.LogDebug("{TraceId} SessionId provided with different user", this.Context.TraceIdentifier);
return await FailAndDeleteSession(sessionId);
}
var user = await authService.GetUserWithId(userId);
if (user is null)
{
await authService.DeleteSession(sessionId);
this.Context.DeleteSession();
return AuthenticateResult.Fail("invalid session");
}
if (session.ExpiresSoon)
{
session = await authService.CreateWeakSession(userId);
this.Context.SetSession(session, user);
}
var claims = new List<Claim>
{
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<AuthenticateResult> FailAndDeleteSession(string sessionId)
{
await authService.DeleteSession(sessionId);
this.Context.DeleteSession();
return AuthenticateResult.Fail("invalid session");
} }
} }

View file

@ -1,13 +1,10 @@
using Femto.Api.Auth; using Femto.Api.Auth;
using Femto.Api.Sessions; using Femto.Api.Sessions;
using Femto.Common; 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.CreateSignupCode;
using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; 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.Interface.Register;
using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Contracts;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -21,73 +18,85 @@ public class AuthController(
IAuthModule authModule, IAuthModule authModule,
IOptions<CookieSettings> cookieSettings, IOptions<CookieSettings> cookieSettings,
ICurrentUserContext currentUserContext, ICurrentUserContext currentUserContext,
ILogger<AuthController> logger ILogger<AuthController> logger,
IAuthService authService
) : ControllerBase ) : ControllerBase
{ {
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request) public async Task<ActionResult<LoginResponse>> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken
)
{ {
var result = await authModule.Command(new LoginCommand(request.Username, request.Password)); var user = await authService.GetUserWithCredentials(
request.Username,
HttpContext.SetSession(result.Session, result.User, logger); request.Password,
cancellationToken
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")] [HttpPost("register")]
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request) public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
{ {
var result = await authModule.Command( var user = await authModule.Command(
new RegisterCommand(request.Username, request.Password, request.SignupCode) new RegisterCommand(request.Username, request.Password, request.SignupCode)
); );
HttpContext.SetSession(result.Session, result.User, logger); var session = await authService.CreateStrongSession(user.Id);
HttpContext.SetSession(session, user);
return new RegisterResponse( return new RegisterResponse(
result.User.Id, user.Id,
result.User.Username, user.Username,
result.User.Roles.Any(r => r == Role.SuperUser) user.Roles.Any(r => r == Role.SuperUser)
); );
} }
[HttpDelete("session")] [HttpDelete("session")]
public async Task<ActionResult> DeleteSession() public async Task<ActionResult> DeleteSession()
{ {
HttpContext.DeleteSession(); var (sessionId, userId) = HttpContext.GetSessionInfo();
if (sessionId is not null)
{
await authService.DeleteSession(sessionId);
HttpContext.DeleteSession();
}
return Ok(new { }); return Ok(new { });
} }
[HttpGet("user/{userId}")] [HttpGet("user/{userId}")]
[Authorize] [Authorize]
public async Task<ActionResult<RefreshUserResult>> RefreshUser( public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
Guid userId, Guid userId,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var currentUser = currentUserContext.CurrentUser!; var currentUser = currentUserContext.CurrentUser;
try if (currentUser is null || currentUser.Id != userId)
{ return this.BadRequest();
var result = await authModule.Command(
new RefreshUserSessionCommand(userId, currentUser),
cancellationToken
);
return new RefreshUserResult( var user = await authService.GetUserWithId(userId, cancellationToken);
result.User.Id,
result.User.Username, if (user is null)
result.User.Roles.Any(r => r == Role.SuperUser) return this.BadRequest();
);
} return new GetUserInfoResult(
catch (Exception) user.Id,
{ user.Username,
HttpContext.DeleteSession(); user.Roles.Any(r => r == Role.SuperUser)
return this.Forbid(); );
}
} }
[HttpPost("signup-codes")] [HttpPost("signup-codes")]

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser);

View file

@ -1,89 +1,102 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Femto.Api.Auth; using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions; namespace Femto.Api.Sessions;
internal record SessionInfo(string? SessionId, Guid? UserId);
internal static class HttpContextSessionExtensions internal static class HttpContextSessionExtensions
{ {
public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger) private static readonly JsonSerializerOptions JsonOptions = new()
{ {
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>(); PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public static SessionInfo GetSessionInfo(this HttpContext httpContext)
{
var sessionId = httpContext.Request.Cookies["sid"];
var secure = cookieSettings?.Value.Secure ?? true; var userJson = httpContext.Request.Cookies["user"];
var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict;
var domain = cookieSettings?.Value.Domain; UserInfo? user = null;
var expires = session.Expires; if (userJson is not null)
{
user = JsonSerializer.Deserialize<UserInfo>(userJson, JsonOptions);
}
logger.LogInformation( return new SessionInfo(sessionId, user?.Id);
"cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", }
secure,
sameSite,
domain,
expires
);
httpContext.Response.Cookies.Append( public static void SetSession(this HttpContext context, Session session, UserInfo user)
"session", {
session.SessionId, var cookieSettings = context.RequestServices.GetRequiredService<
IOptions<CookieSettings>
>();
context.Response.Cookies.Append(
"sid",
session.Id,
new CookieOptions new CookieOptions
{ {
Path = "/",
IsEssential = true, IsEssential = true,
Domain = domain, Domain = cookieSettings.Value.Domain,
HttpOnly = true, HttpOnly = true,
Secure = secure, Secure = cookieSettings.Value.Secure,
SameSite = sameSite, SameSite = cookieSettings.Value.SameSite,
Expires = expires, Expires = session.Expires,
} }
); );
httpContext.Response.Cookies.Append( context.Response.Cookies.Append(
"user", "user",
JsonSerializer.Serialize( JsonSerializer.Serialize(user, JsonOptions),
user,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
}
),
new CookieOptions new CookieOptions
{ {
Domain = domain, Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true, IsEssential = true,
Secure = secure, Secure = cookieSettings.Value.Secure,
SameSite = sameSite, SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires, Expires = session.Expires,
} }
); );
} }
public static void DeleteSession(this HttpContext httpContext) public static void DeleteSession(this HttpContext httpContext)
{ {
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>(); var cookieSettings = httpContext.RequestServices.GetRequiredService<
IOptions<CookieSettings>
var secure = cookieSettings?.Value.Secure ?? true; >();
var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified;
var domain = cookieSettings?.Value.Domain; httpContext.Response.Cookies.Delete(
"sid",
httpContext.Response.Cookies.Delete("session", new CookieOptions new CookieOptions
{ {
HttpOnly = true, Path = "/",
Domain = domain, HttpOnly = true,
IsEssential = true, Domain = cookieSettings.Value.Domain,
Secure = secure, IsEssential = true,
SameSite = sameSite, Secure = cookieSettings.Value.Secure,
Expires = DateTimeOffset.UtcNow.AddDays(-1), SameSite = cookieSettings.Value.SameSite,
}); Expires = DateTimeOffset.UtcNow.AddDays(-1),
httpContext.Response.Cookies.Delete("user", new CookieOptions }
{ );
Domain = domain,
IsEssential = true, httpContext.Response.Cookies.Delete(
Secure = secure, "user",
SameSite = sameSite, new CookieOptions
Expires = DateTimeOffset.UtcNow.AddDays(-1), {
}); Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
}
);
} }
} }

View file

@ -5,4 +5,4 @@ public interface ICurrentUserContext
CurrentUser? CurrentUser { get; } CurrentUser? CurrentUser { get; }
} }
public record CurrentUser(Guid Id, string Username, string SessionId); public record CurrentUser(Guid Id, string Username);

View file

@ -18,7 +18,12 @@ public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken); var response = await next(cancellationToken);
var hasChanges = context.ChangeTracker.HasChanges();
logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
if (context.ChangeTracker.HasChanges()) if (context.ChangeTracker.HasChanges())
{ {
await context.EmitDomainEvents(logger, publisher, cancellationToken); await context.EmitDomainEvents(logger, publisher, cancellationToken);

View file

@ -3,19 +3,24 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common; namespace Femto.Common;
/// <summary> /// <summary>
/// We use this to bind a scope to the request scope in the composition root /// 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 /// Any scoped services provided by this subcontainer should be accessed via a ScopeBinding injected in the host
/// </summary> /// </summary>
/// <param name="scope"></param> /// <param name="scope"></param>
public class ScopeBinding<T>(IServiceScope scope) : IDisposable public class ScopeBinding(IServiceScope scope) : IDisposable
where T : notnull
{ {
public T GetService() { private IServiceScope Scope { get; } = scope;
return scope.ServiceProvider.GetRequiredService<T>();
public T GetService<T>()
where T : notnull
{
return this.Scope.ServiceProvider.GetRequiredService<T>();
} }
public void Dispose() { public virtual void Dispose()
scope.Dispose(); {
this.Scope.Dispose();
} }
} }

View file

@ -0,0 +1,13 @@
-- Migration: addLongTermSessions
-- Created at: 29/05/2025 10:13:46
DROP TABLE authn.user_session;
CREATE TABLE authn.long_term_session
(
id serial PRIMARY KEY,
selector varchar(16) NOT NULL,
hashed_verifier bytea NOT NULL,
expires timestamptz not null,
user_id uuid REFERENCES authn.user_identity (id)
);

View file

@ -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

View file

@ -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

View file

@ -3,6 +3,7 @@ using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox; using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration; using Femto.Common.Integration;
using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Infrastructure; using Femto.Modules.Auth.Infrastructure;
using MediatR; using MediatR;
@ -24,16 +25,25 @@ public static class AuthStartup
) )
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, eventBus, loggerFactory) ConfigureServices(services, connectionString, eventBus, loggerFactory)
); );
var host = hostBuilder.Build(); var host = hostBuilder.Build();
rootContainer.AddScoped(_ => new ScopeBinding<IAuthModule>(host.Services.CreateScope())); rootContainer.AddKeyedScoped<ScopeBinding>(
rootContainer.AddScoped(services => "AuthServiceScope",
services.GetRequiredService<ScopeBinding<IAuthModule>>().GetService() (s, o) =>
{
var scope = host.Services.CreateScope();
return new ScopeBinding(scope);
}
); );
rootContainer.ExposeScopedService<IAuthModule>();
rootContainer.ExposeScopedService<IAuthService>();
rootContainer.AddHostedService(services => new AuthApplication(host)); rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe( eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@ -66,7 +76,7 @@ public static class AuthStartup
{ {
options.WaitForJobsToComplete = true; options.WaitForJobsToComplete = true;
}); });
// #endif
services.AddOutbox<AuthContext, OutboxMessageHandler>(); services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@ -74,8 +84,10 @@ public static class AuthStartup
services.ConfigureDomainServices<AuthContext>(); services.ConfigureDomainServices<AuthContext>();
services.AddSingleton(publisher); services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>();
services.AddScoped<IAuthModule, AuthModule>(); services.AddScoped<IAuthModule, AuthModule>();
services.AddScoped<IAuthService, AuthService>();
} }
private static async Task EventSubscriber( private static async Task EventSubscriber(
@ -107,3 +119,14 @@ public static class AuthStartup
} }
} }
} }
internal static class AuthServiceCollectionExtensions
{
public static void ExposeScopedService<T>(this IServiceCollection container)
where T : class
{
container.AddScoped<T>(services =>
services.GetRequiredKeyedService<ScopeBinding>("AuthServiceScope").GetService<T>()
);
}
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record LoginResult(Session Session, UserInfo User); public record LoginResult(SessionDto SessionDto, UserInfo User);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record RefreshUserSessionResult(Session Session, UserInfo User); public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record RegisterResult(Session Session, UserInfo User); public record RegisterResult(SessionDto SessionDto, UserInfo User);

View file

@ -2,9 +2,16 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto; 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) 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) { }
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record ValidateSessionResult(Session Session, UserInfo User); public record ValidateSessionResult(SessionDto SessionDto);

View file

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

View file

@ -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<DeauthenticateCommand>
{
public async Task Handle(DeauthenticateCommand request, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,6 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.GetUserInfo;
public record GetUserInfoCommand(Guid ForUser) : ICommand<UserInfo?>;

View file

@ -0,0 +1,27 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Interface.GetUserInfo;
internal class GetUserInfoCommandHandler(AuthContext context)
: ICommandHandler<GetUserInfoCommand, UserInfo?>
{
public async Task<UserInfo?> 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);
}
}

View file

@ -1,6 +0,0 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.Login;
public record LoginCommand(string Username, string Password) : ICommand<LoginResult>;

View file

@ -1,28 +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.Login;
internal class LoginCommandHandler(AuthContext context)
: ICommandHandler<LoginCommand, LoginResult>
{
public async Task<LoginResult> 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));
}
}

View file

@ -1,7 +0,0 @@
using Femto.Common;
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
public record RefreshUserSessionCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand<RefreshUserSessionResult>;

View file

@ -1,32 +0,0 @@
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<RefreshUserSessionCommand, RefreshUserSessionResult>
{
public async Task<RefreshUserSessionResult> 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));
}
}

View file

@ -3,4 +3,4 @@ using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.Register; namespace Femto.Modules.Auth.Application.Interface.Register;
public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand<RegisterResult>; public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand<UserInfo>;

View file

@ -6,30 +6,38 @@ using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Interface.Register; namespace Femto.Modules.Auth.Application.Interface.Register;
internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<RegisterCommand, RegisterResult> internal class RegisterCommandHandler(AuthContext context)
: ICommandHandler<RegisterCommand, UserInfo>
{ {
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken) public async Task<UserInfo> Handle(RegisterCommand request, CancellationToken cancellationToken)
{ {
var now = DateTimeOffset.UtcNow; 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.ExpiresAt == null || c.ExpiresAt > now)
.Where(c => c.RedeemingUserId == null) .Where(c => c.RedeemingUserId == null)
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
if (code is null) if (code is null)
throw new DomainError("invalid signup code"); 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); var user = new UserIdentity(request.Username);
await context.AddAsync(user, cancellationToken);
user.SetPassword(request.Password); user.SetPassword(request.Password);
var session = user.StartNewSession();
await context.AddAsync(user, cancellationToken);
code.Redeem(user.Id); code.Redeem(user.Id);
return new(new Session(session.Id, session.Expires), new UserInfo(user)); return new UserInfo(user);
} }
} }

View file

@ -1,10 +0,0 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
/// <summary>
/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future
/// </summary>
/// <param name="SessionId"></param>
public record ValidateSessionCommand(string SessionId) : ICommand<ValidateSessionResult>;

View file

@ -1,34 +0,0 @@
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<ValidateSessionCommand, ValidateSessionResult>
{
public async Task<ValidateSessionResult> 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)
);
}
}

View file

@ -1,9 +1,7 @@
using Femto.Common.Domain; using Femto.Common.Domain;
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Auth.Application; namespace Femto.Modules.Auth.Application.Services;
internal class AuthModule(IMediator mediator) : IAuthModule internal class AuthModule(IMediator mediator) : IAuthModule
{ {

View file

@ -0,0 +1,79 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Infrastructure;
using Femto.Modules.Auth.Models;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Services;
internal class AuthService(AuthContext context, SessionStorage storage) : IAuthService
{
public async Task<UserInfo?> 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<UserInfo?> 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<Session> CreateStrongSession(Guid userId)
{
var session = new Session(userId, true);
await storage.AddSession(session);
return session;
}
public async Task<Session> CreateWeakSession(Guid userId)
{
var session = new Session(userId, false);
await storage.AddSession(session);
return session;
}
public Task<Session?> GetSession(string sessionId)
{
return storage.GetSession(sessionId);
}
public async Task DeleteSession(string sessionId)
{
await storage.DeleteSession(sessionId);
}
public async Task<LongTermSession> CreateLongTermSession(Guid userId, bool isStrong)
{
throw new NotImplementedException();
}
public async Task<LongTermSession> DeleteLongTermSession(string sessionId)
{
throw new NotImplementedException();
}
public async Task<LongTermSession> RefreshLongTermSession(string sessionId)
{
throw new NotImplementedException();
}
public async Task<ValidateSessionResult> ValidateLongTermSession(string sessionId)
{
throw new NotImplementedException();
}
}

View file

@ -1,6 +1,6 @@
using Femto.Common.Domain; using Femto.Common.Domain;
namespace Femto.Modules.Auth.Application; namespace Femto.Modules.Auth.Application.Services;
public interface IAuthModule public interface IAuthModule
{ {

View file

@ -0,0 +1,14 @@
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Services;
public interface IAuthService
{
public Task<UserInfo?> GetUserWithCredentials(string username, string password, CancellationToken cancellationToken = default);
public Task<UserInfo?> GetUserWithId(Guid? userId, CancellationToken cancellationToken = default);
public Task<Session> CreateStrongSession(Guid userId);
public Task<Session> CreateWeakSession(Guid userId);
public Task<Session?> GetSession(string sessionId);
public Task DeleteSession(string sessionId);
}

View file

@ -8,6 +8,7 @@ internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(op
{ {
public virtual DbSet<UserIdentity> Users { get; set; } public virtual DbSet<UserIdentity> Users { get; set; }
public virtual DbSet<SignupCode> SignupCodes { get; set; } public virtual DbSet<SignupCode> SignupCodes { get; set; }
public virtual DbSet<LongTermSession> LongTermSessions { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; } public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)

View file

@ -19,8 +19,6 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIden
} }
); );
builder.OwnsMany(u => u.Sessions).WithOwner().HasForeignKey("user_id");
builder builder
.OwnsMany(u => u.Roles, entity => .OwnsMany(u => u.Roles, entity =>
{ {

View file

@ -0,0 +1,30 @@
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Caching.Memory;
namespace Femto.Modules.Auth.Infrastructure;
internal class SessionStorage(MemoryCacheOptions? options = null)
{
private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions());
public Task<Session?> GetSession(string id)
{
return Task.FromResult(this._storage.Get<Session>(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;
}
}

View file

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

View file

@ -0,0 +1,14 @@
using static System.Security.Cryptography.RandomNumberGenerator;
namespace Femto.Modules.Auth.Models;
public class Session(Guid userId, bool isStrong)
{
public string Id { get; } = Convert.ToBase64String(GetBytes(32));
public Guid UserId { get; } = userId;
public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(15);
public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5);
public bool IsStronglyAuthenticated { get; } = isStrong;
public bool IsExpired => this.Expires < DateTimeOffset.UtcNow;
}

View file

@ -1,9 +1,6 @@
using System.Text;
using System.Text.Unicode;
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events; using Femto.Modules.Auth.Models.Events;
using Geralt;
namespace Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Models;
@ -15,8 +12,6 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; } public Password? Password { get; private set; }
public ICollection<UserSession> Sessions { get; private set; } = [];
public ICollection<UserRole> Roles { get; private set; } = []; public ICollection<UserRole> Roles { get; private set; } = [];
private UserIdentity() { } private UserIdentity() { }
@ -31,12 +26,6 @@ internal class UserIdentity : Entity
this.AddDomainEvent(new UserWasCreatedEvent(this)); this.AddDomainEvent(new UserWasCreatedEvent(this));
} }
public UserIdentity WithPassword(string password)
{
this.SetPassword(password);
return this;
}
public void SetPassword(string password) public void SetPassword(string password)
{ {
this.Password = new Password(password); this.Password = new Password(password);
@ -51,25 +40,6 @@ internal class UserIdentity : Entity
return this.Password.Check(requestPassword); 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); public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);

View file

@ -1,21 +0,0 @@
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
};
}
}

View file

@ -35,9 +35,13 @@ public static class BlogStartup
rootContainer.AddHostedService(_ => new BlogApplication(host)); rootContainer.AddHostedService(_ => new BlogApplication(host));
rootContainer.AddScoped(_ => new ScopeBinding<IBlogModule>(host.Services.CreateScope())); rootContainer.AddKeyedScoped<ScopeBinding>(
"BlogService",
(_, o) => new ScopeBinding(host.Services.CreateScope())
);
rootContainer.AddScoped(services => rootContainer.AddScoped(services =>
services.GetRequiredService<ScopeBinding<IBlogModule>>().GetService() services.GetRequiredKeyedService<ScopeBinding>("BlogService").GetService<IBlogModule>()
); );
bus.Subscribe( bus.Subscribe(