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.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Interface.ValidateSession;
using Femto.Modules.Auth.Errors;
using Femto.Modules.Auth.Application.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Extensions;
namespace Femto.Api.Auth;
@ -15,48 +13,84 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAuthModule authModule,
IAuthService authService,
CurrentUserContext currentUserContext
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var sessionId = this.Request.Cookies["session"];
if (string.IsNullOrWhiteSpace(sessionId))
return AuthenticateResult.NoResult();
Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
try
var (sessionId, maybeUserId) = this.Context.GetSessionInfo();
if (sessionId is null)
{
var result = await authModule.Command(new ValidateSessionCommand(sessionId));
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);
}
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, 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);
currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username);
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)
);
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
}
catch (InvalidSessionError)
private async Task<AuthenticateResult> FailAndDeleteSession(string sessionId)
{
return AuthenticateResult.Fail("Invalid session");
}
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.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Application.Interface.CreateSignupCode;
using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
using Femto.Modules.Auth.Application.Interface.Login;
using Femto.Modules.Auth.Application.Interface.RefreshUserSession;
using Femto.Modules.Auth.Application.Interface.Register;
using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -21,74 +18,86 @@ public class AuthController(
IAuthModule authModule,
IOptions<CookieSettings> cookieSettings,
ICurrentUserContext currentUserContext,
ILogger<AuthController> logger
ILogger<AuthController> logger,
IAuthService authService
) : ControllerBase
{
[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));
HttpContext.SetSession(result.Session, result.User, logger);
return new LoginResponse(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
var user = await authService.GetUserWithCredentials(
request.Username,
request.Password,
cancellationToken
);
if (user is null)
return Forbid();
var session = await authService.CreateStrongSession(user.Id);
HttpContext.SetSession(session, user);
return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser));
}
[HttpPost("register")]
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
{
var result = await authModule.Command(
var user = await authModule.Command(
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(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
}
[HttpDelete("session")]
public async Task<ActionResult> DeleteSession()
{
var (sessionId, userId) = HttpContext.GetSessionInfo();
if (sessionId is not null)
{
await authService.DeleteSession(sessionId);
HttpContext.DeleteSession();
}
return Ok(new { });
}
[HttpGet("user/{userId}")]
[Authorize]
public async Task<ActionResult<RefreshUserResult>> RefreshUser(
public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
Guid userId,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
var currentUser = currentUserContext.CurrentUser;
try
{
var result = await authModule.Command(
new RefreshUserSessionCommand(userId, currentUser),
cancellationToken
);
if (currentUser is null || currentUser.Id != userId)
return this.BadRequest();
return new RefreshUserResult(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
var user = await authService.GetUserWithId(userId, cancellationToken);
if (user is null)
return this.BadRequest();
return new GetUserInfoResult(
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
}
catch (Exception)
{
HttpContext.DeleteSession();
return this.Forbid();
}
}
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]

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,60 +1,66 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions;
internal record SessionInfo(string? SessionId, Guid? UserId);
internal static class HttpContextSessionExtensions
{
public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger)
{
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>();
var secure = cookieSettings?.Value.Secure ?? true;
var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict;
var domain = cookieSettings?.Value.Domain;
var expires = session.Expires;
logger.LogInformation(
"cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}",
secure,
sameSite,
domain,
expires
);
httpContext.Response.Cookies.Append(
"session",
session.SessionId,
new CookieOptions
{
IsEssential = true,
Domain = domain,
HttpOnly = true,
Secure = secure,
SameSite = sameSite,
Expires = expires,
}
);
httpContext.Response.Cookies.Append(
"user",
JsonSerializer.Serialize(
user,
new JsonSerializerOptions
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public static SessionInfo GetSessionInfo(this HttpContext httpContext)
{
var sessionId = httpContext.Request.Cookies["sid"];
var userJson = httpContext.Request.Cookies["user"];
UserInfo? user = null;
if (userJson is not null)
{
user = JsonSerializer.Deserialize<UserInfo>(userJson, JsonOptions);
}
),
return new SessionInfo(sessionId, user?.Id);
}
public static void SetSession(this HttpContext context, Session session, UserInfo user)
{
var cookieSettings = context.RequestServices.GetRequiredService<
IOptions<CookieSettings>
>();
context.Response.Cookies.Append(
"sid",
session.Id,
new CookieOptions
{
Domain = domain,
Path = "/",
IsEssential = true,
Secure = secure,
SameSite = sameSite,
Domain = cookieSettings.Value.Domain,
HttpOnly = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires,
}
);
context.Response.Cookies.Append(
"user",
JsonSerializer.Serialize(user, JsonOptions),
new CookieOptions
{
Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires,
}
);
@ -62,28 +68,35 @@ internal static class HttpContextSessionExtensions
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("session", new CookieOptions
httpContext.Response.Cookies.Delete(
"sid",
new CookieOptions
{
Path = "/",
HttpOnly = true,
Domain = domain,
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = secure,
SameSite = sameSite,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
});
httpContext.Response.Cookies.Delete("user", new CookieOptions
}
);
httpContext.Response.Cookies.Delete(
"user",
new CookieOptions
{
Domain = domain,
Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = secure,
SameSite = sameSite,
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; }
}
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
)
{
logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken);
var hasChanges = context.ChangeTracker.HasChanges();
logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
if (context.ChangeTracker.HasChanges())
{
await context.EmitDomainEvents(logger, publisher, cancellationToken);

View file

@ -3,19 +3,24 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common;
/// <summary>
/// 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
/// </summary>
/// <param name="scope"></param>
public class ScopeBinding<T>(IServiceScope scope) : IDisposable
public class ScopeBinding(IServiceScope scope) : IDisposable
{
private IServiceScope Scope { get; } = scope;
public T GetService<T>()
where T : notnull
{
public T GetService() {
return scope.ServiceProvider.GetRequiredService<T>();
return this.Scope.ServiceProvider.GetRequiredService<T>();
}
public void Dispose() {
scope.Dispose();
public virtual void 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.Outbox;
using Femto.Common.Integration;
using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Infrastructure;
using MediatR;
@ -24,16 +25,25 @@ public static class AuthStartup
)
{
var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, eventBus, loggerFactory)
);
var host = hostBuilder.Build();
rootContainer.AddScoped(_ => new ScopeBinding<IAuthModule>(host.Services.CreateScope()));
rootContainer.AddScoped(services =>
services.GetRequiredService<ScopeBinding<IAuthModule>>().GetService()
rootContainer.AddKeyedScoped<ScopeBinding>(
"AuthServiceScope",
(s, o) =>
{
var scope = host.Services.CreateScope();
return new ScopeBinding(scope);
}
);
rootContainer.ExposeScopedService<IAuthModule>();
rootContainer.ExposeScopedService<IAuthService>();
rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@ -66,7 +76,7 @@ public static class AuthStartup
{
options.WaitForJobsToComplete = true;
});
// #endif
services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@ -74,8 +84,10 @@ public static class AuthStartup
services.ConfigureDomainServices<AuthContext>();
services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>();
services.AddScoped<IAuthModule, AuthModule>();
services.AddScoped<IAuthService, AuthService>();
}
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;
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;
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;
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;
public record Session(string SessionId, DateTimeOffset Expires)
{
internal Session(UserSession session) : this(session.Id, session.Expires)
public record SessionDto(
string SessionId,
DateTimeOffset Expires,
bool Weak,
string? RememberMe = null
)
{
}
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;
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;
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,13 +6,15 @@ using Microsoft.EntityFrameworkCore;
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 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);
@ -20,16 +22,22 @@ internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<Reg
if (code is null)
throw new DomainError("invalid signup code");
var usernameTaken = await context.Users.AnyAsync(
u => u.Username == request.Username,
cancellationToken
);
if (usernameTaken)
throw new DomainError("username taken");
var user = new UserIdentity(request.Username);
user.SetPassword(request.Password);
var session = user.StartNewSession();
await context.AddAsync(user, cancellationToken);
user.SetPassword(request.Password);
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 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
{

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;
namespace Femto.Modules.Auth.Application;
namespace Femto.Modules.Auth.Application.Services;
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<SignupCode> SignupCodes { get; set; }
public virtual DbSet<LongTermSession> LongTermSessions { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; }
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
.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.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events;
using Geralt;
namespace Femto.Modules.Auth.Models;
@ -15,8 +12,6 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; }
public ICollection<UserSession> Sessions { get; private set; } = [];
public ICollection<UserRole> Roles { get; private set; } = [];
private UserIdentity() { }
@ -31,12 +26,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 +40,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);

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.AddScoped(_ => new ScopeBinding<IBlogModule>(host.Services.CreateScope()));
rootContainer.AddKeyedScoped<ScopeBinding>(
"BlogService",
(_, o) => new ScopeBinding(host.Services.CreateScope())
);
rootContainer.AddScoped(services =>
services.GetRequiredService<ScopeBinding<IBlogModule>>().GetService()
services.GetRequiredKeyedService<ScopeBinding>("BlogService").GetService<IBlogModule>()
);
bus.Subscribe(