Merge pull request #136 from aaronpo97/user-registration

User registration
This commit is contained in:
Aaron Po
2026-02-10 23:29:19 -05:00
committed by GitHub
43 changed files with 799 additions and 381 deletions

View File

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

View File

@@ -44,7 +44,7 @@ services:
networks:
- devnet
database.seed:
database.seed:
env_file: ".env.dev"
image: database.seed
container_name: dev-env-database-seed

View File

@@ -1,7 +1,8 @@
using System.Net;
using DataAccessLayer.Entities;
using Repository.Core.Entities;
using Microsoft.AspNetCore.Mvc;
using ServiceCore.Services;
using Service.Core.Auth;
using Service.Core.Jwt;
namespace WebAPI.Controllers
{
@@ -57,4 +58,4 @@ namespace WebAPI.Controllers
return Ok(new ResponseBody("Logged in successfully.", new { AccessToken = jwt }));
}
}
}
}

View File

@@ -1,6 +1,6 @@
using DataAccessLayer.Entities;
using Repository.Core.Entities;
using Microsoft.AspNetCore.Mvc;
using ServiceCore.Services;
using Service.Core.User;
namespace WebAPI.Controllers
{

View File

@@ -1,7 +1,10 @@
using DataAccessLayer.Repositories.UserAccount;
using DataAccessLayer.Repositories.UserCredential;
using DataAccessLayer.Sql;
using ServiceCore.Services;
using Repository.Core.Repositories.Auth;
using Repository.Core.Repositories.UserAccount;
using Repository.Core.Sql;
using Service.Core.Auth;
using Service.Core.Jwt;
using Service.Core.Password;
using Service.Core.User;
var builder = WebApplication.CreateBuilder(args);
@@ -25,9 +28,10 @@ if (!builder.Environment.IsProduction())
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserCredentialRepository, UserCredentialRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IPasswordService, PasswordService>();
var app = builder.Build();
@@ -50,4 +54,4 @@ lifetime.ApplicationStopping.Register(() =>
app.Logger.LogInformation("Application is shutting down gracefully...");
});
app.Run();
app.Run();

View File

@@ -0,0 +1,75 @@
Feature: User Registration
As a new user
I want to register an account
So that I can log in and access authenticated routes
Scenario: Successful registration with valid details
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token
@Ignore
Scenario: Registration fails with existing username
Given the API is running
And I have an existing account with username "existinguser"
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 409
And the response JSON should have "message" equal "Username already exists."
@Ignore
Scenario: Registration fails with existing email
Given the API is running
And I have an existing account with email "existing@example.com"
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 409
And the response JSON should have "message" equal "Email already in use."
@Ignore
Scenario: Registration fails with missing required fields
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! |
Then the response has HTTP status 400
And the response JSON should have "message" equal "Username is required."
@Ignore
Scenario: Registration fails with invalid email format
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400
And the response JSON should have "message" equal "Invalid email format."
@Ignore
Scenario: Registration fails with weak password
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400
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)
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | | Password1! |
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."
Scenario: Registration endpoint only accepts POST requests
Given the API is running
When I submit a registration request using a GET request
Then the response has HTTP status 404
And the response JSON should have "message" equal "Not Found."

View File

@@ -157,4 +157,64 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a registration request with values:")]
public async Task WhenISubmitARegistrationRequestWithValues(Table table)
{
var client = GetClient();
var row = table.Rows[0];
var registrationData = new
{
username = row.TryGetValue("Username", out var value) ? value : null,
firstName = row.TryGetValue("FirstName", out var value1) ? value1 : null,
lastName = row.TryGetValue("LastName", out var value2) ? value2 : null,
email = row.TryGetValue("Email", out var value3) ? value3 : null,
dateOfBirth = row.ContainsKey("DateOfBirth") && !string.IsNullOrEmpty(row["DateOfBirth"])
? row["DateOfBirth"]
: null,
password = row.ContainsKey("Password") ? row["Password"] : null
};
var body = JsonSerializer.Serialize(registrationData);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Given("I have an existing account with username {string}")]
public void GivenIHaveAnExistingAccountWithUsername(string username)
{
}
[Given("I have an existing account with email {string}")]
public void GivenIHaveAnExistingAccountWithEmail(string email)
{
}
[When("I submit a registration request using a GET request")]
public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/register")
{
Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
}

