mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add self-service account management commands (delete, update username/email/password/profile)
This commit is contained in:
@@ -13,6 +13,8 @@ public sealed class ApplicationUser
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? NormalizedEmail { get; set; }
|
||||
public bool EmailConfirmed { get; set; }
|
||||
|
||||
/// <summary>The current Argon2 hash, or <see langword="null" /> if the account has no password set.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
|
||||
@@ -3,12 +3,20 @@ using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>Adapts <see cref="IPasswordInfrastructure" />'s Argon2 hashing to <see cref="IPasswordHasher{TUser}" />.</summary>
|
||||
public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure)
|
||||
: IPasswordHasher<ApplicationUser>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string HashPassword(ApplicationUser user, string password) =>
|
||||
passwordInfrastructure.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Never returns <see cref="PasswordVerificationResult.SuccessRehashNeeded" />: Argon2 parameters are
|
||||
/// fixed by <see cref="IPasswordInfrastructure" />, so <see cref="UserManager{TUser}" />'s automatic
|
||||
/// rehash-on-verify path never triggers.
|
||||
/// </remarks>
|
||||
public PasswordVerificationResult VerifyHashedPassword(
|
||||
ApplicationUser user,
|
||||
string hashedPassword,
|
||||
|
||||
@@ -25,14 +25,18 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
FROM dbo.UserAccount
|
||||
""";
|
||||
|
||||
/// <remarks>No unmanaged resources to release; each method opens and disposes its own connection.</remarks>
|
||||
public void Dispose() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.Id.ToString());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.UserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? userName,
|
||||
@@ -43,11 +47,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedUserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedName,
|
||||
@@ -58,6 +64,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Inserts the account and its initial credential within a single database transaction, mirroring the
|
||||
/// previous <c>AuthRepository.RegisterUserAsync</c> behavior. <see cref="UserManager{TUser}" /> already
|
||||
@@ -131,10 +138,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Persists profile field changes and, when <see cref="ApplicationUser.EmailConfirmed" /> is
|
||||
/// <see langword="true" />, ensures a corresponding <c>dbo.UserVerification</c> row exists (idempotently,
|
||||
/// matching the previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior).
|
||||
/// Persists profile field changes and keeps <c>dbo.UserVerification</c> in sync with
|
||||
/// <see cref="ApplicationUser.EmailConfirmed" />: a row is inserted (idempotently, matching the
|
||||
/// previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior) when
|
||||
/// <see langword="true" />, and removed when <see langword="false" /> -- notably after
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> resets confirmation on an email change.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> UpdateAsync(
|
||||
ApplicationUser user,
|
||||
@@ -166,15 +176,54 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
if (user.EmailConfirmed)
|
||||
await EnsureVerificationRowAsync(connection, user.Id, cancellationToken);
|
||||
else
|
||||
await RemoveVerificationRowAsync(connection, user.Id, cancellationToken);
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
public Task<IdentityResult> DeleteAsync(
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A plain <c>DELETE</c> against <c>dbo.UserAccount</c>: its foreign keys from
|
||||
/// <c>dbo.UserCredential</c>, <c>dbo.UserVerification</c>, and <c>dbo.UserAvatar</c> cascade, so no
|
||||
/// manual child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
|
||||
/// account with <c>ON DELETE NO ACTION</c> by design; deleting an account that still has any of
|
||||
/// those fails with a foreign key violation, which is surfaced as a failed
|
||||
/// <see cref="IdentityResult" /> rather than an unhandled <see cref="SqlException" />.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> DeleteAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => throw new NotSupportedException("User deletion is not supported.");
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"DELETE FROM dbo.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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByIdAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken
|
||||
@@ -198,6 +247,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByNameAsync(
|
||||
string normalizedUserName,
|
||||
CancellationToken cancellationToken
|
||||
@@ -218,24 +268,79 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return user;
|
||||
}
|
||||
|
||||
public Task SetPasswordHashAsync(
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// For an existing account (<see cref="ApplicationUser.Id" /> already assigned), rotates the
|
||||
/// credential transactionally -- revoking the previous <c>dbo.UserCredential</c> row(s) and inserting
|
||||
/// the new hash -- mirroring the previous <c>AuthRepository.RotateCredentialAsync</c> behavior. During
|
||||
/// registration <see cref="UserManager{TUser}" /> calls this before the account has an ID (i.e. before
|
||||
/// <see cref="CreateAsync" /> runs); in that case only the in-memory hash is set, and
|
||||
/// <see cref="CreateAsync" /> writes the initial credential itself as part of its own transaction.
|
||||
/// </remarks>
|
||||
public async Task SetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
string? passwordHash,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
string? previousHash = user.PasswordHash;
|
||||
user.PasswordHash = passwordHash;
|
||||
return Task.CompletedTask;
|
||||
|
||||
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 dbo.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 dbo.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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.PasswordHash);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.PasswordHash != null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? email,
|
||||
@@ -246,14 +351,17 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.Email);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> GetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.EmailConfirmed);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
bool confirmed,
|
||||
@@ -264,6 +372,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByEmailAsync(
|
||||
string normalizedEmail,
|
||||
CancellationToken cancellationToken
|
||||
@@ -284,11 +393,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedEmail);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedEmail,
|
||||
@@ -376,6 +487,23 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes the <c>dbo.UserVerification</c> row for a user, if one exists.</summary>
|
||||
private static Task RemoveVerificationRowAsync(
|
||||
DbConnection connection,
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"DELETE FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||
private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627;
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a foreign key constraint violation (SQL Server error 547).</summary>
|
||||
private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the persistence-agnostic <see cref="UserAccount" /> domain entity into the
|
||||
/// <see cref="ApplicationUser" /> shape required by <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />.
|
||||
/// Kept here, in Features.Auth, rather than on <see cref="UserAccount" /> itself so the domain layer has
|
||||
/// no dependency on ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public static class UserAccountExtensions
|
||||
{
|
||||
public static ApplicationUser ToApplicationUser(this UserAccount userAccount) =>
|
||||
new()
|
||||
{
|
||||
Id = userAccount.UserAccountId,
|
||||
UserName = userAccount.Username,
|
||||
Email = userAccount.Email,
|
||||
FirstName = userAccount.FirstName,
|
||||
LastName = userAccount.LastName,
|
||||
DateOfBirth = userAccount.DateOfBirth,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user