using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Auth; public class VerifyLocalAdminLoginHandler( IDbContextFactory dbContextFactory, ILocalPasswordHasher passwordHasher) : IRequestHandler> { private static readonly BaseError InvalidCredentials = BaseError.New("Invalid username or password"); public async Task> Handle( VerifyLocalAdminLogin request, CancellationToken cancellationToken) { string username = (request.Username ?? string.Empty).Trim(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); // Read the hash and stamp in ONE snapshot so they are consistent (issue: a login racing a password // change must not return a stamp newer than the hash it verified). A concurrent change is then either // wholly before this read (the old password fails to verify) or wholly after it (we return the // pre-change stamp, so the cookie AuthController issues is revoked on its very next request by // CookieSecurityStampValidator). No writes happen here, so there is nothing to clobber. Dictionary config = await dbContext.ConfigElements .Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key || c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key || c.Key == ConfigElementKey.AuthSecurityStamp.Key) .ToDictionaryAsync(c => c.Key, c => c.Value, cancellationToken); config.TryGetValue(ConfigElementKey.AuthLocalAdminUsername.Key, out string storedUser); config.TryGetValue(ConfigElementKey.AuthLocalAdminPasswordHash.Key, out string storedHash); config.TryGetValue(ConfigElementKey.AuthSecurityStamp.Key, out string stamp); // Always run exactly one PBKDF2 verify — against a dummy hash when unconfigured/unknown — so response // timing does not reveal whether the account exists (no user enumeration). string candidateHash = storedHash ?? passwordHasher.DummyHash; LocalPasswordVerification result = passwordHasher.Verify(candidateHash, request.Password ?? string.Empty); bool userMatches = storedUser is not null && string.Equals(storedUser, username, StringComparison.OrdinalIgnoreCase); if (storedHash is null || !userMatches || result == LocalPasswordVerification.Failed) { return InvalidCredentials; } return new LocalAdminPrincipal(storedUser, stamp ?? string.Empty); } }