deleting password

This commit is contained in:
john 2025-07-19 14:10:01 +02:00
parent 36d8cc9a4d
commit 2519fc77d2
15 changed files with 237 additions and 47 deletions

View file

@ -4,6 +4,7 @@ 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.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models; using Femto.Modules.Auth.Models;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -26,10 +27,10 @@ internal class SessionAuthenticationHandler(
if (user is null) if (user is null)
user = await this.TryAuthenticateWithRememberMeToken(); user = await this.TryAuthenticateWithRememberMeToken();
if (user is null) if (user is null)
return AuthenticateResult.NoResult(); return AuthenticateResult.NoResult();
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(ClaimTypes.Name, user.Username), new(ClaimTypes.Name, user.Username),
@ -41,7 +42,11 @@ internal class SessionAuthenticationHandler(
var identity = new ClaimsIdentity(claims, this.Scheme.Name); var identity = new ClaimsIdentity(claims, this.Scheme.Name);
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username); currentUserContext.CurrentUser = new CurrentUser(
user.Id,
user.Username,
user.Roles.Contains(Role.SuperUser)
);
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)); return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
} }
@ -99,21 +104,23 @@ internal class SessionAuthenticationHandler(
* if it is valid, create a new weak session, return the user * if it is valid, create a new weak session, return the user
* if it is almost expired, refresh it * if it is almost expired, refresh it
*/ */
var rememberMeToken = this.Context.GetRememberMeToken(); var rememberMeToken = this.Context.GetRememberMeToken();
if (rememberMeToken is null) if (rememberMeToken is null)
return null; return null;
var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(rememberMeToken); var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(
rememberMeToken
);
if (user is null) if (user is null)
return null; return null;
var session = await authService.CreateWeakSession(user.Id); var session = await authService.CreateWeakSession(user.Id);
this.Context.SetSession(session, user); this.Context.SetSession(session, user);
if (newRememberMeToken is not null) if (newRememberMeToken is not null)
this.Context.SetRememberMeToken(newRememberMeToken); this.Context.SetRememberMeToken(newRememberMeToken);

View file

@ -28,9 +28,9 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
return this.BadRequest(); return this.BadRequest();
var (user, session) = result; var (user, session) = result;
HttpContext.SetSession(session, user); HttpContext.SetSession(session, user);
if (request.RememberMe) if (request.RememberMe)
{ {
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
@ -41,7 +41,10 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
} }
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) public async Task<ActionResult<RegisterResponse>> Register(
[FromBody] RegisterRequest request,
CancellationToken cancellationToken
)
{ {
var (user, session) = await authService.CreateUserWithCredentials( var (user, session) = await authService.CreateUserWithCredentials(
request.Username, request.Username,
@ -51,7 +54,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
); );
HttpContext.SetSession(session, user); HttpContext.SetSession(session, user);
if (request.RememberMe) if (request.RememberMe)
{ {
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
@ -65,6 +68,60 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
); );
} }
[HttpPost("change-password")]
public async Task<ActionResult> ChangePassword(
[FromBody] ChangePasswordRequestBody req,
CancellationToken cancellationToken
)
{
if (currentUserContext.CurrentUser is not {} user)
return this.BadRequest();
// superuser do what superuser want
if (!user.IsSuperUser)
{
if (user.Id != req.UserId)
return this.BadRequest();
var session = await authService.GetSession(this.HttpContext.GetSessionId()!);
// require strong authentication to change password
// the user can re-enter their password
if (session is null || !session.IsStronglyAuthenticated)
return this.BadRequest();
}
await authService.ChangePassword(req.UserId, req.NewPassword, cancellationToken);
// TODO would be better do handle this from inside the auth service. maybe just have it happen in a post-save event handler?
await authService.InvalidateUserSessions(req.UserId, cancellationToken);
return this.Ok(new {});
}
[HttpPost("delete-current-session")]
public async Task<ActionResult> DeleteSessionV2()
{
var sessionId = HttpContext.GetSessionId();
if (sessionId is not null)
{
await authService.DeleteSession(sessionId);
HttpContext.DeleteSession();
}
var rememberMeToken = HttpContext.GetRememberMeToken();
if (rememberMeToken is not null)
{
await authService.DeleteRememberMeToken(rememberMeToken);
HttpContext.DeleteRememberMeToken();
}
return Ok(new { });
}
[Obsolete("use POST /auth/delete-current-session")]
[HttpDelete("session")] [HttpDelete("session")]
public async Task<ActionResult> DeleteSession() public async Task<ActionResult> DeleteSession()
{ {
@ -111,6 +168,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
); );
} }
[Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")] [HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")] [Authorize(Roles = "SuperUser")]
public async Task<ActionResult> CreateSignupCode( public async Task<ActionResult> CreateSignupCode(
@ -123,6 +181,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
return Ok(new { }); return Ok(new { });
} }
[Obsolete("use GET /auth/list-signup-codes")]
[HttpGet("signup-codes")] [HttpGet("signup-codes")]
[Authorize(Roles = "SuperUser")] [Authorize(Roles = "SuperUser")]
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes( public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes(
@ -142,4 +201,36 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
)) ))
); );
} }
[HttpPost("create-signup-code")]
[Authorize(Roles = "SuperUser")]
public async Task<ActionResult> CreateSignupCodeV2(
[FromBody] CreateSignupCodeRequest request,
CancellationToken cancellationToken
)
{
await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
return Ok(new { });
}
[HttpGet("list-signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodesV2(
CancellationToken cancellationToken
)
{
var codes = await authService.GetSignupCodes(cancellationToken);
return new ListSignupCodesResult(
codes.Select(c => new SignupCodeDto(
c.Code,
c.Email,
c.Name,
c.RedeemedByUserId,
c.RedeemedByUsername,
c.ExpiresOn
))
);
}
} }

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record ChangePasswordRequestBody(Guid UserId, string NewPassword);

