diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 06aff2f..1704e87 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -112,6 +112,26 @@ services: networks: - testnet + service.auth.tests: + env_file: ".env.test" + image: service.auth.tests + container_name: test-env-service-auth-tests + depends_on: + database.seed: + condition: service_completed_successfully + build: + context: ./src/Core + dockerfile: Service/Service.Auth.Tests/Dockerfile + args: + BUILD_CONFIGURATION: Release + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + volumes: + - ./test-results:/app/test-results + restart: "no" + networks: + - testnet + volumes: sqlserverdata-test: driver: local diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj index 938fed6..5acab8a 100644 --- a/src/Core/API/API.Core/API.Core.csproj +++ b/src/Core/API/API.Core/API.Core.csproj @@ -20,12 +20,9 @@ - - - + + + diff --git a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs index 0d08dee..a544258 100644 --- a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs +++ b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs @@ -1,4 +1,19 @@ +using Domain.Entities; +using Org.BouncyCastle.Asn1.Cms; + namespace API.Core.Contracts.Auth; -public record UserDTO(Guid UserAccountId, string Username); -public record AuthPayload(UserDTO User, string AccessToken, DateTime CreatedAt, DateTime ExpiresAt); +public record LoginPayload( + Guid UserAccountId, + string Username, + string RefreshToken, + string AccessToken +); + +public record RegistrationPayload( + Guid UserAccountId, + string Username, + string RefreshToken, + string AccessToken, + bool ConfirmationEmailSent +); diff --git a/src/Core/API/API.Core/Contracts/Auth/Login.cs b/src/Core/API/API.Core/Contracts/Auth/Login.cs index 96a8126..c0bb5f8 100644 --- a/src/Core/API/API.Core/Contracts/Auth/Login.cs +++ b/src/Core/API/API.Core/Contracts/Auth/Login.cs @@ -5,19 +5,16 @@ namespace API.Core.Contracts.Auth; public record LoginRequest { - public string Username { get; init; } = default!; - public string Password { get; init; } = default!; + public string Username { get; init; } = default!; + public string Password { get; init; } = default!; } public class LoginRequestValidator : AbstractValidator { - public LoginRequestValidator() - { - RuleFor(x => x.Username) - .NotEmpty().WithMessage("Username is required"); + public LoginRequestValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required"); - RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required"); - } + RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); + } } - diff --git a/src/Core/API/API.Core/Contracts/Auth/Register.cs b/src/Core/API/API.Core/Contracts/Auth/Register.cs index 012575d..31c3541 100644 --- a/src/Core/API/API.Core/Contracts/Auth/Register.cs +++ b/src/Core/API/API.Core/Contracts/Auth/Register.cs @@ -17,38 +17,55 @@ public class RegisterRequestValidator : AbstractValidator public RegisterRequestValidator() { RuleFor(x => x.Username) - .NotEmpty().WithMessage("Username is required") - .Length(3, 64).WithMessage("Username must be between 3 and 64 characters") + .NotEmpty() + .WithMessage("Username is required") + .Length(3, 64) + .WithMessage("Username must be between 3 and 64 characters") .Matches("^[a-zA-Z0-9._-]+$") - .WithMessage("Username can only contain letters, numbers, dots, underscores, and hyphens"); + .WithMessage( + "Username can only contain letters, numbers, dots, underscores, and hyphens" + ); RuleFor(x => x.FirstName) - .NotEmpty().WithMessage("First name is required") - .MaximumLength(128).WithMessage("First name cannot exceed 128 characters"); + .NotEmpty() + .WithMessage("First name is required") + .MaximumLength(128) + .WithMessage("First name cannot exceed 128 characters"); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required") - .MaximumLength(128).WithMessage("Last name cannot exceed 128 characters"); + .NotEmpty() + .WithMessage("Last name is required") + .MaximumLength(128) + .WithMessage("Last name cannot exceed 128 characters"); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required") - .EmailAddress().WithMessage("Invalid email format") - .MaximumLength(128).WithMessage("Email cannot exceed 128 characters"); + .NotEmpty() + .WithMessage("Email is required") + .EmailAddress() + .WithMessage("Invalid email format") + .MaximumLength(128) + .WithMessage("Email cannot exceed 128 characters"); RuleFor(x => x.DateOfBirth) - .NotEmpty().WithMessage("Date of birth is required") + .NotEmpty() + .WithMessage("Date of birth is required") .LessThan(DateTime.Today.AddYears(-19)) .WithMessage("You must be at least 19 years old to register"); RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required") - .MinimumLength(8).WithMessage("Password must be at least 8 characters") - .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter") - .Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter") - .Matches("[0-9]").WithMessage("Password must contain at least one number") - .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character"); + .NotEmpty() + .WithMessage("Password is required") + .MinimumLength(8) + .WithMessage("Password must be at least 8 characters") + .Matches("[A-Z]") + .WithMessage("Password must contain at least one uppercase letter") + .Matches("[a-z]") + .WithMessage("Password must contain at least one lowercase letter") + .Matches("[0-9]") + .WithMessage("Password must contain at least one number") + .Matches("[^a-zA-Z0-9]") + .WithMessage( + "Password must contain at least one special character" + ); } } - - - diff --git a/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs b/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs index e5a6e71..12acd29 100644 --- a/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs +++ b/src/Core/API/API.Core/Contracts/Common/ResponseBody.cs @@ -2,11 +2,11 @@ namespace API.Core.Contracts.Common; public record ResponseBody { - public required string Message { get; init; } - public required T Payload { get; init; } + public required string Message { get; init; } + public required T Payload { get; init; } } public record ResponseBody { - public required string Message { get; init; } + public required string Message { get; init; } } diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 7243141..4214a32 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,7 +1,6 @@ using API.Core.Contracts.Auth; using API.Core.Contracts.Common; using Domain.Entities; -using Infrastructure.Jwt; using Microsoft.AspNetCore.Mvc; using Service.Auth; @@ -9,33 +8,37 @@ namespace API.Core.Controllers { [ApiController] [Route("api/[controller]")] - public class AuthController(IRegisterService register, ILoginService login, ITokenInfrastructure tokenInfrastructure) : ControllerBase + public class AuthController(IRegisterService register, ILoginService login) + : ControllerBase { [HttpPost("register")] - public async Task> Register([FromBody] RegisterRequest req) + public async Task> Register( + [FromBody] RegisterRequest req + ) { - var created = await register.RegisterAsync(new UserAccount - { - UserAccountId = Guid.Empty, - Username = req.Username, - FirstName = req.FirstName, - LastName = req.LastName, - Email = req.Email, - DateOfBirth = req.DateOfBirth - }, req.Password); - - var jwtExpiresAt = DateTime.UtcNow.AddHours(1); - var jwt = tokenInfrastructure.GenerateJwt(created.UserAccountId, created.Username, jwtExpiresAt + var rtn = await register.RegisterAsync( + new UserAccount + { + UserAccountId = Guid.Empty, + Username = req.Username, + FirstName = req.FirstName, + LastName = req.LastName, + Email = req.Email, + DateOfBirth = req.DateOfBirth, + }, + req.Password ); - var response = new ResponseBody + var response = new ResponseBody { Message = "User registered successfully.", - Payload = new AuthPayload( - new UserDTO(created.UserAccountId, created.Username), - jwt, - DateTime.UtcNow, - jwtExpiresAt) + Payload = new RegistrationPayload( + rtn.UserAccount.UserAccountId, + rtn.UserAccount.Username, + rtn.RefreshToken, + rtn.AccessToken, + rtn.EmailSent + ), }; return Created("/", response); } @@ -43,18 +46,20 @@ namespace API.Core.Controllers [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) { - var userAccount = await login.LoginAsync(req.Username, req.Password); + var rtn = await login.LoginAsync(req.Username, req.Password); - UserDTO dto = new(userAccount.UserAccountId, userAccount.Username); - - var jwtExpiresAt = DateTime.UtcNow.AddHours(1); - var jwt = tokenInfrastructure.GenerateJwt(userAccount.UserAccountId, userAccount.Username, jwtExpiresAt); - - return Ok(new ResponseBody - { - Message = "Logged in successfully.", - Payload = new AuthPayload(dto, jwt, DateTime.UtcNow, jwtExpiresAt) - }); + return Ok( + new ResponseBody + { + Message = "Logged in successfully.", + Payload = new LoginPayload( + rtn.UserAccount.UserAccountId, + rtn.UserAccount.Username, + rtn.RefreshToken, + rtn.AccessToken + ), + } + ); } } } diff --git a/src/Core/API/API.Core/Controllers/UserController.cs b/src/Core/API/API.Core/Controllers/UserController.cs index 3cdd55a..7559373 100644 --- a/src/Core/API/API.Core/Controllers/UserController.cs +++ b/src/Core/API/API.Core/Controllers/UserController.cs @@ -9,7 +9,10 @@ namespace API.Core.Controllers public class UserController(IUserService userService) : ControllerBase { [HttpGet] - public async Task>> GetAll([FromQuery] int? limit, [FromQuery] int? offset) + public async Task>> GetAll( + [FromQuery] int? limit, + [FromQuery] int? offset + ) { var users = await userService.GetAllAsync(limit, offset); return Ok(users); diff --git a/src/Core/API/API.Core/GlobalException.cs b/src/Core/API/API.Core/GlobalException.cs index 9855e69..72f7821 100644 --- a/src/Core/API/API.Core/GlobalException.cs +++ b/src/Core/API/API.Core/GlobalException.cs @@ -8,7 +8,8 @@ using Microsoft.AspNetCore.Mvc.Filters; namespace API.Core; -public class GlobalExceptionFilter(ILogger logger) : IExceptionFilter +public class GlobalExceptionFilter(ILogger logger) + : IExceptionFilter { public void OnException(ExceptionContext context) { @@ -17,65 +18,78 @@ public class GlobalExceptionFilter(ILogger logger) : IExc switch (context.Exception) { case FluentValidation.ValidationException fluentValidationException: - var errors = fluentValidationException.Errors - .GroupBy(e => e.PropertyName) + var errors = fluentValidationException + .Errors.GroupBy(e => e.PropertyName) .ToDictionary( g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray() ); - context.Result = new BadRequestObjectResult(new - { - message = "Validation failed", - errors - }); + context.Result = new BadRequestObjectResult( + new { message = "Validation failed", errors } + ); context.ExceptionHandled = true; break; case ConflictException ex: - context.Result = new ObjectResult(new ResponseBody { Message = ex.Message }) + context.Result = new ObjectResult( + new ResponseBody { Message = ex.Message } + ) { - StatusCode = 409 + StatusCode = 409, }; context.ExceptionHandled = true; break; case NotFoundException ex: - context.Result = new ObjectResult(new ResponseBody { Message = ex.Message }) + context.Result = new ObjectResult( + new ResponseBody { Message = ex.Message } + ) { - StatusCode = 404 + StatusCode = 404, }; context.ExceptionHandled = true; break; case UnauthorizedException ex: - context.Result = new ObjectResult(new ResponseBody { Message = ex.Message }) + context.Result = new ObjectResult( + new ResponseBody { Message = ex.Message } + ) { - StatusCode = 401 + StatusCode = 401, }; context.ExceptionHandled = true; break; case ForbiddenException ex: - context.Result = new ObjectResult(new ResponseBody { Message = ex.Message }) + context.Result = new ObjectResult( + new ResponseBody { Message = ex.Message } + ) { - StatusCode = 403 + StatusCode = 403, }; context.ExceptionHandled = true; break; case Domain.Exceptions.ValidationException ex: - context.Result = new ObjectResult(new ResponseBody { Message = ex.Message }) + context.Result = new ObjectResult( + new ResponseBody { Message = ex.Message } + ) { - StatusCode = 400 + StatusCode = 400, }; context.ExceptionHandled = true; break; default: - context.Result = new ObjectResult(new ResponseBody { Message = "An unexpected error occurred" }) + context.Result = new ObjectResult( + new ResponseBody + { + Message = "An unexpected error occurred", + } + ) { - StatusCode = 500 + StatusCode = 500, }; context.ExceptionHandled = true; break; diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index ff70e6d..604ba4d 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,7 +1,11 @@ using API.Core; +using API.Core.Contracts.Common; using Domain.Exceptions; using FluentValidation; using FluentValidation.AspNetCore; +using Infrastructure.Email; +using Infrastructure.Email.Templates; +using Infrastructure.Email.Templates.Rendering; using Infrastructure.Jwt; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; @@ -9,12 +13,8 @@ using Infrastructure.Repository.Sql; using Infrastructure.Repository.UserAccount; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Service.UserManagement.User; -using API.Core.Contracts.Common; -using Infrastructure.Email; -using Infrastructure.Email.Templates; -using Infrastructure.Email.Templates.Rendering; using Service.Auth; +using Service.UserManagement.User; var builder = WebApplication.CreateBuilder(args); @@ -45,7 +45,10 @@ if (!builder.Environment.IsProduction()) // Configure Dependency Injection ------------------------------------------------------------------------------------- -builder.Services.AddSingleton(); +builder.Services.AddSingleton< + ISqlConnectionFactory, + DefaultSqlConnectionFactory +>(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -53,6 +56,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Core/API/API.Specs/API.Specs.csproj b/src/Core/API/API.Specs/API.Specs.csproj index 1a89003..e0a6e42 100644 --- a/src/Core/API/API.Specs/API.Specs.csproj +++ b/src/Core/API/API.Specs/API.Specs.csproj @@ -17,11 +17,17 @@ - + - + @@ -35,7 +41,6 @@ - + diff --git a/src/Core/API/API.Specs/Mocks/MockEmailProvider.cs b/src/Core/API/API.Specs/Mocks/MockEmailProvider.cs new file mode 100644 index 0000000..0c8b113 --- /dev/null +++ b/src/Core/API/API.Specs/Mocks/MockEmailProvider.cs @@ -0,0 +1,68 @@ +using Infrastructure.Email; + +namespace API.Specs.Mocks; + +/// +/// Mock email provider for testing that doesn't actually send emails. +/// Tracks sent emails for verification in tests if needed. +/// +public class MockEmailProvider : IEmailProvider +{ + public List SentEmails { get; } = new(); + + public Task SendAsync( + string to, + string subject, + string body, + bool isHtml = true + ) + { + SentEmails.Add( + new SentEmail + { + To = [to], + Subject = subject, + Body = body, + IsHtml = isHtml, + SentAt = DateTime.UtcNow, + } + ); + + return Task.CompletedTask; + } + + public Task SendAsync( + IEnumerable to, + string subject, + string body, + bool isHtml = true + ) + { + SentEmails.Add( + new SentEmail + { + To = to.ToList(), + Subject = subject, + Body = body, + IsHtml = isHtml, + SentAt = DateTime.UtcNow, + } + ); + + return Task.CompletedTask; + } + + public void Clear() + { + SentEmails.Clear(); + } + + public class SentEmail + { + public List To { get; init; } = new(); + public string Subject { get; init; } = string.Empty; + public string Body { get; init; } = string.Empty; + public bool IsHtml { get; init; } + public DateTime SentAt { get; init; } + } +} diff --git a/src/Core/API/API.Specs/Mocks/MockEmailService.cs b/src/Core/API/API.Specs/Mocks/MockEmailService.cs index e864a0a..7a36528 100644 --- a/src/Core/API/API.Specs/Mocks/MockEmailService.cs +++ b/src/Core/API/API.Specs/Mocks/MockEmailService.cs @@ -1,54 +1,38 @@ -using Infrastructure.Email; +using Domain.Entities; +using Service.Emails; namespace API.Specs.Mocks; -/// -/// Mock email service for testing that doesn't actually send emails. -/// Tracks sent emails for verification in tests if needed. -/// -public class MockEmailProvider : IEmailProvider +public class MockEmailService : IEmailService { - public List SentEmails { get; } = new(); + public List SentRegistrationEmails { get; } = new(); - public Task SendAsync(string to, string subject, string body, bool isHtml = true) - { - SentEmails.Add(new SentEmail - { - To = [to], - Subject = subject, - Body = body, - IsHtml = isHtml, - SentAt = DateTime.UtcNow - }); + public Task SendRegistrationEmailAsync( + UserAccount createdUser, + string confirmationToken + ) + { + SentRegistrationEmails.Add( + new RegistrationEmail + { + UserAccount = createdUser, + ConfirmationToken = confirmationToken, + SentAt = DateTime.UtcNow, + } + ); - return Task.CompletedTask; - } + return Task.CompletedTask; + } - public Task SendAsync(IEnumerable to, string subject, string body, bool isHtml = true) - { - SentEmails.Add(new SentEmail - { - To = to.ToList(), - Subject = subject, - Body = body, - IsHtml = isHtml, - SentAt = DateTime.UtcNow - }); + public void Clear() + { + SentRegistrationEmails.Clear(); + } - return Task.CompletedTask; - } - - public void Clear() - { - SentEmails.Clear(); - } - - public class SentEmail - { - public List To { get; init; } = new(); - public string Subject { get; init; } = string.Empty; - public string Body { get; init; } = string.Empty; - public bool IsHtml { get; init; } - public DateTime SentAt { get; init; } - } + public class RegistrationEmail + { + public UserAccount UserAccount { get; init; } = null!; + public string ConfirmationToken { get; init; } = string.Empty; + public DateTime SentAt { get; init; } + } } diff --git a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs index eb8d411..8821c02 100644 --- a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs +++ b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using Reqnroll; -using FluentAssertions; using API.Specs; +using FluentAssertions; +using Reqnroll; namespace API.Specs.Steps; @@ -20,7 +20,12 @@ public class ApiGeneralSteps(ScenarioContext scenario) return client; } - var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory(); + var factory = scenario.TryGetValue( + FactoryKey, + out var f + ) + ? f + : new TestApiFactory(); scenario[FactoryKey] = factory; client = factory.CreateClient(); @@ -35,13 +40,21 @@ public class ApiGeneralSteps(ScenarioContext scenario) } [When("I send an HTTP request {string} to {string} with body:")] - public async Task WhenISendAnHttpRequestStringToStringWithBody(string method, string url, string jsonBody) + 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") + Content = new StringContent( + jsonBody, + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -52,10 +65,16 @@ public class ApiGeneralSteps(ScenarioContext scenario) } [When("I send an HTTP request {string} to {string}")] - public async Task WhenISendAnHttpRequestStringToString(string method, string url) + public async Task WhenISendAnHttpRequestStringToString( + string method, + string url + ) { var client = GetClient(); - var requestMessage = new HttpRequestMessage(new HttpMethod(method), url); + var requestMessage = new HttpRequestMessage( + new HttpMethod(method), + url + ); var response = await client.SendAsync(requestMessage); var responseBody = await response.Content.ReadAsStringAsync(); @@ -66,34 +85,68 @@ public class ApiGeneralSteps(ScenarioContext scenario) [Then("the response status code should be {int}")] public void ThenTheResponseStatusCodeShouldBeInt(int expected) { - scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); + 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"); + 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) + public void ThenTheResponseJsonShouldHaveStringEqualString( + string field, + string expected + ) { - scenario.TryGetValue(ResponseKey, out var response).Should().BeTrue(); - scenario.TryGetValue(ResponseBodyKey, out var responseBody).Should().BeTrue(); + 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); + 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 + .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/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index adf145f..bfaabe0 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using Reqnroll; -using FluentAssertions; using API.Specs; +using FluentAssertions; +using Reqnroll; namespace API.Specs.Steps; @@ -21,7 +21,12 @@ public class AuthSteps(ScenarioContext scenario) return client; } - var factory = scenario.TryGetValue(FactoryKey, out var f) ? f : new TestApiFactory(); + var factory = scenario.TryGetValue( + FactoryKey, + out var f + ) + ? f + : new TestApiFactory(); scenario[FactoryKey] = factory; client = factory.CreateClient(); @@ -45,15 +50,25 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() { var client = GetClient(); - var (username, password) = scenario.TryGetValue<(string username, string password)>(TestUserKey, out var user) + 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") + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/login" + ) { - Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + body, + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -69,9 +84,16 @@ public class AuthSteps(ScenarioContext scenario) var client = GetClient(); var body = JsonSerializer.Serialize(new { password = "test" }); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/login" + ) { - Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + body, + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -87,9 +109,16 @@ public class AuthSteps(ScenarioContext scenario) var client = GetClient(); var body = JsonSerializer.Serialize(new { username = "test" }); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/login" + ) { - Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + body, + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -103,9 +132,16 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() { var client = GetClient(); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/login" + ) { - Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + "{}", + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -118,37 +154,55 @@ public class AuthSteps(ScenarioContext scenario) [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(); + 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) + if ( + root.TryGetProperty("payload", out var payloadElem) + && payloadElem.ValueKind == JsonValueKind.Object + ) { - hasToken = payloadElem.TryGetProperty("accessToken", out tokenElem) - || payloadElem.TryGetProperty("AccessToken", out tokenElem); + 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'"); + 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") + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/auth/login" + ) { - Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + "{}", + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -184,14 +238,21 @@ public class AuthSteps(ScenarioContext scenario) lastName, email, dateOfBirth, - password + password, }; var body = JsonSerializer.Serialize(registrationData); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/register") + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/register" + ) { - Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + body, + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); @@ -205,9 +266,16 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitARegistrationRequestUsingAGetRequest() { var client = GetClient(); - var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/api/auth/register") + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/auth/register" + ) { - Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") + Content = new StringContent( + "{}", + System.Text.Encoding.UTF8, + "application/json" + ), }; var response = await client.SendAsync(requestMessage); diff --git a/src/Core/API/API.Specs/TestApiFactory.cs b/src/Core/API/API.Specs/TestApiFactory.cs index ad63065..2b32445 100644 --- a/src/Core/API/API.Specs/TestApiFactory.cs +++ b/src/Core/API/API.Specs/TestApiFactory.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Service.Emails; namespace API.Specs { @@ -16,16 +17,29 @@ namespace API.Specs builder.ConfigureServices(services => { - // Replace the real email service with mock for testing - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(IEmailProvider)); + // Replace the real email provider with mock for testing + var emailProviderDescriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IEmailProvider) + ); - if (descriptor != null) + if (emailProviderDescriptor != null) { - services.Remove(descriptor); + services.Remove(emailProviderDescriptor); } services.AddScoped(); + + // Replace the real email service with mock for testing + var emailServiceDescriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IEmailService) + ); + + if (emailServiceDescriptor != null) + { + services.Remove(emailServiceDescriptor); + } + + services.AddScoped(); }); } } diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx index a088dcd..4d6a6f1 100644 --- a/src/Core/Core.slnx +++ b/src/Core/Core.slnx @@ -21,6 +21,7 @@ + diff --git a/src/Core/Service/Service.Auth.Tests/Dockerfile b/src/Core/Service/Service.Auth.Tests/Dockerfile new file mode 100644 index 0000000..0d67ee9 --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] +COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] +COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] +COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] +COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] +COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] +COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] +COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"] +COPY ["Service/Service.Auth.Tests/Service.Auth.Tests.csproj", "Service/Service.Auth.Tests/"] +RUN dotnet restore "Service/Service.Auth.Tests/Service.Auth.Tests.csproj" +COPY . . +WORKDIR "/src/Service/Service.Auth.Tests" +RUN dotnet build "./Service.Auth.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS final +RUN mkdir -p /app/test-results/service-auth-tests +WORKDIR /src/Service/Service.Auth.Tests +ENTRYPOINT ["dotnet", "test", "./Service.Auth.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/service-auth-tests/results.trx"] diff --git a/src/Core/Service/Service.Auth.Tests/LoginService.test.cs b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs index b266d1e..12f2e20 100644 --- a/src/Core/Service/Service.Auth.Tests/LoginService.test.cs +++ b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs @@ -11,15 +11,18 @@ public class LoginServiceTest { private readonly Mock _authRepoMock; private readonly Mock _passwordInfraMock; + private readonly Mock _tokenServiceMock; private readonly LoginService _loginService; public LoginServiceTest() { _authRepoMock = new Mock(); _passwordInfraMock = new Mock(); + _tokenServiceMock = new Mock(); _loginService = new LoginService( _authRepoMock.Object, - _passwordInfraMock.Object + _passwordInfraMock.Object, + _tokenServiceMock.Object ); } @@ -63,13 +66,26 @@ public class LoginServiceTest .Setup(x => x.Verify(It.IsAny(), It.IsAny())) .Returns(true); + _tokenServiceMock + .Setup(x => x.GenerateAccessToken(It.IsAny())) + .Returns("access-token"); + + _tokenServiceMock + .Setup(x => x.GenerateRefreshToken(It.IsAny())) + .Returns("refresh-token"); + // Act - var result = await _loginService.LoginAsync(username, It.IsAny()); + var result = await _loginService.LoginAsync( + username, + It.IsAny() + ); // Assert result.Should().NotBeNull(); - result.UserAccountId.Should().Be(userAccountId); - result.Username.Should().Be(username); + result.UserAccount.UserAccountId.Should().Be(userAccountId); + result.UserAccount.Username.Should().Be(username); + result.AccessToken.Should().Be("access-token"); + result.RefreshToken.Should().Be("refresh-token"); _authRepoMock.Verify( x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), diff --git a/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs index 2b909bc..3f71644 100644 --- a/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs +++ b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs @@ -1,11 +1,10 @@ using Domain.Entities; using Domain.Exceptions; using FluentAssertions; -using Infrastructure.Email; -using Infrastructure.Email.Templates.Rendering; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; using Moq; +using Service.Emails; namespace Service.Auth.Tests; @@ -13,27 +12,27 @@ public class RegisterServiceTest { private readonly Mock _authRepoMock; private readonly Mock _passwordInfraMock; - private readonly Mock _emailProviderMock; - private readonly Mock _emailTemplateProviderMock; + private readonly Mock _tokenServiceMock; + private readonly Mock _emailServiceMock; // todo handle email related test cases here private readonly RegisterService _registerService; public RegisterServiceTest() { _authRepoMock = new Mock(); _passwordInfraMock = new Mock(); - _emailProviderMock = new Mock(); - _emailTemplateProviderMock = new Mock(); + _tokenServiceMock = new Mock(); + _emailServiceMock = new Mock(); _registerService = new RegisterService( _authRepoMock.Object, _passwordInfraMock.Object, - _emailProviderMock.Object, - _emailTemplateProviderMock.Object + _tokenServiceMock.Object, + _emailServiceMock.Object ); } [Fact] - public async Task RegisterAsync_WithValidData_CreatesUserAndSendsEmail() + public async Task RegisterAsync_WithValidData_CreatesUserAndReturnsAuthServiceReturn() { // Arrange var userAccount = new UserAccount @@ -48,7 +47,6 @@ public class RegisterServiceTest const string password = "SecurePassword123!"; const string hashedPassword = "hashed_password_value"; var expectedUserId = Guid.NewGuid(); - const string expectedEmailHtml = "Welcome!"; // Mock: No existing user _authRepoMock @@ -89,36 +87,28 @@ public class RegisterServiceTest } ); - // Mock: Email template rendering - _emailTemplateProviderMock - .Setup(x => - x.RenderUserRegisteredEmailAsync( - userAccount.FirstName, - It.IsAny() - ) - ) - .ReturnsAsync(expectedEmailHtml); + // Mock: Token generation + _tokenServiceMock + .Setup(x => x.GenerateAccessToken(It.IsAny())) + .Returns("access-token"); - // Mock: Email sending - _emailProviderMock - .Setup(x => - x.SendAsync( - userAccount.Email, - "Welcome to The Biergarten App!", - expectedEmailHtml, - true - ) - ) - .Returns(Task.CompletedTask); + _tokenServiceMock + .Setup(x => x.GenerateRefreshToken(It.IsAny())) + .Returns("refresh-token"); // Act - var result = await _registerService.RegisterAsync(userAccount, password); + var result = await _registerService.RegisterAsync( + userAccount, + password + ); // Assert result.Should().NotBeNull(); - result.UserAccountId.Should().Be(expectedUserId); - result.Username.Should().Be(userAccount.Username); - result.Email.Should().Be(userAccount.Email); + result.UserAccount.UserAccountId.Should().Be(expectedUserId); + result.UserAccount.Username.Should().Be(userAccount.Username); + result.UserAccount.Email.Should().Be(userAccount.Email); + result.AccessToken.Should().Be("access-token"); + result.RefreshToken.Should().Be("refresh-token"); // Verify all mocks were called as expected _authRepoMock.Verify( @@ -142,24 +132,14 @@ public class RegisterServiceTest ), Times.Once ); - _emailTemplateProviderMock.Verify( + _emailServiceMock.Verify( x => - x.RenderUserRegisteredEmailAsync( - userAccount.FirstName, + x.SendRegistrationEmailAsync( + It.IsAny(), It.IsAny() ), Times.Once ); - _emailProviderMock.Verify( - x => - x.SendAsync( - userAccount.Email, - "Welcome to The Biergarten App!", - expectedEmailHtml, - true - ), - Times.Once - ); } [Fact] @@ -195,7 +175,8 @@ public class RegisterServiceTest .ReturnsAsync((UserAccount?)null); // Act - var act = async () => await _registerService.RegisterAsync(userAccount, password); + var act = async () => + await _registerService.RegisterAsync(userAccount, password); // Assert await act.Should() @@ -215,18 +196,6 @@ public class RegisterServiceTest ), Times.Never ); - - // Verify email was never sent - _emailProviderMock.Verify( - x => - x.SendAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ), - Times.Never - ); } [Fact] @@ -262,7 +231,8 @@ public class RegisterServiceTest .ReturnsAsync(existingUser); // Act - var act = async () => await _registerService.RegisterAsync(userAccount, password); + var act = async () => + await _registerService.RegisterAsync(userAccount, password); // Assert await act.Should() @@ -323,14 +293,13 @@ public class RegisterServiceTest ) .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); - _emailTemplateProviderMock - .Setup(x => - x.RenderUserRegisteredEmailAsync( - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(""); + _tokenServiceMock + .Setup(x => x.GenerateAccessToken(It.IsAny())) + .Returns("access-token"); + + _tokenServiceMock + .Setup(x => x.GenerateRefreshToken(It.IsAny())) + .Returns("refresh-token"); // Act await _registerService.RegisterAsync(userAccount, plainPassword); @@ -350,152 +319,4 @@ public class RegisterServiceTest Times.Once ); } - - [Fact] - public async Task RegisterAsync_EmailConfirmationLink_ContainsUserEmail() - { - // Arrange - var userAccount = new UserAccount - { - Username = "testuser", - FirstName = "Test", - LastName = "User", - Email = "test@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - var password = "Password123!"; - string? capturedConfirmationLink = null; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - - _passwordInfraMock - .Setup(x => x.Hash(It.IsAny())) - .Returns("hashed"); - - _authRepoMock - .Setup(x => - x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync( - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = userAccount.Username, - FirstName = userAccount.FirstName, - LastName = userAccount.LastName, - Email = userAccount.Email, - DateOfBirth = userAccount.DateOfBirth, - } - ); - - _emailTemplateProviderMock - .Setup(x => - x.RenderUserRegisteredEmailAsync( - It.IsAny(), - It.IsAny() - ) - ) - .Callback( - (_, link) => capturedConfirmationLink = link - ) - .ReturnsAsync(""); - - // Act - await _registerService.RegisterAsync(userAccount, password); - - // Assert - capturedConfirmationLink.Should().NotBeNull(); - capturedConfirmationLink - .Should() - .Contain(Uri.EscapeDataString(userAccount.Email)); - } - - [Fact] - public async Task RegisterAsync_WhenEmailSendingFails_ExceptionPropagates() - { - // Arrange - var userAccount = new UserAccount - { - Username = "testuser", - FirstName = "Test", - LastName = "User", - Email = "test@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - var password = "Password123!"; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - - _passwordInfraMock - .Setup(x => x.Hash(It.IsAny())) - .Returns("hashed"); - - _authRepoMock - .Setup(x => - x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync( - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Email = userAccount.Email, - } - ); - - _emailTemplateProviderMock - .Setup(x => - x.RenderUserRegisteredEmailAsync( - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(""); - - _emailProviderMock - .Setup(x => - x.SendAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) - ) - .ThrowsAsync( - new InvalidOperationException("SMTP server unavailable") - ); - - // Act - var act = async () => await _registerService.RegisterAsync(userAccount, password); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("SMTP server unavailable"); - } } diff --git a/src/Core/Service/Service.Auth/ILoginService.cs b/src/Core/Service/Service.Auth/ILoginService.cs index f0bbf84..9554a36 100644 --- a/src/Core/Service/Service.Auth/ILoginService.cs +++ b/src/Core/Service/Service.Auth/ILoginService.cs @@ -4,5 +4,5 @@ namespace Service.Auth; public interface ILoginService { - Task LoginAsync(string username, string password); + Task LoginAsync(string username, string password); } diff --git a/src/Core/Service/Service.Auth/IRegisterService.cs b/src/Core/Service/Service.Auth/IRegisterService.cs index efb05b1..3f1fae6 100644 --- a/src/Core/Service/Service.Auth/IRegisterService.cs +++ b/src/Core/Service/Service.Auth/IRegisterService.cs @@ -2,7 +2,38 @@ using Domain.Entities; namespace Service.Auth; +public record RegisterServiceReturn +{ + public bool IsAuthenticated { get; init; } = false; + public bool EmailSent { get; init; } = false; + public UserAccount UserAccount { get; init; } + public string AccessToken { get; init; } = string.Empty; + public string RefreshToken { get; init; } = string.Empty; + + public RegisterServiceReturn( + UserAccount userAccount, + string accessToken, + string refreshToken, + bool emailSent + ) + { + IsAuthenticated = true; + EmailSent = emailSent; + UserAccount = userAccount; + AccessToken = accessToken; + RefreshToken = refreshToken; + } + + public RegisterServiceReturn(UserAccount userAccount) + { + UserAccount = userAccount; + } +} + public interface IRegisterService { - Task RegisterAsync(UserAccount userAccount, string password); + Task RegisterAsync( + UserAccount userAccount, + string password + ); } diff --git a/src/Core/Service/Service.Auth/ITokenService.cs b/src/Core/Service/Service.Auth/ITokenService.cs new file mode 100644 index 0000000..3d2f0f8 --- /dev/null +++ b/src/Core/Service/Service.Auth/ITokenService.cs @@ -0,0 +1,34 @@ +using Domain.Entities; +using Infrastructure.Jwt; + +namespace Service.Auth; + +public interface ITokenService +{ + public string GenerateAccessToken(UserAccount user); + public string GenerateRefreshToken(UserAccount user); +} + +public class TokenService(ITokenInfrastructure tokenInfrastructure) + : ITokenService +{ + public string GenerateAccessToken(UserAccount userAccount) + { + var jwtExpiresAt = DateTime.UtcNow.AddHours(1); + return tokenInfrastructure.GenerateJwt( + userAccount.UserAccountId, + userAccount.Username, + jwtExpiresAt + ); + } + + public string GenerateRefreshToken(UserAccount userAccount) + { + var jwtExpiresAt = DateTime.UtcNow.AddDays(21); + return tokenInfrastructure.GenerateJwt( + userAccount.UserAccountId, + userAccount.Username, + jwtExpiresAt + ); + } +} diff --git a/src/Core/Service/Service.Auth/LoginService.cs b/src/Core/Service/Service.Auth/LoginService.cs index 2cb0902..4cd0bb3 100644 --- a/src/Core/Service/Service.Auth/LoginService.cs +++ b/src/Core/Service/Service.Auth/LoginService.cs @@ -5,30 +5,42 @@ using Infrastructure.Repository.Auth; namespace Service.Auth; +public record LoginServiceReturn( + UserAccount UserAccount, + string RefreshToken, + string AccessToken +); + public class LoginService( IAuthRepository authRepo, - IPasswordInfrastructure passwordInfrastructure + IPasswordInfrastructure passwordInfrastructure, + ITokenService tokenService ) : ILoginService { - - public async Task LoginAsync(string username, string password) + 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) - throw new UnauthorizedException("Invalid username or password."); + var user = + await authRepo.GetUserByUsernameAsync(username) + ?? throw new UnauthorizedException("Invalid username or password."); // @todo handle expired passwords - var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); - - if (activeCred is null) - throw new UnauthorizedException("Invalid username or password."); + var activeCred = + await authRepo.GetActiveCredentialByUserAccountIdAsync( + user.UserAccountId + ) + ?? throw new UnauthorizedException("Invalid username or password."); if (!passwordInfrastructure.Verify(password, activeCred.Hash)) throw new UnauthorizedException("Invalid username or password."); - return user; + string accessToken = tokenService.GenerateAccessToken(user); + string refreshToken = tokenService.GenerateRefreshToken(user); + + return new LoginServiceReturn(user, refreshToken, accessToken); } } diff --git a/src/Core/Service/Service.Auth/RegisterService.cs b/src/Core/Service/Service.Auth/RegisterService.cs index 3554005..89e33ba 100644 --- a/src/Core/Service/Service.Auth/RegisterService.cs +++ b/src/Core/Service/Service.Auth/RegisterService.cs @@ -4,28 +4,40 @@ using Infrastructure.Email; using Infrastructure.Email.Templates.Rendering; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; +using Microsoft.Extensions.Logging; +using Service.Emails; namespace Service.Auth; public class RegisterService( IAuthRepository authRepo, IPasswordInfrastructure passwordInfrastructure, - IEmailProvider emailProvider, - IEmailTemplateProvider emailTemplateProvider + ITokenService tokenService, + IEmailService emailService ) : IRegisterService { - public async Task RegisterAsync(UserAccount userAccount, string password) + private async Task ValidateUserDoesNotExist(UserAccount userAccount) { // Check if user already exists - var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username); - var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email); + var existingUsername = await authRepo.GetUserByUsernameAsync( + userAccount.Username + ); + var existingEmail = await authRepo.GetUserByEmailAsync( + userAccount.Email + ); if (existingUsername != null || existingEmail != null) { throw new ConflictException("Username or email already exists"); } + } - + public async Task RegisterAsync( + UserAccount userAccount, + string password + ) + { + await ValidateUserDoesNotExist(userAccount); // password hashing var hashed = passwordInfrastructure.Hash(password); @@ -36,26 +48,41 @@ public class RegisterService( userAccount.LastName, userAccount.Email, userAccount.DateOfBirth, - hashed); - - - // Generate confirmation link (TODO: implement proper token-based confirmation) - var confirmationLink = $"https://thebiergarten.app/confirm?email={Uri.EscapeDataString(createdUser.Email)}"; - - // Render email template - var emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync( - createdUser.FirstName, - confirmationLink + hashed ); - // Send welcome email with rendered template - await emailProvider.SendAsync( - createdUser.Email, - "Welcome to The Biergarten App!", - emailHtml, - isHtml: true - ); + var accessToken = tokenService.GenerateAccessToken(createdUser); + var refreshToken = tokenService.GenerateRefreshToken(createdUser); - return createdUser; + if ( + string.IsNullOrEmpty(accessToken) + || string.IsNullOrEmpty(refreshToken) + ) + { + return new RegisterServiceReturn(createdUser); + } + + bool emailSent = false; + try + { + // send confirmation email + await emailService.SendRegistrationEmailAsync( + createdUser, + "some-confirmation-token" + ); + + emailSent = true; + } + catch + { + // ignored + } + + return new RegisterServiceReturn( + createdUser, + accessToken, + refreshToken, + emailSent + ); } } diff --git a/src/Core/Service/Service.Auth/Service.Auth.csproj b/src/Core/Service/Service.Auth/Service.Auth.csproj index b7efbea..fd88236 100644 --- a/src/Core/Service/Service.Auth/Service.Auth.csproj +++ b/src/Core/Service/Service.Auth/Service.Auth.csproj @@ -5,20 +5,14 @@ enable - - - - - - - - + + + + + + diff --git a/src/Core/Service/Service.Emails/EmailService.cs b/src/Core/Service/Service.Emails/EmailService.cs new file mode 100644 index 0000000..7532769 --- /dev/null +++ b/src/Core/Service/Service.Emails/EmailService.cs @@ -0,0 +1,41 @@ +using Domain.Entities; +using Infrastructure.Email; +using Infrastructure.Email.Templates.Rendering; + +namespace Service.Emails; + +public interface IEmailService +{ + public Task SendRegistrationEmailAsync( + UserAccount createdUser, + string confirmationToken + ); +} + +public class EmailService( + IEmailProvider emailProvider, + IEmailTemplateProvider emailTemplateProvider +) : IEmailService +{ + public async Task SendRegistrationEmailAsync( + UserAccount createdUser, + string confirmationToken + ) + { + var confirmationLink = + $"https://thebiergarten.app/confirm?token={confirmationToken}"; + + var emailHtml = + await emailTemplateProvider.RenderUserRegisteredEmailAsync( + createdUser.FirstName, + confirmationLink + ); + + await emailProvider.SendAsync( + createdUser.Email, + "Welcome to The Biergarten App!", + emailHtml, + isHtml: true + ); + } +} diff --git a/src/Core/Service/Service.Emails/Service.Emails.csproj b/src/Core/Service/Service.Emails/Service.Emails.csproj new file mode 100644 index 0000000..2cdcb2a --- /dev/null +++ b/src/Core/Service/Service.Emails/Service.Emails.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj index b26eb31..173c0b8 100644 --- a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj +++ b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj @@ -1,15 +1,12 @@  + + net10.0 + enable + enable + - - net10.0 - enable - enable - - - - - - - + + + + diff --git a/src/Core/Service/Service.UserManagement/User/IUserService.cs b/src/Core/Service/Service.UserManagement/User/IUserService.cs index ab66525..75b30e5 100644 --- a/src/Core/Service/Service.UserManagement/User/IUserService.cs +++ b/src/Core/Service/Service.UserManagement/User/IUserService.cs @@ -4,7 +4,10 @@ namespace Service.UserManagement.User; public interface IUserService { - Task> GetAllAsync(int? limit = null, int? offset = null); + Task> GetAllAsync( + int? limit = null, + int? offset = null + ); Task GetByIdAsync(Guid id); Task UpdateAsync(UserAccount userAccount); diff --git a/src/Core/Service/Service.UserManagement/User/UserService.cs b/src/Core/Service/Service.UserManagement/User/UserService.cs index 43a7816..a767477 100644 --- a/src/Core/Service/Service.UserManagement/User/UserService.cs +++ b/src/Core/Service/Service.UserManagement/User/UserService.cs @@ -6,7 +6,10 @@ namespace Service.UserManagement.User; public class UserService(IUserAccountRepository repository) : IUserService { - public async Task> GetAllAsync(int? limit = null, int? offset = null) + public async Task> GetAllAsync( + int? limit = null, + int? offset = null + ) { return await repository.GetAllAsync(limit, offset); }