using System.Data.Common;
using Dapper;
using Database.Connection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.SqlClient;
namespace Features.Auth.Identity;
///
/// Dapper-based implementation of the ASP.NET Core Identity store interfaces consumed by
/// , persisting against the existing Auth.UserAccount,
/// Auth.UserCredential, and Auth.UserVerification tables. No EF Core is used, and no schema
/// changes were required: normalized name/email are computed on the fly rather than persisted (the
/// existing unique indexes are already case-insensitive under SQL Server's default collation).
///
public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IUserStore,
IUserPasswordStore,
IUserEmailStore
{
private const string SelectUserSql = """
SELECT UserAccountID AS Id, Username AS UserName, FirstName, LastName, Email, DateOfBirth
FROM Auth.UserAccount
""";
/// No unmanaged resources to release; each method opens and disposes its own connection.
public void Dispose() { }
///
public Task GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult(user.Id.ToString());
///
public Task GetUserNameAsync(
ApplicationUser user,
CancellationToken cancellationToken
) => Task.FromResult(user.UserName);
///
public Task SetUserNameAsync(
ApplicationUser user,
string? userName,
CancellationToken cancellationToken
)
{
user.UserName = userName ?? string.Empty;
return Task.CompletedTask;
}
///
public Task GetNormalizedUserNameAsync(
ApplicationUser user,
CancellationToken cancellationToken
) => Task.FromResult(user.NormalizedUserName);
///
public Task SetNormalizedUserNameAsync(
ApplicationUser user,
string? normalizedName,
CancellationToken cancellationToken
)
{
user.NormalizedUserName = normalizedName;
return Task.CompletedTask;
}
///
///
/// Inserts the account and its initial credential within a single database transaction, mirroring the
/// previous AuthRepository.RegisterUserAsync behavior. already
/// checks username/email uniqueness before calling this method, so the duplicate-key branch here only
/// guards against a genuine race between two concurrent registrations.
///
public async Task CreateAsync(
ApplicationUser user,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(
cancellationToken
);
try
{
Guid userAccountId = await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
INSERT INTO Auth.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
OUTPUT INSERTED.UserAccountID
VALUES (@UserName, @FirstName, @LastName, @DateOfBirth, @Email);
""",
new
{
user.UserName,
user.FirstName,
user.LastName,
user.DateOfBirth,
user.Email,
},
transaction,
cancellationToken: cancellationToken
)
);
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO Auth.UserCredential (UserAccountId, Hash)
VALUES (@UserAccountId, @Hash);
""",
new { UserAccountId = userAccountId, Hash = user.PasswordHash },
transaction,
cancellationToken: cancellationToken
)
);
await transaction.CommitAsync(cancellationToken);
user.Id = userAccountId;
}
catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
{
await transaction.RollbackAsync(cancellationToken);
return IdentityResult.Failed(
new IdentityError
{
Code = "DuplicateUserName",
Description = "Username or email already exists.",
}
);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return IdentityResult.Success;
}
///
///
/// Persists profile field changes and keeps Auth.UserVerification in sync with
/// : a row is inserted (idempotently, matching the
/// previous AuthRepository.ConfirmUserAccountAsync behavior) when
/// , and removed when -- notably after
/// resets confirmation on an email change.
///
public async Task UpdateAsync(
ApplicationUser user,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE Auth.UserAccount
SET Username = @UserName, FirstName = @FirstName, LastName = @LastName,
Email = @Email, DateOfBirth = @DateOfBirth, UpdatedAt = GETDATE()
WHERE UserAccountID = @Id
""",
new
{
user.Id,
user.UserName,
user.FirstName,
user.LastName,
user.Email,
user.DateOfBirth,
},
cancellationToken: cancellationToken
)
);
if (user.EmailConfirmed)
await EnsureVerificationRowAsync(connection, user.Id, cancellationToken);
else
await RemoveVerificationRowAsync(connection, user.Id, cancellationToken);
return IdentityResult.Success;
}
///
///
/// A plain DELETE against Auth.UserAccount: its foreign keys from
/// Auth.UserCredential, Auth.UserVerification, and Social.UserProfile cascade
/// (Social.UserAvatar cascades transitively through Social.UserProfile), so no manual
/// child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
/// account with ON DELETE NO ACTION by design; deleting an account that still has any of
/// those fails with a foreign key violation, which is surfaced as a failed
/// rather than an unhandled .
///
public async Task DeleteAsync(
ApplicationUser user,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"DELETE FROM Auth.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;
}
///
public async Task FindByIdAsync(
string userId,
CancellationToken cancellationToken
)
{
if (!Guid.TryParse(userId, out Guid id))
return null;
await using DbConnection connection = await CreateConnection();
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync(
new CommandDefinition(
$"{SelectUserSql} WHERE UserAccountID = @Id",
new { Id = id },
cancellationToken: cancellationToken
)
);
if (user != null)
await HydrateAsync(connection, user, cancellationToken);
return user;
}
///
public async Task FindByNameAsync(
string normalizedUserName,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync(
new CommandDefinition(
$"{SelectUserSql} WHERE Username = @UserName",
new { UserName = normalizedUserName },
cancellationToken: cancellationToken
)
);
if (user != null)
await HydrateAsync(connection, user, cancellationToken);
return user;
}
///
///
/// For an existing account ( already assigned), rotates the
/// credential transactionally -- revoking the previous Auth.UserCredential row(s) and inserting
/// the new hash -- mirroring the previous AuthRepository.RotateCredentialAsync behavior. During
/// registration calls this before the account has an ID (i.e. before
/// runs); in that case only the in-memory hash is set, and
/// writes the initial credential itself as part of its own transaction.
///
public async Task SetPasswordHashAsync(
ApplicationUser user,
string? passwordHash,
CancellationToken cancellationToken
)
{
string? previousHash = user.PasswordHash;
user.PasswordHash = passwordHash;
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 Auth.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 Auth.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;
}
}
///
public Task GetPasswordHashAsync(
ApplicationUser user,
CancellationToken cancellationToken
) => Task.FromResult(user.PasswordHash);
///
public Task HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult(user.PasswordHash != null);
///
public Task SetEmailAsync(
ApplicationUser user,
string? email,
CancellationToken cancellationToken
)
{
user.Email = email ?? string.Empty;
return Task.CompletedTask;
}
///
public Task GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult(user.Email);
///
public Task GetEmailConfirmedAsync(
ApplicationUser user,
CancellationToken cancellationToken
) => Task.FromResult(user.EmailConfirmed);
///
public Task SetEmailConfirmedAsync(
ApplicationUser user,
bool confirmed,
CancellationToken cancellationToken
)
{
user.EmailConfirmed = confirmed;
return Task.CompletedTask;
}
///
public async Task FindByEmailAsync(
string normalizedEmail,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync(
new CommandDefinition(
$"{SelectUserSql} WHERE Email = @Email",
new { Email = normalizedEmail },
cancellationToken: cancellationToken
)
);
if (user != null)
await HydrateAsync(connection, user, cancellationToken);
return user;
}
///
public Task GetNormalizedEmailAsync(
ApplicationUser user,
CancellationToken cancellationToken
) => Task.FromResult(user.NormalizedEmail);
///
public Task SetNormalizedEmailAsync(
ApplicationUser user,
string? normalizedEmail,
CancellationToken cancellationToken
)
{
user.NormalizedEmail = normalizedEmail;
return Task.CompletedTask;
}
/// Loads the active credential hash and verification status onto a freshly queried user.
private static async Task HydrateAsync(
DbConnection connection,
ApplicationUser user,
CancellationToken cancellationToken
)
{
user.PasswordHash = await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
SELECT Hash
FROM Auth.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
""",
new { UserAccountId = user.Id },
cancellationToken: cancellationToken
)
);
int? verified = await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
SELECT TOP 1 1
FROM Auth.UserVerification
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = user.Id },
cancellationToken: cancellationToken
)
);
user.EmailConfirmed = verified.HasValue;
}
///
/// 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.
///
private static async Task EnsureVerificationRowAsync(
DbConnection connection,
Guid userAccountId,
CancellationToken cancellationToken
)
{
int? alreadyVerified = await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
SELECT TOP 1 1
FROM Auth.UserVerification
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId },
cancellationToken: cancellationToken
)
);
if (alreadyVerified.HasValue)
return;
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO Auth.UserVerification (UserAccountId, VerificationDateTime)
VALUES (@UserAccountId, GETDATE())
""",
new { UserAccountId = userAccountId },
cancellationToken: cancellationToken
)
);
}
catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
{
// A concurrent request verified this user first. Keep behavior idempotent.
}
}
/// Removes the Auth.UserVerification row for a user, if one exists.
private static Task RemoveVerificationRowAsync(
DbConnection connection,
Guid userAccountId,
CancellationToken cancellationToken
) =>
connection.ExecuteAsync(
new CommandDefinition(
"DELETE FROM Auth.UserVerification WHERE UserAccountID = @UserAccountId",
new { UserAccountId = userAccountId },
cancellationToken: cancellationToken
)
);
/// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627).
private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627;
/// Determines whether a is a foreign key constraint violation (SQL Server error 547).
private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547;
}