View File

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

View File

@@ -1,8 +1,8 @@
using System.Data;
using System.Security.Cryptography;
using System.Text;
using DataAccessLayer.Entities;
using DataAccessLayer.Repositories;
using Repository.Core.Entities;
using Repository.Core.Repositories;
using idunno.Password;
using Konscious.Security.Cryptography;
using Microsoft.Data.SqlClient;
@@ -133,15 +133,15 @@ namespace DBSeed
var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password");
var userAccountId = await RegisterUserAsync(
connection,
$"{firstName}.{lastName}",
firstName,
lastName,
dob,
email,
hash
);
await RegisterUserAsync(
connection,
$"{firstName}.{lastName}",
firstName,
lastName,
dob,
email,
hash
);
}
foreach (var (firstName, lastName) in SeedNames)
{
@@ -160,7 +160,7 @@ namespace DBSeed
// register the user (creates account + credential)
var userAccountId = await RegisterUserAsync(
var id = await RegisterUserAsync(
connection,
username,
firstName,
@@ -172,10 +172,13 @@ namespace DBSeed
createdUsers++;
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++;
}
@@ -197,11 +200,6 @@ namespace DBSeed
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
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("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
@@ -210,8 +208,11 @@ namespace DBSeed
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
await command.ExecuteNonQueryAsync();
return (Guid)idParam.Value;
var result = await command.ExecuteScalarAsync();
return (Guid)result!;
}
private static string GeneratePasswordHash(string pwd)
@@ -269,4 +270,4 @@ namespace DBSeed
return baseDate.AddDays(-offsetDays);
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,167 @@
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;
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();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
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();
}
/// <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,59 @@
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);
}
}

View File

@@ -1,7 +1,7 @@
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)
where T : class

View File

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

View File

@@ -1,8 +1,8 @@
using System.Data;
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)
: Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository
@@ -126,4 +126,4 @@ namespace DataAccessLayer.Repositories.UserAccount
command.Parameters.Add(p);
}
}
}
}

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>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>DataAccessLayer</RootNamespace>
<RootNamespace>Repository.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />

View File

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

View File

@@ -1,9 +1,9 @@
using System.Data.Common;
namespace DataAccessLayer.Sql
namespace Repository.Core.Sql
{
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}
}
}

View File

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

View File

@@ -0,0 +1,219 @@
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();
}
}

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 DataAccessLayer.Sql;
using Repository.Core.Sql;
namespace Repository.Tests.Database;
@@ -7,4 +7,4 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
}
}

View File

@@ -1,5 +1,5 @@
using Apps72.Dev.Data.DbMocker;
using DataAccessLayer.Repositories.UserAccount;
using Repository.Core.Repositories.UserAccount;
using FluentAssertions;
using Repository.Tests.Database;
@@ -116,4 +116,4 @@ public class UserAccountRepositoryTest
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
}
}

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

