mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Remove obsolete AuthRepository/IAuthRepository
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, the Argon2 password hashing
|
||||
/// infrastructure, and ASP.NET Core Identity (<see cref="UserManager{TUser}" /> backed by
|
||||
/// <see cref="DapperUserStore" />) as scoped services.
|
||||
/// Adds <see cref="ITokenService" /> and ASP.NET Core Identity (<see cref="UserManager{TUser}" />
|
||||
/// backed by <see cref="DapperUserStore" /> and <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
|
||||
services
|
||||
.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
// AddDefaultTokenProviders() is deliberately not called: it registers
|
||||
// DataProtectorTokenProvider<TUser>, 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<ApplicationUser>(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<DapperUserStore>();
|
||||
services.AddScoped<IUserStore<ApplicationUser>>(sp =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IAuthRepository" />, handling user registration,
|
||||
/// credential lookup/rotation, and account verification.
|
||||
/// </summary>
|
||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
IAuthRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
|
||||
public async Task<UserAccount> RegisterUserAsync(UserAccount ua)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
|
||||
Guid userAccountId;
|
||||
|
||||
try
|
||||
{
|
||||
userAccountId = await connection.ExecuteScalarAsync<Guid>(
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE Email = @Email
|
||||
""",
|
||||
new { Email = email }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE Username = @Username
|
||||
""",
|
||||
new { Username = username }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserCredential>(
|
||||
"""
|
||||
SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion
|
||||
FROM dbo.UserCredential
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<int?>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. 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.
|
||||
/// </remarks>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||
/// Thrown when the database command fails for a reason other than a duplicate verification record.
|
||||
/// </exception>
|
||||
public async Task<UserAccount?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
int? result = await connection.ExecuteScalarAsync<int?>(
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
return result.HasValue;
|
||||
}
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||
{
|
||||
// 2601/2627 are duplicate key violations in SQL Server.
|
||||
return ex.Number == 2601 || ex.Number == 2627;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for authentication-related database operations including user registration and credential management.
|
||||
/// </summary>
|
||||
public interface IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user account and its initial credential as a single atomic operation, then
|
||||
/// returns the created account.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
Task<UserAccount> RegisterUserAsync(UserAccount ua);
|
||||
|
||||
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
/// <summary>Retrieves a user account by username, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||
|
||||
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Retrieves a user account by ID, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Determines whether a user account has been verified.</summary>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
Reference in New Issue
Block a user