diff --git a/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs b/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs new file mode 100644 index 00000000..a901bd58 --- /dev/null +++ b/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs @@ -0,0 +1,20 @@ +namespace Features.Auth.Identity; + +/// +/// The user type driving for +/// Features.Auth. A plain POCO (not IdentityUser<TKey>) backed by +/// rather than EF Core, matching the rest of the solution's Dapper-only persistence. +/// +public sealed class ApplicationUser +{ + public Guid Id { get; set; } + public string UserName { get; set; } = string.Empty; + public string? NormalizedUserName { get; set; } + public string Email { get; set; } = string.Empty; + public string? NormalizedEmail { get; set; } + public bool EmailConfirmed { get; set; } + public string? PasswordHash { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } +} diff --git a/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs b/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs new file mode 100644 index 00000000..a8bcd37f --- /dev/null +++ b/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs @@ -0,0 +1,20 @@ +using Infrastructure.PasswordHashing; +using Microsoft.AspNetCore.Identity; + +namespace Features.Auth.Identity; + +public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure) + : IPasswordHasher +{ + public string HashPassword(ApplicationUser user, string password) => + passwordInfrastructure.Hash(password); + + public PasswordVerificationResult VerifyHashedPassword( + ApplicationUser user, + string hashedPassword, + string providedPassword + ) => + passwordInfrastructure.Verify(providedPassword, hashedPassword) + ? PasswordVerificationResult.Success + : PasswordVerificationResult.Failed; +} diff --git a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs new file mode 100644 index 00000000..f555f3fc --- /dev/null +++ b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs @@ -0,0 +1,381 @@ +using System.Data.Common; +using Dapper; +using Infrastructure.Sql; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.SqlClient; + +namespace Features.Auth.Identity; + +/// +/// Dapper-based implementation of the ASP.NET Core Identity store interfaces consumed by +/// , persisting against the existing dbo.UserAccount, +/// dbo.UserCredential, and dbo.UserVerification tables. No EF Core is used, and no schema +/// changes were required: normalized name/email are computed on the fly rather than persisted (the +/// existing unique indexes are already case-insensitive under SQL Server's default collation). +/// +public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) + : Repository(connectionFactory), + IUserStore, + IUserPasswordStore, + IUserEmailStore +{ + private const string SelectUserSql = + """ + SELECT UserAccountID AS Id, Username AS UserName, FirstName, LastName, Email, DateOfBirth + FROM dbo.UserAccount + """; + + public void Dispose() { } + + public Task GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) => + Task.FromResult(user.Id.ToString()); + + public Task GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) => + Task.FromResult(user.UserName); + + public Task SetUserNameAsync( + ApplicationUser user, + string? userName, + CancellationToken cancellationToken + ) + { + user.UserName = userName ?? string.Empty; + return Task.CompletedTask; + } + + public Task GetNormalizedUserNameAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) => Task.FromResult(user.NormalizedUserName); + + public Task SetNormalizedUserNameAsync( + ApplicationUser user, + string? normalizedName, + CancellationToken cancellationToken + ) + { + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + /// + /// Inserts the account and its initial credential within a single database transaction, mirroring the + /// previous AuthRepository.RegisterUserAsync behavior. already + /// checks username/email uniqueness before calling this method, so the duplicate-key branch here only + /// guards against a genuine race between two concurrent registrations. + /// + public async Task CreateAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = await connection.BeginTransactionAsync( + cancellationToken + ); + + try + { + Guid userAccountId = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email) + OUTPUT INSERTED.UserAccountID + VALUES (@UserName, @FirstName, @LastName, @DateOfBirth, @Email); + """, + new + { + user.UserName, + user.FirstName, + user.LastName, + user.DateOfBirth, + user.Email, + }, + transaction, + cancellationToken: cancellationToken + ) + ); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserCredential (UserAccountId, Hash) + VALUES (@UserAccountId, @Hash); + """, + new { UserAccountId = userAccountId, Hash = user.PasswordHash }, + transaction, + cancellationToken: cancellationToken + ) + ); + + await transaction.CommitAsync(cancellationToken); + user.Id = userAccountId; + } + catch (SqlException ex) when (IsDuplicateKeyViolation(ex)) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityResult.Failed( + new IdentityError + { + Code = "DuplicateUserName", + Description = "Username or email already exists.", + } + ); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return IdentityResult.Success; + } + + /// + /// Persists profile field changes and, when is + /// , ensures a corresponding dbo.UserVerification row exists (idempotently, + /// matching the previous AuthRepository.ConfirmUserAccountAsync behavior). + /// + public async Task UpdateAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.UserAccount + SET Username = @UserName, FirstName = @FirstName, LastName = @LastName, + Email = @Email, DateOfBirth = @DateOfBirth, UpdatedAt = GETDATE() + WHERE UserAccountID = @Id + """, + new + { + user.Id, + user.UserName, + user.FirstName, + user.LastName, + user.Email, + user.DateOfBirth, + }, + cancellationToken: cancellationToken + ) + ); + + if (user.EmailConfirmed) + await EnsureVerificationRowAsync(connection, user.Id, cancellationToken); + + return IdentityResult.Success; + } + + public Task DeleteAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) => throw new NotSupportedException("User deletion is not supported."); + + public async Task FindByIdAsync( + string userId, + CancellationToken cancellationToken + ) + { + if (!Guid.TryParse(userId, out Guid id)) + return null; + + await using DbConnection connection = await CreateConnection(); + ApplicationUser? user = await connection.QueryFirstOrDefaultAsync( + new CommandDefinition( + $"{SelectUserSql} WHERE UserAccountID = @Id", + new { Id = id }, + cancellationToken: cancellationToken + ) + ); + + if (user != null) + await HydrateAsync(connection, user, cancellationToken); + + return user; + } + + public async Task FindByNameAsync( + string normalizedUserName, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + ApplicationUser? user = await connection.QueryFirstOrDefaultAsync( + new CommandDefinition( + $"{SelectUserSql} WHERE Username = @UserName", + new { UserName = normalizedUserName }, + cancellationToken: cancellationToken + ) + ); + + if (user != null) + await HydrateAsync(connection, user, cancellationToken); + + return user; + } + + public Task SetPasswordHashAsync( + ApplicationUser user, + string? passwordHash, + CancellationToken cancellationToken + ) + { + user.PasswordHash = passwordHash; + return Task.CompletedTask; + } + + public Task GetPasswordHashAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) => Task.FromResult(user.PasswordHash); + + public Task HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) => + Task.FromResult(user.PasswordHash != null); + + public Task SetEmailAsync( + ApplicationUser user, + string? email, + CancellationToken cancellationToken + ) + { + user.Email = email ?? string.Empty; + return Task.CompletedTask; + } + + public Task GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) => + Task.FromResult(user.Email); + + public Task GetEmailConfirmedAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) => Task.FromResult(user.EmailConfirmed); + + public Task SetEmailConfirmedAsync( + ApplicationUser user, + bool confirmed, + CancellationToken cancellationToken + ) + { + user.EmailConfirmed = confirmed; + return Task.CompletedTask; + } + + public async Task FindByEmailAsync( + string normalizedEmail, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + ApplicationUser? user = await connection.QueryFirstOrDefaultAsync( + new CommandDefinition( + $"{SelectUserSql} WHERE Email = @Email", + new { Email = normalizedEmail }, + cancellationToken: cancellationToken + ) + ); + + if (user != null) + await HydrateAsync(connection, user, cancellationToken); + + return user; + } + + public Task GetNormalizedEmailAsync( + ApplicationUser user, + CancellationToken cancellationToken + ) => Task.FromResult(user.NormalizedEmail); + + public Task SetNormalizedEmailAsync( + ApplicationUser user, + string? normalizedEmail, + CancellationToken cancellationToken + ) + { + user.NormalizedEmail = normalizedEmail; + return Task.CompletedTask; + } + + /// Loads the active credential hash and verification status onto a freshly queried user. + private static async Task HydrateAsync( + DbConnection connection, + ApplicationUser user, + CancellationToken cancellationToken + ) + { + user.PasswordHash = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT Hash + FROM dbo.UserCredential + WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 + """, + new { UserAccountId = user.Id }, + cancellationToken: cancellationToken + ) + ); + + int? verified = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT TOP 1 1 + FROM dbo.UserVerification + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = user.Id }, + cancellationToken: cancellationToken + ) + ); + user.EmailConfirmed = verified.HasValue; + } + + /// + /// If a concurrent request verifies the same user first, the resulting duplicate-key SQL exception + /// (error 2601/2627) is swallowed to keep the operation idempotent. + /// + private static async Task EnsureVerificationRowAsync( + DbConnection connection, + Guid userAccountId, + CancellationToken cancellationToken + ) + { + int? alreadyVerified = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT TOP 1 1 + FROM dbo.UserVerification + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId }, + cancellationToken: cancellationToken + ) + ); + + if (alreadyVerified.HasValue) + return; + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) + VALUES (@UserAccountId, GETDATE()) + """, + new { UserAccountId = userAccountId }, + cancellationToken: cancellationToken + ) + ); + } + catch (SqlException ex) when (IsDuplicateKeyViolation(ex)) + { + // A concurrent request verified this user first. Keep behavior idempotent. + } + } + + /// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627). + private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627; +}