View file

@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus);
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory); builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory); builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System);
builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>(); builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>()); builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());

View file

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

View file

@ -0,0 +1,5 @@
-- Migration: AddInvalidateToLongTermSession
-- Created at: 19/07/2025 10:42:00
ALTER TABLE authn.long_term_session
ADD COLUMN is_invalidated BOOLEAN NOT NULL DEFAULT FALSE;

View file

@ -11,7 +11,7 @@ namespace Femto.Modules.Auth.Application;
internal class AuthService( internal class AuthService(
AuthContext context, AuthContext context,
SessionStorage storage, SessionStorage sessionStorage,
IDbConnectionFactory connectionFactory IDbConnectionFactory connectionFactory
) : IAuthService ) : IAuthService
{ {
@ -33,7 +33,7 @@ internal class AuthService(
var session = new Session(user.Id, true); var session = new Session(user.Id, true);
await storage.AddSession(session); await sessionStorage.AddSession(session);
return new( return new(
new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()), new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()),
@ -53,7 +53,7 @@ internal class AuthService(
{ {
var session = new Session(userId, true); var session = new Session(userId, true);
await storage.AddSession(session); await sessionStorage.AddSession(session);
return session; return session;
} }
@ -62,19 +62,19 @@ internal class AuthService(
{ {
var session = new Session(userId, false); var session = new Session(userId, false);
await storage.AddSession(session); await sessionStorage.AddSession(session);
return session; return session;
} }
public Task<Session?> GetSession(string sessionId) public Task<Session?> GetSession(string sessionId)
{ {
return storage.GetSession(sessionId); return sessionStorage.GetSession(sessionId);
} }
public async Task DeleteSession(string sessionId) public async Task DeleteSession(string sessionId)
{ {
await storage.DeleteSession(sessionId); await sessionStorage.DeleteSession(sessionId);
} }
public async Task<UserAndSession> CreateUserWithCredentials( public async Task<UserAndSession> CreateUserWithCredentials(
@ -113,7 +113,7 @@ internal class AuthService(
var session = new Session(user.Id, true); var session = new Session(user.Id, true);
await storage.AddSession(session); await sessionStorage.AddSession(session);
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
@ -189,7 +189,7 @@ internal class AuthService(
if (token is null) if (token is null)
return (null, null); return (null, null);
if (!token.Validate(rememberMeToken.Verifier)) if (!token.CheckVerifier(rememberMeToken.Verifier))
return (null, null); return (null, null);
var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId); var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId);
@ -218,13 +218,34 @@ internal class AuthService(
if (session is null) if (session is null)
return; return;
if (!session.Validate(rememberMeToken.Verifier)) if (!session.CheckVerifier(rememberMeToken.Verifier))
return; return;
context.Remove(session); context.Remove(session);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public async Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken)
{
// change the password
// invalidate long term sessions
// invalidate sessions
var user = await context.Users.SingleOrDefaultAsync(u => u.Id == userId,cancellationToken);
if (user is null)
throw new DomainError("invalid user");
user.SetPassword(password);
await context.SaveChangesAsync(cancellationToken);
}
public async Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken)
{
await sessionStorage.InvalidateUserSessions(userId);
}
private class GetSignupCodesQueryResultRow private class GetSignupCodesQueryResultRow
{ {
public string Code { get; set; } public string Code { get; set; }

View file

@ -20,13 +20,14 @@ public static class AuthStartup
this IServiceCollection rootContainer, this IServiceCollection rootContainer,
string connectionString, string connectionString,
IEventBus eventBus, IEventBus eventBus,
ILoggerFactory loggerFactory ILoggerFactory loggerFactory,
TimeProvider timeProvider
) )
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, eventBus, loggerFactory) ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
); );
var host = hostBuilder.Build(); var host = hostBuilder.Build();
@ -52,9 +53,12 @@ public static class AuthStartup
IServiceCollection services, IServiceCollection services,
string connectionString, string connectionString,
IEventPublisher publisher, IEventPublisher publisher,
ILoggerFactory loggerFactory ILoggerFactory loggerFactory,
TimeProvider timeProvider
) )
{ {
services.AddSingleton(timeProvider);
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString)); services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<AuthContext>(builder => services.AddDbContext<AuthContext>(builder =>
@ -83,11 +87,8 @@ public static class AuthStartup
services.AddSingleton(publisher); services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>(); services.AddSingleton<SessionStorage>();
services.AddScoped( services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
typeof(IPipelineBehavior<,>),
typeof(SaveChangesPipelineBehaviour<,>)
);
services.AddScoped<IAuthService, AuthService>(); services.AddScoped<IAuthService, AuthService>();
} }

View file

@ -3,12 +3,6 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application; namespace Femto.Modules.Auth.Application;
/// <summary>
/// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling,
/// particularly in regards to session management. I may or may not bother to move the commands and queries here also,
/// but for controller actions I do quite like having the abstraction, and there is less drive within me to bother.
/// It just seems redundant to expose them both, and it's a bit confusin'
/// </summary>
public interface IAuthService public interface IAuthService
{ {
public Task<UserAndSession?> AuthenticateUserCredentials( public Task<UserAndSession?> AuthenticateUserCredentials(
@ -43,6 +37,9 @@ public interface IAuthService
Task<NewRememberMeToken> CreateRememberMeToken(Guid userId); Task<NewRememberMeToken> CreateRememberMeToken(Guid userId);
Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken); Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken);
Task DeleteRememberMeToken(RememberMeToken rememberMeToken); Task DeleteRememberMeToken(RememberMeToken rememberMeToken);
Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken = default);
Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken = default);
} }
public record UserAndSession(UserInfo User, Session Session); public record UserAndSession(UserInfo User, Session Session);

