Consolidate auth logic, update password service, and update namespaces

This commit is contained in:
Aaron Po
2026-02-08 23:05:08 -05:00
parent 881a94893f
commit ff1ce15419
33 changed files with 588 additions and 270 deletions

View File

@@ -96,7 +96,7 @@ Website/ # Next.js frontend application
**Repository Layer** (`Repository.Core`) **Repository Layer** (`Repository.Core`)
- Abstraction over SQL Server using ADO.NET - Abstraction over SQL Server using ADO.NET
- `ISqlConnectionFactory` for connection management - `ISqlConnectionFactory` for connection management
- Repositories: `UserAccountRepository`, `UserCredentialRepository` - Repositories: `AuthRepository`, `UserAccountRepository`
- All data access via stored procedures (no inline SQL) - All data access via stored procedures (no inline SQL)
**Service Layer** (`Service.Core`) **Service Layer** (`Service.Core`)

View File

@@ -1,5 +1,5 @@
using System.Net; using System.Net;
using DataAccessLayer.Entities; using Repository.Core.Entities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ServiceCore.Services; using ServiceCore.Services;

View File

@@ -1,4 +1,4 @@
using DataAccessLayer.Entities; using Repository.Core.Entities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ServiceCore.Services; using ServiceCore.Services;

View File

@@ -1,6 +1,6 @@
using DataAccessLayer.Repositories.UserAccount; using Repository.Core.Repositories.Auth;
using DataAccessLayer.Repositories.UserCredential; using Repository.Core.Repositories.UserAccount;
using DataAccessLayer.Sql; using Repository.Core.Sql;
using ServiceCore.Services; using ServiceCore.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -25,9 +25,10 @@ if (!builder.Environment.IsProduction())
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>(); builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserCredentialRepository, UserCredentialRepository>(); builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IJwtService, JwtService>(); builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IPasswordService, PasswordService>();
var app = builder.Build(); var app = builder.Build();

View File

@@ -2,7 +2,7 @@ Feature: User Registration
As a new user As a new user
I want to register an account I want to register an account
So that I can log in and access authenticated routes So that I can log in and access authenticated routes
@Ignore
Scenario: Successful registration with valid details Scenario: Successful registration with valid details
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
@@ -11,7 +11,7 @@ Feature: User Registration
Then the response has HTTP status 201 Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully." And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token And the response JSON should have an access token
@Ignore
Scenario: Registration fails with existing username Scenario: Registration fails with existing username
Given the API is running Given the API is running
And I have an existing account with username "existinguser" And I have an existing account with username "existinguser"
@@ -20,7 +20,7 @@ Feature: User Registration
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! | | existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
And the response JSON should have "message" equal "Username already exists." And the response JSON should have "message" equal "Username already exists."
@Ignore
Scenario: Registration fails with existing email Scenario: Registration fails with existing email
Given the API is running Given the API is running
And I have an existing account with email "existing@example.com" And I have an existing account with email "existing@example.com"
@@ -29,7 +29,7 @@ Feature: User Registration
| newuser | New | User | existing@example.com | 1990-01-01 | Password1! | | newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
And the response JSON should have "message" equal "Email already in use." And the response JSON should have "message" equal "Email already in use."
@Ignore
Scenario: Registration fails with missing required fields Scenario: Registration fails with missing required fields
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
@@ -37,7 +37,7 @@ Feature: User Registration
| | New | User | | | Password1! | | | New | User | | | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Username is required." And the response JSON should have "message" equal "Username is required."
@Ignore
Scenario: Registration fails with invalid email format Scenario: Registration fails with invalid email format
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
@@ -45,7 +45,7 @@ Feature: User Registration
| newuser | New | User | invalidemail | 1990-01-01 | Password1! | | newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Invalid email format." And the response JSON should have "message" equal "Invalid email format."
@Ignore
Scenario: Registration fails with weak password Scenario: Registration fails with weak password
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
@@ -53,7 +53,7 @@ Feature: User Registration
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Password does not meet complexity requirements." And the response JSON should have "message" equal "Password does not meet complexity requirements."
@Ignore
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement) Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
@@ -61,7 +61,7 @@ Feature: User Registration
| younguser | Young | User | younguser@example.com | | Password1! | | younguser | Young | User | younguser@example.com | | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "You must be at least 19 years old to register." And the response JSON should have "message" equal "You must be at least 19 years old to register."
@Ignore
Scenario: Registration endpoint only accepts POST requests Scenario: Registration endpoint only accepts POST requests
Given the API is running Given the API is running
When I submit a registration request using a GET request When I submit a registration request using a GET request

