diff --git a/.csharpierrc.json b/.csharpierrc.json index 0e17843..afd1cb3 100644 --- a/.csharpierrc.json +++ b/.csharpierrc.json @@ -1,9 +1,19 @@ { + "$schema": "https://json.schemastore.org/csharpier.json", + "printWidth": 80, "useTabs": false, - "tabWidth": 4, - "endOfLine": "auto", - "indentStyle": "space", - "lineEndings": "auto", - "wrapLineLength": 80 + "indentSize": 4, + "endOfLine": "lf", + + "overrides": [ + { + "files": "*.xml", + "indentSize": 2 + }, + { + "files": "*.csx", + "printWidth": 80 + } + ] } 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.prod.yaml b/docker-compose.prod.yaml index d41d268..b8926d2 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -66,7 +66,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: - prodnet 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/docs/environment-variables.md b/docs/environment-variables.md index 1578e4a..90adb68 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -58,38 +58,52 @@ built from components. **Implementation**: See `DefaultSqlConnectionFactory.cs` -### JWT Authentication +### JWT Authentication Secrets (Backend) + +The backend uses separate secrets for different token types to enable independent key rotation and validation isolation. ```bash -JWT_SECRET=your-secret-key-minimum-32-characters-required +# Access token secret (1-hour tokens) +ACCESS_TOKEN_SECRET= # Signs short-lived access tokens + +# Refresh token secret (21-day tokens) +REFRESH_TOKEN_SECRET= # Signs long-lived refresh tokens + +# Confirmation token secret (30-minute tokens) +CONFIRMATION_TOKEN_SECRET= # Signs email confirmation tokens ``` -- **Required**: Yes -- **Minimum Length**: 32 characters (enforced) -- **Purpose**: Signs JWT tokens for user authentication -- **Algorithm**: HS256 (HMAC-SHA256) +**Security Requirements**: -**Generate Secret**: +- Each secret should be minimum 32 characters +- Recommend 127+ characters for production +- Generate using cryptographically secure random functions +- Never reuse secrets across token types or environments +- Rotate secrets periodically in production + +**Generate Secrets**: ```bash -# macOS/Linux +# macOS/Linux - Generate 127-character base64 secret openssl rand -base64 127 # Windows PowerShell [Convert]::ToBase64String((1..127 | %{Get-Random -Max 256})) ``` -**Additional JWT Settings** (appsettings.json): +**Token Expiration**: -```json -{ - "Jwt": { - "ExpirationMinutes": 60, - "Issuer": "biergarten-api", - "Audience": "biergarten-users" - } -} -``` +- **Access tokens**: 1 hour +- **Refresh tokens**: 21 days +- **Confirmation tokens**: 30 minutes + +(Defined in `TokenServiceExpirationHours` class) + +**JWT Implementation**: + +- **Algorithm**: HS256 (HMAC-SHA256) +- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler +- **Validation**: Token signature, expiration, and malformed token checks ### Migration Control @@ -274,8 +288,10 @@ touch .env.local | `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components | | `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True | | `SA_PASSWORD` | | | ✓ | Yes | SQL Server container | -| **Authentication (Backend)** | -| `JWT_SECRET` | ✓ | | ✓ | Yes | Min 32 chars | +| **Authentication (Backend - JWT)** | +| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret | +| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret | +| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret | | **Authentication (Frontend)** | | `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation | | `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset | @@ -339,8 +355,10 @@ DB_NAME=Biergarten DB_USER=sa DB_PASSWORD=Dev_Password_123! -# JWT -JWT_SECRET=development-secret-key-at-least-32-characters-long-recommended-longer +# JWT Authentication Secrets +ACCESS_TOKEN_SECRET= +REFRESH_TOKEN_SECRET= +CONFIRMATION_TOKEN_SECRET= # Migration CLEAR_DATABASE=true @@ -363,8 +381,6 @@ BASE_URL=http://localhost:3000 NODE_ENV=development # Authentication -CONFIRMATION_TOKEN_SECRET= -RESET_PASSWORD_TOKEN_SECRET= SESSION_SECRET= # Database (current Prisma setup) diff --git a/docs/token-validation.md b/docs/token-validation.md new file mode 100644 index 0000000..ee6331d --- /dev/null +++ b/docs/token-validation.md @@ -0,0 +1,205 @@ +# Token Validation Architecture + +## Overview + +The Core project implements comprehensive JWT token validation across three token types: + +- **Access Tokens**: Short-lived (1 hour) tokens for API authentication +- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access tokens +- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email confirmation + +## Components + +### Infrastructure Layer + +#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs) + +Low-level JWT operations. + +**Methods:** +- `GenerateJwt()` - Creates signed JWT tokens +- `ValidateJwtAsync()` - Validates token signature, expiration, and format + +**Implementation:** [JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs) +- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler +- Algorithm: HS256 (HMAC-SHA256) +- Validates token lifetime, signature, and well-formedness + +### Service Layer + +#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs) + +High-level token validation with context (token type, user extraction). + +**Methods:** +- `ValidateAccessTokenAsync(string token)` - Validates access tokens +- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens +- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens + +**Returns:** `ValidatedToken` record containing: +- `UserId` (Guid) +- `Username` (string) +- `Principal` (ClaimsPrincipal) - Full JWT claims + +**Implementation:** [TokenValidationService.cs](Service.Auth/TokenValidationService.cs) +- Reads token secrets from environment variables +- Extracts and validates claims (Sub, UniqueName) +- Throws `UnauthorizedException` on validation failure + +#### [ITokenService](Service.Auth/ITokenService.cs) + +Token generation (existing service extended). + +**Methods:** +- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token +- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token +- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation token + +### Integration Points + +#### [ConfirmationService](Service.Auth/IConfirmationService.cs) + +**Flow:** +1. Receives confirmation token from user +2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()` +3. Extracts user ID from validated token +4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database +5. Returns confirmation result + +#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs) + +**Flow:** +1. Receives refresh token from user +2. Calls `TokenValidationService.ValidateRefreshTokenAsync()` +3. Retrieves user account via `AuthRepository.GetUserByIdAsync()` +4. Issues new access and refresh tokens via `TokenService` +5. Returns new token pair + +#### [AuthController](API.Core/Controllers/AuthController.cs) + +**Endpoints:** +- `POST /api/auth/register` - Register new user +- `POST /api/auth/login` - Authenticate user +- `POST /api/auth/confirm?token=...` - Confirm email +- `POST /api/auth/refresh` - Refresh access token + +## Validation Security + +### Token Secrets + +Three independent secrets enable: +- **Key rotation** - Rotate each secret type independently +- **Isolation** - Compromise of one secret doesn't affect others +- **Different expiration** - Different token types can expire at different rates + +**Environment Variables:** +```bash +ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens +REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens +CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens +``` + +### Validation Checks + +Each token is validated for: + +1. **Signature Verification** - Token must be signed with correct secret +2. **Expiration** - Token must not be expired (checked against current time) +3. **Claims Presence** - Required claims (Sub, UniqueName) must be present +4. **Claims Format** - UserId claim must be a valid GUID + +### Error Handling + +Validation failures return HTTP 401 Unauthorized: +- Invalid signature → "Invalid token" +- Expired token → "Invalid token" (message doesn't reveal reason for security) +- Missing claims → "Invalid token" +- Malformed claims → "Invalid token" + +## Token Lifecycle + +### Access Token Lifecycle + +1. **Generation**: During login (1-hour validity) +2. **Usage**: Included in Authorization header on API requests +3. **Validation**: Validated on protected endpoints +4. **Expiration**: Token becomes invalid after 1 hour +5. **Refresh**: Use refresh token to obtain new access token + +### Refresh Token Lifecycle + +1. **Generation**: During login (21-day validity) +2. **Storage**: Client-side (secure storage) +3. **Usage**: Posted to `/api/auth/refresh` endpoint +4. **Validation**: Validated by RefreshTokenService +5. **Rotation**: New refresh token issued on successful refresh +6. **Expiration**: Token becomes invalid after 21 days + +### Confirmation Token Lifecycle + +1. **Generation**: During user registration (30-minute validity) +2. **Delivery**: Emailed to user in confirmation link +3. **Usage**: User clicks link, token posted to `/api/auth/confirm` +4. **Validation**: Validated by ConfirmationService +5. **Completion**: User account marked as confirmed +6. **Expiration**: Token becomes invalid after 30 minutes + +## Testing + +### Unit Tests + +**TokenValidationService.test.cs** +- Happy path: Valid token extraction +- Error cases: Invalid, expired, malformed tokens +- Missing/invalid claims scenarios + +**RefreshTokenService.test.cs** +- Successful refresh with valid token +- Invalid/expired refresh token rejection +- Non-existent user handling + +**ConfirmationService.test.cs** +- Successful confirmation with valid token +- Token validation failures +- User not found scenarios + +### BDD Tests (Reqnroll) + +**TokenRefresh.feature** +- Successful token refresh +- Invalid/expired token rejection +- Missing token validation + +**Confirmation.feature** +- Successful email confirmation +- Expired/tampered token rejection +- Missing token validation + +**AccessTokenValidation.feature** +- Protected endpoint access token validation +- Invalid/expired access token rejection +- Token type mismatch (refresh used as access token) + +## Future Enhancements + +### Stretch Goals + +1. **Middleware for Access Token Validation** + - Automatically validate access tokens on protected routes + - Populate HttpContext.User from token claims + - Return 401 for invalid/missing tokens + +2. **Token Blacklisting** + - Implement token revocation (e.g., on logout) + - Store blacklisted tokens in cache/database + - Check blacklist during validation + +3. **Refresh Token Rotation Strategy** + - Detect token reuse (replay attacks) + - Automatically invalidate entire token chain on reuse + - Log suspicious activity + +4. **Structured Logging** + - Log token validation attempts + - Track failed validation reasons + - Alert on repeated validation failures (brute force detection) diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj index 5acab8a..61c6ba5 100644 --- a/src/Core/API/API.Core/API.Core.csproj +++ b/src/Core/API/API.Core/API.Core.csproj @@ -1,36 +1,42 @@ - - net10.0 - enable - enable - API.Core - Linux - + + net10.0 + enable + enable + API.Core + Linux + - - - - - + + + + + - - - + + + - - - - - - - - - - + + + + + + + + + + - - - .dockerignore - - + + + .dockerignore + + diff --git a/src/Core/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/src/Core/API/API.Core/Authentication/JwtAuthenticationHandler.cs new file mode 100644 index 0000000..c531e2b --- /dev/null +++ b/src/Core/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using API.Core.Contracts.Common; +using Infrastructure.Jwt; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace API.Core.Authentication; + +public class JwtAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ITokenInfrastructure tokenInfrastructure, + IConfiguration configuration +) : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task HandleAuthenticateAsync() + { + // Use the same access-token secret source as TokenService to avoid mismatched validation. + var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET"); + if (string.IsNullOrWhiteSpace(secret)) + { + secret = configuration["Jwt:SecretKey"]; + } + + if (string.IsNullOrWhiteSpace(secret)) + { + return AuthenticateResult.Fail("JWT secret is not configured"); + } + + // Check if Authorization header exists + if ( + !Request.Headers.TryGetValue( + "Authorization", + out var authHeaderValue + ) + ) + { + return AuthenticateResult.Fail("Authorization header is missing"); + } + + var authHeader = authHeaderValue.ToString(); + if ( + !authHeader.StartsWith( + "Bearer ", + StringComparison.OrdinalIgnoreCase + ) + ) + { + return AuthenticateResult.Fail( + "Invalid authorization header format" + ); + } + + var token = authHeader.Substring("Bearer ".Length).Trim(); + + try + { + var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync( + token, + secret + ); + var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name); + return AuthenticateResult.Success(ticket); + } + catch (Exception ex) + { + return AuthenticateResult.Fail( + $"Token validation failed: {ex.Message}" + ); + } + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.ContentType = "application/json"; + Response.StatusCode = 401; + + var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" }; + await Response.WriteAsJsonAsync(response); + } +} + +public class JwtAuthenticationOptions : AuthenticationSchemeOptions { } diff --git a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs index a544258..124ae2e 100644 --- a/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs +++ b/src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs @@ -17,3 +17,5 @@ public record RegistrationPayload( string AccessToken, bool ConfirmationEmailSent ); + +public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); diff --git a/src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs b/src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs new file mode 100644 index 0000000..0914d52 --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace API.Core.Contracts.Auth; + +public record RefreshTokenRequest +{ + public string RefreshToken { get; init; } = default!; +} + +public class RefreshTokenRequestValidator + : AbstractValidator +{ + public RefreshTokenRequestValidator() + { + RuleFor(x => x.RefreshToken) + .NotEmpty() + .WithMessage("Refresh token is required"); + } +} diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 4214a32..e9ce3b4 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -8,15 +8,19 @@ namespace API.Core.Controllers { [ApiController] [Route("api/[controller]")] - public class AuthController(IRegisterService register, ILoginService login) - : ControllerBase + public class AuthController( + IRegisterService registerService, + ILoginService loginService, + IConfirmationService confirmationService, + ITokenService tokenService + ) : ControllerBase { [HttpPost("register")] public async Task> Register( [FromBody] RegisterRequest req ) { - var rtn = await register.RegisterAsync( + var rtn = await registerService.RegisterAsync( new UserAccount { UserAccountId = Guid.Empty, @@ -46,7 +50,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 +65,42 @@ 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 + ), + } + ); + } + + [HttpPost("refresh")] + public async Task Refresh( + [FromBody] RefreshTokenRequest req + ) + { + var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken); + + return Ok( + new ResponseBody + { + Message = "Token refreshed successfully.", + Payload = new LoginPayload( + rtn.UserAccount.UserAccountId, + rtn.UserAccount.Username, + rtn.RefreshToken, + rtn.AccessToken + ), + } + ); + } } } diff --git a/src/Core/API/API.Core/Controllers/ProtectedController.cs b/src/Core/API/API.Core/Controllers/ProtectedController.cs new file mode 100644 index 0000000..6b6d8b7 --- /dev/null +++ b/src/Core/API/API.Core/Controllers/ProtectedController.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using API.Core.Contracts.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Core.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize(AuthenticationSchemes = "JWT")] +public class ProtectedController : ControllerBase +{ + [HttpGet] + public ActionResult> Get() + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var username = User.FindFirst(ClaimTypes.Name)?.Value; + + return Ok( + new ResponseBody + { + Message = "Protected endpoint accessed successfully", + Payload = new { userId, username }, + } + ); + } +} diff --git a/src/Core/API/API.Core/Dockerfile b/src/Core/API/API.Core/Dockerfile index 593fb67..03dd219 100644 --- a/src/Core/API/API.Core/Dockerfile +++ b/src/Core/API/API.Core/Dockerfile @@ -9,8 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] -COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] -COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] +COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] +COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] diff --git a/src/Core/API/API.Core/GlobalException.cs b/src/Core/API/API.Core/GlobalException.cs index 72f7821..6115f64 100644 --- a/src/Core/API/API.Core/GlobalException.cs +++ b/src/Core/API/API.Core/GlobalException.cs @@ -3,6 +3,7 @@ using API.Core.Contracts.Common; using Domain.Exceptions; using FluentValidation; +using Microsoft.Data.SqlClient; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -71,6 +72,16 @@ public class GlobalExceptionFilter(ILogger logger) context.ExceptionHandled = true; break; + case SqlException ex: + context.Result = new ObjectResult( + new ResponseBody { Message = "A database error occurred." } + ) + { + StatusCode = 503, + }; + context.ExceptionHandled = true; + break; + case Domain.Exceptions.ValidationException ex: context.Result = new ObjectResult( new ResponseBody { Message = ex.Message } diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index ca0b67d..e186dba 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,4 +1,5 @@ using API.Core; +using API.Core.Authentication; using API.Core.Contracts.Common; using Domain.Exceptions; using FluentValidation; @@ -11,11 +12,12 @@ using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; using Infrastructure.Repository.Sql; using Infrastructure.Repository.UserAccount; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Service.Auth; -using Service.UserManagement.User; using Service.Emails; +using Service.UserManagement.User; var builder = WebApplication.CreateBuilder(args); @@ -64,10 +66,21 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register the exception filter builder.Services.AddScoped(); +// Configure JWT Authentication +builder + .Services.AddAuthentication("JWT") + .AddScheme( + "JWT", + options => { } + ); + +builder.Services.AddAuthorization(); + var app = builder.Build(); app.UseSwagger(); @@ -76,6 +89,9 @@ app.MapOpenApi(); app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + // Health check endpoint (used by Docker health checks and orchestrators) app.MapHealthChecks("/health"); diff --git a/src/Core/API/API.Specs/API.Specs.csproj b/src/Core/API/API.Specs/API.Specs.csproj index e0a6e42..705f4cb 100644 --- a/src/Core/API/API.Specs/API.Specs.csproj +++ b/src/Core/API/API.Specs/API.Specs.csproj @@ -1,46 +1,46 @@ - - net10.0 - enable - enable - false - API.Specs - + + net10.0 + enable + enable + false + API.Specs + - - - - - - + + + + + + - - - - + + + + - - - + + + - - - - + + + + - - - + + + - - - - + + + + diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile index f82f51b..1f3c815 100644 --- a/src/Core/API/API.Specs/Dockerfile +++ b/src/Core/API/API.Specs/Dockerfile @@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] -COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] -COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] +COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] +COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] diff --git a/src/Core/API/API.Specs/Features/AccessTokenValidation.feature b/src/Core/API/API.Specs/Features/AccessTokenValidation.feature new file mode 100644 index 0000000..e52e1bb --- /dev/null +++ b/src/Core/API/API.Specs/Features/AccessTokenValidation.feature @@ -0,0 +1,51 @@ +Feature: Protected Endpoint Access Token Validation + As a backend developer + I want protected endpoints to validate access tokens + So that unauthorized requests are rejected + + Scenario: Protected endpoint accepts valid access token + Given the API is running + And I have an existing account + And I am logged in + When I submit a request to a protected endpoint with a valid access token + Then the response has HTTP status 200 + + Scenario: Protected endpoint rejects missing access token + Given the API is running + When I submit a request to a protected endpoint without an access token + Then the response has HTTP status 401 + + Scenario: Protected endpoint rejects invalid access token + Given the API is running + When I submit a request to a protected endpoint with an invalid access token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Unauthorized" + + Scenario: Protected endpoint rejects expired access token + Given the API is running + And I have an existing account + And I am logged in with an immediately-expiring access token + When I submit a request to a protected endpoint with the expired token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Unauthorized" + + Scenario: Protected endpoint rejects token signed with wrong secret + Given the API is running + And I have an access token signed with the wrong secret + When I submit a request to a protected endpoint with the tampered token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Unauthorized" + + Scenario: Protected endpoint rejects refresh token as access token + Given the API is running + And I have an existing account + And I am logged in + When I submit a request to a protected endpoint with my refresh token instead of access token + Then the response has HTTP status 401 + + Scenario: Protected endpoint rejects confirmation token as access token + Given the API is running + And I have registered a new account + And I have a valid confirmation token + When I submit a request to a protected endpoint with my confirmation token instead of access token + Then the response has HTTP status 401 diff --git a/src/Core/API/API.Specs/Features/Confirmation.feature b/src/Core/API/API.Specs/Features/Confirmation.feature new file mode 100644 index 0000000..ac77184 --- /dev/null +++ b/src/Core/API/API.Specs/Features/Confirmation.feature @@ -0,0 +1,59 @@ +Feature: User Account Confirmation + As a newly registered user + I want to confirm my email address via a validation token + So that my account is fully activated + Scenario: Successful confirmation with valid token + Given the API is running + And I have registered a new account + And I have a valid confirmation token for my account + When I submit a confirmation request with the valid token + Then the response has HTTP status 200 + And the response JSON should have "message" containing "is confirmed" + + Scenario: Re-confirming an already verified account remains successful + Given the API is running + And I have registered a new account + And I have a valid confirmation token for my account + When I submit a confirmation request with the valid token + And I submit the same confirmation request again + Then the response has HTTP status 200 + And the response JSON should have "message" containing "is confirmed" + + Scenario: Confirmation fails with invalid token + Given the API is running + When I submit a confirmation request with an invalid token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid token" + + Scenario: Confirmation fails with expired token + Given the API is running + And I have registered a new account + And I have an expired confirmation token for my account + When I submit a confirmation request with the expired token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid token" + + Scenario: Confirmation fails with tampered token (wrong secret) + Given the API is running + And I have registered a new account + And I have a confirmation token signed with the wrong secret + When I submit a confirmation request with the tampered token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid token" + + Scenario: Confirmation fails when token is missing + Given the API is running + When I submit a confirmation request with a missing token + Then the response has HTTP status 400 + + Scenario: Confirmation endpoint only accepts POST requests + Given the API is running + And I have a valid confirmation token + When I submit a confirmation request using an invalid HTTP method + Then the response has HTTP status 404 + + Scenario: Confirmation fails with malformed token + Given the API is running + When I submit a confirmation request with a malformed token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid token" diff --git a/src/Core/API/API.Specs/Features/TokenRefresh.feature b/src/Core/API/API.Specs/Features/TokenRefresh.feature new file mode 100644 index 0000000..c63bc55 --- /dev/null +++ b/src/Core/API/API.Specs/Features/TokenRefresh.feature @@ -0,0 +1,39 @@ +Feature: Token Refresh + As an authenticated user + I want to refresh my access token using my refresh token + So that I can maintain my session without logging in again + + Scenario: Successful token refresh with valid refresh token + Given the API is running + And I have an existing account + And I am logged in + When I submit a refresh token request with a valid refresh token + Then the response has HTTP status 200 + And the response JSON should have "message" equal "Token refreshed successfully." + And the response JSON should have a new access token + And the response JSON should have a new refresh token + + Scenario: Token refresh fails with invalid refresh token + Given the API is running + When I submit a refresh token request with an invalid refresh token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid" + + Scenario: Token refresh fails with expired refresh token + Given the API is running + And I have an existing account + And I am logged in with an immediately-expiring refresh token + When I submit a refresh token request with the expired refresh token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid token" + + Scenario: Token refresh fails when refresh token is missing + Given the API is running + When I submit a refresh token request with a missing refresh token + Then the response has HTTP status 400 + + Scenario: Token refresh endpoint only accepts POST requests + Given the API is running + And I have a valid refresh token + When I submit a refresh token request using a GET request + Then the response has HTTP status 404 diff --git a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs index 8821c02..44273f4 100644 --- a/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs +++ b/src/Core/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -149,4 +149,61 @@ public class ApiGeneralSteps(ScenarioContext scenario) ); value.GetString().Should().Be(expected); } + + [Then("the response JSON should have {string} containing {string}")] + public void ThenTheResponseJsonShouldHaveStringContainingString( + string field, + string expectedSubstring + ) + { + 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 + ); + var actualValue = value.GetString(); + actualValue + .Should() + .Contain( + expectedSubstring, + "Expected field '{0}' to contain '{1}' but was '{2}'", + field, + expectedSubstring, + actualValue + ); + } } diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index bfaabe0..26b26c9 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -1,6 +1,7 @@ using System.Text.Json; using API.Specs; using FluentAssertions; +using Infrastructure.Jwt; using Reqnroll; namespace API.Specs.Steps; @@ -13,6 +14,10 @@ public class AuthSteps(ScenarioContext scenario) private const string ResponseKey = "response"; private const string ResponseBodyKey = "responseBody"; private const string TestUserKey = "testUser"; + private const string RegisteredUserIdKey = "registeredUserId"; + private const string RegisteredUsernameKey = "registeredUsername"; + private const string PreviousAccessTokenKey = "previousAccessToken"; + private const string PreviousRefreshTokenKey = "previousRefreshToken"; private HttpClient GetClient() { @@ -34,6 +39,66 @@ public class AuthSteps(ScenarioContext scenario) return client; } + private static string GetRequiredEnvVar(string name) + { + return Environment.GetEnvironmentVariable(name) + ?? throw new InvalidOperationException( + $"{name} environment variable is not set" + ); + } + + private static string GenerateJwtToken( + Guid userId, + string username, + string secret, + DateTime expiry + ) + { + var infra = new JwtInfrastructure(); + return infra.GenerateJwt(userId, username, expiry, secret); + } + + private static Guid ParseRegisteredUserId(JsonElement root) + { + return root + .GetProperty("payload") + .GetProperty("userAccountId") + .GetGuid(); + } + + private static string ParseRegisteredUsername(JsonElement root) + { + return root + .GetProperty("payload") + .GetProperty("username") + .GetString() + ?? throw new InvalidOperationException( + "username missing from registration payload" + ); + } + + private static string ParseTokenFromPayload( + JsonElement payload, + string camelCaseName, + string pascalCaseName + ) + { + if ( + payload.TryGetProperty(camelCaseName, out var tokenElem) + || payload.TryGetProperty(pascalCaseName, out tokenElem) + ) + { + return tokenElem.GetString() + ?? throw new InvalidOperationException( + $"{camelCaseName} is null" + ); + } + + throw new InvalidOperationException( + $"Could not find token field '{camelCaseName}' in payload" + ); + } + [Given("I have an existing account")] public void GivenIHaveAnExistingAccount() { @@ -229,6 +294,18 @@ public class AuthSteps(ScenarioContext scenario) dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd"); } + // Keep default registration fixture values unique across repeated runs. + if (email == "newuser@example.com") + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + email = $"newuser-{suffix}@example.com"; + + if (username == "newuser") + { + username = $"newuser-{suffix}"; + } + } + var password = row["Password"]; var registrationData = new @@ -284,4 +361,686 @@ public class AuthSteps(ScenarioContext scenario) scenario[ResponseKey] = response; scenario[ResponseBodyKey] = responseBody; } + + [Given("I have registered a new account")] + public async Task GivenIHaveRegisteredANewAccount() + { + var client = GetClient(); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var registrationData = new + { + username = $"newuser-{suffix}", + firstName = "New", + lastName = "User", + email = $"newuser-{suffix}@example.com", + dateOfBirth = "1990-01-01", + password = "Password1!", + }; + + var body = JsonSerializer.Serialize(registrationData); + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/register" + ) + { + 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; + + using var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root); + scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); + } + + [Given("I am logged in")] + public async Task GivenIAmLoggedIn() + { + var client = GetClient(); + var loginData = new { username = "test.user", password = "password" }; + var body = JsonSerializer.Serialize(loginData); + + 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(); + + var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + if (root.TryGetProperty("payload", out var payloadElem)) + { + if ( + payloadElem.TryGetProperty("accessToken", out var tokenElem) + || payloadElem.TryGetProperty("AccessToken", out tokenElem) + ) + { + scenario["accessToken"] = tokenElem.GetString(); + } + if ( + payloadElem.TryGetProperty("refreshToken", out var refreshElem) + || payloadElem.TryGetProperty("RefreshToken", out refreshElem) + ) + { + scenario["refreshToken"] = refreshElem.GetString(); + } + } + } + + [Given("I have a valid refresh token")] + public async Task GivenIHaveAValidRefreshToken() + { + await GivenIAmLoggedIn(); + } + + [Given("I am logged in with an immediately-expiring refresh token")] + public async Task GivenIAmLoggedInWithAnImmediatelyExpiringRefreshToken() + { + // For now, create a normal login; in production this would generate an expiring token + await GivenIAmLoggedIn(); + } + + [Given("I have a valid confirmation token for my account")] + public void GivenIHaveAValidConfirmationTokenForMyAccount() + { + var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + ? id + : throw new InvalidOperationException( + "registered user ID not found in scenario" + ); + var username = scenario.TryGetValue( + RegisteredUsernameKey, + out var user + ) + ? user + : throw new InvalidOperationException( + "registered username not found in scenario" + ); + + var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); + scenario["confirmationToken"] = GenerateJwtToken( + userId, + username, + secret, + DateTime.UtcNow.AddMinutes(5) + ); + } + + [Given("I have an expired confirmation token for my account")] + public void GivenIHaveAnExpiredConfirmationTokenForMyAccount() + { + var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + ? id + : throw new InvalidOperationException( + "registered user ID not found in scenario" + ); + var username = scenario.TryGetValue( + RegisteredUsernameKey, + out var user + ) + ? user + : throw new InvalidOperationException( + "registered username not found in scenario" + ); + + var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); + scenario["confirmationToken"] = GenerateJwtToken( + userId, + username, + secret, + DateTime.UtcNow.AddMinutes(-5) + ); + } + + [Given("I have a confirmation token signed with the wrong secret")] + public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret() + { + var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + ? id + : throw new InvalidOperationException( + "registered user ID not found in scenario" + ); + var username = scenario.TryGetValue( + RegisteredUsernameKey, + out var user + ) + ? user + : throw new InvalidOperationException( + "registered username not found in scenario" + ); + + const string wrongSecret = + "wrong-confirmation-secret-that-is-very-long-1234567890"; + scenario["confirmationToken"] = GenerateJwtToken( + userId, + username, + wrongSecret, + DateTime.UtcNow.AddMinutes(5) + ); + } + + [When( + "I submit a request to a protected endpoint with a valid access token" + )] + public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("accessToken", out var t) + ? t + : "invalid-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ) + { + Headers = { { "Authorization", $"Bearer {token}" } }, + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When( + "I submit a request to a protected endpoint with an invalid access token" + )] + public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken() + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ) + { + Headers = { { "Authorization", "Bearer invalid-token-format" } }, + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a confirmation request with the valid token")] + public async Task WhenISubmitAConfirmationRequestWithTheValidToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "valid-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit the same confirmation request again")] + public async Task WhenISubmitTheSameConfirmationRequestAgain() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "valid-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a confirmation request with a malformed token")] + public async Task WhenISubmitAConfirmationRequestWithAMalformedToken() + { + var client = GetClient(); + const string token = "malformed-token-not-jwt"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a refresh token request with a valid refresh token")] + public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken() + { + var client = GetClient(); + if (scenario.TryGetValue("accessToken", out var oldAccessToken)) + { + scenario[PreviousAccessTokenKey] = oldAccessToken; + } + if (scenario.TryGetValue("refreshToken", out var oldRefreshToken)) + { + scenario[PreviousRefreshTokenKey] = oldRefreshToken; + } + + var token = scenario.TryGetValue("refreshToken", out var t) + ? t + : "valid-refresh-token"; + var body = JsonSerializer.Serialize(new { refreshToken = token }); + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/refresh" + ) + { + 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 refresh token request with an invalid refresh token")] + public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken() + { + var client = GetClient(); + var body = JsonSerializer.Serialize( + new { refreshToken = "invalid-refresh-token" } + ); + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/refresh" + ) + { + 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 refresh token request with the expired refresh token")] + public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken() + { + var client = GetClient(); + // Use an expired token + var body = JsonSerializer.Serialize( + new { refreshToken = "expired-refresh-token" } + ); + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/refresh" + ) + { + 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 refresh token request with a missing refresh token")] + public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken() + { + var client = GetClient(); + var body = JsonSerializer.Serialize(new { }); + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + "/api/auth/refresh" + ) + { + 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 refresh token request using a GET request")] + public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest() + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/auth/refresh" + ) + { + 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; + } + + // Protected Endpoint Steps + [When("I submit a request to a protected endpoint without an access token")] + public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken() + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Given("I am logged in with an immediately-expiring access token")] + public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken() + { + // Simulate an expired access token for auth rejection behavior. + scenario["accessToken"] = "expired-access-token"; + return Task.CompletedTask; + } + + [Given("I have an access token signed with the wrong secret")] + public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret() + { + // Create a token with a different secret + scenario["accessToken"] = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + } + + [When("I submit a request to a protected endpoint with the expired token")] + public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("accessToken", out var t) + ? t + : "expired-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ) + { + Headers = { { "Authorization", $"Bearer {token}" } }, + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a request to a protected endpoint with the tampered token")] + public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("accessToken", out var t) + ? t + : "tampered-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ) + { + Headers = { { "Authorization", $"Bearer {token}" } }, + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When( + "I submit a request to a protected endpoint with my refresh token instead of access token" + )] + public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("refreshToken", out var t) + ? t + : "refresh-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ) + { + Headers = { { "Authorization", $"Bearer {token}" } }, + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Given("I have a valid confirmation token")] + public void GivenIHaveAValidConfirmationToken() + { + scenario["confirmationToken"] = "valid-confirmation-token"; + } + + [When("I submit a confirmation request with the expired token")] + public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "expired-confirmation-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a confirmation request with the tampered token")] + public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "tampered-confirmation-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a confirmation request with a missing token")] + public async Task WhenISubmitAConfirmationRequestWithAMissingToken() + { + var client = GetClient(); + var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm"); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a confirmation request using an invalid HTTP method")] + public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "valid-confirmation-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When( + "I submit a request to a protected endpoint with my confirmation token instead of access token" + )] + public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : "confirmation-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Get, + "/api/protected" + ) + { + Headers = { { "Authorization", $"Bearer {token}" } }, + }; + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a confirmation request with an invalid token")] + public async Task WhenISubmitAConfirmationRequestWithAnInvalidToken() + { + var client = GetClient(); + const string token = "invalid-confirmation-token"; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [Then("the response JSON should have a new access token")] + public void ThenTheResponseJsonShouldHaveANewAccessToken() + { + scenario + .TryGetValue(ResponseBodyKey, out var responseBody) + .Should() + .BeTrue(); + + using var doc = JsonDocument.Parse(responseBody!); + var payload = doc.RootElement.GetProperty("payload"); + var accessToken = ParseTokenFromPayload( + payload, + "accessToken", + "AccessToken" + ); + + accessToken.Should().NotBeNullOrWhiteSpace(); + + if ( + scenario.TryGetValue( + PreviousAccessTokenKey, + out var previousAccessToken + ) + ) + { + accessToken.Should().NotBe(previousAccessToken); + } + } + + [Then("the response JSON should have a new refresh token")] + public void ThenTheResponseJsonShouldHaveANewRefreshToken() + { + scenario + .TryGetValue(ResponseBodyKey, out var responseBody) + .Should() + .BeTrue(); + + using var doc = JsonDocument.Parse(responseBody!); + var payload = doc.RootElement.GetProperty("payload"); + var refreshToken = ParseTokenFromPayload( + payload, + "refreshToken", + "RefreshToken" + ); + + refreshToken.Should().NotBeNullOrWhiteSpace(); + + if ( + scenario.TryGetValue( + PreviousRefreshTokenKey, + out var previousRefreshToken + ) + ) + { + refreshToken.Should().NotBe(previousRefreshToken); + } + } } diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx index 4d6a6f1..b1ff5d5 100644 --- a/src/Core/Core.slnx +++ b/src/Core/Core.slnx @@ -8,16 +8,19 @@ - - + + - + - + - + diff --git a/src/Core/Database/Database.Seed/Database.Seed.csproj b/src/Core/Database/Database.Seed/Database.Seed.csproj index 88bebda..984411c 100644 --- a/src/Core/Database/Database.Seed/Database.Seed.csproj +++ b/src/Core/Database/Database.Seed/Database.Seed.csproj @@ -18,7 +18,8 @@ - - + + diff --git a/src/Core/Domain.Entities/Domain.Entities.csproj b/src/Core/Domain/Domain.Entities/Domain.Entities.csproj similarity index 100% rename from src/Core/Domain.Entities/Domain.Entities.csproj rename to src/Core/Domain/Domain.Entities/Domain.Entities.csproj diff --git a/src/Core/Domain.Entities/Entities/UserAccount.cs b/src/Core/Domain/Domain.Entities/Entities/UserAccount.cs similarity index 100% rename from src/Core/Domain.Entities/Entities/UserAccount.cs rename to src/Core/Domain/Domain.Entities/Entities/UserAccount.cs diff --git a/src/Core/Domain.Entities/Entities/UserCredential.cs b/src/Core/Domain/Domain.Entities/Entities/UserCredential.cs similarity index 100% rename from src/Core/Domain.Entities/Entities/UserCredential.cs rename to src/Core/Domain/Domain.Entities/Entities/UserCredential.cs diff --git a/src/Core/Domain.Entities/Entities/UserVerification.cs b/src/Core/Domain/Domain.Entities/Entities/UserVerification.cs similarity index 100% rename from src/Core/Domain.Entities/Entities/UserVerification.cs rename to src/Core/Domain/Domain.Entities/Entities/UserVerification.cs diff --git a/src/Core/Domain.Exceptions/Domain.Exceptions.csproj b/src/Core/Domain/Domain.Exceptions/Domain.Exceptions.csproj similarity index 100% rename from src/Core/Domain.Exceptions/Domain.Exceptions.csproj rename to src/Core/Domain/Domain.Exceptions/Domain.Exceptions.csproj diff --git a/src/Core/Domain.Exceptions/Exceptions.cs b/src/Core/Domain/Domain.Exceptions/Exceptions.cs similarity index 100% rename from src/Core/Domain.Exceptions/Exceptions.cs rename to src/Core/Domain/Domain.Exceptions/Exceptions.cs diff --git a/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs b/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs index 7929da0..2535c2b 100644 --- a/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs +++ b/src/Core/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs @@ -1,6 +1,15 @@ +using System.Security.Claims; + namespace Infrastructure.Jwt; public interface ITokenInfrastructure { - string GenerateJwt(Guid userId, string username, DateTime expiry); -} + string GenerateJwt( + Guid userId, + string username, + DateTime expiry, + string secret + ); + + Task ValidateJwtAsync(string token, string secret); +} \ No newline at end of file diff --git a/src/Core/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj b/src/Core/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj index cddd219..59caedd 100644 --- a/src/Core/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj +++ b/src/Core/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj @@ -16,4 +16,8 @@ Version="8.2.1" /> + + + + diff --git a/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs b/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs index 1b093c8..2616d8f 100644 --- a/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs +++ b/src/Core/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs @@ -3,28 +3,33 @@ using System.Text; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; +using Domain.Exceptions; 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") - ); - - // Base claims (always present) + var key = Encoding.UTF8.GetBytes(secret); var claims = new List { new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.UniqueName, username), + new( + JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() + ), + new( + JwtRegisteredClaimNames.Exp, + new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString() + ), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; @@ -40,4 +45,36 @@ public class JwtInfrastructure : ITokenInfrastructure return handler.CreateToken(tokenDescriptor); } -} + + + public async Task ValidateJwtAsync( + string token, + string secret + ) + { + var handler = new JsonWebTokenHandler(); + var keyBytes = Encoding.UTF8.GetBytes( + secret + ); + var parameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + IssuerSigningKey = new SymmetricSecurityKey(keyBytes), + }; + + try + { + var result = await handler.ValidateTokenAsync(token, parameters); + if (!result.IsValid || result.ClaimsIdentity == null) + throw new UnauthorizedAccessException(); + + return new ClaimsPrincipal(result.ClaimsIdentity); + } + catch (Exception e) + { + throw new UnauthorizedException("Invalid token"); + } + } +} \ No newline at end of file diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile index f06bb9e..a995f50 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile +++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile @@ -1,8 +1,8 @@ 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 ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] +COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"] RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj" diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index 83b26d7..132499d 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -2,6 +2,7 @@ using System.Data; using System.Data.Common; using Domain.Entities; using Infrastructure.Repository.Sql; +using Microsoft.Data.SqlClient; namespace Infrastructure.Repository.Auth; @@ -107,6 +108,78 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) await command.ExecuteNonQueryAsync(); } + public async Task GetUserByIdAsync( + Guid userAccountId + ) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountById"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + + await using var reader = await command.ExecuteReaderAsync(); + return await reader.ReadAsync() ? MapToEntity(reader) : null; + } + + public async Task ConfirmUserAccountAsync( + Guid userAccountId + ) + { + var user = await GetUserByIdAsync(userAccountId); + if (user == null) + { + return null; + } + + // Idempotency: if already verified, treat as successful confirmation. + if (await IsUserVerifiedAsync(userAccountId)) + { + return user; + } + + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_CreateUserVerification"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountID_", userAccountId); + + try + { + await command.ExecuteNonQueryAsync(); + } + catch (SqlException ex) when (IsDuplicateVerificationViolation(ex)) + { + // A concurrent request verified this user first. Keep behavior idempotent. + } + + // Fetch and return the updated user + return await GetUserByIdAsync(userAccountId); + } + + private async Task IsUserVerifiedAsync(Guid userAccountId) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; + command.CommandType = CommandType.Text; + + AddParameter(command, "@UserAccountID", userAccountId); + + var result = await command.ExecuteScalarAsync(); + return result != null && result != DBNull.Value; + } + + private static bool IsDuplicateVerificationViolation(SqlException ex) + { + // 2601/2627 are duplicate key violations in SQL Server. + return ex.Number == 2601 || ex.Number == 2627; + } + + /// /// Maps a data reader row to a UserAccount entity. /// diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs index f8472c1..108f5c7 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs @@ -60,4 +60,19 @@ public interface IAuthRepository /// ID of the user account /// New hashed password Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); + + /// + /// Marks a user account as confirmed. + /// + /// ID of the user account to confirm + /// The confirmed UserAccount entity + /// If user account not found + Task ConfirmUserAccountAsync(Guid userAccountId); + + /// + /// Retrieves a user account by ID. + /// + /// ID of the user account + /// UserAccount if found, null otherwise + Task GetUserByIdAsync(Guid userAccountId); } diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj index 4748809..3b418d7 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj +++ b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj @@ -18,6 +18,6 @@ /> - + diff --git a/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs b/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs new file mode 100644 index 0000000..0caf31f --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs @@ -0,0 +1,155 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Infrastructure.Repository.Auth; +using Moq; + +namespace Service.Auth.Tests; + +public class ConfirmationServiceTest +{ + private readonly Mock _authRepositoryMock; + private readonly Mock _tokenServiceMock; + private readonly ConfirmationService _confirmationService; + + public ConfirmationServiceTest() + { + _authRepositoryMock = new Mock(); + _tokenServiceMock = new Mock(); + + _confirmationService = new ConfirmationService( + _authRepositoryMock.Object, + _tokenServiceMock.Object + ); + } + + [Fact] + public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string confirmationToken = "valid-confirmation-token"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + var validatedToken = new ValidatedToken(userId, username, principal); + var userAccount = new UserAccount + { + UserAccountId = userId, + Username = username, + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) + .ReturnsAsync(validatedToken); + + _authRepositoryMock + .Setup(x => x.ConfirmUserAccountAsync(userId)) + .ReturnsAsync(userAccount); + + // Act + var result = + await _confirmationService.ConfirmUserAsync(confirmationToken); + + // Assert + result.Should().NotBeNull(); + result.UserId.Should().Be(userId); + result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + + _tokenServiceMock.Verify( + x => x.ValidateConfirmationTokenAsync(confirmationToken), + Times.Once + ); + + _authRepositoryMock.Verify( + x => x.ConfirmUserAccountAsync(userId), + Times.Once + ); + } + + [Fact] + public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() + { + // Arrange + const string invalidToken = "invalid-confirmation-token"; + + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) + .ThrowsAsync(new UnauthorizedException( + "Invalid confirmation token" + )); + + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(invalidToken) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() + { + // Arrange + const string expiredToken = "expired-confirmation-token"; + + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) + .ThrowsAsync(new UnauthorizedException( + "Confirmation token has expired" + )); + + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(expiredToken) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "nonexistent"; + const string confirmationToken = "valid-token-for-nonexistent-user"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + var validatedToken = new ValidatedToken(userId, username, principal); + + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) + .ReturnsAsync(validatedToken); + + _authRepositoryMock + .Setup(x => x.ConfirmUserAccountAsync(userId)) + .ReturnsAsync((UserAccount?)null); + + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(confirmationToken) + ).Should().ThrowAsync() + .WithMessage("*User account not found*"); + } +} diff --git a/src/Core/Service/Service.Auth.Tests/Dockerfile b/src/Core/Service/Service.Auth.Tests/Dockerfile index 0d67ee9..5ab1feb 100644 --- a/src/Core/Service/Service.Auth.Tests/Dockerfile +++ b/src/Core/Service/Service.Auth.Tests/Dockerfile @@ -1,8 +1,8 @@ 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 ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] +COPY ["Domain/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/"] diff --git a/src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs b/src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs new file mode 100644 index 0000000..a2634de --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs @@ -0,0 +1,162 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Infrastructure.Jwt; +using Infrastructure.Repository.Auth; +using Moq; + +namespace Service.Auth.Tests; + +public class TokenServiceRefreshTest +{ + private readonly Mock _tokenInfraMock; + private readonly Mock _authRepositoryMock; + private readonly TokenService _tokenService; + + public TokenServiceRefreshTest() + { + _tokenInfraMock = new Mock(); + _authRepositoryMock = new Mock(); + + // Set environment variables for tokens + Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"); + Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); + Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890"); + + _tokenService = new TokenService( + _tokenInfraMock.Object, + _authRepositoryMock.Object + ); + } + + [Fact] + public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string refreshToken = "valid-refresh-token"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + var userAccount = new UserAccount + { + UserAccountId = userId, + Username = username, + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; + + // Mock the validation of refresh token + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny())) + .ReturnsAsync(principal); + + // Mock the generation of new tokens + _tokenInfraMock + .Setup(x => x.GenerateJwt(userId, username, It.IsAny(), It.IsAny())) + .Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"); + + _authRepositoryMock + .Setup(x => x.GetUserByIdAsync(userId)) + .ReturnsAsync(userAccount); + + // Act + var result = await _tokenService.RefreshTokenAsync(refreshToken); + + // Assert + result.Should().NotBeNull(); + result.UserAccount.UserAccountId.Should().Be(userId); + result.UserAccount.Username.Should().Be(username); + result.AccessToken.Should().NotBeEmpty(); + result.RefreshToken.Should().NotBeEmpty(); + + _authRepositoryMock.Verify( + x => x.GetUserByIdAsync(userId), + Times.Once + ); + + // Verify tokens were generated (called twice - once for access, once for refresh) + _tokenInfraMock.Verify( + x => x.GenerateJwt(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } + + [Fact] + public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException() + { + // Arrange + const string invalidToken = "invalid-refresh-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid refresh token")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.RefreshTokenAsync(invalidToken) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException() + { + // Arrange + const string expiredToken = "expired-refresh-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Refresh token has expired")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.RefreshTokenAsync(expiredToken) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string refreshToken = "valid-refresh-token"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny())) + .ReturnsAsync(principal); + + _authRepositoryMock + .Setup(x => x.GetUserByIdAsync(userId)) + .ReturnsAsync((UserAccount?)null); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.RefreshTokenAsync(refreshToken) + ).Should().ThrowAsync() + .WithMessage("*User account not found*"); + } +} diff --git a/src/Core/Service/Service.Auth.Tests/TokenServiceValidation.test.cs b/src/Core/Service/Service.Auth.Tests/TokenServiceValidation.test.cs new file mode 100644 index 0000000..1c729c8 --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/TokenServiceValidation.test.cs @@ -0,0 +1,282 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Infrastructure.Jwt; +using Infrastructure.Repository.Auth; +using Moq; + +namespace Service.Auth.Tests; + +public class TokenServiceValidationTest +{ + private readonly Mock _tokenInfraMock; + private readonly Mock _authRepositoryMock; + private readonly TokenService _tokenService; + + public TokenServiceValidationTest() + { + _tokenInfraMock = new Mock(); + _authRepositoryMock = new Mock(); + + // Set environment variables for tokens + Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"); + Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); + Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890"); + + _tokenService = new TokenService( + _tokenInfraMock.Object, + _authRepositoryMock.Object + ); + } + + [Fact] + public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string token = "valid-access-token"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); + + // Act + var result = + await _tokenService.ValidateAccessTokenAsync(token); + + // Assert + result.Should().NotBeNull(); + result.UserId.Should().Be(userId); + result.Username.Should().Be(username); + result.Principal.Should().NotBeNull(); + result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString()); + } + + [Fact] + public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string token = "valid-refresh-token"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); + + // Act + var result = + await _tokenService.ValidateRefreshTokenAsync(token); + + // Assert + result.Should().NotBeNull(); + result.UserId.Should().Be(userId); + result.Username.Should().Be(username); + } + + [Fact] + public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string token = "valid-confirmation-token"; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); + + // Act + var result = + await _tokenService.ValidateConfirmationTokenAsync(token); + + // Assert + result.Should().NotBeNull(); + result.UserId.Should().Be(userId); + result.Username.Should().Be(username); + } + + [Fact] + public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() + { + // Arrange + const string token = "invalid-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid token")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException() + { + // Arrange + const string token = "expired-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException( + "Token has expired" + )); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException() + { + // Arrange + const string username = "testuser"; + const string token = "token-without-user-id"; + + // Claims without Sub (user ID) + var claims = new List + { + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync() + .WithMessage("*missing required claims*"); + } + + [Fact] + public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException() + { + // Arrange + var userId = Guid.NewGuid(); + const string token = "token-without-username"; + + // Claims without UniqueName (username) + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync() + .WithMessage("*missing required claims*"); + } + + [Fact] + public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException() + { + // Arrange + const string username = "testuser"; + const string token = "token-with-malformed-user-id"; + + // Claims with invalid GUID format + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync() + .WithMessage("*malformed user ID*"); + } + + [Fact] + public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() + { + // Arrange + const string token = "invalid-refresh-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid token")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateRefreshTokenAsync(token) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() + { + // Arrange + const string token = "invalid-confirmation-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid token")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateConfirmationTokenAsync(token) + ).Should().ThrowAsync(); + } +} diff --git a/src/Core/Service/Service.Auth/ConfirmationService.cs b/src/Core/Service/Service.Auth/ConfirmationService.cs new file mode 100644 index 0000000..ff4abf0 --- /dev/null +++ b/src/Core/Service/Service.Auth/ConfirmationService.cs @@ -0,0 +1,34 @@ + +using Domain.Exceptions; +using Infrastructure.Repository.Auth; + +namespace Service.Auth; + +public class ConfirmationService( + IAuthRepository authRepository, + ITokenService tokenService +) : IConfirmationService +{ + public async Task ConfirmUserAsync( + string confirmationToken + ) + { + var validatedToken = await tokenService.ValidateConfirmationTokenAsync( + confirmationToken + ); + + var user = await authRepository.ConfirmUserAccountAsync( + validatedToken.UserId + ); + + if (user == null) + { + throw new UnauthorizedException("User account not found"); + } + + return new ConfirmationServiceReturn( + DateTime.UtcNow, + user.UserAccountId + ); + } +} diff --git a/src/Core/Service/Service.Auth/IConfirmationService.cs b/src/Core/Service/Service.Auth/IConfirmationService.cs new file mode 100644 index 0000000..8abb9be --- /dev/null +++ b/src/Core/Service/Service.Auth/IConfirmationService.cs @@ -0,0 +1,11 @@ +using Domain.Exceptions; +using Infrastructure.Repository.Auth; + +namespace Service.Auth; + +public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId); + +public interface IConfirmationService +{ + Task ConfirmUserAsync(string confirmationToken); +} 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..6252579 100644 --- a/src/Core/Service/Service.Auth/ITokenService.cs +++ b/src/Core/Service/Service.Auth/ITokenService.cs @@ -1,34 +1,156 @@ +using System.Security.Claims; +using System.IdentityModel.Tokens.Jwt; using Domain.Entities; +using Domain.Exceptions; using Infrastructure.Jwt; +using Infrastructure.Repository.Auth; namespace Service.Auth; +public enum TokenType +{ + AccessToken, + RefreshToken, + ConfirmationToken, +} + +public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); + +public record RefreshTokenResult( + UserAccount UserAccount, + string RefreshToken, + string AccessToken +); + +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 interface ITokenService { - public string GenerateAccessToken(UserAccount user); - public string GenerateRefreshToken(UserAccount user); + string GenerateAccessToken(UserAccount user); + string GenerateRefreshToken(UserAccount user); + string GenerateConfirmationToken(UserAccount user); + string GenerateToken(UserAccount user) where T : struct, Enum; + Task ValidateAccessTokenAsync(string token); + Task ValidateRefreshTokenAsync(string token); + Task ValidateConfirmationTokenAsync(string token); + Task RefreshTokenAsync(string refreshTokenString); } -public class TokenService(ITokenInfrastructure tokenInfrastructure) - : ITokenService +public class TokenService : ITokenService { - public string GenerateAccessToken(UserAccount userAccount) + private readonly ITokenInfrastructure _tokenInfrastructure; + private readonly IAuthRepository _authRepository; + + private readonly string _accessTokenSecret; + private readonly string _refreshTokenSecret; + private readonly string _confirmationTokenSecret; + + public TokenService( + ITokenInfrastructure tokenInfrastructure, + IAuthRepository authRepository + ) { - var jwtExpiresAt = DateTime.UtcNow.AddHours(1); - return tokenInfrastructure.GenerateJwt( - userAccount.UserAccountId, - userAccount.Username, - jwtExpiresAt - ); + _tokenInfrastructure = tokenInfrastructure; + _authRepository = authRepository; + + _accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET") + ?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set"); + + _refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET") + ?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set"); + + _confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET") + ?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set"); } - public string GenerateRefreshToken(UserAccount userAccount) + public string GenerateAccessToken(UserAccount user) { - var jwtExpiresAt = DateTime.UtcNow.AddDays(21); - return tokenInfrastructure.GenerateJwt( - userAccount.UserAccountId, - userAccount.Username, - jwtExpiresAt - ); + var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); + return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret); + } + + public string GenerateRefreshToken(UserAccount user) + { + var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours); + return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret); + } + + public string GenerateConfirmationToken(UserAccount user) + { + var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours); + return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret); + } + + public string GenerateToken(UserAccount user) where T : struct, Enum + { + if (typeof(T) != typeof(TokenType)) + throw new InvalidOperationException("Invalid token type"); + + var tokenTypeName = typeof(T).Name; + if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed)) + throw new InvalidOperationException("Invalid token type"); + + var tokenType = (TokenType)parsed; + return tokenType switch + { + TokenType.AccessToken => GenerateAccessToken(user), + TokenType.RefreshToken => GenerateRefreshToken(user), + TokenType.ConfirmationToken => GenerateConfirmationToken(user), + _ => throw new InvalidOperationException("Invalid token type"), + }; + } + + public async Task ValidateAccessTokenAsync(string token) + => await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); + + public async Task ValidateRefreshTokenAsync(string token) + => await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); + + public async Task ValidateConfirmationTokenAsync(string token) + => await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); + + private async Task ValidateTokenInternalAsync(string token, string secret, string tokenType) + { + try + { + var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret); + + var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim)) + throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims"); + + if (!Guid.TryParse(userIdClaim, out var userId)) + throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID"); + + return new ValidatedToken(userId, usernameClaim, principal); + } + catch (UnauthorizedException) + { + throw; + } + catch (Exception e) + { + throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}"); + } + } + + public async Task RefreshTokenAsync(string refreshTokenString) + { + var validated = await ValidateRefreshTokenAsync(refreshTokenString); + var user = await _authRepository.GetUserByIdAsync(validated.UserId); + if (user == null) + throw new UnauthorizedException("User account not found"); + + var newAccess = GenerateAccessToken(user); + var newRefresh = GenerateRefreshToken(user); + + return new RefreshTokenResult(user, newRefresh, newAccess); } } 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 diff --git a/src/Core/Service/Service.Auth/Service.Auth.csproj b/src/Core/Service/Service.Auth/Service.Auth.csproj index fd88236..29b1f80 100644 --- a/src/Core/Service/Service.Auth/Service.Auth.csproj +++ b/src/Core/Service/Service.Auth/Service.Auth.csproj @@ -6,13 +6,17 @@ - - - - + + + + - - + + diff --git a/src/Core/Service/Service.Emails/Service.Emails.csproj b/src/Core/Service/Service.Emails/Service.Emails.csproj index 2cdcb2a..df009f9 100644 --- a/src/Core/Service/Service.Emails/Service.Emails.csproj +++ b/src/Core/Service/Service.Emails/Service.Emails.csproj @@ -6,8 +6,10 @@ - - - + + + diff --git a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj index 173c0b8..5e93f9d 100644 --- a/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj +++ b/src/Core/Service/Service.UserManagement/Service.UserManagement.csproj @@ -6,7 +6,8 @@ - - + +