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