diff --git a/API/API.Core/Controllers/AuthController.cs b/API/API.Core/Controllers/AuthController.cs new file mode 100644 index 0000000..18668d6 --- /dev/null +++ b/API/API.Core/Controllers/AuthController.cs @@ -0,0 +1,47 @@ +using BusinessLayer.Services; +using DataAccessLayer.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace WebAPI.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AuthController(IAuthService auth) : ControllerBase + { + public record RegisterRequest( + string Username, + string FirstName, + string LastName, + string Email, + DateTime DateOfBirth, + string Password + ); + + public record LoginRequest(string UsernameOrEmail, string Password); + + [HttpPost("register")] + public async Task> Register([FromBody] RegisterRequest req) + { + var user = new UserAccount + { + UserAccountId = Guid.Empty, + Username = req.Username, + FirstName = req.FirstName, + LastName = req.LastName, + Email = req.Email, + DateOfBirth = req.DateOfBirth + }; + + var created = await auth.RegisterAsync(user, req.Password); + return CreatedAtAction(nameof(Register), new { id = created.UserAccountId }, created); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest req) + { + var ok = await auth.LoginAsync(req.UsernameOrEmail, req.Password); + if (!ok) return Unauthorized(); + return Ok(new { success = true }); + } + } +} diff --git a/API/API.Core/Controllers/NotFoundController.cs b/API/API.Core/Controllers/NotFoundController.cs index 857694d..52fb2aa 100644 --- a/API/API.Core/Controllers/NotFoundController.cs +++ b/API/API.Core/Controllers/NotFoundController.cs @@ -4,10 +4,10 @@ namespace WebAPI.Controllers { [ApiController] [ApiExplorerSettings(IgnoreApi = true)] - [Route("error")] // ← required + [Route("error")] // required public class NotFoundController : ControllerBase { - [HttpGet("404")] // ← required + [HttpGet("404")] //required public IActionResult Handle404() { return NotFound(new { message = "Route not found." }); diff --git a/API/API.Core/Program.cs b/API/API.Core/Program.cs index 81bab8e..98fb89f 100644 --- a/API/API.Core/Program.cs +++ b/API/API.Core/Program.cs @@ -1,5 +1,6 @@ using BusinessLayer.Services; -using DataAccessLayer.Repositories; +using DataAccessLayer.Repositories.UserAccount; +using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Sql; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +14,8 @@ builder.Services.AddOpenApi(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.UseSwagger(); diff --git a/Database/Database.Core/scripts/01-schema/schema.sql b/Database/Database.Core/scripts/01-schema/schema.sql index 637832e..1fdf3ba 100644 --- a/Database/Database.Core/scripts/01-schema/schema.sql +++ b/Database/Database.Core/scripts/01-schema/schema.sql @@ -164,7 +164,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted Hash NVARCHAR(MAX) NOT NULL, -- uses argon2 - Timer ROWVERSION, + IsRevoked BIT NOT NULL + CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0, + + RevokedAt DATETIME NULL, + + Timer ROWVERSION, CONSTRAINT PK_UserCredential PRIMARY KEY (UserCredentialID), @@ -173,9 +178,6 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted FOREIGN KEY (UserAccountID) REFERENCES UserAccount(UserAccountID) ON DELETE CASCADE, - - CONSTRAINT AK_UserCredential_UserAccountID - UNIQUE (UserAccountID) ); CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql index 548bbda..a99b12d 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql @@ -1,7 +1,7 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount ( - @UserAccountId UNIQUEIDENTIFIER = NULL, + @UserAccountId UNIQUEIDENTIFIER OUTPUT, @Username VARCHAR(64), @FirstName NVARCHAR(128), @LastName NVARCHAR(128), @@ -10,13 +10,10 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount ) AS BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION + SET NOCOUNT ON; INSERT INTO UserAccount ( - UserAccountID, Username, FirstName, LastName, @@ -25,12 +22,12 @@ BEGIN ) VALUES ( - COALESCE(@UserAccountId, NEWID()), @Username, @FirstName, @LastName, @DateOfBirth, @Email ); - COMMIT TRANSACTION + + SELECT @UserAccountId AS UserAccountId; END; diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql index 4813ad5..7ea5d3e 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql @@ -6,8 +6,6 @@ CREATE OR ALTER PROCEDURE usp_DeleteUserAccount AS BEGIN SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) BEGIN @@ -19,5 +17,4 @@ BEGIN DELETE FROM UserAccount WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION END; diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql index 799debe..233acf5 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql @@ -1,4 +1,3 @@ - CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts AS BEGIN diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql index 22d3e44..5e6fc2e 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail -( +CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail( @Email VARCHAR(128) ) AS diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql index ace7aa5..7113807 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountById -( +CREATE OR ALTER PROCEDURE USP_GetUserAccountById( @UserAccountId UNIQUEIDENTIFIER ) AS @@ -18,4 +16,4 @@ BEGIN Timer FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId; -END; +END diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql index 3f12b63..96fcca9 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername -( +CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername( @Username VARCHAR(64) ) AS diff --git a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql index 1326ef5..e0355b8 100644 --- a/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql +++ b/Database/Database.Core/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql @@ -1,6 +1,4 @@ - -CREATE OR ALTER PROCEDURE usp_UpdateUserAccount -( +CREATE OR ALTER PROCEDURE usp_UpdateUserAccount( @Username VARCHAR(64), @FirstName NVARCHAR(128), @LastName NVARCHAR(128), @@ -10,26 +8,20 @@ CREATE OR ALTER PROCEDURE usp_UpdateUserAccount ) AS BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION - - IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) - BEGIN - RAISERROR('UserAccount with the specified ID does not exist.', 16, - 1); - ROLLBACK TRANSACTION - RETURN - END + SET + NOCOUNT ON; UPDATE UserAccount - SET - Username = @Username, - FirstName = @FirstName, - LastName = @LastName, + SET Username = @Username, + FirstName = @FirstName, + LastName = @LastName, DateOfBirth = @DateOfBirth, - Email = @Email + Email = @Email WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION + IF @@ROWCOUNT = 0 + BEGIN + THROW + 50001, 'UserAccount with the specified ID does not exist.', 1; + END END; diff --git a/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql new file mode 100644 index 0000000..bc385e9 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql @@ -0,0 +1,17 @@ +CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId( + @UserAccountId UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON; + + SELECT + UserCredentialId, + UserAccountId, + Hash, + IsRevoked, + CreatedAt, + RevokedAt + FROM dbo.UserCredential + WHERE UserAccountId = @UserAccountId AND IsRevoked = 0; +END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql new file mode 100644 index 0000000..08c7d53 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql @@ -0,0 +1,24 @@ +CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( + @UserAccountId_ UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + BEGIN TRANSACTION; + + EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_; + IF @@ROWCOUNT = 0 + THROW 50001, 'User account not found', 1; + + -- invalidate all other credentials by setting them to revoked + UPDATE dbo.UserCredential + SET IsRevoked = 1, + RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId_ + AND IsRevoked != 1; + + + COMMIT TRANSACTION; +END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql new file mode 100644 index 0000000..8f10b83 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RegisterUser.sql @@ -0,0 +1,42 @@ +CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser( + @UserAccountId_ UNIQUEIDENTIFIER OUTPUT, + @Username VARCHAR(64), + @FirstName NVARCHAR(128), + @LastName NVARCHAR(128), + @DateOfBirth DATETIME, + @Email VARCHAR(128), + @Hash NVARCHAR(MAX) +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + BEGIN TRANSACTION; + + EXEC usp_CreateUserAccount + @UserAccountId = @UserAccountId_ OUTPUT, + @Username = @Username, + @FirstName = @FirstName, + @LastName = @LastName, + @DateOfBirth = @DateOfBirth, + @Email = @Email; + + IF @UserAccountId_ IS NULL + BEGIN + THROW 50000, 'Failed to create user account.', 1; + END + + + EXEC dbo.usp_RotateUserCredential + @UserAccountId = @UserAccountId_, + @Hash = @Hash; + + IF @@ROWCOUNT = 0 + BEGIN + THROW 50002, 'Failed to create user credential.', 1; + END + COMMIT TRANSACTION; + + +END \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql new file mode 100644 index 0000000..d4d4e60 --- /dev/null +++ b/Database/Database.Core/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql @@ -0,0 +1,28 @@ +CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( + @UserAccountId_ UNIQUEIDENTIFIER, + @Hash NVARCHAR(MAX) +) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + BEGIN TRANSACTION; + + EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_ + + IF @@ROWCOUNT = 0 + THROW 50001, 'User account not found', 1; + + + -- invalidate all other credentials -- set them to revoked + UPDATE dbo.UserCredential + SET IsRevoked = 1, + RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId_; + + INSERT INTO dbo.UserCredential + (UserAccountId, Hash) + VALUES (@UserAccountId_, @Hash); + + +END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql b/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql deleted file mode 100644 index bd9395e..0000000 --- a/Database/Database.Core/scripts/03-crud/02-UserCredential/USP_AddUserCredential.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE OR ALTER PROCEDURE dbo.USP_AddUserCredential( - @UserAccountId uniqueidentifier, - @Hash nvarchar(max) -) -AS -BEGIN - SET NOCOUNT ON; - SET XACT_ABORT ON; - - BEGIN TRANSACTION; - - IF NOT EXISTS ( - SELECT 1 - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId - ) - THROW 50001, 'UserAccountID does not exist.', 1; - - IF EXISTS ( - SELECT 1 - FROM dbo.UserCredential - WHERE UserAccountID = @UserAccountId - ) - THROW 50002, 'UserCredential for this UserAccountID already exists.', 1; - - - INSERT INTO dbo.UserCredential - (UserAccountId, Hash) - VALUES - (@UserAccountId, @Hash); - - COMMIT TRANSACTION; -END; \ No newline at end of file diff --git a/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql b/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql index d556fa2..e2fbe68 100644 --- a/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql +++ b/Database/Database.Core/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql @@ -1,6 +1,5 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification - @UserAccountID uniqueidentifier, - @VerificationDateTime datetime = NULL +CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER, + @VerificationDateTime DATETIME = NULL AS BEGIN SET NOCOUNT ON; @@ -11,10 +10,13 @@ BEGIN BEGIN TRANSACTION; + EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_; + IF @@ROWCOUNT = 0 + THROW 50001, 'Could not find a user with that id', 1; + INSERT INTO dbo.UserVerification - (UserAccountId, VerificationDateTime) - VALUES - (@UserAccountID, @VerificationDateTime); + (UserAccountId, VerificationDateTime) + VALUES (@UserAccountID_, @VerificationDateTime); COMMIT TRANSACTION; END diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql index bae29a1..0de7c52 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCity.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateCity -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateCity( @CityName NVARCHAR(100), @StateProvinceCode NVARCHAR(6) ) @@ -8,23 +7,24 @@ BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); - IF @StateProvinceId IS NULL - BEGIN - RAISERROR('State/province not found for code.', 16, 1); - RETURN; - END + BEGIN TRANSACTION + DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); + IF @StateProvinceId IS NULL + BEGIN + THROW 50001, 'State/province does not exist', 1; + END - IF EXISTS ( - SELECT 1 - FROM dbo.City - WHERE CityName = @CityName - AND StateProvinceID = @StateProvinceId - ) - RETURN; + IF EXISTS (SELECT 1 + FROM dbo.City + WHERE CityName = @CityName + AND StateProvinceID = @StateProvinceId) + BEGIN - INSERT INTO dbo.City - (StateProvinceID, CityName) - VALUES - (@StateProvinceId, @CityName); + THROW 50002, 'City already exists.', 1; + END + + INSERT INTO dbo.City + (StateProvinceID, CityName) + VALUES (@StateProvinceId, @CityName); + COMMIT TRANSACTION END; diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql index e6e79e1..e321746 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateCountry.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry( @CountryName NVARCHAR(100), @ISO3616_1 NVARCHAR(2) ) @@ -7,16 +6,15 @@ AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; + BEGIN TRANSACTION; - IF EXISTS ( - SELECT 1 - FROM dbo.Country - WHERE ISO3616_1 = @ISO3616_1 - ) - RETURN; + IF EXISTS (SELECT 1 + FROM dbo.Country + WHERE ISO3616_1 = @ISO3616_1) + THROW 50001, 'Country already exists', 1; INSERT INTO dbo.Country (CountryName, ISO3616_1) - VALUES - (@CountryName, @ISO3616_1); + VALUES (@CountryName, @ISO3616_1); + COMMIT TRANSACTION; END; diff --git a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql index 7caca90..39e55c1 100644 --- a/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql +++ b/Database/Database.Core/scripts/03-crud/04-Location/USP_CreateStateProvince.sql @@ -1,5 +1,4 @@ -CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince -( +CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince( @StateProvinceName NVARCHAR(100), @ISO3616_2 NVARCHAR(6), @CountryCode NVARCHAR(2) @@ -9,22 +8,19 @@ BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - IF EXISTS ( - SELECT 1 - FROM dbo.StateProvince - WHERE ISO3616_2 = @ISO3616_2 - ) + IF EXISTS (SELECT 1 + FROM dbo.StateProvince + WHERE ISO3616_2 = @ISO3616_2) RETURN; DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode); IF @CountryId IS NULL - BEGIN - RAISERROR('Country not found for code.', 16, 1); - RETURN; - END + BEGIN + THROW 50001, 'Country does not exist', 1; + + END INSERT INTO dbo.StateProvince (StateProvinceName, ISO3616_2, CountryID) - VALUES - (@StateProvinceName, @ISO3616_2, @CountryId); + VALUES (@StateProvinceName, @ISO3616_2, @CountryId); END; diff --git a/Repository/Repository.Core/Entities/UserAccount.cs b/Repository/Repository.Core/Entities/UserAccount.cs index cf9baf5..ac9b723 100644 --- a/Repository/Repository.Core/Entities/UserAccount.cs +++ b/Repository/Repository.Core/Entities/UserAccount.cs @@ -12,3 +12,5 @@ public class UserAccount public DateTime DateOfBirth { get; set; } public byte[]? Timer { get; set; } } + + diff --git a/Repository/Repository.Core/Repositories/IUserAccountRepository.cs b/Repository/Repository.Core/Repositories/IUserAccountRepository.cs deleted file mode 100644 index ca18801..0000000 --- a/Repository/Repository.Core/Repositories/IUserAccountRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using DataAccessLayer.Entities; - -namespace DataAccessLayer.Repositories -{ - public interface IUserAccountRepository - { - Task Add(UserAccount userAccount); - Task GetById(Guid id); - Task> GetAll(int? limit, int? offset); - Task Update(UserAccount userAccount); - Task Delete(Guid id); - Task GetByUsername(string username); - Task GetByEmail(string email); - } -} diff --git a/Repository/Repository.Core/Repositories/Repository.cs b/Repository/Repository.Core/Repositories/Repository.cs index 14cab10..154c6d6 100644 --- a/Repository/Repository.Core/Repositories/Repository.cs +++ b/Repository/Repository.Core/Repositories/Repository.cs @@ -1,24 +1,24 @@ +using System.Data.Common; using DataAccessLayer.Sql; -using Microsoft.Data.SqlClient; namespace DataAccessLayer.Repositories { public abstract class Repository(ISqlConnectionFactory connectionFactory) where T : class { - protected async Task CreateConnection() + protected async Task CreateConnection() { var connection = connectionFactory.CreateConnection(); await connection.OpenAsync(); return connection; } - public abstract Task Add(T entity); - public abstract Task> GetAll(int? limit, int? offset); - public abstract Task GetById(Guid id); - public abstract Task Update(T entity); - public abstract Task Delete(Guid id); + public abstract Task AddAsync(T entity); + public abstract Task> GetAllAsync(int? limit, int? offset); + public abstract Task GetByIdAsync(Guid id); + public abstract Task UpdateAsync(T entity); + public abstract Task DeleteAsync(Guid id); - protected abstract T MapToEntity(SqlDataReader reader); + protected abstract T MapToEntity(DbDataReader reader); } } diff --git a/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs new file mode 100644 index 0000000..6fcd832 --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs @@ -0,0 +1,15 @@ + + +namespace DataAccessLayer.Repositories.UserAccount +{ + public interface IUserAccountRepository + { + Task AddAsync(Entities.UserAccount userAccount); + Task GetByIdAsync(Guid id); + Task> GetAllAsync(int? limit, int? offset); + Task UpdateAsync(Entities.UserAccount userAccount); + Task DeleteAsync(Guid id); + Task GetByUsernameAsync(string username); + Task GetByEmailAsync(string email); + } +} diff --git a/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs new file mode 100644 index 0000000..4dab2dc --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -0,0 +1,149 @@ +using System.Data; +using System.Data.Common; +using DataAccessLayer.Sql; + +namespace DataAccessLayer.Repositories.UserAccount +{ + public class UserAccountRepository(ISqlConnectionFactory connectionFactory) + : Repository(connectionFactory), IUserAccountRepository + { + /** + * @todo update the create user account stored proc to add user credential creation in + * a single transaction, use that transaction instead. + */ + public override async Task AddAsync(Entities.UserAccount userAccount) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_CreateUserAccount"; + command.CommandType = CommandType.StoredProcedure; + AddParameter(command, "@UserAccountId", userAccount.UserAccountId); + AddParameter(command, "@Username", userAccount.Username); + AddParameter(command, "@FirstName", userAccount.FirstName); + AddParameter(command, "@LastName", userAccount.LastName); + AddParameter(command, "@Email", userAccount.Email); + AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth); + + await command.ExecuteNonQueryAsync(); + } + + public override async Task GetByIdAsync(Guid id) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountById"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", id); + + await using var reader = await command.ExecuteReaderAsync(); + return await reader.ReadAsync() ? MapToEntity(reader) : null; + } + + public override async Task> GetAllAsync(int? limit, int? offset) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetAllUserAccounts"; + command.CommandType = CommandType.StoredProcedure; + + if (limit.HasValue) + AddParameter(command, "@Limit", limit.Value); + + if (offset.HasValue) + AddParameter(command, "@Offset", offset.Value); + + await using var reader = await command.ExecuteReaderAsync(); + var users = new List(); + + while (await reader.ReadAsync()) + { + users.Add(MapToEntity(reader)); + } + + return users; + } + + public override async Task UpdateAsync(Entities.UserAccount userAccount) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_UpdateUserAccount"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccount.UserAccountId); + AddParameter(command, "@Username", userAccount.Username); + AddParameter(command, "@FirstName", userAccount.FirstName); + AddParameter(command, "@LastName", userAccount.LastName); + AddParameter(command, "@Email", userAccount.Email); + AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth); + + await command.ExecuteNonQueryAsync(); + } + + public override async Task DeleteAsync(Guid id) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_DeleteUserAccount"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", id); + await command.ExecuteNonQueryAsync(); + } + + public async Task GetByUsernameAsync(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 GetByEmailAsync(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; + } + + 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"] + }; + } + + 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/Repository/Repository.Core/Repositories/UserAccountRepository.cs b/Repository/Repository.Core/Repositories/UserAccountRepository.cs deleted file mode 100644 index 40eb278..0000000 --- a/Repository/Repository.Core/Repositories/UserAccountRepository.cs +++ /dev/null @@ -1,134 +0,0 @@ -using DataAccessLayer.Entities; -using DataAccessLayer.Sql; -using Microsoft.Data.SqlClient; -using System.Data; - -namespace DataAccessLayer.Repositories -{ - public class UserAccountRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), IUserAccountRepository - { - public override async Task Add(UserAccount userAccount) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_CreateUserAccount", connection); - command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username; - command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName; - command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email; - command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth; - - await command.ExecuteNonQueryAsync(); - } - - public override async Task GetById(Guid id) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountById", connection) - { - CommandType = CommandType.StoredProcedure - }; - - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id; - - await using var reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; - } - - public override async Task> GetAll(int? limit, int? offset) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetAllUserAccounts", connection); - command.CommandType = CommandType.StoredProcedure; - - if (limit.HasValue) - command.Parameters.Add("@Limit", SqlDbType.Int).Value = limit.Value; - - if (offset.HasValue) - command.Parameters.Add("@Offset", SqlDbType.Int).Value = offset.Value; - - await using var reader = await command.ExecuteReaderAsync(); - var users = new List(); - - while (await reader.ReadAsync()) - { - users.Add(MapToEntity(reader)); - } - - return users; - } - - public override async Task Update(UserAccount userAccount) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_UpdateUserAccount", connection); - command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId; - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username; - command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName; - command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName; - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email; - command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth; - - await command.ExecuteNonQueryAsync(); - } - - public override async Task Delete(Guid id) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_DeleteUserAccount", connection); - command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id; - await command.ExecuteNonQueryAsync(); - } - - public async Task GetByUsername(string username) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountByUsername", connection); - command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = username; - - await using var reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; - } - - public async Task GetByEmail(string email) - { - await using var connection = await CreateConnection(); - await using var command = new SqlCommand("usp_GetUserAccountByEmail", connection); - command.CommandType = CommandType.StoredProcedure; - - command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = email; - - await using var reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; - } - - protected override UserAccount MapToEntity(SqlDataReader reader) - { - return new 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"] - }; - } - } -} diff --git a/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs new file mode 100644 index 0000000..72dadb9 --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserCredential/IUserCredentialRepository.cs @@ -0,0 +1,8 @@ +using DataAccessLayer.Entities; + +public interface IUserCredentialRepository +{ + Task RotateCredentialAsync(Guid userAccountId, UserCredential credential); + Task GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); + Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId); +} diff --git a/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs new file mode 100644 index 0000000..e27e03b --- /dev/null +++ b/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs @@ -0,0 +1,93 @@ +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(); + } + + public override Task AddAsync(Entities.UserCredential entity) + => throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials."); + + public override Task> GetAllAsync(int? limit, int? offset) + => throw new NotSupportedException("Listing credentials is not supported."); + + public override Task GetByIdAsync(Guid id) + => throw new NotSupportedException("Fetching credential by ID is not supported."); + + public override Task UpdateAsync(Entities.UserCredential entity) + => throw new NotSupportedException("Use RotateCredentialAsync to update credentials."); + + public override Task DeleteAsync(Guid id) + => throw new NotSupportedException("Deleting a credential by ID is not supported."); + + 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/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs b/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs index 2011fd5..eb8abad 100644 --- a/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs +++ b/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -12,7 +13,7 @@ namespace DataAccessLayer.Sql "Database connection string not configured. Set DB_CONNECTION_STRING env var or ConnectionStrings:Default." ); - public SqlConnection CreateConnection() + public DbConnection CreateConnection() { return new SqlConnection(_connectionString); } diff --git a/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs b/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs index db981f5..db3de9b 100644 --- a/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs +++ b/Repository/Repository.Core/Sql/ISqlConnectionFactory.cs @@ -1,9 +1,9 @@ -using Microsoft.Data.SqlClient; +using System.Data.Common; namespace DataAccessLayer.Sql { public interface ISqlConnectionFactory { - SqlConnection CreateConnection(); + DbConnection CreateConnection(); } } \ No newline at end of file diff --git a/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs b/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs new file mode 100644 index 0000000..33e9688 --- /dev/null +++ b/Repository/Repository.Tests/Database/DefaultSqlConnectionFactory.test.cs @@ -0,0 +1,73 @@ +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/Repository/Repository.Tests/Database/TestConnectionFactory.cs b/Repository/Repository.Tests/Database/TestConnectionFactory.cs new file mode 100644 index 0000000..25c9e94 --- /dev/null +++ b/Repository/Repository.Tests/Database/TestConnectionFactory.cs @@ -0,0 +1,10 @@ +using System.Data.Common; +using DataAccessLayer.Sql; + +namespace Repository.Tests.Database; + +internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory +{ + private readonly DbConnection _conn = conn; + public DbConnection CreateConnection() => _conn; +} \ No newline at end of file diff --git a/Repository/Repository.Tests/Repository.Tests.csproj b/Repository/Repository.Tests/Repository.Tests.csproj index 4fa2c9e..a1d64e0 100644 --- a/Repository/Repository.Tests/Repository.Tests.csproj +++ b/Repository/Repository.Tests/Repository.Tests.csproj @@ -4,14 +4,21 @@ enable enable false - DALTests + Repository.Tests + + + + + + + @@ -21,4 +28,4 @@ - + \ No newline at end of file diff --git a/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs new file mode 100644 index 0000000..925017a --- /dev/null +++ b/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs @@ -0,0 +1,136 @@ +using Apps72.Dev.Data.DbMocker; +using DataAccessLayer.Repositories.UserAccount; +using FluentAssertions; +using Repository.Tests.Database; + +namespace Repository.Tests.UserAccount; + +public class UserAccountRepositoryTest +{ + private static UserAccountRepository CreateRepo(MockDbConnection conn) + => new(new TestConnectionFactory(conn)); + + [Fact] + public async Task GetByIdAsync_ReturnsRow_Mapped() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetUserAccountById") + .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(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + "yerb","Aaron","Po","aaronpo@example.com", + new DateTime(2020,1,1), null, + new DateTime(1990,1,1), null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + + result.Should().NotBeNull(); + result!.Username.Should().Be("yerb"); + result.Email.Should().Be("aaronpo@example.com"); + } + + [Fact] + public async Task GetAllAsync_ReturnsMultipleRows() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_GetAllUserAccounts") + .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(Guid.NewGuid(), "a","A","A","a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null) + .AddRow(Guid.NewGuid(), "b","B","B","b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var results = (await repo.GetAllAsync(null, null)).ToList(); + results.Should().HaveCount(2); + results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" }); + } + + [Fact] + public async Task AddAsync_ExecutesStoredProcedure() + { + var conn = new MockDbConnection(); + conn.Mocks + .When(cmd => cmd.CommandText == "usp_CreateUserAccount") + .ReturnsScalar(1); + + var repo = CreateRepo(conn); + var user = new DataAccessLayer.Entities.UserAccount + { + UserAccountId = Guid.NewGuid(), + Username = "newuser", + FirstName = "New", + LastName = "User", + Email = "newuser@example.com", + DateOfBirth = new DateTime(1991,1,1) + }; + + await repo.AddAsync(user); + } + + [Fact] + public async Task GetByUsername_ReturnsRow() + { + 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(Guid.NewGuid(), "lookupuser","L","U","lookup@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByUsernameAsync("lookupuser"); + result.Should().NotBeNull(); + result!.Email.Should().Be("lookup@example.com"); + } + + [Fact] + public async Task GetByEmail_ReturnsRow() + { + 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(Guid.NewGuid(), "byemail","B","E","byemail@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date, null)); + + var repo = CreateRepo(conn); + var result = await repo.GetByEmailAsync("byemail@example.com"); + result.Should().NotBeNull(); + result!.Username.Should().Be("byemail"); + } +} diff --git a/Repository/Repository.Tests/UserAccountRepositoryTests.cs b/Repository/Repository.Tests/UserAccountRepositoryTests.cs deleted file mode 100644 index f5d9ddb..0000000 --- a/Repository/Repository.Tests/UserAccountRepositoryTests.cs +++ /dev/null @@ -1,280 +0,0 @@ -using DataAccessLayer; -using DataAccessLayer.Entities; -using DataAccessLayer.Repositories; - -namespace DALTests -{ - public class UserAccountRepositoryTests - { - private readonly IUserAccountRepository _repository = new InMemoryUserAccountRepository(); - - [Fact] - public async Task Add_ShouldInsertUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "testuser", - FirstName = "Test", - LastName = "User", - Email = "testuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1990, 1, 1), - }; - - // Act - await _repository.Add(userAccount); - var retrievedUser = await _repository.GetById(userAccount.UserAccountId); - - // Assert - Assert.NotNull(retrievedUser); - Assert.Equal(userAccount.Username, retrievedUser.Username); - } - - [Fact] - public async Task GetById_ShouldReturnUserAccount() - { - // Arrange - var userId = Guid.NewGuid(); - var userAccount = new UserAccount - { - UserAccountId = userId, - Username = "existinguser", - FirstName = "Existing", - LastName = "User", - Email = "existinguser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1985, 5, 15), - }; - await _repository.Add(userAccount); - - // Act - var retrievedUser = await _repository.GetById(userId); - - // Assert - Assert.NotNull(retrievedUser); - Assert.Equal(userId, retrievedUser.UserAccountId); - } - - [Fact] - public async Task Update_ShouldModifyUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "updatableuser", - FirstName = "Updatable", - LastName = "User", - Email = "updatableuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 3, 10), - }; - await _repository.Add(userAccount); - - // Act - userAccount.FirstName = "Updated"; - await _repository.Update(userAccount); - var updatedUser = await _repository.GetById(userAccount.UserAccountId); - - // Assert - Assert.NotNull(updatedUser); - Assert.Equal("Updated", updatedUser.FirstName); - } - - [Fact] - public async Task Delete_ShouldRemoveUserAccount() - { - // Arrange - var userAccount = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "deletableuser", - FirstName = "Deletable", - LastName = "User", - Email = "deletableuser@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1995, 7, 20), - }; - await _repository.Add(userAccount); - - // Act - await _repository.Delete(userAccount.UserAccountId); - var deletedUser = await _repository.GetById(userAccount.UserAccountId); - - // Assert - Assert.Null(deletedUser); - } - - [Fact] - public async Task GetAll_ShouldReturnAllUserAccounts() - { - // Arrange - var user1 = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "user1", - FirstName = "User", - LastName = "One", - Email = "user1@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1990, 1, 1), - }; - var user2 = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "user2", - FirstName = "User", - LastName = "Two", - Email = "user2@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 2, 2), - }; - await _repository.Add(user1); - await _repository.Add(user2); - - // Act - var allUsers = await _repository.GetAll(null, null); - - // Assert - Assert.NotNull(allUsers); - Assert.True(allUsers.Count() >= 2); - } - - [Fact] - public async Task GetAll_WithPagination_ShouldRespectLimit() - { - // Arrange - var users = new List - { - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1991, 4, 4), - }, - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1992, 5, 5), - }, - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = $"pageuser_{Guid.NewGuid():N}", - FirstName = "Page", - LastName = "User", - Email = $"pageuser_{Guid.NewGuid():N}@example.com", - CreatedAt = DateTime.UtcNow, - DateOfBirth = new DateTime(1993, 6, 6), - }, - }; - - foreach (var user in users) - { - await _repository.Add(user); - } - - // Act - var page = (await _repository.GetAll(2, 0)).ToList(); - - // Assert - Assert.Equal(2, page.Count); - } - - [Fact] - public async Task GetAll_WithPagination_ShouldValidateArguments() - { - await Assert.ThrowsAsync(async () => - (await _repository.GetAll(0, 0)).ToList() - ); - await Assert.ThrowsAsync(async () => - (await _repository.GetAll(1, -1)).ToList() - ); - } - } - - internal class InMemoryUserAccountRepository : IUserAccountRepository - { - private readonly Dictionary _store = new(); - - public Task Add(UserAccount userAccount) - { - if (userAccount.UserAccountId == Guid.Empty) - { - userAccount.UserAccountId = Guid.NewGuid(); - } - _store[userAccount.UserAccountId] = Clone(userAccount); - return Task.CompletedTask; - } - - public Task GetById(Guid id) - { - _store.TryGetValue(id, out var user); - return Task.FromResult(user is null ? null : Clone(user)); - } - - public Task> GetAll(int? limit, int? offset) - { - if (limit.HasValue && limit.Value <= 0) throw new ArgumentOutOfRangeException(nameof(limit)); - if (offset.HasValue && offset.Value < 0) throw new ArgumentOutOfRangeException(nameof(offset)); - - var query = _store.Values - .OrderBy(u => u.Username) - .Select(Clone); - - if (offset.HasValue) query = query.Skip(offset.Value); - if (limit.HasValue) query = query.Take(limit.Value); - - return Task.FromResult>(query.ToList()); - } - - public Task Update(UserAccount userAccount) - { - if (!_store.ContainsKey(userAccount.UserAccountId)) return Task.CompletedTask; - _store[userAccount.UserAccountId] = Clone(userAccount); - return Task.CompletedTask; - } - - public Task Delete(Guid id) - { - _store.Remove(id); - return Task.CompletedTask; - } - - public Task GetByUsername(string username) - { - var user = _store.Values.FirstOrDefault(u => u.Username == username); - return Task.FromResult(user is null ? null : Clone(user)); - } - - public Task GetByEmail(string email) - { - var user = _store.Values.FirstOrDefault(u => u.Email == email); - return Task.FromResult(user is null ? null : Clone(user)); - } - - private static UserAccount Clone(UserAccount u) => new() - { - UserAccountId = u.UserAccountId, - Username = u.Username, - FirstName = u.FirstName, - LastName = u.LastName, - Email = u.Email, - CreatedAt = u.CreatedAt, - UpdatedAt = u.UpdatedAt, - DateOfBirth = u.DateOfBirth, - Timer = u.Timer is null ? null : (byte[])u.Timer.Clone(), - }; - } -} diff --git a/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs b/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs new file mode 100644 index 0000000..708b2a5 --- /dev/null +++ b/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs @@ -0,0 +1,75 @@ +using Apps72.Dev.Data.DbMocker; +using DataAccessLayer.Repositories.UserCredential; +using DataAccessLayer.Sql; +using FluentAssertions; +using Moq; +using Repository.Tests.Database; + +namespace Repository.Tests.UserCredential; + +public class UserCredentialRepositoryTests +{ + private static UserCredentialRepository CreateRepo() + { + var factoryMock = new Mock(MockBehavior.Strict); + // NotSupported methods do not use the factory; keep strict to ensure no unexpected calls. + return new UserCredentialRepository(factoryMock.Object); + } + + [Fact] + public async Task AddAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.AddAsync(new DataAccessLayer.Entities.UserCredential()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAllAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.GetAllAsync(null, null); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByIdAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.GetByIdAsync(Guid.NewGuid()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.UpdateAsync(new DataAccessLayer.Entities.UserCredential()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_ShouldThrow_NotSupported() + { + var repo = CreateRepo(); + var act = async () => await repo.DeleteAsync(Guid.NewGuid()); + await act.Should().ThrowAsync(); + } + + [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); + + } +} diff --git a/Service/Service.Core/Service.Core.csproj b/Service/Service.Core/Service.Core.csproj index 3a607d0..d80bd8b 100644 --- a/Service/Service.Core/Service.Core.csproj +++ b/Service/Service.Core/Service.Core.csproj @@ -6,6 +6,10 @@ BusinessLayer + + + + diff --git a/Service/Service.Core/Services/AuthService.cs b/Service/Service.Core/Services/AuthService.cs new file mode 100644 index 0000000..64aa5b0 --- /dev/null +++ b/Service/Service.Core/Services/AuthService.cs @@ -0,0 +1,48 @@ +using DataAccessLayer.Entities; +using DataAccessLayer.Repositories.UserAccount; +using DataAccessLayer.Repositories.UserCredential; + +namespace BusinessLayer.Services +{ + public class AuthService(IUserAccountRepository userRepo, IUserCredentialRepository credRepo) : IAuthService + { + public async Task RegisterAsync(UserAccount userAccount, string password) + { + if (userAccount.UserAccountId == Guid.Empty) + { + userAccount.UserAccountId = Guid.NewGuid(); + } + + await userRepo.AddAsync(userAccount); + + var credential = new UserCredential + { + UserAccountId = userAccount.UserAccountId, + Hash = PasswordHasher.Hash(password) + }; + + await credRepo.RotateCredentialAsync(userAccount.UserAccountId, credential); + + return userAccount; + } + + public async Task LoginAsync(string usernameOrEmail, string password) + { + // Attempt lookup by username, then email + var user = await userRepo.GetByUsernameAsync(usernameOrEmail) + ?? await userRepo.GetByEmailAsync(usernameOrEmail); + + if (user is null) return false; + + var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); + if (activeCred is null) return false; + + return PasswordHasher.Verify(password, activeCred.Hash); + } + + public async Task InvalidateAsync(Guid userAccountId) + { + await credRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId); + } + } +} diff --git a/Service/Service.Core/Services/IAuthService.cs b/Service/Service.Core/Services/IAuthService.cs new file mode 100644 index 0000000..490bc6c --- /dev/null +++ b/Service/Service.Core/Services/IAuthService.cs @@ -0,0 +1,11 @@ +using DataAccessLayer.Entities; + +namespace BusinessLayer.Services +{ + public interface IAuthService + { + Task RegisterAsync(UserAccount userAccount, string password); + Task LoginAsync(string usernameOrEmail, string password); + Task InvalidateAsync(Guid userAccountId); + } +} diff --git a/Service/Service.Core/Services/IUserService.cs b/Service/Service.Core/Services/IUserService.cs index d75e665..193a1e3 100644 --- a/Service/Service.Core/Services/IUserService.cs +++ b/Service/Service.Core/Services/IUserService.cs @@ -6,5 +6,9 @@ namespace BusinessLayer.Services { Task> GetAllAsync(int? limit = null, int? offset = null); Task GetByIdAsync(Guid id); + + Task AddAsync(UserAccount userAccount); + + Task UpdateAsync(UserAccount userAccount); } } diff --git a/Service/Service.Core/Services/PasswordHasher.cs b/Service/Service.Core/Services/PasswordHasher.cs new file mode 100644 index 0000000..68c2f94 --- /dev/null +++ b/Service/Service.Core/Services/PasswordHasher.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; + +namespace BusinessLayer.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/Service/Service.Core/Services/UserService.cs b/Service/Service.Core/Services/UserService.cs index 20a8e68..1445f41 100644 --- a/Service/Service.Core/Services/UserService.cs +++ b/Service/Service.Core/Services/UserService.cs @@ -1,5 +1,6 @@ using DataAccessLayer.Entities; using DataAccessLayer.Repositories; +using DataAccessLayer.Repositories.UserAccount; namespace BusinessLayer.Services { @@ -7,12 +8,22 @@ namespace BusinessLayer.Services { public async Task> GetAllAsync(int? limit = null, int? offset = null) { - return await repository.GetAll(limit, offset); + return await repository.GetAllAsync(limit, offset); } public async Task GetByIdAsync(Guid id) { - return await repository.GetById(id); + return await repository.GetByIdAsync(id); + } + + public async Task AddAsync(UserAccount userAccount) + { + await repository.AddAsync(userAccount); + } + + public async Task UpdateAsync(UserAccount userAccount) + { + await repository.UpdateAsync(userAccount); } } }