diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index b464b1f..7a0c82b 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,7 +1,8 @@ using System.Net; using Repository.Core.Entities; using Microsoft.AspNetCore.Mvc; -using ServiceCore.Services; +using Service.Core.Auth; +using Service.Core.Jwt; namespace WebAPI.Controllers { diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs index 3430804..f7760de 100644 --- a/src/Core/API/API.Core/Controllers/UserController.cs +++ b/src/Core/API/API.Core/Controllers/UserController.cs @@ -1,6 +1,6 @@ using Repository.Core.Entities; using Microsoft.AspNetCore.Mvc; -using ServiceCore.Services; +using Service.Core.User; namespace WebAPI.Controllers { diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index a59d989..4dfb8cd 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,7 +1,10 @@ using Repository.Core.Repositories.Auth; using Repository.Core.Repositories.UserAccount; using Repository.Core.Sql; -using ServiceCore.Services; +using Service.Core.Auth; +using Service.Core.Jwt; +using Service.Core.Password; +using Service.Core.User; var builder = WebApplication.CreateBuilder(args); diff --git a/src/Core/Service/Service.Core/Auth/AuthService.cs b/src/Core/Service/Service.Core/Auth/AuthService.cs new file mode 100644 index 0000000..8f4622c --- /dev/null +++ b/src/Core/Service/Service.Core/Auth/AuthService.cs @@ -0,0 +1,53 @@ +using Repository.Core.Entities; +using Repository.Core.Repositories.Auth; +using Service.Core.Password; + +namespace Service.Core.Auth; + +public class AuthService( + IAuthRepository authRepo, + IPasswordService passwordService +) : IAuthService +{ + public async Task RegisterAsync(UserAccount userAccount, string password) + { + // Check if user already exists + var user = await authRepo.GetUserByUsernameAsync(userAccount.Username); + if (user is not null) + { + return null; + } + + // password hashing + var hashed = passwordService.Hash(password); + + // Register user with hashed password + return await authRepo.RegisterUserAsync( + userAccount.Username, + userAccount.FirstName, + userAccount.LastName, + userAccount.Email, + userAccount.DateOfBirth, + hashed); + } + + public async Task LoginAsync(string username, string password) + { + // Attempt lookup by username + var user = await authRepo.GetUserByUsernameAsync(username); + + // the user was not found + if (user is null) return null; + + // @todo handle expired passwords + var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); + + if (activeCred is null) return null; + return !passwordService.Verify(password, activeCred.Hash) ? null : user; + } + + public async Task InvalidateAsync(Guid userAccountId) + { + await authRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId); + } +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Auth/IAuthService.cs b/src/Core/Service/Service.Core/Auth/IAuthService.cs new file mode 100644 index 0000000..45a4bd8 --- /dev/null +++ b/src/Core/Service/Service.Core/Auth/IAuthService.cs @@ -0,0 +1,9 @@ +using Repository.Core.Entities; + +namespace Service.Core.Auth; + +public interface IAuthService +{ + Task RegisterAsync(UserAccount userAccount, string password); + Task LoginAsync(string username, string password); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/IJwtService.cs b/src/Core/Service/Service.Core/Jwt/IJwtService.cs similarity index 76% rename from src/Core/Service/Service.Core/Services/IJwtService.cs rename to src/Core/Service/Service.Core/Jwt/IJwtService.cs index 0dfc6e5..730a17c 100644 --- a/src/Core/Service/Service.Core/Services/IJwtService.cs +++ b/src/Core/Service/Service.Core/Jwt/IJwtService.cs @@ -1,4 +1,4 @@ -namespace ServiceCore.Services; +namespace Service.Core.Jwt; public interface IJwtService { diff --git a/src/Core/Service/Service.Core/Services/JwtService.cs b/src/Core/Service/Service.Core/Jwt/JwtService.cs similarity index 93% rename from src/Core/Service/Service.Core/Services/JwtService.cs rename to src/Core/Service/Service.Core/Jwt/JwtService.cs index 197dd21..5e91144 100644 --- a/src/Core/Service/Service.Core/Services/JwtService.cs +++ b/src/Core/Service/Service.Core/Jwt/JwtService.cs @@ -1,12 +1,10 @@ -using System; using System.Security.Claims; using System.Text; -using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; -namespace ServiceCore.Services; +namespace Service.Core.Jwt; public class JwtService : IJwtService { private readonly string? _secret = Environment.GetEnvironmentVariable("JWT_SECRET"); diff --git a/src/Core/Service/Service.Core/Services/IPasswordService.cs b/src/Core/Service/Service.Core/Password/IPasswordService.cs similarity index 80% rename from src/Core/Service/Service.Core/Services/IPasswordService.cs rename to src/Core/Service/Service.Core/Password/IPasswordService.cs index e19a4f0..809fd8b 100644 --- a/src/Core/Service/Service.Core/Services/IPasswordService.cs +++ b/src/Core/Service/Service.Core/Password/IPasswordService.cs @@ -1,4 +1,4 @@ -namespace ServiceCore.Services; +namespace Service.Core.Password; public interface IPasswordService { diff --git a/src/Core/Service/Service.Core/Password/PasswordService.cs b/src/Core/Service/Service.Core/Password/PasswordService.cs new file mode 100644 index 0000000..c66b47c --- /dev/null +++ b/src/Core/Service/Service.Core/Password/PasswordService.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; + +namespace Service.Core.Password; + +public class PasswordService : IPasswordService +{ + private const int SaltSize = 16; // 128-bit + private const int HashSize = 32; // 256-bit + private const int ArgonIterations = 4; + private const int ArgonMemoryKb = 65536; // 64MB + + public string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), + MemorySize = ArgonMemoryKb, + Iterations = ArgonIterations + }; + + var hash = argon2.GetBytes(HashSize); + return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; + } + + public bool Verify(string password, string stored) + { + try + { + var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) return false; + + var salt = Convert.FromBase64String(parts[0]); + var expected = Convert.FromBase64String(parts[1]); + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), + MemorySize = ArgonMemoryKb, + Iterations = ArgonIterations + }; + + var actual = argon2.GetBytes(expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Service.Core.csproj b/src/Core/Service/Service.Core/Service.Core.csproj index 9c34a8f..5d8a635 100644 --- a/src/Core/Service/Service.Core/Service.Core.csproj +++ b/src/Core/Service/Service.Core/Service.Core.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - ServiceCore + Service.Core diff --git a/src/Core/Service/Service.Core/Services/AuthService.cs b/src/Core/Service/Service.Core/Services/AuthService.cs deleted file mode 100644 index ea7c374..0000000 --- a/src/Core/Service/Service.Core/Services/AuthService.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Repository.Core.Entities; -using Repository.Core.Repositories.Auth; - -namespace ServiceCore.Services -{ - public class AuthService( - IAuthRepository authRepo, - IPasswordService passwordService - ) : IAuthService - { - public async Task RegisterAsync(UserAccount userAccount, string password) - { - // Check if user already exists - var user = await authRepo.GetUserByUsernameAsync(userAccount.Username); - if (user is not null) - { - return null; - } - - // password hashing - var hashed = passwordService.Hash(password); - - // Register user with hashed password - return await authRepo.RegisterUserAsync( - userAccount.Username, - userAccount.FirstName, - userAccount.LastName, - userAccount.Email, - userAccount.DateOfBirth, - hashed); - } - - public async Task LoginAsync(string username, string password) - { - // Attempt lookup by username - var user = await authRepo.GetUserByUsernameAsync(username); - - // the user was not found - if (user is null) return null; - - // @todo handle expired passwords - var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); - - if (activeCred is null) return null; - return !passwordService.Verify(password, activeCred.Hash) ? null : user; - } - - public async Task InvalidateAsync(Guid userAccountId) - { - await authRepo.InvalidateCredentialsByUserAccountIdAsync(userAccountId); - } - } -} diff --git a/src/Core/Service/Service.Core/Services/IAuthService.cs b/src/Core/Service/Service.Core/Services/IAuthService.cs deleted file mode 100644 index adec189..0000000 --- a/src/Core/Service/Service.Core/Services/IAuthService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Repository.Core.Entities; - -namespace ServiceCore.Services -{ - public interface IAuthService - { - Task RegisterAsync(UserAccount userAccount, string password); - Task LoginAsync(string username, string password); - } -} diff --git a/src/Core/Service/Service.Core/Services/IUserService.cs b/src/Core/Service/Service.Core/Services/IUserService.cs deleted file mode 100644 index a5caca6..0000000 --- a/src/Core/Service/Service.Core/Services/IUserService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Repository.Core.Entities; - -namespace ServiceCore.Services -{ - public interface IUserService - { - Task> GetAllAsync(int? limit = null, int? offset = null); - Task GetByIdAsync(Guid id); - - Task UpdateAsync(UserAccount userAccount); - } -} diff --git a/src/Core/Service/Service.Core/Services/PasswordService.cs b/src/Core/Service/Service.Core/Services/PasswordService.cs deleted file mode 100644 index 19ebf47..0000000 --- a/src/Core/Service/Service.Core/Services/PasswordService.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Konscious.Security.Cryptography; - -namespace ServiceCore.Services -{ - public class PasswordService : IPasswordService - { - private const int SaltSize = 16; // 128-bit - private const int HashSize = 32; // 256-bit - private const int ArgonIterations = 4; - private const int ArgonMemoryKb = 65536; // 64MB - - public string Hash(string password) - { - var salt = RandomNumberGenerator.GetBytes(SaltSize); - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) - { - Salt = salt, - DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), - MemorySize = ArgonMemoryKb, - Iterations = ArgonIterations - }; - - var hash = argon2.GetBytes(HashSize); - return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; - } - - public bool Verify(string password, string stored) - { - try - { - var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 2) return false; - - var salt = Convert.FromBase64String(parts[0]); - var expected = Convert.FromBase64String(parts[1]); - - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) - { - Salt = salt, - DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), - MemorySize = ArgonMemoryKb, - Iterations = ArgonIterations - }; - - var actual = argon2.GetBytes(expected.Length); - return CryptographicOperations.FixedTimeEquals(actual, expected); - } - catch - { - return false; - } - } - } -} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/UserService.cs b/src/Core/Service/Service.Core/Services/UserService.cs deleted file mode 100644 index 9393634..0000000 --- a/src/Core/Service/Service.Core/Services/UserService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Repository.Core.Entities; -using Repository.Core.Repositories.UserAccount; - -namespace ServiceCore.Services -{ - public class UserService(IUserAccountRepository repository) : IUserService - { - public async Task> GetAllAsync(int? limit = null, int? offset = null) - { - return await repository.GetAllAsync(limit, offset); - } - - public async Task GetByIdAsync(Guid id) - { - return await repository.GetByIdAsync(id); - } - - public async Task UpdateAsync(UserAccount userAccount) - { - await repository.UpdateAsync(userAccount); - } - } -} diff --git a/src/Core/Service/Service.Core/User/IUserService.cs b/src/Core/Service/Service.Core/User/IUserService.cs new file mode 100644 index 0000000..ee256af --- /dev/null +++ b/src/Core/Service/Service.Core/User/IUserService.cs @@ -0,0 +1,11 @@ +using Repository.Core.Entities; + +namespace Service.Core.User; + +public interface IUserService +{ + Task> GetAllAsync(int? limit = null, int? offset = null); + Task GetByIdAsync(Guid id); + + Task UpdateAsync(UserAccount userAccount); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/User/UserService.cs b/src/Core/Service/Service.Core/User/UserService.cs new file mode 100644 index 0000000..cbb1423 --- /dev/null +++ b/src/Core/Service/Service.Core/User/UserService.cs @@ -0,0 +1,22 @@ +using Repository.Core.Entities; +using Repository.Core.Repositories.UserAccount; + +namespace Service.Core.User; + +public class UserService(IUserAccountRepository repository) : IUserService +{ + public async Task> GetAllAsync(int? limit = null, int? offset = null) + { + return await repository.GetAllAsync(limit, offset); + } + + public async Task GetByIdAsync(Guid id) + { + return await repository.GetByIdAsync(id); + } + + public async Task UpdateAsync(UserAccount userAccount) + { + await repository.UpdateAsync(userAccount); + } +} \ No newline at end of file