View File

@@ -1,5 +1,4 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser( CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
@UserAccountId_ UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64), @Username VARCHAR(64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@@ -12,6 +11,8 @@ BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
SET XACT_ABORT ON; SET XACT_ABORT ON;
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
BEGIN TRANSACTION; BEGIN TRANSACTION;
EXEC usp_CreateUserAccount EXEC usp_CreateUserAccount
@@ -37,5 +38,5 @@ BEGIN
END END
COMMIT TRANSACTION; COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END END

View File

@@ -1,8 +1,8 @@
using System.Data; using System.Data;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DataAccessLayer.Entities; using Repository.Core.Entities;
using DataAccessLayer.Repositories; using Repository.Core.Repositories;
using idunno.Password; using idunno.Password;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
@@ -133,7 +133,7 @@ namespace DBSeed
var dob = new DateTime(1985, 03, 01); var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password"); var hash = GeneratePasswordHash("password");
var userAccountId = await RegisterUserAsync( await RegisterUserAsync(
connection, connection,
$"{firstName}.{lastName}", $"{firstName}.{lastName}",
firstName, firstName,
@@ -160,7 +160,7 @@ namespace DBSeed
// register the user (creates account + credential) // register the user (creates account + credential)
var userAccountId = await RegisterUserAsync( var id = await RegisterUserAsync(
connection, connection,
username, username,
firstName, firstName,
@@ -172,10 +172,13 @@ namespace DBSeed
createdUsers++; createdUsers++;
createdCredentials++; createdCredentials++;
// add user verification
if (await HasUserVerificationAsync(connection, userAccountId)) continue;
await AddUserVerificationAsync(connection, userAccountId);
// add user verification
if (await HasUserVerificationAsync(connection, id)) continue;
await AddUserVerificationAsync(connection, id);
createdVerifications++; createdVerifications++;
} }
@@ -197,11 +200,6 @@ namespace DBSeed
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection); await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
var idParam = new SqlParameter("@UserAccountId_", SqlDbType.UniqueIdentifier)
{
Direction = ParameterDirection.Output
};
command.Parameters.Add(idParam);
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username; command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName; command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
@@ -210,8 +208,11 @@ namespace DBSeed
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email; command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash; command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
await command.ExecuteNonQueryAsync(); var result = await command.ExecuteScalarAsync();
return (Guid)idParam.Value;
return (Guid)result!;
} }
private static string GeneratePasswordHash(string pwd) private static string GeneratePasswordHash(string pwd)

View File