@@ -0,0 +1,48 @@
using Repository.Core.Entities;
using Repository.Core.Repositories.Auth;
using Service.Core.Password;
namespace Service.Core.Auth;
public class AuthService(
IAuthRepository authRepo,
IPasswordService passwordService
) : IAuthService
{
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{
// 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)
{
// Attempt lookup by username
var user = await authRepo.GetUserByUsernameAsync(username);
// the user was not found
if (user is null) return null;
// @todo handle expired passwords
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null;
return !passwordService.Verify(password, activeCred.Hash) ? null : user;
}
}

View File

@@ -0,0 +1,9 @@
using Repository.Core.Entities;
namespace Service.Core.Auth;
public interface IAuthService
{
Task<UserAccount> RegisterAsync(UserAccount userAccount, string password);
Task<UserAccount?> LoginAsync(string username, string password);
}

View File

@@ -1,4 +1,4 @@
namespace ServiceCore.Services;
namespace Service.Core.Jwt;
public interface IJwtService
{

View File

@@ -1,12 +1,10 @@
using System;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace ServiceCore.Services;
namespace Service.Core.Jwt;
public class JwtService : IJwtService
{
private readonly string? _secret = Environment.GetEnvironmentVariable("JWT_SECRET");

View File

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

View File

@@ -0,0 +1,55 @@
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
namespace Service.Core.Password;
public class PasswordService : IPasswordService
{
private const int SaltSize = 16; // 128-bit
private const int HashSize = 32; // 256-bit
private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB
public string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations
};
var hash = argon2.GetBytes(HashSize);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
public bool Verify(string password, string stored)
{
try
{
var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) return false;
var salt = Convert.FromBase64String(parts[0]);
var expected = Convert.FromBase64String(parts[1]);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations
};
var actual = argon2.GetBytes(expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch
{
return false;
}
}
}

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ServiceCore</RootNamespace>
<RootNamespace>Service.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,35 +0,0 @@
using DataAccessLayer.Entities;
using DataAccessLayer.Repositories.UserAccount;
namespace ServiceCore.Services
{
public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService
{
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{
throw new NotImplementedException();
}
public async Task<UserAccount?> LoginAsync(string username, string password)
{
// Attempt lookup by username
var user = await userRepo.GetByUsernameAsync(username);
// the user was not found
if (user is null) return null;
// @todo handle expired passwords
var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null;
if (!PasswordHasher.Verify(password, activeCred.Hash)) return null;
return user;
}
public async Task InvalidateAsync(Guid userAccountId)
{
await credRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId);
}
}
}

View File

@@ -1,10 +0,0 @@
using DataAccessLayer.Entities;
namespace ServiceCore.Services
{
public interface IAuthService
{
Task<UserAccount> RegisterAsync(UserAccount userAccount, string password);
Task<UserAccount?> LoginAsync(string username, string password);
}
}

View File

@@ -1,12 +0,0 @@
using DataAccessLayer.Entities;
namespace ServiceCore.Services
{
public interface IUserService
{
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
Task<UserAccount?> GetByIdAsync(Guid id);
Task UpdateAsync(UserAccount userAccount);
}
}

View File

@@ -1,56 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
namespace ServiceCore.Services
{
public static class PasswordHasher
{
private const int SaltSize = 16; // 128-bit
private const int HashSize = 32; // 256-bit
private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations
};
var hash = argon2.GetBytes(HashSize);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string stored)
{
try
{
var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) return false;
var salt = Convert.FromBase64String(parts[0]);
var expected = Convert.FromBase64String(parts[1]);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations
};
var actual = argon2.GetBytes(expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch
{
return false;
}
}
}
}

View File

@@ -1,23 +0,0 @@
using DataAccessLayer.Entities;
using DataAccessLayer.Repositories.UserAccount;
namespace ServiceCore.Services
{
public class UserService(IUserAccountRepository repository) : IUserService
{
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null)
{
return await repository.GetAllAsync(limit, offset);
}
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
return await repository.GetByIdAsync(id);
}
public async Task UpdateAsync(UserAccount userAccount)
{
await repository.UpdateAsync(userAccount);
}
}
}

View File

@@ -0,0 +1,11 @@
using Repository.Core.Entities;
namespace Service.Core.User;
public interface IUserService
{
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
Task<UserAccount?> GetByIdAsync(Guid id);
Task UpdateAsync(UserAccount userAccount);
}

View File

@@ -0,0 +1,22 @@
using Repository.Core.Entities;
using Repository.Core.Repositories.UserAccount;
namespace Service.Core.User;
public class UserService(IUserAccountRepository repository) : IUserService
{
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null)
{
return await repository.GetAllAsync(limit, offset);
}
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
return await repository.GetByIdAsync(id);
}
public async Task UpdateAsync(UserAccount userAccount)
{
await repository.UpdateAsync(userAccount);
}
}