using System.Data.Common; using Dapper; using Database.Connection; 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 Auth.UserAccount, /// Auth.UserCredential, and Auth.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) : DapperRepository(connectionFactory), IUserStore, IUserPasswordStore, IUserEmailStore { private const string SelectUserSql = """ SELECT UserAccountID AS Id, Username AS UserName, FirstName, LastName, Email, DateOfBirth FROM Auth.UserAccount """; /// No unmanaged resources to release; each method opens and disposes its own connection. 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 Auth.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 Auth.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 keeps Auth.UserVerification in sync with /// : a row is inserted (idempotently, matching the /// previous AuthRepository.ConfirmUserAccountAsync behavior) when /// , and removed when -- notably after /// resets confirmation on an email change. /// public async Task UpdateAsync( ApplicationUser user, CancellationToken cancellationToken ) { await using DbConnection connection = await CreateConnection(); await connection.ExecuteAsync( new CommandDefinition( """ UPDATE Auth.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); else await RemoveVerificationRowAsync(connection, user.Id, cancellationToken); return IdentityResult.Success; } /// /// /// A plain DELETE against Auth.UserAccount: its foreign keys from /// Auth.UserCredential, Auth.UserVerification, and Social.UserProfile cascade /// (Social.UserAvatar cascades transitively through Social.UserProfile), so no manual /// child cleanup is needed. Other tables (posts, comments, photos, follows) reference the /// account with ON DELETE NO ACTION by design; deleting an account that still has any of /// those fails with a foreign key violation, which is surfaced as a failed /// rather than an unhandled . /// public async Task DeleteAsync( ApplicationUser user, CancellationToken cancellationToken ) { await using DbConnection connection = await CreateConnection(); try { await connection.ExecuteAsync( new CommandDefinition( "DELETE FROM Auth.UserAccount WHERE UserAccountID = @Id", new { user.Id }, cancellationToken: cancellationToken ) ); } catch (SqlException ex) when (IsForeignKeyViolation(ex)) { return IdentityResult.Failed( new IdentityError { Code = "AccountHasDependentRecords", Description = "Account cannot be deleted because other records still reference it.", } ); } return IdentityResult.Success; } /// 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; } /// /// /// For an existing account ( already assigned), rotates the /// credential transactionally -- revoking the previous Auth.UserCredential row(s) and inserting /// the new hash -- mirroring the previous AuthRepository.RotateCredentialAsync behavior. During /// registration calls this before the account has an ID (i.e. before /// runs); in that case only the in-memory hash is set, and /// writes the initial credential itself as part of its own transaction. /// public async Task SetPasswordHashAsync( ApplicationUser user, string? passwordHash, CancellationToken cancellationToken ) { string? previousHash = user.PasswordHash; user.PasswordHash = passwordHash; if (user.Id == Guid.Empty || passwordHash == null || passwordHash == previousHash) return; await using DbConnection connection = await CreateConnection(); await using DbTransaction transaction = await connection.BeginTransactionAsync( cancellationToken ); try { await connection.ExecuteAsync( new CommandDefinition( """ UPDATE Auth.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 """, new { UserAccountId = user.Id }, transaction, cancellationToken: cancellationToken ) ); await connection.ExecuteAsync( new CommandDefinition( """ INSERT INTO Auth.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash); """, new { UserAccountId = user.Id, Hash = passwordHash }, transaction, cancellationToken: cancellationToken ) ); await transaction.CommitAsync(cancellationToken); } catch { await transaction.RollbackAsync(cancellationToken); throw; } } /// 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 Auth.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 Auth.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 Auth.UserVerification WHERE UserAccountID = @UserAccountId """, new { UserAccountId = userAccountId }, cancellationToken: cancellationToken ) ); if (alreadyVerified.HasValue) return; try { await connection.ExecuteAsync( new CommandDefinition( """ INSERT INTO Auth.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. } } /// Removes the Auth.UserVerification row for a user, if one exists. private static Task RemoveVerificationRowAsync( DbConnection connection, Guid userAccountId, CancellationToken cancellationToken ) => connection.ExecuteAsync( new CommandDefinition( "DELETE FROM Auth.UserVerification WHERE UserAccountID = @UserAccountId", new { UserAccountId = userAccountId }, cancellationToken: cancellationToken ) ); /// 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; /// Determines whether a is a foreign key constraint violation (SQL Server error 547). private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547; }