@@ -1,4 +1,4 @@
namespace DataAccessLayer.Entities; namespace Repository.Core.Entities;
public class UserAccount public class UserAccount
{ {

View File

@@ -1,4 +1,4 @@
namespace DataAccessLayer.Entities; namespace Repository.Core.Entities;
public class UserCredential public class UserCredential
{ {

View File

@@ -1,4 +1,4 @@
namespace DataAccessLayer.Entities; namespace Repository.Core.Entities;
public class UserVerification public class UserVerification
{ {

View File

@@ -0,0 +1,175 @@
using System.Data;
using System.Data.Common;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.Auth
{
public class AuthRepository : Repository<Entities.UserAccount>, IAuthRepository
{
public AuthRepository(ISqlConnectionFactory connectionFactory)
: base(connectionFactory)
{
}
public async Task<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;
// Input parameters
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
// Execute and retrieve the generated UserAccountId from result set
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
// Return the newly created user account
return new Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow
};
}
public async Task<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;
}
public async Task<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;
}
public async Task<Entities.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;
}
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();
}
public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId)
{
throw new NotImplementedException("InvalidateCredentialsByUserAccountIdAsync");
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Entities.UserAccount MapToEntity(DbDataReader reader)
{
return new 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.
/// </summary>
private static Entities.UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new Entities.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.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,66 @@
namespace Repository.Core.Repositories.Auth
{
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <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>
Task<Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Entities.UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Entities.UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<Entities.UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary>
/// Invalidates all credentials for a user account (e.g., for logout or security purposes).
/// Uses stored procedure: USP_InvalidateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId);
}
}

View File

@@ -1,7 +1,7 @@
using System.Data.Common; using System.Data.Common;
using DataAccessLayer.Sql; using Repository.Core.Sql;
namespace DataAccessLayer.Repositories namespace Repository.Core.Repositories
{ {
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory) public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class where T : class

View File

@@ -1,6 +1,6 @@
namespace DataAccessLayer.Repositories.UserAccount namespace Repository.Core.Repositories.UserAccount
{ {
public interface IUserAccountRepository public interface IUserAccountRepository
{ {

View File

@@ -1,8 +1,8 @@
using System.Data; using System.Data;
using System.Data.Common; using System.Data.Common;
using DataAccessLayer.Sql; using Repository.Core.Sql;
namespace DataAccessLayer.Repositories.UserAccount namespace Repository.Core.Repositories.UserAccount
{ {
public class UserAccountRepository(ISqlConnectionFactory connectionFactory) public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository : Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository

View File

@@ -1,8 +0,0 @@
using DataAccessLayer.Entities;
public interface IUserCredentialRepository
{
Task RotateCredentialAsync(Guid userAccountId, UserCredential credential);
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId);
}

View File

@@ -1,78 +0,0 @@
using System.Data;
using System.Data.Common;
using DataAccessLayer.Sql;
namespace DataAccessLayer.Repositories.UserCredential
{
public class UserCredentialRepository(ISqlConnectionFactory connectionFactory)
: DataAccessLayer.Repositories.Repository<Entities.UserCredential>(connectionFactory), IUserCredentialRepository
{
public async Task RotateCredentialAsync(Guid userAccountId, Entities.UserCredential credential)
{
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", credential.Hash);
await command.ExecuteNonQueryAsync();
}
public async Task<Entities.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() ? MapToEntity(reader) : null;
}
public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_InvalidateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
await command.ExecuteNonQueryAsync();
}
protected override Entities.UserCredential MapToEntity(DbDataReader reader)
{
var entity = new Entities.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;
}
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);
}
}
}

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>DataAccessLayer</RootNamespace> <RootNamespace>Repository.Core</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />

View File

@@ -3,7 +3,7 @@ using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace DataAccessLayer.Sql namespace Repository.Core.Sql
{ {
public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{ {

View File

@@ -1,6 +1,6 @@
using System.Data.Common; using System.Data.Common;
namespace DataAccessLayer.Sql namespace Repository.Core.Sql
{ {
public interface ISqlConnectionFactory public interface ISqlConnectionFactory
{ {

View File

@@ -1,6 +1,6 @@
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace DataAccessLayer.Sql namespace Repository.Core.Sql
{ {
public static class SqlConnectionStringHelper public static class SqlConnectionStringHelper
{ {

View File

@@ -0,0 +1,232 @@
using Apps72.Dev.Data.DbMocker;
using Repository.Core.Repositories.Auth;
using FluentAssertions;
using Repository.Tests.Database;
using System.Data;
namespace Repository.Tests.Auth;
public class AuthRepositoryTest
{
private static AuthRepository CreateRepo(MockDbConnection conn)
=> new(new TestConnectionFactory(conn));
[Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{
var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsTable(MockTable.WithColumns(("UserAccountId", typeof(Guid)))
.AddRow(expectedUserId));
var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync(
username: "testuser",
firstName: "Test",
lastName: "User",
email: "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123"
);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId);
result.Username.Should().Be("testuser");
result.FirstName.Should().Be("Test");
result.LastName.Should().Be("User");
result.Email.Should().Be("test@example.com");
result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1));
}
[Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(
userId,
"emailuser",
"Email",
"User",
"emailuser@example.com",
DateTime.UtcNow,
null,
new DateTime(1990, 5, 15),
null
));
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
result.Username.Should().Be("emailuser");
result.Email.Should().Be("emailuser@example.com");
result.FirstName.Should().Be("Email");
result.LastName.Should().Be("User");
}
[Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull();
}
[Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(
userId,
"usernameuser",
"Username",
"User",
"username@example.com",
DateTime.UtcNow,
null,
new DateTime(1985, 8, 20),
null
));
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
result.Username.Should().Be("usernameuser");
result.Email.Should().Be("username@example.com");
}
[Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull();
}
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{
var userId = Guid.NewGuid();
var credentialId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.WithColumns(
("UserCredentialId", typeof(Guid)),
("UserAccountId", typeof(Guid)),
("Hash", typeof(string)),
("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(
credentialId,
userId,
"hashed_password_value",
DateTime.UtcNow,
null
));
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId);
result.UserAccountId.Should().Be(userId);
result.Hash.Should().Be("hashed_password_value");
}
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull();
}
[Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully()
{
var userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1);
var repo = CreateRepo(conn);
// Should not throw
var act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}
[Fact]
public async Task InvalidateCredentialsByUserAccountIdAsync_ExecutesSuccessfully()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
var repo = CreateRepo(conn);
// Should complete without error
var act = async () => await repo.InvalidateCredentialsByUserAccountIdAsync(userId);
await act.Should().NotThrowAsync();
}
}

View File

@@ -1,73 +0,0 @@
using DataAccessLayer.Sql;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Repository.Tests.Database;
public class DefaultSqlConnectionFactoryTest
{
private static IConfiguration EmptyConfig()
=> new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build();
[Fact]
public void CreateConnection_Uses_EnvVar_WhenAvailable()
{
var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
try
{
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", "Server=localhost;Database=TestDb;Trusted_Connection=True;Encrypt=False");
var factory = new DefaultSqlConnectionFactory(EmptyConfig());
var conn = factory.CreateConnection();
conn.Should().BeOfType<SqlConnection>();
conn.ConnectionString.Should().Contain("Database=TestDb");
}
finally
{
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous);
}
}
[Fact]
public void CreateConnection_Uses_Config_WhenEnvMissing()
{
var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
try
{
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null);
var cfg = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "ConnectionStrings:Default", "Server=localhost;Database=CfgDb;Trusted_Connection=True;Encrypt=False" }
})
.Build();
var factory = new DefaultSqlConnectionFactory(cfg);
var conn = factory.CreateConnection();
conn.ConnectionString.Should().Contain("Database=CfgDb");
}
finally
{
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous);
}
}
[Fact]
public void Constructor_Throws_When_NoEnv_NoConfig()
{
var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
try
{
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null);
var cfg = EmptyConfig();
Action act = () => _ = new DefaultSqlConnectionFactory(cfg);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Database connection string not configured*");
}
finally
{
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous);
}
}
}

View File

@@ -1,5 +1,5 @@
using System.Data.Common; using System.Data.Common;
using DataAccessLayer.Sql; using Repository.Core.Sql;
namespace Repository.Tests.Database; namespace Repository.Tests.Database;

View File

@@ -1,5 +1,5 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using DataAccessLayer.Repositories.UserAccount; using Repository.Core.Repositories.UserAccount;
using FluentAssertions; using FluentAssertions;
using Repository.Tests.Database; using Repository.Tests.Database;

View File

@@ -1,24 +0,0 @@
using Apps72.Dev.Data.DbMocker;
using DataAccessLayer.Repositories.UserCredential;
using Repository.Tests.Database;
namespace Repository.Tests.UserCredential;
public class UserCredentialRepositoryTests
{
[Fact]
public async Task RotateCredentialAsync_ExecutesWithoutError()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsRow(0);
var repo = new UserCredentialRepository(new TestConnectionFactory(conn));
var credential = new DataAccessLayer.Entities.UserCredential
{
Hash = "hashed_password"
};
await repo.RotateCredentialAsync(Guid.NewGuid(), credential);
}
}

