Add self-service account management commands (delete, update username/email/password/profile)

This commit is contained in:
Aaron Po
2026-08-22 17:24:53 -04:00
parent 93e0d089f9
commit c380bebdd6
30 changed files with 1143 additions and 42 deletions

View File

@@ -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;

View File

@@ -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,

View File

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

View File

@@ -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,
};
}