diff --git a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs index 35681939..97d632a0 100644 --- a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Features.Auth.Identity; -using Features.Auth.Repository; using Features.Auth.Services; using Infrastructure.PasswordHashing; using Microsoft.AspNetCore.Identity; @@ -11,30 +10,31 @@ namespace Features.Auth.DependencyInjection; public static class FeaturesAuthServiceCollectionExtensions { /// - /// Adds , , the Argon2 password hashing - /// infrastructure, and ASP.NET Core Identity ( backed by - /// ) as scoped services. + /// Adds and ASP.NET Core Identity ( + /// backed by and ) as scoped services. /// public static IServiceCollection AddFeaturesAuth(this IServiceCollection services) { - services.AddScoped(); services.AddScoped(); services.AddScoped(); - services - .AddIdentityCore(options => - { - options.User.RequireUniqueEmail = true; + // AddDefaultTokenProviders() is deliberately not called: it registers + // DataProtectorTokenProvider, which UserManager's constructor resolves eagerly and which + // requires IDataProtectionProvider -- not registered in the plain-console-app DI containers this + // solution builds (e.g. Database.Seed). Identity's own token providers are unused here anyway; + // account confirmation keeps using its own JWT-based tokens (see ITokenService). + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; - // Password/username complexity is already enforced by RegisterUserValidator - // (FluentValidation); disable Identity's own checks to avoid duplicated/drifting rules. - options.Password.RequireDigit = false; - options.Password.RequireLowercase = false; - options.Password.RequireUppercase = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequiredLength = 1; - }) - .AddDefaultTokenProviders(); + // Password/username complexity is already enforced by RegisterUserValidator + // (FluentValidation); disable Identity's own checks to avoid duplicated/drifting rules. + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 1; + }); services.AddScoped(); services.AddScoped>(sp => diff --git a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs deleted file mode 100644 index 00bc05c1..00000000 --- a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Data.Common; -using Dapper; -using Domain.Entities; -using Domain.Exceptions; -using Infrastructure.Sql; -using Microsoft.Data.SqlClient; - -namespace Features.Auth.Repository; - -/// -/// Dapper-based implementation of , handling user registration, -/// credential lookup/rotation, and account verification. -/// -public class AuthRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), - IAuthRepository -{ - /// - /// The account and credential are inserted within a single database transaction. - public async Task RegisterUserAsync(UserAccount ua) - { - await using DbConnection connection = await CreateConnection(); - await using DbTransaction transaction = await connection.BeginTransactionAsync(); - - - Guid userAccountId; - - try - { - 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 { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email}, - transaction - ) - ); - - int credentialRows = await connection.ExecuteAsync( - new CommandDefinition( - """ - INSERT INTO dbo.UserCredential (UserAccountId, Hash) - VALUES (@UserAccountId, @Hash); - """, - new { UserAccountId = userAccountId, ua.UserCredential?.Hash }, - transaction - ) - ); - - if (credentialRows == 0) - throw new InvalidOperationException("Failed to create user credential."); - - await transaction.CommitAsync(); - } - catch - { - await transaction.RollbackAsync(); - throw; - } - - // perform round trip to get new User entity - return await GetUserByIdAsync(userAccountId) - ?? throw new Exception("Failed to retrieve newly registered user."); - } - - /// - public async Task GetUserByEmailAsync(string email) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - WHERE Email = @Email - """, - new { Email = email } - ); - } - - /// - public async Task GetUserByUsernameAsync(string username) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - WHERE Username = @Username - """, - new { Username = username } - ); - } - - /// - public async Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion - FROM dbo.UserCredential - WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 - """, - new { UserAccountId = userAccountId } - ); - } - - /// - public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash) - { - await using DbConnection connection = await CreateConnection(); - await using DbTransaction transaction = await connection.BeginTransactionAsync(); - - try - { - bool exists = - await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId - """, - new { UserAccountId = userAccountId }, - transaction - ) - ) is not null; - - if (!exists) - throw new NotFoundException("User account not found."); - - await connection.ExecuteAsync( - new CommandDefinition( - """ - UPDATE dbo.UserCredential - SET IsRevoked = 1, RevokedAt = GETDATE() - WHERE UserAccountId = @UserAccountId - """, - new { UserAccountId = userAccountId }, - transaction - ) - ); - - await connection.ExecuteAsync( - new CommandDefinition( - """ - INSERT INTO dbo.UserCredential (UserAccountId, Hash) - VALUES (@UserAccountId, @Hash) - """, - new { UserAccountId = userAccountId, Hash = newPasswordHash }, - transaction - ) - ); - - await transaction.CommitAsync(); - } - catch - { - await transaction.RollbackAsync(); - throw; - } - } - - /// - public async Task GetUserByIdAsync(Guid userAccountId) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId - """, - new { UserAccountId = userAccountId } - ); - } - - /// - /// - /// Confirmation is recorded by inserting a row into dbo.UserVerification. 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. - /// - /// - /// Thrown when the database command fails for a reason other than a duplicate verification record. - /// - public async Task ConfirmUserAccountAsync(Guid userAccountId) - { - UserAccount? user = await GetUserByIdAsync(userAccountId); - if (user == null) - return null; - - // Idempotency: if already verified, treat as successful confirmation. - if (await IsUserVerifiedAsync(userAccountId)) - return user; - - await using DbConnection connection = await CreateConnection(); - - try - { - await connection.ExecuteAsync( - """ - INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) - VALUES (@UserAccountId, GETDATE()) - """, - new { UserAccountId = userAccountId } - ); - } - catch (SqlException ex) when (IsDuplicateVerificationViolation(ex)) - { - // A concurrent request verified this user first. Keep behavior idempotent. - } - - // Fetch and return the updated user - return await GetUserByIdAsync(userAccountId); - } - - /// - public async Task IsUserVerifiedAsync(Guid userAccountId) - { - await using DbConnection connection = await CreateConnection(); - int? result = await connection.ExecuteScalarAsync( - """ - SELECT TOP 1 1 - FROM dbo.UserVerification - WHERE UserAccountID = @UserAccountId - """, - new { UserAccountId = userAccountId } - ); - return result.HasValue; - } - - /// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627). - private static bool IsDuplicateVerificationViolation(SqlException ex) - { - // 2601/2627 are duplicate key violations in SQL Server. - return ex.Number == 2601 || ex.Number == 2627; - } -} diff --git a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs deleted file mode 100644 index 0305e166..00000000 --- a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Domain.Entities; - -namespace Features.Auth.Repository; - -/// -/// Repository for authentication-related database operations including user registration and credential management. -/// -public interface IAuthRepository -{ - /// - /// Registers a new user account and its initial credential as a single atomic operation, then - /// returns the created account. - /// - /// Thrown when the newly registered user cannot be retrieved after registration. - Task RegisterUserAsync(UserAccount ua); - - /// Retrieves a user account by email, or if none exists. - Task GetUserByEmailAsync(string email); - - /// Retrieves a user account by username, or if none exists. - Task GetUserByUsernameAsync(string username); - - /// Retrieves the active (non-revoked) credential for a user account, or if none exists. - Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); - - /// Rotates a user's credential by invalidating all existing credentials and creating a new one. - /// Thrown when no account with exists. - Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); - - /// Marks a user account as confirmed. Idempotent if the account is already verified. - Task ConfirmUserAccountAsync(Guid userAccountId); - - /// Retrieves a user account by ID, or if none exists. - Task GetUserByIdAsync(Guid userAccountId); - - /// Determines whether a user account has been verified. - Task IsUserVerifiedAsync(Guid userAccountId); -}