diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 18668d6..516bfbe 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,12 +1,13 @@ -using BusinessLayer.Services; +using System.Net; using DataAccessLayer.Entities; using Microsoft.AspNetCore.Mvc; +using ServiceCore.Services; namespace WebAPI.Controllers { [ApiController] [Route("api/[controller]")] - public class AuthController(IAuthService auth) : ControllerBase + public class AuthController(IAuthService auth, IJwtService jwtService) : ControllerBase { public record RegisterRequest( string Username, @@ -17,7 +18,13 @@ namespace WebAPI.Controllers string Password ); - public record LoginRequest(string UsernameOrEmail, string Password); + public record LoginRequest + { + public string Username { get; init; } = default!; + public string Password { get; init; } = default!; + } + + private record ResponseBody(string Message, object? Payload); [HttpPost("register")] public async Task> Register([FromBody] RegisterRequest req) @@ -39,9 +46,15 @@ namespace WebAPI.Controllers [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 }); + var userAccount = await auth.LoginAsync(req.Username, req.Password); + if (userAccount is null) + { + return Unauthorized(new ResponseBody("Invalid username or password.", null)); + } + + var jwt = jwtService.GenerateJwt(userAccount.UserAccountId, userAccount.Username, userAccount.DateOfBirth); + + return Ok(new ResponseBody("Logged in successfully.", new { AccessToken = jwt })); } } -} +} \ No newline at end of file diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs index fb1d867..ce2b11a 100644 --- a/src/Core/API/API.Core/Controllers/UserController.cs +++ b/src/Core/API/API.Core/Controllers/UserController.cs @@ -1,6 +1,6 @@ -using BusinessLayer.Services; using DataAccessLayer.Entities; using Microsoft.AspNetCore.Mvc; +using ServiceCore.Services; namespace WebAPI.Controllers { diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index 98fb89f..48ca639 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,7 +1,7 @@ -using BusinessLayer.Services; using DataAccessLayer.Repositories.UserAccount; using DataAccessLayer.Repositories.UserCredential; using DataAccessLayer.Sql; +using ServiceCore.Services; var builder = WebApplication.CreateBuilder(args); @@ -16,6 +16,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.UseSwagger(); diff --git a/src/Core/API/API.Core/Properties/launchSettings.json b/src/Core/API/API.Core/Properties/launchSettings.json deleted file mode 100644 index 5941a5e..0000000 --- a/src/Core/API/API.Core/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5069", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7002;http://localhost:5069", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Core/API/API.Specs/Features/Auth.feature b/src/Core/API/API.Specs/Features/Auth.feature new file mode 100644 index 0000000..0adad13 --- /dev/null +++ b/src/Core/API/API.Specs/Features/Auth.feature @@ -0,0 +1,39 @@ +Feature: User Login +As a registered user +I want to log in to my account +So that I receive an authentication token to access authenticated routes + + Scenario: Successful login with valid credentials + Given the API is running + And I have an existing account + When I submit a login request with a username and password + Then the response has HTTP status 200 + And the response JSON should have "message" equal "Logged in successfully." + And the response JSON should have an access token + + Scenario: Login fails with invalid credentials + Given the API is running + And I do not have an existing account + When I submit a login request with a username and password + Then the response has HTTP status 401 + And the response JSON should have "message" equal "Invalid username or password." + + Scenario: Login fails when required missing username + Given the API is running + When I submit a login request with a missing username + Then the response has HTTP status 400 + + Scenario: Login fails when required missing password + Given the API is running + When I submit a login request with a missing password + Then the response has HTTP status 400 + + Scenario: Login fails when both username and password are missing + Given the API is running + When I submit a login request with both username and password missing + Then the response has HTTP status 400 + + Scenario: Login endpoint only accepts POST requests + Given the API is running + When I submit a login request using a GET request + Then the response has HTTP status 404 \ No newline at end of file diff --git a/src/Core/API/API.Specs/Features/NotFound.feature b/src/Core/API/API.Specs/Features/NotFound.feature index 68806c8..fcfc041 100644 --- a/src/Core/API/API.Specs/Features/NotFound.feature +++ b/src/Core/API/API.Specs/Features/NotFound.feature @@ -1,10 +1,10 @@ -Feature: NotFound API - As a client of the API - I want consistent 404 responses - So that consumers can handle missing routes +Feature: NotFound Responses +As a client of the API +I want consistent 404 responses +So that consumers can gracefully handle missing routes - Scenario: GET error 404 returns NotFound message - Given the API is running - When I GET "/error/404" - Then the response status code should be 404 - And the response JSON should have "message" equal "Route not found." + Scenario: GET request to an invalid route returns 404 + Given the API is running + When I send an HTTP request "GET" to "/invalid-route" + Then the response has HTTP status 404 + And the response JSON should have "message" equal "Route not found." \ No newline at end of file diff --git a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs new file mode 100644 index 0000000..eb8d411 --- /dev/null +++ b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using Reqnroll; +using FluentAssertions; +using API.Specs; + +namespace API.Specs.Steps; + +[Binding] +public class ApiGeneralSteps(ScenarioContext scenario) +{ + private const string ClientKey = "client"; + private const string FactoryKey = "factory"; + private const string ResponseKey = "response"; + private const string ResponseBodyKey = "responseBody"; + + private HttpClient GetClient() + { + if (scenario.TryGetValue(ClientKey, out var client)) + { + return client; + } + + var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory(); + scenario[FactoryKey] = factory; + + client = factory.CreateClient(); + scenario[ClientKey] = client; + return client; + } + + [Given("the API is running")] + public void GivenTheApiIsRunning() + { + GetClient(); + } + + [When("I send an HTTP request {string} to {string} with body:")] + public async Task WhenISendAnHttpRequestStringToStringWithBody(string method, string url, string jsonBody) + { + var client = GetClient(); + + var requestMessage = new HttpRequestMessage(new HttpMethod(method), url) + { + Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I send an HTTP request {string} to {string}")] + public async Task WhenISendAnHttpRequestStringToString(string method, string url) + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage(new HttpMethod(method), url); + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Then("the response status code should be {int}")] + public void ThenTheResponseStatusCodeShouldBeInt(int expected) + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + ((int)response!.StatusCode).Should().Be(expected); + } + + [Then("the response has HTTP status {int}")] + public void ThenTheResponseHasHttpStatusInt(int expectedCode) + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue("No response was received from the API"); + ((int)response!.StatusCode).Should().Be(expectedCode); + } + + [Then("the response JSON should have {string} equal {string}")] + public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected) + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue(); + + using var doc = JsonDocument.Parse(responseBody!); + var root = doc.RootElement; + + if (!root.TryGetProperty(field, out var value)) + { + root.TryGetProperty("payload", out var payloadElem).Should().BeTrue("Expected field '{0}' to be present either at the root or inside 'payload'", field); + payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object"); + payloadElem.TryGetProperty(field, out value).Should().BeTrue("Expected field '{0}' to be present inside 'payload'", field); + } + + value.ValueKind.Should().Be(JsonValueKind.String, "Expected field '{0}' to be a string", field); + value.GetString().Should().Be(expected); + } +} diff --git a/src/Core/API/API.Specs/Steps/ApiSteps.cs b/src/Core/API/API.Specs/Steps/ApiSteps.cs deleted file mode 100644 index a7fb6b3..0000000 --- a/src/Core/API/API.Specs/Steps/ApiSteps.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using Reqnroll; -using FluentAssertions; - -namespace API.Specs.Steps; - -[Binding] -public class ApiSteps -{ - private readonly TestApiFactory _factory; - private HttpClient? _client; - private HttpResponseMessage? _response; - - public ApiSteps() - { - _factory = new TestApiFactory(); - } - - [Given("the API is running")] - public void GivenTheApiIsRunning() - { - _client = _factory.CreateClient(); - } - - // No user service assumptions needed for 404 tests - - [When("I GET {string}")] - public async Task WhenIGet(string path) - { - _client.Should().NotBeNull("API client must be initialized"); - _response = await _client!.GetAsync(path); - } - - [Then("the response status code should be {int}")] - public void ThenStatusCodeShouldBe(int expected) - { - _response.Should().NotBeNull(); - ((int)_response!.StatusCode).Should().Be(expected); - } - - [Then("the response JSON should have {string} equal {string}")] - public async Task ThenResponseJsonShouldHaveFieldEqual(string field, string expected) - { - _response.Should().NotBeNull(); - var dict = await _response!.Content.ReadFromJsonAsync>(); - dict.Should().NotBeNull(); - dict!.TryGetValue(field, out var value).Should().BeTrue(); - (value?.ToString()).Should().Be(expected); - } -} diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs new file mode 100644 index 0000000..92ae4c7 --- /dev/null +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using Reqnroll; +using FluentAssertions; +using API.Specs; + +namespace API.Specs.Steps; + +[Binding] +public class AuthSteps(ScenarioContext scenario) +{ + private const string ClientKey = "client"; + private const string FactoryKey = "factory"; + private const string ResponseKey = "response"; + private const string ResponseBodyKey = "responseBody"; + private const string TestUserKey = "testUser"; + + private HttpClient GetClient() + { + if (scenario.TryGetValue(ClientKey, out var client)) + { + return client; + } + + var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory(); + scenario[FactoryKey] = factory; + + client = factory.CreateClient(); + scenario[ClientKey] = client; + return client; + } + + [Given("I have an existing account")] + public void GivenIHaveAnExistingAccount() + { + scenario[TestUserKey] = ("test.user", "password"); + } + + [Given("I do not have an existing account")] + public void GivenIDoNotHaveAnExistingAccount() + { + scenario[TestUserKey] = ("Failing", "User"); + } + + [When("I submit a login request with a username and password")] + public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() + { + var client = GetClient(); + var (username, password) = scenario.TryGetValue<(string username, string password)>(TestUserKey, out var user) + ? user + : ("test.user", "password"); + + var body = JsonSerializer.Serialize(new { username, password }); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a login request with a missing username")] + public async Task WhenISubmitALoginRequestWithAMissingUsername() + { + var client = GetClient(); + var body = JsonSerializer.Serialize(new { password = "test" }); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a login request with a missing password")] + public async Task WhenISubmitALoginRequestWithAMissingPassword() + { + var client = GetClient(); + var body = JsonSerializer.Serialize(new { username = "test" }); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a login request with both username and password missing")] + public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Then("the response JSON should have an access token")] + public void ThenTheResponseJsonShouldHaveAnAccessToken() + { + scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue(); + + var doc = JsonDocument.Parse(responseBody!); + var root = doc.RootElement; + JsonElement tokenElem = default; + var hasToken = false; + + + if (root.TryGetProperty("payload", out var payloadElem) && payloadElem.ValueKind == JsonValueKind.Object) + { + hasToken = payloadElem.TryGetProperty("accessToken", out tokenElem) + || payloadElem.TryGetProperty("AccessToken", out tokenElem); + } + + + hasToken.Should().BeTrue("Expected an access token either at the root or inside 'payload'"); + + var token = tokenElem.GetString(); + token.Should().NotBeNullOrEmpty(); + } + + + [When("I submit a login request using a GET request")] + public async Task WhenISubmitALoginRequestUsingAgetRequest() + { + var client = GetClient(); + // testing GET + var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/login") + { + Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } +} \ No newline at end of file diff --git a/src/Core/Database/Database.Seed/UserSeeder.cs b/src/Core/Database/Database.Seed/UserSeeder.cs index 437ad46..940fd71 100644 --- a/src/Core/Database/Database.Seed/UserSeeder.cs +++ b/src/Core/Database/Database.Seed/UserSeeder.cs @@ -126,7 +126,23 @@ namespace DBSeed int createdCredentials = 0; int createdVerifications = 0; + { + const string firstName = "Test"; + const string lastName = "User"; + const string email = "test.user@thebiergarten.app"; + var dob = new DateTime(1985, 03, 01); + var hash = GeneratePasswordHash("password"); + var userAccountId = await RegisterUserAsync( + connection, + $"{firstName}.{lastName}", + firstName, + lastName, + dob, + email, + hash + ); + } foreach (var (firstName, lastName) in SeedNames) { // prepare user fields @@ -142,6 +158,7 @@ namespace DBSeed ); string hash = GeneratePasswordHash(pwd); + // register the user (creates account + credential) var userAccountId = await RegisterUserAsync( connection, diff --git a/src/Core/Repository/Repository.Core/Repositories/Repository.cs b/src/Core/Repository/Repository.Core/Repositories/Repository.cs index 154c6d6..37a3664 100644 --- a/src/Core/Repository/Repository.Core/Repositories/Repository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/Repository.cs @@ -13,12 +13,6 @@ namespace DataAccessLayer.Repositories return connection; } - 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(DbDataReader reader); } } diff --git a/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs index 6fcd832..85913f7 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserAccount/IUserAccountRepository.cs @@ -4,7 +4,6 @@ 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); diff --git a/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs index 4dab2dc..b0772fa 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserAccount/UserAccountRepository.cs @@ -7,27 +7,7 @@ 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) + public async Task GetByIdAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -40,7 +20,7 @@ namespace DataAccessLayer.Repositories.UserAccount return await reader.ReadAsync() ? MapToEntity(reader) : null; } - public override async Task> GetAllAsync(int? limit, int? offset) + public async Task> GetAllAsync(int? limit, int? offset) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -64,7 +44,7 @@ namespace DataAccessLayer.Repositories.UserAccount return users; } - public override async Task UpdateAsync(Entities.UserAccount userAccount) + public async Task UpdateAsync(Entities.UserAccount userAccount) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -81,7 +61,7 @@ namespace DataAccessLayer.Repositories.UserAccount await command.ExecuteNonQueryAsync(); } - public override async Task DeleteAsync(Guid id) + public async Task DeleteAsync(Guid id) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); @@ -146,4 +126,4 @@ namespace DataAccessLayer.Repositories.UserAccount command.Parameters.Add(p); } } -} +} \ No newline at end of file diff --git a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs index e27e03b..e1c6d8a 100644 --- a/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs +++ b/src/Core/Repository/Repository.Core/Repositories/UserCredential/UserCredentialRepository.cs @@ -14,7 +14,7 @@ namespace DataAccessLayer.Repositories.UserCredential command.CommandText = "USP_RotateUserCredential"; command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId", userAccountId); + AddParameter(command, "@UserAccountId_", userAccountId); AddParameter(command, "@Hash", credential.Hash); await command.ExecuteNonQueryAsync(); @@ -40,25 +40,10 @@ namespace DataAccessLayer.Repositories.UserCredential command.CommandText = "USP_InvalidateUserCredential"; command.CommandType = CommandType.StoredProcedure; - AddParameter(command, "@UserAccountId", userAccountId); + 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 diff --git a/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs b/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs index 925017a..dc6e8bd 100644 --- a/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs +++ b/src/Core/Repository/Repository.Tests/UserAccount/UserAccountRepository.test.cs @@ -27,9 +27,9 @@ public class UserAccountRepositoryTest ("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)); + "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")); @@ -45,18 +45,20 @@ public class UserAccountRepositoryTest 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)); + .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(); @@ -64,27 +66,6 @@ public class UserAccountRepositoryTest 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() @@ -102,7 +83,8 @@ public class UserAccountRepositoryTest ("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)); + ).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"); @@ -126,11 +108,12 @@ public class UserAccountRepositoryTest ("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)); + ).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"); } -} +} \ No newline at end of file diff --git a/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs b/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs index 708b2a5..71d4beb 100644 --- a/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs +++ b/src/Core/Repository/Repository.Tests/UserCredential/UserCredentialRepository.test.cs @@ -1,61 +1,11 @@ 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() { @@ -70,6 +20,5 @@ public class UserCredentialRepositoryTests Hash = "hashed_password" }; await repo.RotateCredentialAsync(Guid.NewGuid(), credential); - } -} +} \ 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 d80bd8b..9c34a8f 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 - BusinessLayer + ServiceCore diff --git a/src/Core/Service/Service.Core/Services/AuthService.cs b/src/Core/Service/Service.Core/Services/AuthService.cs index 64aa5b0..1c40d6d 100644 --- a/src/Core/Service/Service.Core/Services/AuthService.cs +++ b/src/Core/Service/Service.Core/Services/AuthService.cs @@ -1,43 +1,30 @@ using DataAccessLayer.Entities; using DataAccessLayer.Repositories.UserAccount; -using DataAccessLayer.Repositories.UserCredential; -namespace BusinessLayer.Services +namespace ServiceCore.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; + throw new NotImplementedException(); } - public async Task LoginAsync(string usernameOrEmail, string password) + public async Task LoginAsync(string username, string password) { - // Attempt lookup by username, then email - var user = await userRepo.GetByUsernameAsync(usernameOrEmail) - ?? await userRepo.GetByEmailAsync(usernameOrEmail); - - if (user is null) return false; + // Attempt lookup by username + var user = await userRepo.GetByUsernameAsync(username); + + // the user was not found + if (user is null) return null; + // @todo handle expired passwords var activeCred = await credRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); - if (activeCred is null) return false; - - return PasswordHasher.Verify(password, activeCred.Hash); + + if (activeCred is null) return null; + if (!PasswordHasher.Verify(password, activeCred.Hash)) return null; + + return user; } public async Task InvalidateAsync(Guid userAccountId) diff --git a/src/Core/Service/Service.Core/Services/IAuthService.cs b/src/Core/Service/Service.Core/Services/IAuthService.cs index 490bc6c..ca410a8 100644 --- a/src/Core/Service/Service.Core/Services/IAuthService.cs +++ b/src/Core/Service/Service.Core/Services/IAuthService.cs @@ -1,11 +1,10 @@ using DataAccessLayer.Entities; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public interface IAuthService { Task RegisterAsync(UserAccount userAccount, string password); - Task LoginAsync(string usernameOrEmail, string password); - Task InvalidateAsync(Guid userAccountId); + 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/Services/IJwtService.cs new file mode 100644 index 0000000..0dfc6e5 --- /dev/null +++ b/src/Core/Service/Service.Core/Services/IJwtService.cs @@ -0,0 +1,6 @@ +namespace ServiceCore.Services; + +public interface IJwtService +{ + string GenerateJwt(Guid userId, string username, DateTime expiry); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/IUserService.cs b/src/Core/Service/Service.Core/Services/IUserService.cs index 193a1e3..e94dda6 100644 --- a/src/Core/Service/Service.Core/Services/IUserService.cs +++ b/src/Core/Service/Service.Core/Services/IUserService.cs @@ -1,14 +1,12 @@ using DataAccessLayer.Entities; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public interface IUserService { Task> GetAllAsync(int? limit = null, int? offset = null); Task GetByIdAsync(Guid id); - Task AddAsync(UserAccount userAccount); - Task UpdateAsync(UserAccount userAccount); } -} +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/JwtService.cs b/src/Core/Service/Service.Core/Services/JwtService.cs new file mode 100644 index 0000000..c881e70 --- /dev/null +++ b/src/Core/Service/Service.Core/Services/JwtService.cs @@ -0,0 +1,38 @@ +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; +public class JwtService(IConfiguration config) : IJwtService +{ + // private readonly string? _secret = config["Jwt:Secret"]; + private readonly string? _secret = "128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR!@#$fdsafjdslajfl"; + public string GenerateJwt(Guid userId, string username, DateTime expiry) + { + var handler = new JsonWebTokenHandler(); + + var key = Encoding.UTF8.GetBytes(_secret ?? throw new InvalidOperationException("secret not set")); + + // Base claims (always present) + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = expiry, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256) + }; + + return handler.CreateToken(tokenDescriptor); + } +} \ No newline at end of file diff --git a/src/Core/Service/Service.Core/Services/PasswordHasher.cs b/src/Core/Service/Service.Core/Services/PasswordHasher.cs index 68c2f94..f691076 100644 --- a/src/Core/Service/Service.Core/Services/PasswordHasher.cs +++ b/src/Core/Service/Service.Core/Services/PasswordHasher.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; using Konscious.Security.Cryptography; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public static class PasswordHasher { diff --git a/src/Core/Service/Service.Core/Services/UserService.cs b/src/Core/Service/Service.Core/Services/UserService.cs index 1445f41..a0869f5 100644 --- a/src/Core/Service/Service.Core/Services/UserService.cs +++ b/src/Core/Service/Service.Core/Services/UserService.cs @@ -1,8 +1,7 @@ using DataAccessLayer.Entities; -using DataAccessLayer.Repositories; using DataAccessLayer.Repositories.UserAccount; -namespace BusinessLayer.Services +namespace ServiceCore.Services { public class UserService(IUserAccountRepository repository) : IUserService { @@ -15,12 +14,7 @@ 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);