diff --git a/README.md b/README.md index 917e8de..f1d8087 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/docker-compose.db.yaml b/docker-compose.db.yaml index 4c359b3..caed3c6 100644 --- a/docker-compose.db.yaml +++ b/docker-compose.db.yaml @@ -44,7 +44,7 @@ services: networks: - devnet -database.seed: + database.seed: env_file: ".env.dev" image: database.seed container_name: dev-env-database-seed diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 516bfbe..7a0c82b 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -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 })); } } -} \ No newline at end of file +} diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs index ce2b11a..f7760de 100644 --- a/src/Core/API/API.Core/Controllers/UserController.cs +++ b/src/Core/API/API.Core/Controllers/UserController.cs @@ -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 { diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index 3a44298..4dfb8cd 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -50,4 +54,4 @@ lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Application is shutting down gracefully..."); }); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/src/Core/API/API.Specs/Features/Auth.feature b/src/Core/API/API.Specs/Features/Login.feature similarity index 100% rename from src/Core/API/API.Specs/Features/Auth.feature rename to src/Core/API/API.Specs/Features/Login.feature diff --git a/src/Core/API/API.Specs/Features/Registration.feature b/src/Core/API/API.Specs/Features/Registration.feature new file mode 100644 index 0000000..5e09b31 --- /dev/null +++ b/src/Core/API/API.Specs/Features/Registration.feature @@ -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." \ No newline at end of file diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index 92ae4c7..bc745f4 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -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; + } } \ No newline at end of file diff --git a/src/Core/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql b/src/Core/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql index a784788..d09a7e0 100644 --- a/src/Core/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql +++ b/src/Core/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql @@ -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 \ No newline at end of file + SELECT @UserAccountId_ AS UserAccountId; +END diff --git a/src/Core/Database/Database.Seed/UserSeeder.cs b/src/Core/Database/Database.Seed/UserSeeder.cs index 940fd71..ead8905 100644 --- a/src/Core/Database/Database.Seed/UserSeeder.cs +++ b/src/Core/Database/Database.Seed/UserSeeder.cs @@ -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); } } -} \ No newline at end of file +} diff --git a/src/Core/Repository/Repository.Core/Entities/UserAccount.cs b/src/Core/Repository/Repository.Core/Entities/UserAccount.cs index ac9b723..0fea458 100644 --- a/src/Core/Repository/Repository.Core/Entities/UserAccount.cs +++ b/src/Core/Repository/Repository.Core/Entities/UserAccount.cs @@ -1,4 +1,4 @@ -namespace DataAccessLayer.Entities; +namespace Repository.Core.Entities; public class UserAccount { diff --git a/src/Core/Repository/Repository.Core/Entities/UserCredential.cs b/src/Core/Repository/Repository.Core/Entities/UserCredential.cs index 07468ff..f2b44c9 100644 --- a/src/Core/Repository/Repository.Core/Entities/UserCredential.cs +++ b/src/Core/Repository/Repository.Core/Entities/UserCredential.cs @@ -1,4 +1,4 @@ -namespace DataAccessLayer.Entities; +namespace Repository.Core.Entities; public class UserCredential { diff --git a/src/Core/Repository/Repository.Core/Entities/UserVerification.cs b/src/Core/Repository/Repository.Core/Entities/UserVerification.cs index 6a503d8..cc693bb 100644 --- a/src/Core/Repository/Repository.Core/Entities/UserVerification.cs +++ b/src/Core/Repository/Repository.Core/Entities/UserVerification.cs @@ -1,4 +1,4 @@ -namespace DataAccessLayer.Entities; +namespace Repository.Core.Entities; public class UserVerification { diff --git a/src/Core/Repository/Repository.Core/Repositories/Auth/AuthRepository.cs b/src/Core/Repository/Repository.Core/Repositories/Auth/AuthRepository.cs new file mode 100644 index 0000000..e3a8495 --- /dev/null +++ b/src/Core/Repository/Repository.Core/Repositories/Auth/AuthRepository.cs @@ -0,0 +1,167 @@ +using System.Data; +using System.Data.Common; +using Repository.Core.Sql; + +namespace Repository.Core.Repositories.Auth +{ + public class AuthRepository : Repository, IAuthRepository + { + public AuthRepository(ISqlConnectionFactory connectionFactory) + : base(connectionFactory) + { + } + + + public async Task 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 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 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 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(); + } + + /// + /// Maps a data reader row to a UserAccount entity. + /// + 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"] + }; + } + + /// + /// Maps a data reader row to a UserCredential entity. + /// + 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() + .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; + } + + /// + /// Helper method to add a parameter to a database command. + /// + 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); + } + } +} diff --git a/src/Core/Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs b/src/Core/Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs new file mode 100644 index 0000000..df945a1 --- /dev/null +++ b/src/Core/Repository/Repository.Core/Repositories/Auth/IAuthRepository.cs @@ -0,0 +1,59 @@ +namespace Repository.Core.Repositories.Auth +{ + /// + /// Repository for authentication-related database operations including user registration and credential management. + /// + public interface IAuthRepository + { + /// + /// Registers a new user with account details and initial credential. + /// Uses stored procedure: USP_RegisterUser + /// + /// Unique username for the user + /// User's first name + /// User's last name + /// User's email address + /// User's date of birth + /// Hashed password + /// The newly created UserAccount with generated ID + Task RegisterUserAsync( + string username, + string firstName, + string lastName, + string email, + DateTime dateOfBirth, + string passwordHash); + + /// + /// Retrieves a user account by email address (typically used for login). + /// Uses stored procedure: usp_GetUserAccountByEmail + /// + /// Email address to search for + /// UserAccount if found, null otherwise + Task GetUserByEmailAsync(string email); + + /// + /// Retrieves a user account by username (typically used for login). + /// Uses stored procedure: usp_GetUserAccountByUsername + /// + /// Username to search for + /// UserAccount if found, null otherwise + Task GetUserByUsernameAsync(string username); + + /// + /// Retrieves the active (non-revoked) credential for a user account. + /// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId + /// + /// ID of the user account + /// Active UserCredential if found, null otherwise + Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); + + /// + /// Rotates a user's credential by invalidating all existing credentials and creating a new one. + /// Uses stored procedure: USP_RotateUserCredential + /// + /// ID of the user account + /// New hashed password + Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); + } +} diff --git a/src/Core/Repository/Repository.Core/Repositories/Repository.cs b/src/Core/Repository/Repository.Core/Repositories/Repository.cs index 37a3664..6ac9eab 100644 --- a/src/Core/Repository/Repository.Core/Repositories/Repository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/Repository.cs @@ -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(ISqlConnectionFactory connectionFactory) where T : class diff --git a/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs index 85913f7..96bffba 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs @@ -1,6 +1,6 @@ -namespace DataAccessLayer.Repositories.UserAccount +namespace Repository.Core.Repositories.UserAccount { public interface IUserAccountRepository { diff --git a/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index b0772fa..ccc5c63 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -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(connectionFactory), IUserAccountRepository @@ -126,4 +126,4 @@ namespace DataAccessLayer.Repositories.UserAccount command.Parameters.Add(p); } } -} \ No newline at end of file +} diff --git a/src/Core/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs deleted file mode 100644 index 72dadb9..0000000 --- a/src/Core/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -using DataAccessLayer.Entities; - -public interface IUserCredentialRepository -{ - Task RotateCredentialAsync(Guid userAccountId, UserCredential credential); - Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); - Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId); -} diff --git a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs deleted file mode 100644 index e1c6d8a..0000000 --- a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs +++ /dev/null @@ -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(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 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() - .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); - } - } -} diff --git a/src/Core/Repository/Repository.Core/Repository.Core.csproj b/src/Core/Repository/Repository.Core/Repository.Core.csproj index f9aea58..90cf6b9 100644 --- a/src/Core/Repository/Repository.Core/Repository.Core.csproj +++ b/src/Core/Repository/Repository.Core/Repository.Core.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - DataAccessLayer + Repository.Core diff --git a/src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs b/src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs index b38f25d..b8cdec6 100644 --- a/src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs +++ b/src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs @@ -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 { diff --git a/src/Core/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs b/src/Core/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs index db3de9b..c8be898 100644 --- a/src/Core/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs +++ b/src/Core/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs @@ -1,9 +1,9 @@ using System.Data.Common; -namespace DataAccessLayer.Sql +namespace Repository.Core.Sql { public interface ISqlConnectionFactory { DbConnection CreateConnection(); } -} \ No newline at end of file +} diff --git a/src/Core/Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs b/src/Core/Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs index 907c82f..7e50707 100644 --- a/src/Core/Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs +++ b/src/Core/Repository/Repository.Core/Sql/SqlConnectionStringHelper.cs @@ -1,6 +1,6 @@ using Microsoft.Data.SqlClient; -namespace DataAccessLayer.Sql +namespace Repository.Core.Sql { public static class SqlConnectionStringHelper { diff --git a/src/Core/Repository/Repository.Tests/Auth/AuthRepository.test.cs b/src/Core/Repository/Repository.Tests/Auth/AuthRepository.test.cs new file mode 100644 index 0000000..91b6d4a --- /dev/null +++ b/src/Core/Repository/Repository.Tests/Auth/AuthRepository.test.cs @@ -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(); + } +} diff --git a/src/Core/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs b/src/Core/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs deleted file mode 100644 index 33e9688..0000000 --- a/src/Core/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs +++ /dev/null @@ -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()).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(); - 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 - { - { "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() - .WithMessage("*Database connection string not configured*"); - } - finally - { - Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous); - } - } -} \ No newline at end of file diff --git a/src/Core/Repository/Repository.Tests/Database/TestConnectionFactory.cs b/src/Core/Repository/Repository.Tests/Database/TestConnectionFactory.cs index 25c9e94..dc15914 100644 --- a/src/Core/Repository/Repository.Tests/Database/TestConnectionFactory.cs +++ b/src/Core/Repository/Repository.Tests/Database/TestConnectionFactory.cs @@ -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; -} \ No newline at end of file +} diff --git a/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs index dc6e8bd..5cf9a64 100644 --- a/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs +++ b/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs @@ -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"); } -} \ No newline at end of file +} diff --git a/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs b/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs deleted file mode 100644 index 71d4beb..0000000 --- a/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Auth/AuthService.cs b/src/Core/Service/Service.Core/Auth/AuthService.cs new file mode 100644 index 0000000..d939919 --- /dev/null +++ b/src/Core/Service/Service.Core/Auth/AuthService.cs @@ -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 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 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; + } +} diff --git a/src/Core/Service/Service.Core/Auth/IAuthService.cs b/src/Core/Service/Service.Core/Auth/IAuthService.cs new file mode 100644 index 0000000..45a4bd8 --- /dev/null +++ b/src/Core/Service/Service.Core/Auth/IAuthService.cs @@ -0,0 +1,9 @@ +using Repository.Core.Entities; + +namespace Service.Core.Auth; + +public interface IAuthService +{ + Task RegisterAsync(UserAccount userAccount, string password); + Task LoginAsync(string username, string password); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/IJwtService.cs b/src/Core/Service/Service.Core/Jwt/IJwtService.cs similarity index 76% rename from src/Core/Service/Service.Core/Services/IJwtService.cs rename to src/Core/Service/Service.Core/Jwt/IJwtService.cs index 0dfc6e5..730a17c 100644 --- a/src/Core/Service/Service.Core/Services/IJwtService.cs +++ b/src/Core/Service/Service.Core/Jwt/IJwtService.cs @@ -1,4 +1,4 @@ -namespace ServiceCore.Services; +namespace Service.Core.Jwt; public interface IJwtService { diff --git a/src/Core/Service/Service.Core/Services/JwtService.cs b/src/Core/Service/Service.Core/Jwt/JwtService.cs similarity index 93% rename from src/Core/Service/Service.Core/Services/JwtService.cs rename to src/Core/Service/Service.Core/Jwt/JwtService.cs index 197dd21..5e91144 100644 --- a/src/Core/Service/Service.Core/Services/JwtService.cs +++ b/src/Core/Service/Service.Core/Jwt/JwtService.cs @@ -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"); diff --git a/src/Core/Service/Service.Core/Password/IPasswordService.cs b/src/Core/Service/Service.Core/Password/IPasswordService.cs new file mode 100644 index 0000000..809fd8b --- /dev/null +++ b/src/Core/Service/Service.Core/Password/IPasswordService.cs @@ -0,0 +1,7 @@ +namespace Service.Core.Password; + +public interface IPasswordService +{ + public string Hash(string password); + public bool Verify(string password, string stored); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Password/PasswordService.cs b/src/Core/Service/Service.Core/Password/PasswordService.cs new file mode 100644 index 0000000..c66b47c --- /dev/null +++ b/src/Core/Service/Service.Core/Password/PasswordService.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Service.Core.csproj b/src/Core/Service/Service.Core/Service.Core.csproj index 9c34a8f..5d8a635 100644 --- a/src/Core/Service/Service.Core/Service.Core.csproj +++ b/src/Core/Service/Service.Core/Service.Core.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - ServiceCore + Service.Core diff --git a/src/Core/Service/Service.Core/Services/AuthService.cs b/src/Core/Service/Service.Core/Services/AuthService.cs deleted file mode 100644 index 1c40d6d..0000000 --- a/src/Core/Service/Service.Core/Services/AuthService.cs +++ /dev/null @@ -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 RegisterAsync(UserAccount userAccount, string password) - { - throw new NotImplementedException(); - } - - public async Task 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); - } - } -} diff --git a/src/Core/Service/Service.Core/Services/IAuthService.cs b/src/Core/Service/Service.Core/Services/IAuthService.cs deleted file mode 100644 index ca410a8..0000000 --- a/src/Core/Service/Service.Core/Services/IAuthService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using DataAccessLayer.Entities; - -namespace ServiceCore.Services -{ - public interface IAuthService - { - Task RegisterAsync(UserAccount userAccount, string password); - Task LoginAsync(string username, string password); - } -} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/IUserService.cs b/src/Core/Service/Service.Core/Services/IUserService.cs deleted file mode 100644 index e94dda6..0000000 --- a/src/Core/Service/Service.Core/Services/IUserService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using DataAccessLayer.Entities; - -namespace ServiceCore.Services -{ - public interface IUserService - { - Task> GetAllAsync(int? limit = null, int? offset = null); - Task GetByIdAsync(Guid id); - - Task UpdateAsync(UserAccount userAccount); - } -} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/PasswordHasher.cs b/src/Core/Service/Service.Core/Services/PasswordHasher.cs deleted file mode 100644 index f691076..0000000 --- a/src/Core/Service/Service.Core/Services/PasswordHasher.cs +++ /dev/null @@ -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; - } - } - } -} diff --git a/src/Core/Service/Service.Core/Services/UserService.cs b/src/Core/Service/Service.Core/Services/UserService.cs deleted file mode 100644 index a0869f5..0000000 --- a/src/Core/Service/Service.Core/Services/UserService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using DataAccessLayer.Entities; -using DataAccessLayer.Repositories.UserAccount; - -namespace ServiceCore.Services -{ - public class UserService(IUserAccountRepository repository) : IUserService - { - public async Task> GetAllAsync(int? limit = null, int? offset = null) - { - return await repository.GetAllAsync(limit, offset); - } - - public async Task GetByIdAsync(Guid id) - { - return await repository.GetByIdAsync(id); - } - - public async Task UpdateAsync(UserAccount userAccount) - { - await repository.UpdateAsync(userAccount); - } - } -} diff --git a/src/Core/Service/Service.Core/User/IUserService.cs b/src/Core/Service/Service.Core/User/IUserService.cs new file mode 100644 index 0000000..ee256af --- /dev/null +++ b/src/Core/Service/Service.Core/User/IUserService.cs @@ -0,0 +1,11 @@ +using Repository.Core.Entities; + +namespace Service.Core.User; + +public interface IUserService +{ + Task> GetAllAsync(int? limit = null, int? offset = null); + Task GetByIdAsync(Guid id); + + Task UpdateAsync(UserAccount userAccount); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/User/UserService.cs b/src/Core/Service/Service.Core/User/UserService.cs new file mode 100644 index 0000000..cbb1423 --- /dev/null +++ b/src/Core/Service/Service.Core/User/UserService.cs @@ -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> GetAllAsync(int? limit = null, int? offset = null) + { + return await repository.GetAllAsync(limit, offset); + } + + public async Task GetByIdAsync(Guid id) + { + return await repository.GetByIdAsync(id); + } + + public async Task UpdateAsync(UserAccount userAccount) + { + await repository.UpdateAsync(userAccount); + } +} \ No newline at end of file