View file

@ -1,29 +1,55 @@
using System.Collections;
using System.Collections.Concurrent;
using Femto.Modules.Auth.Models; using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
namespace Femto.Modules.Auth.Infrastructure; namespace Femto.Modules.Auth.Infrastructure;
internal class SessionStorage(MemoryCacheOptions? options = null) internal class SessionStorage(TimeProvider timeProvider)
{ {
private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions()); private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions());
public Task<Session?> GetSession(string id) public async Task<Session?> GetSession(string id)
{ {
return Task.FromResult(this._storage.Get<Session>(id)); var session = this._storage.Get<Session>($"session:{id}");
if (session is null)
return null;
var invalidUntil = this._storage.Get<DateTimeOffset?>(
$"user:invalid_until:{session.UserId}"
);
if (invalidUntil is not null && invalidUntil > session.Expires)
return null;
return session;
} }
public Task AddSession(Session session) public Task AddSession(Session session)
{ {
using var entry = this._storage.CreateEntry(session.Id); using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}");
entry.Value = session; sessionEntry.Value = session;
entry.SetAbsoluteExpiration(session.Expires); sessionEntry.SetAbsoluteExpiration(session.Expires);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task DeleteSession(string id) public Task DeleteSession(string id)
{ {
this._storage.Remove(id); this._storage.Remove($"session:{id}");
return Task.CompletedTask;
}
public Task InvalidateUserSessions(Guid userId)
{
var invalidUntil = timeProvider.GetUtcNow() + Session.ValidityPeriod;
// invalidate sessions who are currently valid
// any sessions created after this will have a validity period that extends past invalid_until
// this cache entry doesn't need to live longer than that point in time
this._storage.Set($"user:invalid_until:{userId}", invalidUntil, invalidUntil);
return Task.CompletedTask; return Task.CompletedTask;
} }

View file

@ -0,0 +1,22 @@
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models.Events;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Models.DomainEventHandlers;
internal class UserPasswordChangedHandler(AuthContext context)
: INotificationHandler<UserWasCreatedEvent>
{
public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken)
{
var longTermSessions = await context
.LongTermSessions.Where(s => s.UserId == notification.User.Id)
.ToListAsync(cancellationToken);
foreach (var session in longTermSessions)
{
session.Invalidate();
}
}
}

