deleting password
This commit is contained in:
parent
36d8cc9a4d
commit
2519fc77d2
15 changed files with 237 additions and 47 deletions
|
@ -4,6 +4,7 @@ using Femto.Api.Sessions;
|
|||
using Femto.Common;
|
||||
using Femto.Modules.Auth.Application;
|
||||
using Femto.Modules.Auth.Application.Dto;
|
||||
using Femto.Modules.Auth.Contracts;
|
||||
using Femto.Modules.Auth.Models;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
@ -41,7 +42,11 @@ internal class SessionAuthenticationHandler(
|
|||
|
||||
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
|
||||
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));
|
||||
}
|
||||
|
@ -105,7 +110,9 @@ internal class SessionAuthenticationHandler(
|
|||
if (rememberMeToken is null)
|
||||
return null;
|
||||
|
||||
var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(rememberMeToken);
|
||||
var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(
|
||||
rememberMeToken
|
||||
);
|
||||
|
||||
if (user is null)
|
||||
return null;
|
||||
|
|
|
@ -41,7 +41,10 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
|||
}
|
||||
|
||||
[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(
|
||||
request.Username,
|
||||
|
@ -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")]
|
||||
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")]
|
||||
[Authorize(Roles = "SuperUser")]
|
||||
public async Task<ActionResult> CreateSignupCode(
|
||||
|
@ -123,6 +181,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
|||
return Ok(new { });
|
||||
}
|
||||
|
||||
[Obsolete("use GET /auth/list-signup-codes")]
|
||||
[HttpGet("signup-codes")]
|
||||
[Authorize(Roles = "SuperUser")]
|
||||
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
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
3
Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
Normal file
3
Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
Normal file
|
@ -0,0 +1,3 @@
|
|||
namespace Femto.Api.Controllers.Auth;
|
||||
|
||||
public record ChangePasswordRequestBody(Guid UserId, string NewPassword);
|
|
@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus);
|
|||
|
||||
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
|
||||
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<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());
|
||||
|
|
|
@ -5,4 +5,4 @@ public interface ICurrentUserContext
|
|||
CurrentUser? CurrentUser { get; }
|
||||
}
|
||||
|
||||
public record CurrentUser(Guid Id, string Username);
|
||||
public record CurrentUser(Guid Id, string Username, bool IsSuperUser);
|
|
@ -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;
|
|
@ -11,7 +11,7 @@ namespace Femto.Modules.Auth.Application;
|
|||
|
||||
internal class AuthService(
|
||||
AuthContext context,
|
||||
SessionStorage storage,
|
||||
SessionStorage sessionStorage,
|
||||
IDbConnectionFactory connectionFactory
|
||||
) : IAuthService
|
||||
{
|
||||
|
@ -33,7 +33,7 @@ internal class AuthService(
|
|||
|
||||
var session = new Session(user.Id, true);
|
||||
|
||||
await storage.AddSession(session);
|
||||
await sessionStorage.AddSession(session);
|
||||
|
||||
return new(
|
||||
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);
|
||||
|
||||
await storage.AddSession(session);
|
||||
await sessionStorage.AddSession(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
@ -62,19 +62,19 @@ internal class AuthService(
|
|||
{
|
||||
var session = new Session(userId, false);
|
||||
|
||||
await storage.AddSession(session);
|
||||
await sessionStorage.AddSession(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
public Task<Session?> GetSession(string sessionId)
|
||||
{
|
||||
return storage.GetSession(sessionId);
|
||||
return sessionStorage.GetSession(sessionId);
|
||||
}
|
||||
|
||||
public async Task DeleteSession(string sessionId)
|
||||
{
|
||||
await storage.DeleteSession(sessionId);
|
||||
await sessionStorage.DeleteSession(sessionId);
|
||||
}
|
||||
|
||||
public async Task<UserAndSession> CreateUserWithCredentials(
|
||||
|
@ -113,7 +113,7 @@ internal class AuthService(
|
|||
|
||||
var session = new Session(user.Id, true);
|
||||
|
||||
await storage.AddSession(session);
|
||||
await sessionStorage.AddSession(session);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
@ -189,7 +189,7 @@ internal class AuthService(
|
|||
if (token is null)
|
||||
return (null, null);
|
||||
|
||||
if (!token.Validate(rememberMeToken.Verifier))
|
||||
if (!token.CheckVerifier(rememberMeToken.Verifier))
|
||||
return (null, null);
|
||||
|
||||
var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId);
|
||||
|
@ -218,13 +218,34 @@ internal class AuthService(
|
|||
if (session is null)
|
||||
return;
|
||||
|
||||
if (!session.Validate(rememberMeToken.Verifier))
|
||||
if (!session.CheckVerifier(rememberMeToken.Verifier))
|
||||
return;
|
||||
|
||||
context.Remove(session);
|
||||
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
|
||||
{
|
||||
public string Code { get; set; }
|
||||
|
|
|
@ -20,13 +20,14 @@ public static class AuthStartup
|
|||
this IServiceCollection rootContainer,
|
||||
string connectionString,
|
||||
IEventBus eventBus,
|
||||
ILoggerFactory loggerFactory
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider timeProvider
|
||||
)
|
||||
{
|
||||
var hostBuilder = Host.CreateDefaultBuilder();
|
||||
|
||||
hostBuilder.ConfigureServices(services =>
|
||||
ConfigureServices(services, connectionString, eventBus, loggerFactory)
|
||||
ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
|
||||
);
|
||||
|
||||
var host = hostBuilder.Build();
|
||||
|
@ -52,9 +53,12 @@ public static class AuthStartup
|
|||
IServiceCollection services,
|
||||
string connectionString,
|
||||
IEventPublisher publisher,
|
||||
ILoggerFactory loggerFactory
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider timeProvider
|
||||
)
|
||||
{
|
||||
services.AddSingleton(timeProvider);
|
||||
|
||||
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
|
||||
|
||||
services.AddDbContext<AuthContext>(builder =>
|
||||
|
@ -84,10 +88,7 @@ public static class AuthStartup
|
|||
services.AddSingleton(publisher);
|
||||
services.AddSingleton<SessionStorage>();
|
||||
|
||||
services.AddScoped(
|
||||
typeof(IPipelineBehavior<,>),
|
||||
typeof(SaveChangesPipelineBehaviour<,>)
|
||||
);
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
|
||||
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
}
|
||||
|
|
|
@ -3,12 +3,6 @@ using Femto.Modules.Auth.Models;
|
|||
|
||||
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 Task<UserAndSession?> AuthenticateUserCredentials(
|
||||
|
@ -43,6 +37,9 @@ public interface IAuthService
|
|||
Task<NewRememberMeToken> CreateRememberMeToken(Guid userId);
|
||||
Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(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);
|
|
@ -1,29 +1,55 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using Femto.Modules.Auth.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
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)
|
||||
{
|
||||
using var entry = this._storage.CreateEntry(session.Id);
|
||||
entry.Value = session;
|
||||
entry.SetAbsoluteExpiration(session.Expires);
|
||||
using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}");
|
||||
sessionEntry.Value = session;
|
||||
sessionEntry.SetAbsoluteExpiration(session.Expires);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
using Femto.Common.Domain;
|
||||
|
||||
namespace Femto.Modules.Auth.Models.Events;
|
||||
|
||||
internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;
|
|
@ -19,6 +19,8 @@ public class LongTermSession
|
|||
|
||||
public Guid UserId { get; private set; }
|
||||
|
||||
public bool IsInvalidated { get; private set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
|
||||
|
||||
|
@ -46,8 +48,11 @@ public class LongTermSession
|
|||
return (longTermSession, verifier);
|
||||
}
|
||||
|
||||
public bool Validate(string verifier)
|
||||
public bool CheckVerifier(string verifier)
|
||||
{
|
||||
if (this.IsInvalidated)
|
||||
return false;
|
||||
|
||||
if (this.Expires < DateTimeOffset.UtcNow)
|
||||
return false;
|
||||
|
||||
|
@ -60,4 +65,9 @@ public class LongTermSession
|
|||
var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
|
||||
return hashedVerifier;
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
this.IsInvalidated = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ namespace Femto.Modules.Auth.Models;
|
|||
|
||||
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);
|
||||
public string Id { get; } = Convert.ToBase64String(GetBytes(32));
|
||||
public Guid UserId { get; } = userId;
|
||||
|
|
|
@ -28,6 +28,8 @@ internal class UserIdentity : Entity
|
|||
|
||||
public void SetPassword(string password)
|
||||
{
|
||||
if (this.Password is not null)
|
||||
this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
|
||||
this.Password = new Password(password);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue