mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
Consolidate auth logic, update password service, and update namespaces
This commit is contained in:
@@ -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`)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- devnet
|
- devnet
|
||||||
|
|
||||||
database.seed:
|
database.seed:
|
||||||
env_file: ".env.dev"
|
env_file: ".env.dev"
|
||||||
image: database.seed
|
image: database.seed
|
||||||
container_name: dev-env-database-seed
|
container_name: dev-env-database-seed
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace DataAccessLayer.Entities;
|
namespace Repository.Core.Entities;
|
||||||
|
|
||||||
public class UserAccount
|
public class UserAccount
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace DataAccessLayer.Entities;
|
namespace Repository.Core.Entities;
|
||||||
|
|
||||||
public class UserCredential
|
public class UserCredential
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace DataAccessLayer.Entities;
|
namespace Repository.Core.Entities;
|
||||||
|
|
||||||
public class UserVerification
|
public class UserVerification
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
namespace DataAccessLayer.Repositories.UserAccount
|
namespace Repository.Core.Repositories.UserAccount
|
||||||
{
|
{
|
||||||
public interface IUserAccountRepository
|
public interface IUserAccountRepository
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
232
src/Core/Repository/Repository.Tests/Auth/AuthRepository.test.cs
Normal file
232
src/Core/Repository/Repository.Tests/Auth/AuthRepository.test.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using DataAccessLayer.Entities;
|
using Repository.Core.Entities;
|
||||||
|
|
||||||
namespace ServiceCore.Services
|
namespace ServiceCore.Services
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ServiceCore.Services;
|
||||||
|
|
||||||
|
public interface IPasswordService
|
||||||
|
{
|
||||||
|
public string Hash(string password);
|
||||||
|
public bool Verify(string password, string stored);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using DataAccessLayer.Entities;
|
using Repository.Core.Entities;
|
||||||
|
|
||||||
namespace ServiceCore.Services
|
namespace ServiceCore.Services
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user