mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Compare commits
13 Commits
main-2.0
...
3fd531c9f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fd531c9f0 | ||
|
|
ef27d6f553 | ||
|
|
4b3f3dc50a | ||
|
|
7c97825f91 | ||
|
|
4e48089c18 | ||
|
|
f6dc64b88b | ||
|
|
769c717405 | ||
|
|
c5571fcf47 | ||
|
|
c20be03f89 | ||
|
|
d1fedc72af | ||
|
|
b850d1047e | ||
|
|
250e5f2c9c | ||
|
|
0ab2eaaec9 |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ======================
|
||||
|
||||
@@ -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,10 @@ 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}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- devnet
|
||||
@@ -99,18 +102,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
|
||||
|
||||
91
docker-compose.min.yaml
Normal file
91
docker-compose.min.yaml
Normal file
@@ -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
|
||||
@@ -66,7 +66,10 @@ 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}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
@@ -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,10 @@ 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}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
volumes:
|
||||
- ./test-results:/app/test-results
|
||||
restart: "no"
|
||||
|
||||
@@ -58,38 +58,55 @@ 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=<generated-secret> # Signs short-lived access tokens
|
||||
|
||||
# Refresh token secret (21-day tokens)
|
||||
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
|
||||
|
||||
# Confirmation token secret (30-minute tokens)
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
|
||||
|
||||
# Website base URL (used in confirmation emails)
|
||||
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||
```
|
||||
|
||||
- **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 +291,11 @@ 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 |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| **Authentication (Frontend)** |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
|
||||
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
|
||||
@@ -339,8 +359,11 @@ 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=<generated-with-openssl>
|
||||
REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||
WEBSITE_BASE_URL=http://localhost:3000
|
||||
|
||||
# Migration
|
||||
CLEAR_DATABASE=true
|
||||
@@ -363,8 +386,6 @@ BASE_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Authentication
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||
RESET_PASSWORD_TOKEN_SECRET=<generated-with-openssl>
|
||||
SESSION_SECRET=<generated-with-openssl>
|
||||
|
||||
# Database (current Prisma setup)
|
||||
|
||||
205
docs/token-validation.md
Normal file
205
docs/token-validation.md
Normal file
@@ -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)
|
||||
@@ -1,36 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>API.Core</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>API.Core</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.OpenApi"
|
||||
Version="9.0.11"
|
||||
/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference
|
||||
Include="FluentValidation.AspNetCore"
|
||||
Version="11.3.0"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<JwtAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IConfiguration configuration
|
||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> 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 { }
|
||||
@@ -17,3 +17,5 @@ public record RegistrationPayload(
|
||||
string AccessToken,
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
|
||||
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class RefreshTokenRequestValidator
|
||||
: AbstractValidator<RefreshTokenRequest>
|
||||
{
|
||||
public RefreshTokenRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using API.Core.Contracts.Auth;
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.Auth;
|
||||
|
||||
@@ -8,15 +9,21 @@ namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController(IRegisterService register, ILoginService login)
|
||||
: ControllerBase
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(
|
||||
IRegisterService registerService,
|
||||
ILoginService loginService,
|
||||
IConfirmationService confirmationService,
|
||||
ITokenService tokenService
|
||||
) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<UserAccount>> Register(
|
||||
[FromBody] RegisterRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await register.RegisterAsync(
|
||||
var rtn = await registerService.RegisterAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.Empty,
|
||||
@@ -43,10 +50,11 @@ namespace API.Core.Controllers
|
||||
return Created("/", response);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult> 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<LoginPayload>
|
||||
@@ -61,5 +69,43 @@ namespace API.Core.Controllers
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||
{
|
||||
var rtn = await confirmationService.ConfirmUserAsync(token);
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + rtn.UserId + " is confirmed.",
|
||||
Payload = new ConfirmationPayload(
|
||||
rtn.UserId,
|
||||
rtn.ConfirmedAt
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult> Refresh(
|
||||
[FromBody] RefreshTokenRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = new LoginPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
@@ -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<ResponseBody<object>> Get()
|
||||
{
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<object>
|
||||
{
|
||||
Message = "Protected endpoint accessed successfully",
|
||||
Payload = new { userId, username },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/"]
|
||||
|
||||
@@ -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<GlobalExceptionFilter> 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 }
|
||||
|
||||
@@ -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<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
|
||||
|
||||
// Register the exception filter
|
||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||
|
||||
// Configure JWT Authentication
|
||||
builder
|
||||
.Services.AddAuthentication("JWT")
|
||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||
"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");
|
||||
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>API.Specs</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>API.Specs</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
|
||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||
<PackageReference
|
||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||
Version="3.3.3"
|
||||
PrivateAssets="all"
|
||||
/>
|
||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||
<PackageReference
|
||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||
Version="3.3.3"
|
||||
PrivateAssets="all"
|
||||
/>
|
||||
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Ensure feature files are included in the project -->
|
||||
<None Include="Features\**\*.feature" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Ensure feature files are included in the project -->
|
||||
<None Include="Features\**\*.feature" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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/"]
|
||||
|
||||
@@ -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
|
||||
76
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
76
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
@@ -0,0 +1,76 @@
|
||||
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
|
||||
And I have a valid access 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
|
||||
And I have a valid access 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
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
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
|
||||
And I have a valid access 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
|
||||
And I have a valid access token for my account
|
||||
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
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
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
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
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"
|
||||
|
||||
Scenario: Confirmation fails without an access 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 without an access token
|
||||
Then the response has HTTP status 401
|
||||
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
@@ -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
|
||||
@@ -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<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,767 @@ 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 access token for my account")]
|
||||
public void GivenIHaveAValidAccessTokenForMyAccount()
|
||||
{
|
||||
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||
? id
|
||||
: throw new InvalidOperationException(
|
||||
"registered user ID not found in scenario"
|
||||
);
|
||||
var username = scenario.TryGetValue<string>(
|
||||
RegisteredUsernameKey,
|
||||
out var user
|
||||
)
|
||||
? user
|
||||
: throw new InvalidOperationException(
|
||||
"registered username not found in scenario"
|
||||
);
|
||||
|
||||
var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
|
||||
scenario["accessToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
secret,
|
||||
DateTime.UtcNow.AddMinutes(60)
|
||||
);
|
||||
}
|
||||
|
||||
[Given("I have a valid confirmation token for my account")]
|
||||
public void GivenIHaveAValidConfirmationTokenForMyAccount()
|
||||
{
|
||||
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||
? id
|
||||
: throw new InvalidOperationException(
|
||||
"registered user ID not found in scenario"
|
||||
);
|
||||
var username = scenario.TryGetValue<string>(
|
||||
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<Guid>(RegisteredUserIdKey, out var id)
|
||||
? id
|
||||
: throw new InvalidOperationException(
|
||||
"registered user ID not found in scenario"
|
||||
);
|
||||
var username = scenario.TryGetValue<string>(
|
||||
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<Guid>(RegisteredUserIdKey, out var id)
|
||||
? id
|
||||
: throw new InvalidOperationException(
|
||||
"registered user ID not found in scenario"
|
||||
);
|
||||
var username = scenario.TryGetValue<string>(
|
||||
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<string>("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<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "valid-token";
|
||||
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "valid-token";
|
||||
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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 accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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<string>("accessToken", out var oldAccessToken))
|
||||
{
|
||||
scenario[PreviousAccessTokenKey] = oldAccessToken;
|
||||
}
|
||||
if (scenario.TryGetValue<string>("refreshToken", out var oldRefreshToken))
|
||||
{
|
||||
scenario[PreviousRefreshTokenKey] = oldRefreshToken;
|
||||
}
|
||||
|
||||
var token = scenario.TryGetValue<string>("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<string>("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<string>("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<string>("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<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "expired-confirmation-token";
|
||||
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "tampered-confirmation-token";
|
||||
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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 accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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<string>("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<string>("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 accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||
? at
|
||||
: string.Empty;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
|
||||
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 without an access token")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("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;
|
||||
}
|
||||
|
||||
[Then("the response JSON should have a new access token")]
|
||||
public void ThenTheResponseJsonShouldHaveANewAccessToken()
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<string>(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<string>(
|
||||
PreviousAccessTokenKey,
|
||||
out var previousAccessToken
|
||||
)
|
||||
)
|
||||
{
|
||||
accessToken.Should().NotBe(previousAccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Then("the response JSON should have a new refresh token")]
|
||||
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<string>(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<string>(
|
||||
PreviousRefreshTokenKey,
|
||||
out var previousRefreshToken
|
||||
)
|
||||
)
|
||||
{
|
||||
refreshToken.Should().NotBe(previousRefreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,19 @@
|
||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Domain/">
|
||||
<Project Path="Domain.Entities\Domain.Entities.csproj" />
|
||||
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
||||
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Service/">
|
||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
||||
}
|
||||
@@ -16,4 +16,8 @@
|
||||
Version="8.2.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<Claim>
|
||||
{
|
||||
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<ClaimsPrincipal> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Domain.Entities.UserAccount?> 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<Domain.Entities.UserAccount?> 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<bool> 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;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// </summary>
|
||||
|
||||
@@ -60,4 +60,19 @@ public interface IAuthRepository
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a user account as confirmed.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||
/// <returns>The confirmed UserAccount entity</returns>
|
||||
/// <exception cref="UnauthorizedException">If user account not found</exception>
|
||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by ID.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
155
src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs
Normal file
155
src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs
Normal file
@@ -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<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly ConfirmationService _confirmationService;
|
||||
|
||||
public ConfirmationServiceTest()
|
||||
{
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
|
||||
_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<Claim>
|
||||
{
|
||||
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<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[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<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[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<Claim>
|
||||
{
|
||||
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<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
@@ -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/"]
|
||||
|
||||
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
@@ -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<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceRefreshTest()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// 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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Mock the generation of new tokens
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.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<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||
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<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
@@ -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<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceValidationTest()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// 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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.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<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "expired-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Token has expired"
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.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<Claim>
|
||||
{
|
||||
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<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.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<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "invalid-confirmation-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
34
src/Core/Service/Service.Auth/ConfirmationService.cs
Normal file
34
src/Core/Service/Service.Auth/ConfirmationService.cs
Normal file
@@ -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<ConfirmationServiceReturn> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal file
11
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal file
@@ -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<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||
}
|
||||
@@ -2,6 +2,11 @@ using Domain.Entities;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record LoginServiceReturn(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
public interface ILoginService
|
||||
{
|
||||
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
||||
|
||||
@@ -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<T>(UserAccount user) where T : struct, Enum;
|
||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||
Task<RefreshTokenResult> 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<T>(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<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
|
||||
private async Task<ValidatedToken> 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<RefreshTokenResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -17,13 +17,17 @@ public class EmailService(
|
||||
IEmailTemplateProvider emailTemplateProvider
|
||||
) : IEmailService
|
||||
{
|
||||
private static readonly string WebsiteBaseUrl =
|
||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||
|
||||
public async Task SendRegistrationEmailAsync(
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var confirmationLink =
|
||||
$"https://thebiergarten.app/confirm?token={confirmationToken}";
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
var emailHtml =
|
||||
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user