View file

@ -0,0 +1,5 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Models.Events;
internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;

View file

@ -18,6 +18,8 @@ public class LongTermSession
public DateTimeOffset Expires { get; private set; } public DateTimeOffset Expires { get; private set; }
public Guid UserId { get; private set; } public Guid UserId { get; private set; }
public bool IsInvalidated { get; private set; }
[NotMapped] [NotMapped]
public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer; public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
@ -46,8 +48,11 @@ public class LongTermSession
return (longTermSession, verifier); return (longTermSession, verifier);
} }
public bool Validate(string verifier) public bool CheckVerifier(string verifier)
{ {
if (this.IsInvalidated)
return false;
if (this.Expires < DateTimeOffset.UtcNow) if (this.Expires < DateTimeOffset.UtcNow)
return false; return false;
@ -60,4 +65,9 @@ public class LongTermSession
var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
return hashedVerifier; return hashedVerifier;
} }
public void Invalidate()
{
this.IsInvalidated = true;
}
} }

View file

@ -4,7 +4,7 @@ namespace Femto.Modules.Auth.Models;
public class Session(Guid userId, bool isStrong) public class Session(Guid userId, bool isStrong)
{ {
private static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15); public static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15);
private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5); private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5);
public string Id { get; } = Convert.ToBase64String(GetBytes(32)); public string Id { get; } = Convert.ToBase64String(GetBytes(32));
public Guid UserId { get; } = userId; public Guid UserId { get; } = userId;

View file

@ -28,6 +28,8 @@ internal class UserIdentity : Entity
public void SetPassword(string password) public void SetPassword(string password)
{ {
if (this.Password is not null)
this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
this.Password = new Password(password); this.Password = new Password(password);
} }