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/Program.cs b/API/API.Core/Program.cs index 6d36b50..98fb89f 100644 --- a/API/API.Core/Program.cs +++ b/API/API.Core/Program.cs @@ -1,6 +1,6 @@ using BusinessLayer.Services; -using DataAccessLayer.Repositories; using DataAccessLayer.Repositories.UserAccount; +using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Sql; var builder = WebApplication.CreateBuilder(args); @@ -14,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/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 2085f8b..1445f41 100644 --- a/Service/Service.Core/Services/UserService.cs +++ b/Service/Service.Core/Services/UserService.cs @@ -15,5 +15,15 @@ namespace BusinessLayer.Services { 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); + } } }