From 0ab2eaaec998b4b9b15eb5c1fb63c3c35dae3ebf Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 21 Feb 2026 20:44:49 -0500 Subject: [PATCH] Begin work on user confirmation workflow --- .env.example | 4 +- docker-compose.dev.yaml | 28 +++--- docker-compose.min.yaml | 91 +++++++++++++++++++ docker-compose.test.yaml | 6 +- .../API/API.Core/Contracts/Auth/AuthDTO.cs | 4 + .../API.Core/Controllers/AuthController.cs | 24 ++++- src/Core/API/API.Core/Program.cs | 1 + .../ITokenInfrastructure.cs | 7 +- .../Infrastructure.Jwt/JwtInfrastructure.cs | 13 +-- .../Service.Auth/IConfirmationService.cs | 21 +++++ .../Service/Service.Auth/ILoginService.cs | 5 + .../Service/Service.Auth/IRegisterService.cs | 2 +- .../Service/Service.Auth/ITokenService.cs | 53 ++++++++++- src/Core/Service/Service.Auth/LoginService.cs | 5 - .../Service/Service.Auth/RegisterService.cs | 10 +- 15 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 docker-compose.min.yaml create mode 100644 src/Core/Service/Service.Auth/IConfirmationService.cs diff --git a/.env.example b/.env.example index 80880ae..9204f19 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,9 @@ DB_PASSWORD=YourStrong!Passw0rd # JWT Secret for signing tokens # IMPORTANT: Generate a secure secret (minimum 32 characters) # Command: openssl rand -base64 32 -JWT_SECRET=128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR +ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key +REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key +CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key # ====================== diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 0fd96a4..6593c7e 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -13,7 +13,7 @@ services: volumes: - sqlserverdata-dev:/var/opt/mssql healthcheck: - test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"] + test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ] interval: 10s timeout: 5s retries: 12 @@ -91,7 +91,9 @@ services: DB_NAME: "${DB_NAME}" DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" - JWT_SECRET: "${JWT_SECRET}" + ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" + REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" + CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" restart: unless-stopped networks: - devnet @@ -99,18 +101,18 @@ services: - nuget-cache-dev:/root/.nuget/packages mailpit: - image: axllent/mailpit:latest - container_name: dev-env-mailpit - ports: - - "8025:8025" # Web UI - - "1025:1025" # SMTP server + image: axllent/mailpit:latest + container_name: dev-env-mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP server - restart: unless-stopped - environment: - MP_SMTP_AUTH_ACCEPT_ANY: 1 - MP_SMTP_AUTH_ALLOW_INSECURE: 1 - networks: - - devnet + restart: unless-stopped + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + networks: + - devnet volumes: sqlserverdata-dev: driver: local diff --git a/docker-compose.min.yaml b/docker-compose.min.yaml new file mode 100644 index 0000000..2b580dc --- /dev/null +++ b/docker-compose.min.yaml @@ -0,0 +1,91 @@ +services: + sqlserver: + env_file: ".env.local" + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + container_name: dev-env-sqlserver + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "${DB_PASSWORD}" + MSSQL_PID: "Express" + ports: + - "1433:1433" + volumes: + - sqlserverdata-dev:/var/opt/mssql + healthcheck: + test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + networks: + - devnet + database.migrations: + env_file: ".env.local" + image: database.migrations + container_name: dev-env-database-migrations + depends_on: + sqlserver: + condition: service_healthy + build: + context: ./src/Core/Database + dockerfile: Database.Migrations/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_SERVER: "${DB_SERVER}" + DB_NAME: "${DB_NAME}" + DB_USER: "${DB_USER}" + DB_PASSWORD: "${DB_PASSWORD}" + CLEAR_DATABASE: "true" + restart: "no" + networks: + - devnet + + mailpit: + image: axllent/mailpit:latest + container_name: dev-env-mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP server + + restart: unless-stopped + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + networks: + - devnet + + database.seed: + env_file: ".env.local" + image: database.seed + container_name: dev-env-database-seed + depends_on: + database.migrations: + condition: service_completed_successfully + build: + context: ./src/Core + dockerfile: Database/Database.Seed/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_SERVER: "${DB_SERVER}" + DB_NAME: "${DB_NAME}" + DB_USER: "${DB_USER}" + DB_PASSWORD: "${DB_PASSWORD}" + restart: "no" + networks: + - devnet +volumes: + sqlserverdata-dev: + driver: local + nuget-cache-dev: + driver: local + +networks: + devnet: + driver: bridge diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 1704e87..6162b28 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -12,7 +12,7 @@ services: volumes: - sqlserverdata-test:/var/opt/mssql healthcheck: - test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"] + test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ] interval: 10s timeout: 5s retries: 12 @@ -85,7 +85,9 @@ services: DB_NAME: "${DB_NAME}" DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" - JWT_SECRET: "${JWT_SECRET}" + ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" + REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" + CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" volumes: - ./test-results:/app/test-results restart: "no" diff --git a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs index a544258..849f4e1 100644 --- a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs +++ b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs @@ -17,3 +17,7 @@ public record RegistrationPayload( string AccessToken, bool ConfirmationEmailSent ); + +public record ConfirmationPayload( + Guid UserAccountId, + DateTime ConfirmedDate); diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 4214a32..16ae1f8 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -8,7 +8,10 @@ namespace API.Core.Controllers { [ApiController] [Route("api/[controller]")] - public class AuthController(IRegisterService register, ILoginService login) + public class AuthController( + IRegisterService registerService, + ILoginService loginService, + IConfirmationService confirmationService) : ControllerBase { [HttpPost("register")] @@ -16,7 +19,7 @@ namespace API.Core.Controllers [FromBody] RegisterRequest req ) { - var rtn = await register.RegisterAsync( + var rtn = await registerService.RegisterAsync( new UserAccount { UserAccountId = Guid.Empty, @@ -46,7 +49,7 @@ namespace API.Core.Controllers [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) { - var rtn = await login.LoginAsync(req.Username, req.Password); + var rtn = await loginService.LoginAsync(req.Username, req.Password); return Ok( new ResponseBody @@ -61,5 +64,18 @@ namespace API.Core.Controllers } ); } + + [HttpPost("confirm")] + public async Task Confirm([FromQuery] string token) + { + var rtn = await confirmationService.ConfirmUserAsync(token); + return Ok(new ResponseBody + { + Message = "User with ID " + rtn.userId + " is confirmed.", + Payload = new ConfirmationPayload( + rtn.userId, rtn.confirmedAt + ) + }); + } } -} +} \ No newline at end of file diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index ca0b67d..07dce8f 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register the exception filter builder.Services.AddScoped(); diff --git a/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs b/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs index 7929da0..6e6b9f1 100644 --- a/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs +++ b/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs @@ -2,5 +2,10 @@ namespace Infrastructure.Jwt; public interface ITokenInfrastructure { - string GenerateJwt(Guid userId, string username, DateTime expiry); + string GenerateJwt( + Guid userId, + string username, + DateTime expiry, + string secret + ); } diff --git a/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs b/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs index 1b093c8..2f3ce9d 100644 --- a/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs +++ b/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs @@ -8,16 +8,17 @@ namespace Infrastructure.Jwt; public class JwtInfrastructure : ITokenInfrastructure { - private readonly string? _secret = Environment.GetEnvironmentVariable( - "JWT_SECRET" - ); - - public string GenerateJwt(Guid userId, string username, DateTime expiry) + public string GenerateJwt( + Guid userId, + string username, + DateTime expiry, + string secret + ) { var handler = new JsonWebTokenHandler(); var key = Encoding.UTF8.GetBytes( - _secret ?? throw new InvalidOperationException("secret not set") + secret ?? throw new InvalidOperationException("secret not set") ); // Base claims (always present) diff --git a/src/Core/Service/Service.Auth/IConfirmationService.cs b/src/Core/Service/Service.Auth/IConfirmationService.cs new file mode 100644 index 0000000..58afbdd --- /dev/null +++ b/src/Core/Service/Service.Auth/IConfirmationService.cs @@ -0,0 +1,21 @@ +using System.Runtime.InteropServices.JavaScript; +using Infrastructure.Repository.Auth; + +namespace Service.Auth; + +public record ConfirmationServiceReturn(DateTime confirmedAt, Guid userId); + +public interface IConfirmationService +{ + Task ConfirmUserAsync(string confirmationToken); +} + + +public class ConfirmationService(IAuthRepository authRepository) : IConfirmationService +{ + + public async Task ConfirmUserAsync(string confirmationToken) + { + return new ConfirmationServiceReturn(DateTime.Now, Guid.NewGuid()); + } +} \ No newline at end of file diff --git a/src/Core/Service/Service.Auth/ILoginService.cs b/src/Core/Service/Service.Auth/ILoginService.cs index 9554a36..fc39dfc 100644 --- a/src/Core/Service/Service.Auth/ILoginService.cs +++ b/src/Core/Service/Service.Auth/ILoginService.cs @@ -2,6 +2,11 @@ using Domain.Entities; namespace Service.Auth; +public record LoginServiceReturn( + UserAccount UserAccount, + string RefreshToken, + string AccessToken +); public interface ILoginService { 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 3f1fae6..8c0dccc 100644 --- a/src/Core/Service/Service.Auth/IRegisterService.cs +++ b/src/Core/Service/Service.Auth/IRegisterService.cs @@ -36,4 +36,4 @@ public interface IRegisterService UserAccount userAccount, string password ); -} +} \ No newline at end of file diff --git a/src/Core/Service/Service.Auth/ITokenService.cs b/src/Core/Service/Service.Auth/ITokenService.cs index 3d2f0f8..81b0f4a 100644 --- a/src/Core/Service/Service.Auth/ITokenService.cs +++ b/src/Core/Service/Service.Auth/ITokenService.cs @@ -7,28 +7,73 @@ public interface ITokenService { public string GenerateAccessToken(UserAccount user); public string GenerateRefreshToken(UserAccount user); + public string GenerateConfirmationToken(UserAccount user); +} + +public static class TokenServiceExpirationHours +{ + public const double AccessTokenHours = 1; + public const double RefreshTokenHours = 504; // 21 days + public const double ConfirmationTokenHours = 0.5; // 30 minutes } public class TokenService(ITokenInfrastructure tokenInfrastructure) : ITokenService { + private readonly string _accessTokenSecret = + Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET") + ?? throw new InvalidOperationException( + "ACCESS_TOKEN_SECRET environment variable is not set" + ); + + private readonly string _refreshTokenSecret = + Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET") + ?? throw new InvalidOperationException( + "REFRESH_TOKEN_SECRET environment variable is not set" + ); + + private readonly string _confirmationTokenSecret = + Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET") + ?? throw new InvalidOperationException( + "CONFIRMATION_TOKEN_SECRET environment variable is not set" + ); + public string GenerateAccessToken(UserAccount userAccount) { - var jwtExpiresAt = DateTime.UtcNow.AddHours(1); + var jwtExpiresAt = DateTime.UtcNow.AddHours( + TokenServiceExpirationHours.AccessTokenHours + ); return tokenInfrastructure.GenerateJwt( userAccount.UserAccountId, userAccount.Username, - jwtExpiresAt + jwtExpiresAt, + _accessTokenSecret ); } public string GenerateRefreshToken(UserAccount userAccount) { - var jwtExpiresAt = DateTime.UtcNow.AddDays(21); + var jwtExpiresAt = DateTime.UtcNow.AddHours( + TokenServiceExpirationHours.RefreshTokenHours + ); return tokenInfrastructure.GenerateJwt( userAccount.UserAccountId, userAccount.Username, - jwtExpiresAt + jwtExpiresAt, + _refreshTokenSecret + ); + } + + public string GenerateConfirmationToken(UserAccount userAccount) + { + var jwtExpiresAt = DateTime.UtcNow.AddHours( + TokenServiceExpirationHours.ConfirmationTokenHours + ); + return tokenInfrastructure.GenerateJwt( + userAccount.UserAccountId, + userAccount.Username, + jwtExpiresAt, + _confirmationTokenSecret ); } } diff --git a/src/Core/Service/Service.Auth/LoginService.cs b/src/Core/Service/Service.Auth/LoginService.cs index 4cd0bb3..892977b 100644 --- a/src/Core/Service/Service.Auth/LoginService.cs +++ b/src/Core/Service/Service.Auth/LoginService.cs @@ -5,11 +5,6 @@ using Infrastructure.Repository.Auth; namespace Service.Auth; -public record LoginServiceReturn( - UserAccount UserAccount, - string RefreshToken, - string AccessToken -); public class LoginService( IAuthRepository authRepo, diff --git a/src/Core/Service/Service.Auth/RegisterService.cs b/src/Core/Service/Service.Auth/RegisterService.cs index 89e33ba..13b157c 100644 --- a/src/Core/Service/Service.Auth/RegisterService.cs +++ b/src/Core/Service/Service.Auth/RegisterService.cs @@ -53,6 +53,7 @@ public class RegisterService( var accessToken = tokenService.GenerateAccessToken(createdUser); var refreshToken = tokenService.GenerateRefreshToken(createdUser); + var confirmationToken = tokenService.GenerateConfirmationToken(createdUser); if ( string.IsNullOrEmpty(accessToken) @@ -67,14 +68,15 @@ public class RegisterService( { // send confirmation email await emailService.SendRegistrationEmailAsync( - createdUser, - "some-confirmation-token" + createdUser, confirmationToken ); emailSent = true; } - catch + catch (Exception ex) { + await Console.Error.WriteLineAsync(ex.Message); + Console.WriteLine("Could not send email."); // ignored } @@ -85,4 +87,4 @@ public class RegisterService( emailSent ); } -} +} \ No newline at end of file