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 7a981bc331
commit 9e9fc98d33
30 changed files with 1143 additions and 42 deletions

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