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/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/Contracts/Auth/RefreshToken.cs b/src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs new file mode 100644 index 0000000..e697656 --- /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 54d80b5..d39f56f 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -11,7 +11,8 @@ namespace API.Core.Controllers public class AuthController( IRegisterService registerService, ILoginService loginService, - IConfirmationService confirmationService + IConfirmationService confirmationService, + ITokenService tokenService ) : ControllerBase { [HttpPost("register")] @@ -80,5 +81,28 @@ namespace API.Core.Controllers } ); } + + [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 + ), + } + ); + } } }