mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 02:39:03 +00:00
Merge remote-tracking branch 'dotnet/auth-updates' into integrate-dotnet-backend-repo
This commit is contained in:
47
API/API.Core/Controllers/AuthController.cs
Normal file
47
API/API.Core/Controllers/AuthController.cs
Normal file
@@ -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<ActionResult<UserAccount>> 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<ActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var ok = await auth.LoginAsync(req.UsernameOrEmail, req.Password);
|
||||
if (!ok) return Unauthorized();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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." });
|
||||
|
||||
@@ -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<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
||||
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserCredentialRepository, UserCredentialRepository>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
||||
AS
|
||||
BEGIN
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail
|
||||
(
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||
@Email VARCHAR(128)
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername
|
||||
(
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||
@Username VARCHAR(64)
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,3 +12,5 @@ public class UserAccount
|
||||
public DateTime DateOfBirth { get; set; }
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using DataAccessLayer.Entities;
|
||||
|
||||
namespace DataAccessLayer.Repositories
|
||||
{
|
||||
public interface IUserAccountRepository
|
||||
{
|
||||
Task Add(UserAccount userAccount);
|
||||
Task<UserAccount?> GetById(Guid id);
|
||||
Task<IEnumerable<UserAccount>> GetAll(int? limit, int? offset);
|
||||
Task Update(UserAccount userAccount);
|
||||
Task Delete(Guid id);
|
||||
Task<UserAccount?> GetByUsername(string username);
|
||||
Task<UserAccount?> GetByEmail(string email);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
using System.Data.Common;
|
||||
using DataAccessLayer.Sql;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace DataAccessLayer.Repositories
|
||||
{
|
||||
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
|
||||
where T : class
|
||||
{
|
||||
protected async Task<SqlConnection> CreateConnection()
|
||||
protected async Task<DbConnection> CreateConnection()
|
||||
{
|
||||
var connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
return connection;
|
||||
}
|
||||
|
||||
public abstract Task Add(T entity);
|
||||
public abstract Task<IEnumerable<T>> GetAll(int? limit, int? offset);
|
||||
public abstract Task<T?> GetById(Guid id);
|
||||
public abstract Task Update(T entity);
|
||||
public abstract Task Delete(Guid id);
|
||||
public abstract Task AddAsync(T entity);
|
||||
public abstract Task<IEnumerable<T>> GetAllAsync(int? limit, int? offset);
|
||||
public abstract Task<T?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
|
||||
namespace DataAccessLayer.Repositories.UserAccount
|
||||
{
|
||||
public interface IUserAccountRepository
|
||||
{
|
||||
Task AddAsync(Entities.UserAccount userAccount);
|
||||
Task<Entities.UserAccount?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
Task UpdateAsync(Entities.UserAccount userAccount);
|
||||
Task DeleteAsync(Guid id);
|
||||
Task<Entities.UserAccount?> GetByUsernameAsync(string username);
|
||||
Task<Entities.UserAccount?> GetByEmailAsync(string email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using DataAccessLayer.Sql;
|
||||
|
||||
namespace DataAccessLayer.Repositories.UserAccount
|
||||
{
|
||||
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<Entities.UserAccount>(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<Entities.UserAccount?> 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<IEnumerable<Entities.UserAccount>> 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<Entities.UserAccount>();
|
||||
|
||||
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<Entities.UserAccount?> 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<Entities.UserAccount?> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UserAccount>(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<UserAccount?> 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<IEnumerable<UserAccount>> 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<UserAccount>();
|
||||
|
||||
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<UserAccount?> 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<UserAccount?> 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"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using DataAccessLayer.Entities;
|
||||
|
||||
public interface IUserCredentialRepository
|
||||
{
|
||||
Task RotateCredentialAsync(Guid userAccountId, UserCredential credential);
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId);
|
||||
}
|
||||
@@ -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<Entities.UserCredential>(connectionFactory), IUserCredentialRepository
|
||||
{
|
||||
public async Task RotateCredentialAsync(Guid userAccountId, Entities.UserCredential credential)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_RotateUserCredential";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
AddParameter(command, "@Hash", credential.Hash);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async Task<Entities.UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_InvalidateUserCredential";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public override Task AddAsync(Entities.UserCredential entity)
|
||||
=> throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials.");
|
||||
|
||||
public override Task<IEnumerable<Entities.UserCredential>> GetAllAsync(int? limit, int? offset)
|
||||
=> throw new NotSupportedException("Listing credentials is not supported.");
|
||||
|
||||
public override Task<Entities.UserCredential?> 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<System.Data.DataRow>()
|
||||
.Any(r => string.Equals(r["ColumnName"]?.ToString(), "Timer", StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||
|
||||
if (hasTimer)
|
||||
{
|
||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"];
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace DataAccessLayer.Sql
|
||||
{
|
||||
public interface ISqlConnectionFactory
|
||||
{
|
||||
SqlConnection CreateConnection();
|
||||
DbConnection CreateConnection();
|
||||
}
|
||||
}
|
||||
@@ -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<string, string?>()).Build();
|
||||
|
||||
[Fact]
|
||||
public void CreateConnection_Uses_EnvVar_WhenAvailable()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", "Server=localhost;Database=TestDb;Trusted_Connection=True;Encrypt=False");
|
||||
var factory = new DefaultSqlConnectionFactory(EmptyConfig());
|
||||
|
||||
var conn = factory.CreateConnection();
|
||||
conn.Should().BeOfType<SqlConnection>();
|
||||
conn.ConnectionString.Should().Contain("Database=TestDb");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateConnection_Uses_Config_WhenEnvMissing()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null);
|
||||
var cfg = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "ConnectionStrings:Default", "Server=localhost;Database=CfgDb;Trusted_Connection=True;Encrypt=False" }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var factory = new DefaultSqlConnectionFactory(cfg);
|
||||
var conn = factory.CreateConnection();
|
||||
conn.ConnectionString.Should().Contain("Database=CfgDb");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_When_NoEnv_NoConfig()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", null);
|
||||
var cfg = EmptyConfig();
|
||||
Action act = () => _ = new DefaultSqlConnectionFactory(cfg);
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Database connection string not configured*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DB_CONNECTION_STRING", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,14 +4,21 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>DALTests</RootNamespace>
|
||||
<RootNamespace>Repository.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<UserAccount>
|
||||
{
|
||||
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<ArgumentOutOfRangeException>(async () =>
|
||||
(await _repository.GetAll(0, 0)).ToList()
|
||||
);
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
|
||||
(await _repository.GetAll(1, -1)).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
internal class InMemoryUserAccountRepository : IUserAccountRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, UserAccount> _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<UserAccount?> GetById(Guid id)
|
||||
{
|
||||
_store.TryGetValue(id, out var user);
|
||||
return Task.FromResult(user is null ? null : Clone(user));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<UserAccount>> 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<IEnumerable<UserAccount>>(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<UserAccount?> GetByUsername(string username)
|
||||
{
|
||||
var user = _store.Values.FirstOrDefault(u => u.Username == username);
|
||||
return Task.FromResult(user is null ? null : Clone(user));
|
||||
}
|
||||
|
||||
public Task<UserAccount?> 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<ISqlConnectionFactory>(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<NotSupportedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ShouldThrow_NotSupported()
|
||||
{
|
||||
var repo = CreateRepo();
|
||||
var act = async () => await repo.GetAllAsync(null, null);
|
||||
await act.Should().ThrowAsync<NotSupportedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldThrow_NotSupported()
|
||||
{
|
||||
var repo = CreateRepo();
|
||||
var act = async () => await repo.GetByIdAsync(Guid.NewGuid());
|
||||
await act.Should().ThrowAsync<NotSupportedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ShouldThrow_NotSupported()
|
||||
{
|
||||
var repo = CreateRepo();
|
||||
var act = async () => await repo.UpdateAsync(new DataAccessLayer.Entities.UserCredential());
|
||||
await act.Should().ThrowAsync<NotSupportedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ShouldThrow_NotSupported()
|
||||
{
|
||||
var repo = CreateRepo();
|
||||
var act = async () => await repo.DeleteAsync(Guid.NewGuid());
|
||||
await act.Should().ThrowAsync<NotSupportedException>();
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
<RootNamespace>BusinessLayer</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
48
Service/Service.Core/Services/AuthService.cs
Normal file
48
Service/Service.Core/Services/AuthService.cs
Normal file
@@ -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<UserAccount> 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<bool> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Service/Service.Core/Services/IAuthService.cs
Normal file
11
Service/Service.Core/Services/IAuthService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using DataAccessLayer.Entities;
|
||||
|
||||
namespace BusinessLayer.Services
|
||||
{
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<UserAccount> RegisterAsync(UserAccount userAccount, string password);
|
||||
Task<bool> LoginAsync(string usernameOrEmail, string password);
|
||||
Task InvalidateAsync(Guid userAccountId);
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,9 @@ namespace BusinessLayer.Services
|
||||
{
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
|
||||
Task AddAsync(UserAccount userAccount);
|
||||
|
||||
Task UpdateAsync(UserAccount userAccount);
|
||||
}
|
||||
}
|
||||
|
||||
56
Service/Service.Core/Services/PasswordHasher.cs
Normal file
56
Service/Service.Core/Services/PasswordHasher.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null)
|
||||
{
|
||||
return await repository.GetAll(limit, offset);
|
||||
return await repository.GetAllAsync(limit, offset);
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user