mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 09:37:23 +00:00
Auth and Emails land together since Auth's registration/resend flows need Emails to exist first. Service.Auth's four services (Register, Login, Confirmation, Token) collapse into MediatR commands/queries in Features.Auth; TokenService stays as a slice-internal service since multiple handlers call it. Auth no longer references Emails directly — it sends SendRegistrationEmailCommand/SendResendConfirmationEmailCommand (defined in Shared.Application) which Features.Emails handles, so neither slice has a project reference on the other. Service.Emails' IEmailService becomes Features.Emails' IEmailDispatcher, simplified to take (firstName, email, token) instead of a full UserAccount since that's all it ever used. API.Specs' TestApiFactory/MockEmailService are updated to swap in the relocated interface. Also deletes Infrastructure.Repository now that Breweries, UserManagement, and Auth have all moved their repos out of it, and replaces the two now-dead docker-compose test services (repository.tests, service.auth.tests, both pointing at deleted Dockerfiles) with a single unit.tests service that runs every Features.*.Tests project via Dockerfile.tests.
371 lines
15 KiB
C#
371 lines
15 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
using Domain.Entities;
|
|
using Infrastructure.Sql;
|
|
using Microsoft.Data.SqlClient;
|
|
|
|
namespace Features.Auth.Repository;
|
|
|
|
/// <summary>
|
|
/// ADO.NET-based implementation of <see cref="IAuthRepository"/> backed by SQL Server stored procedures,
|
|
/// handling user registration, credential lookup/rotation, and account verification.
|
|
/// </summary>
|
|
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
|
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
|
|
IAuthRepository
|
|
{
|
|
/// <summary>
|
|
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
|
/// procedure, then fetches and returns the newly created user.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
|
/// it may be returned as a <see cref="Guid"/>, a parseable <see cref="string"/>, or a 16-byte array.
|
|
/// If the result cannot be interpreted, <see cref="Guid.Empty"/> is used, which will cause the
|
|
/// subsequent lookup to fail.
|
|
/// </remarks>
|
|
/// <param name="username">Unique username for the user</param>
|
|
/// <param name="firstName">User's first name</param>
|
|
/// <param name="lastName">User's last name</param>
|
|
/// <param name="email">User's email address</param>
|
|
/// <param name="dateOfBirth">User's date of birth</param>
|
|
/// <param name="passwordHash">Hashed password</param>
|
|
/// <returns>The newly created UserAccount with generated ID</returns>
|
|
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
|
string username,
|
|
string firstName,
|
|
string lastName,
|
|
string email,
|
|
DateTime dateOfBirth,
|
|
string passwordHash
|
|
)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
|
|
command.CommandText = "USP_RegisterUser";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@Username", username);
|
|
AddParameter(command, "@FirstName", firstName);
|
|
AddParameter(command, "@LastName", lastName);
|
|
AddParameter(command, "@Email", email);
|
|
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
|
AddParameter(command, "@Hash", passwordHash);
|
|
|
|
var result = await command.ExecuteScalarAsync();
|
|
|
|
Guid userAccountId = Guid.Empty;
|
|
if (result != null && result != DBNull.Value)
|
|
{
|
|
if (result is Guid g)
|
|
{
|
|
userAccountId = g;
|
|
}
|
|
else if (result is string s && Guid.TryParse(s, out var parsed))
|
|
{
|
|
userAccountId = parsed;
|
|
}
|
|
else if (result is byte[] bytes && bytes.Length == 16)
|
|
{
|
|
userAccountId = new Guid(bytes);
|
|
}
|
|
else
|
|
{
|
|
// Fallback: try to convert and parse string representation
|
|
try
|
|
{
|
|
var str = result.ToString();
|
|
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
|
|
userAccountId = p;
|
|
}
|
|
catch
|
|
{
|
|
userAccountId = Guid.Empty;
|
|
}
|
|
}
|
|
}
|
|
|
|
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a user account by email address (typically used for login) using the
|
|
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
|
/// </summary>
|
|
/// <param name="email">Email address to search for</param>
|
|
/// <returns>UserAccount if found, null otherwise</returns>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
|
string email
|
|
)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "usp_GetUserAccountByEmail";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@Email", email);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a user account by username (typically used for login) using the
|
|
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
|
/// </summary>
|
|
/// <param name="username">Username to search for</param>
|
|
/// <returns>UserAccount if found, null otherwise</returns>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
|
string username
|
|
)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "usp_GetUserAccountByUsername";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@Username", username);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves the active (non-revoked) credential for a user account using the
|
|
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
|
/// </summary>
|
|
/// <param name="userAccountId">ID of the user account</param>
|
|
/// <returns>Active UserCredential if found, null otherwise</returns>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
|
Guid userAccountId
|
|
)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@UserAccountId", userAccountId);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
|
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
|
/// </summary>
|
|
/// <param name="userAccountId">ID of the user account</param>
|
|
/// <param name="newPasswordHash">New hashed password</param>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task RotateCredentialAsync(
|
|
Guid userAccountId,
|
|
string newPasswordHash
|
|
)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "USP_RotateUserCredential";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@UserAccountId_", userAccountId);
|
|
AddParameter(command, "@Hash", newPasswordHash);
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
|
/// </summary>
|
|
/// <param name="userAccountId">ID of the user account</param>
|
|
/// <returns>UserAccount if found, null otherwise</returns>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
|
Guid userAccountId
|
|
)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "usp_GetUserAccountById";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@UserAccountId", userAccountId);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a user account as confirmed by creating a verification record via the
|
|
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
|
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
|
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
|
/// </summary>
|
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
|
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if the user account does not exist.</returns>
|
|
/// <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<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
|
Guid userAccountId
|
|
)
|
|
{
|
|
var 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 var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "USP_CreateUserVerification";
|
|
command.CommandType = CommandType.StoredProcedure;
|
|
|
|
AddParameter(command, "@UserAccountID_", userAccountId);
|
|
|
|
try
|
|
{
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether a user account has been verified by querying the
|
|
/// <c>dbo.UserVerification</c> table for a matching record.
|
|
/// </summary>
|
|
/// <param name="userAccountId">ID of the user account</param>
|
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
|
{
|
|
await using var connection = await CreateConnection();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText =
|
|
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
|
command.CommandType = CommandType.Text;
|
|
|
|
AddParameter(command, "@UserAccountID", userAccountId);
|
|
|
|
var result = await command.ExecuteScalarAsync();
|
|
return result != null && result != DBNull.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether a <see cref="SqlException"/> represents a duplicate key violation
|
|
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
|
/// </summary>
|
|
/// <param name="ex">The SQL exception to inspect.</param>
|
|
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
|
|
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
|
{
|
|
// 2601/2627 are duplicate key violations in SQL Server.
|
|
return ex.Number == 2601 || ex.Number == 2627;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Maps a data reader row to a UserAccount entity.
|
|
/// </summary>
|
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
|
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns>
|
|
protected override Domain.Entities.UserAccount MapToEntity(
|
|
DbDataReader reader
|
|
)
|
|
{
|
|
return new Domain.Entities.UserAccount
|
|
{
|
|
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
|
Username = reader.GetString(reader.GetOrdinal("Username")),
|
|
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
|
|
LastName = reader.GetString(reader.GetOrdinal("LastName")),
|
|
Email = reader.GetString(reader.GetOrdinal("Email")),
|
|
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
|
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
|
|
? null
|
|
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
|
|
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
|
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
|
? null
|
|
: (byte[])reader["Timer"],
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
|
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
|
/// </summary>
|
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
|
/// <returns>The mapped <see cref="UserCredential"/> instance.</returns>
|
|
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
|
{
|
|
var entity = new UserCredential
|
|
{
|
|
UserCredentialId = reader.GetGuid(
|
|
reader.GetOrdinal("UserCredentialId")
|
|
),
|
|
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
|
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
|
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
|
};
|
|
|
|
// Optional columns
|
|
var hasTimer =
|
|
reader
|
|
.GetSchemaTable()
|
|
?.Rows.Cast<System.Data.DataRow>()
|
|
.Any(r =>
|
|
string.Equals(
|
|
r["ColumnName"]?.ToString(),
|
|
"Timer",
|
|
StringComparison.OrdinalIgnoreCase
|
|
)
|
|
) ?? false;
|
|
|
|
if (hasTimer)
|
|
{
|
|
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
|
? null
|
|
: (byte[])reader["Timer"];
|
|
}
|
|
|
|
return entity;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
|
/// <see cref="DBNull.Value"/>.
|
|
/// </summary>
|
|
/// <param name="command">The command to add the parameter to.</param>
|
|
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
|
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
|
|
private static void AddParameter(
|
|
DbCommand command,
|
|
string name,
|
|
object? value
|
|
)
|
|
{
|
|
var p = command.CreateParameter();
|
|
p.ParameterName = name;
|
|
p.Value = value ?? DBNull.Value;
|
|
command.Parameters.Add(p);
|
|
}
|
|
}
|