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; }