View File

@@ -1,35 +1,53 @@
using DataAccessLayer.Entities; using Repository.Core.Entities;
using DataAccessLayer.Repositories.UserAccount; using Repository.Core.Repositories.Auth;
namespace ServiceCore.Services namespace ServiceCore.Services
{ {
public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService public class AuthService(
IAuthRepository authRepo,
IPasswordService passwordService
) : IAuthService
{ {
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password) public async Task<UserAccount?> RegisterAsync(UserAccount userAccount, string password)
{ {
throw new NotImplementedException(); // Check if user already exists
var user = await authRepo.GetUserByUsernameAsync(userAccount.Username);
if (user is not null)
{
return null;
}
// password hashing
var hashed = passwordService.Hash(password);
// Register user with hashed password
return await authRepo.RegisterUserAsync(
userAccount.Username,
userAccount.FirstName,
userAccount.LastName,
userAccount.Email,
userAccount.DateOfBirth,
hashed);
} }
public async Task<UserAccount?> LoginAsync(string username, string password) public async Task<UserAccount?> LoginAsync(string username, string password)
{ {
// Attempt lookup by username // Attempt lookup by username
var user = await userRepo.GetByUsernameAsync(username); var user = await authRepo.GetUserByUsernameAsync(username);
// the user was not found // the user was not found
if (user is null) return null; if (user is null) return null;
// @todo handle expired passwords // @todo handle expired passwords
var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null; if (activeCred is null) return null;
if (!PasswordHasher.Verify(password, activeCred.Hash)) return null; return !passwordService.Verify(password, activeCred.Hash) ? null : user;
return user;
} }
public async Task InvalidateAsync(Guid userAccountId) public async Task InvalidateAsync(Guid userAccountId)
{ {
await credRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId); await authRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
using DataAccessLayer.Entities; using Repository.Core.Entities;
namespace ServiceCore.Services namespace ServiceCore.Services
{ {

View File

@@ -0,0 +1,7 @@
namespace ServiceCore.Services;
public interface IPasswordService
{
public string Hash(string password);
public bool Verify(string password, string stored);
}

View File

@@ -1,4 +1,4 @@
using DataAccessLayer.Entities; using Repository.Core.Entities;
namespace ServiceCore.Services namespace ServiceCore.Services
{ {

View File

@@ -4,14 +4,14 @@ using Konscious.Security.Cryptography;
namespace ServiceCore.Services namespace ServiceCore.Services
{ {
public static class PasswordHasher public class PasswordService : IPasswordService
{ {
private const int SaltSize = 16; // 128-bit private const int SaltSize = 16; // 128-bit
private const int HashSize = 32; // 256-bit private const int HashSize = 32; // 256-bit
private const int ArgonIterations = 4; private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB private const int ArgonMemoryKb = 65536; // 64MB
public static string Hash(string password) public string Hash(string password)
{ {
var salt = RandomNumberGenerator.GetBytes(SaltSize); var salt = RandomNumberGenerator.GetBytes(SaltSize);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
@@ -26,7 +26,7 @@ namespace ServiceCore.Services
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
} }
public static bool Verify(string password, string stored) public bool Verify(string password, string stored)
{ {
try try
{ {

View File

@@ -1,5 +1,5 @@
using DataAccessLayer.Entities; using Repository.Core.Entities;
using DataAccessLayer.Repositories.UserAccount; using Repository.Core.Repositories.UserAccount;
namespace ServiceCore.Services namespace ServiceCore.Services
{ {