[AZ-529] [AZ-530] Cycle-2 documentation refresh

Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.

Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
  /mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}

Step 13 (Update Docs) output for cycle 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:22:53 +03:00
parent c2c659ef62
commit a77b3f8a59
35 changed files with 3624 additions and 468 deletions
+88 -53
View File
@@ -5,28 +5,43 @@ Application entry point: configures DI, middleware, authentication, authorizatio
## Public Interface (HTTP Endpoints)
> **Cycle 1 (2026-05-13) note** — endpoint surface changed by AZ-513 (detection-class CRUD), AZ-196 (device auto-registration), AZ-197 (hardware-binding removal). AZ-183 (OTA update check + publish) was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete; see `_docs/05_security/security_report.md` for context. The table reflects the post-cycle-1 state including that revert.
> **Cycle 1 (2026-05-13) note** — endpoint surface changed by AZ-513 (detection-class CRUD), AZ-196 (device auto-registration), AZ-197 (hardware-binding removal). AZ-183 (OTA update check + publish) was reverted later the same day after the security audit (finding F-1).
>
> **Cycle 2 (2026-05-14) note** — three more endpoints were removed as obsolete: `POST /resources/get/{dataFolder?}`, `GET /resources/get-installer`, `GET /resources/get-installer/stage`. The encrypted-download support stack (`Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo`, `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest` DTO, `WrongResourceName = 50` enum value, `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder`) went with them. ADR-003 in `architecture.md` was retired in the same change.
> **Cycle 2 (2026-05-14) note A** — three resource endpoints removed as obsolete: `POST /resources/get/{dataFolder?}`, `GET /resources/get-installer`, `GET /resources/get-installer/stage`. The encrypted-download support stack went with them. ADR-003 in `architecture.md` was retired.
>
> **Cycle 2 (2026-05-14) note B (auth modernization)** — eight endpoints added or replaced as part of Epic AZ-529 (Auth Modernization) + AZ-530 (CMMC Hardening). The `/login` shape is now dual-token (access + refresh) when MFA is off, or `MfaRequiredResponse` when MFA is enabled. CORS dropped the cleartext origin (AZ-538). HSTS + HTTPS redirection are wired in non-Development environments. Per-IP sliding-window rate limit added to `/login` (and `/login/mfa`). Public-key JWKS feed live at `/.well-known/jwks.json` (AZ-532).
| Method | Path | Auth | Summary | Cycle 1 origin |
|--------|------|------|---------|----------------|
| POST | `/login` | Anonymous | Validates credentials, returns JWT token | — |
| POST | `/users` | ApiAdmin | Creates a new user | — |
| POST | `/devices` | ApiAdmin | Creates a CompanionPC device user (auto serial / email / 32-hex password) | AZ-196 |
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims | — |
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters | — |
| PUT | `/users/queue-offsets/set` | Any authenticated | Updates user's queue offsets | — |
| PUT | `/users/{email}/set-role/{role}` | ApiAdmin | Changes a user's role | — |
| PUT | `/users/{email}/enable` | ApiAdmin | Enables a user account | — |
| PUT | `/users/{email}/disable` | ApiAdmin | Disables a user account | — |
| DELETE | `/users/{email}` | ApiAdmin | Removes a user | — |
| POST | `/resources/{dataFolder?}` | Any authenticated | Uploads a resource file | — |
| GET | `/resources/list/{dataFolder?}` | Any authenticated | Lists files in a resource folder | — |
| POST | `/resources/clear/{dataFolder?}` | ApiAdmin | Clears a resource folder | — |
| POST | `/classes` | ApiAdmin | Creates a detection class | AZ-513 |
| PATCH | `/classes/{id:int}` | ApiAdmin | Updates a detection class (partial-merge) | AZ-513 |
| DELETE | `/classes/{id:int}` | ApiAdmin | Deletes a detection class | AZ-513 |
| Method | Path | Auth | Summary | Cycle origin |
|--------|------|------|---------|--------------|
| GET | `/health/live` | Anonymous | Liveness check (`Cache-Control: no-store`); excluded from Swagger | AZ-510 |
| GET | `/health/ready` | Anonymous | Readiness check — pings both DB connections with a 2-s timeout; 503 with reason on failure | AZ-510 |
| POST | `/login` | Anonymous + per-IP rate limit | Validates credentials. Returns `LoginResponse` (access + refresh) when MFA is off, `MfaRequiredResponse` when MFA is enabled. | AZ-531 / AZ-534 / AZ-537 |
| POST | `/login/mfa` | Anonymous + per-IP rate limit | Second-factor verification (TOTP or recovery code). Returns `LoginResponse`. | AZ-534 |
| POST | `/token/refresh` | Anonymous (token in body) | Rotates a refresh token; returns a fresh `LoginResponse`. Reuse-detection kills the family. | AZ-531 |
| POST | `/logout` | Authenticated | Revokes the caller's current session (idempotent — returns `{ alreadyRevoked }`). | AZ-535 |
| POST | `/logout/all` | Authenticated | Revokes every active session for the caller's user (returns `{ revoked: N }`). | AZ-535 |
| POST | `/sessions/{sid:guid}/revoke` | ApiAdmin | Admin revoke-by-session-id. | AZ-535 |
| GET | `/sessions/revoked` | revocationReader (Service or ApiAdmin) | Verifier-poll snapshot of revoked-but-not-yet-expired sessions. `Cache-Control: no-cache`; `since` clamped to `now - 12h`. | AZ-535 |
| POST | `/sessions/mission` | Authenticated | Mints a long-lived no-refresh mission token bound to one aircraft. AZ-533 AC-6 step-up MFA gate is a TODO comment until org-wide MFA adoption. | AZ-533 |
| POST | `/users/me/mfa/enroll` | Authenticated | Returns TOTP secret + otpauth URL + QR PNG + 10 recovery codes (ONCE). | AZ-534 |
| POST | `/users/me/mfa/confirm` | Authenticated | Validates one TOTP code and flips `mfa_enabled=true`. | AZ-534 |
| POST | `/users/me/mfa/disable` | Authenticated | Removes MFA (requires password + valid code). | AZ-534 |
| GET | `/.well-known/jwks.json` | Anonymous (excluded from Swagger) | Public JWKS feed for verifiers; `Cache-Control: public, max-age=3600`. | AZ-532 |
| POST | `/users` | ApiAdmin | Creates a new user. | — |
| POST | `/devices` | ApiAdmin | Creates a CompanionPC device user (auto serial / email / 32-hex password). | AZ-196 |
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims. | — |
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters. | — |
| PUT | `/users/queue-offsets/set` | Any authenticated | Updates user's queue offsets. | — |
| PUT | `/users/{email}/set-role/{role}` | ApiAdmin | Changes a user's role. | — |
| PUT | `/users/{email}/enable` | ApiAdmin | Enables a user account. | — |
| PUT | `/users/{email}/disable` | ApiAdmin | Disables a user account. | — |
| DELETE | `/users/{email}` | ApiAdmin | Removes a user. | — |
| POST | `/resources/{dataFolder?}` | Any authenticated | Uploads a resource file. | — |
| GET | `/resources/list/{dataFolder?}` | Any authenticated | Lists files in a resource folder. | — |
| POST | `/resources/clear/{dataFolder?}` | ApiAdmin | Clears a resource folder. | — |
| POST | `/classes` | ApiAdmin | Creates a detection class. | AZ-513 |
| PATCH | `/classes/{id:int}` | ApiAdmin | Updates a detection class (partial-merge). | AZ-513 |
| DELETE | `/classes/{id:int}` | ApiAdmin | Deletes a detection class. | AZ-513 |
### Removed endpoints
@@ -34,79 +49,99 @@ The following endpoints have been removed and now return `404`:
| Method | Path | Removed in | Reason |
|--------|------|------------|--------|
| PUT | `/users/hardware/set` | cycle 1 (AZ-197) | hardware-binding feature deleted (no fielded clients in target architecture) |
| POST | `/resources/check` | cycle 1 (AZ-197) | was the hardware-binding side-effect probe; no remaining purpose |
| POST | `/get-update` | post-cycle-1 (AZ-183 reverted) | security audit F-1: endpoint disclosed plaintext per-resource encryption keys to any authenticated caller; the underlying installer-distribution flow is itself obsolete |
| POST | `/resources/publish` | post-cycle-1 (AZ-183 reverted) | same revert as `/get-update` — the publish counterpart of the OTA flow |
| POST | `/resources/get/{dataFolder?}` | cycle 2 (2026-05-14) | obsolete — per-user encrypted-download flow no longer used by any client; ADR-003 retired |
| GET | `/resources/get-installer` | cycle 2 (2026-05-14) | obsolete — installer-shipping era is over (browser SaaS + fTPM Jetsons) |
| GET | `/resources/get-installer/stage` | cycle 2 (2026-05-14) | same as `/resources/get-installer` |
| PUT | `/users/hardware/set` | cycle 1 (AZ-197) | hardware-binding feature deleted |
| POST | `/resources/check` | cycle 1 (AZ-197) | hardware-binding side-effect probe |
| POST | `/get-update` | post-cycle-1 (AZ-183 reverted) | security audit F-1 |
| POST | `/resources/publish` | post-cycle-1 (AZ-183 reverted) | OTA flow obsolete |
| POST | `/resources/get/{dataFolder?}` | cycle 2 | obsolete; ADR-003 retired |
| GET | `/resources/get-installer` | cycle 2 | installer-shipping era over |
| GET | `/resources/get-installer/stage` | cycle 2 | same as above |
## Internal Logic
### DI Registration
- `IJwtSigningKeyProvider``JwtSigningKeyProvider` (Singleton; eagerly built before `app.Build()` so `JwtBearer` and DI share one instance) — **AZ-532**
- `IUserService``UserService` (Scoped)
- `IAuthService``AuthService` (Scoped)
- `IRefreshTokenService``RefreshTokenService` (Scoped) — **AZ-531**
- `ISessionService``SessionService` (Scoped) — **AZ-535**
- `IMissionTokenService``MissionTokenService` (Scoped) — **AZ-533**
- `IMfaService``MfaService` (Scoped) — **AZ-534**
- `IResourcesService``ResourcesService` (Scoped)
- `IDetectionClassService``DetectionClassService` (Scoped) — added by AZ-513
- `IDetectionClassService``DetectionClassService` (Scoped)
- `IAuditLog``AuditLog` (Scoped) — **AZ-537 / AZ-534**
- `IDbFactory``DbFactory` (Singleton)
- `ICache``MemoryCache` (Scoped)
- `LazyCache` via `AddLazyCache()`
- FluentValidation validators auto-discovered from `RegisterUserValidator` assembly (also picks up `CreateDetectionClassRequest`, `UpdateDetectionClassRequest` validators introduced in cycle 1)
- ASP.NET Core `DataProtection``SetApplicationName("Azaion.AdminApi")`; if `DataProtection:KeysFolder` is set, persisted to filesystem (production requirement for MFA-secret durability) — **AZ-534**
- FluentValidation validators auto-discovered from `RegisterUserValidator` assembly
- `BusinessExceptionHandler` registered as exception handler
### Middleware Pipeline
1. Swagger (dev only)
2. CORS (`AdminCorsPolicy`)
3. Authentication (JWT Bearer)
4. Authorization
5. URL rewrite: root `/``/swagger`
6. Exception handler
1. Swagger (Development only)
2. **HSTS + HTTPS redirection (non-Development only)** — AZ-538
3. CORS (`AdminCorsPolicy`)
4. Authentication (JWT Bearer with `ValidAlgorithms = [ES256]` and an `IssuerSigningKeyResolver` that picks by `kid` from `IJwtSigningKeyProvider.All`)
5. Authorization
6. **Rate limiter (`UseRateLimiter`)** — AZ-537
7. URL rewrite: root `/``/swagger`
8. Exception handler
### Authorization Policies
- `apiAdminPolicy`: requires `RoleEnum.ApiAdmin` role
- `revocationReaderPolicy`: requires `RoleEnum.Service` OR `RoleEnum.ApiAdmin` (gates `/sessions/revoked`) — **AZ-535**
> The `apiUploaderPolicy` (`RoleEnum.ResourceUploader` OR `ApiAdmin`) was added by AZ-183 and removed in the same cycle when the OTA endpoints it guarded were retired (see "Removed in cycle 1" above). `RoleEnum.ResourceUploader` itself remains as a data value (the seed `uploader@azaion.com` still uses it) but is no longer wired to any endpoint policy.
### Rate Limit Policies
- `LoginPerIpPolicy = "login-per-ip"` — sliding-window limiter keyed on `RemoteIpAddress`. Configured from `AuthConfig.RateLimit.PerIpPermitLimit` / `PerIpWindowSeconds`. On rejection, sets `Retry-After` from the `RetryAfter` lease metadata. Applied to `/login` and `/login/mfa`.
### Configuration Sections
- `JwtConfig` — JWT signing/validation
- `JwtConfig` — JWT signing/validation (Issuer, Audience, KeysFolder, ActiveKid, AccessTokenLifetimeMinutes)
- `SessionConfig` — refresh-token sliding/absolute window (RefreshSlidingHours, RefreshAbsoluteHours) — **AZ-531**
- `AuthConfig` — rate-limit and lockout knobs — **AZ-537**
- `ConnectionStrings` — DB connections
- `ResourcesConfig` — file storage path (`ResourcesFolder`); the installer subfolders were dropped in cycle 2 along with the installer endpoints
- `ResourcesConfig` — file storage path
### Kestrel
- Max request body size: 200 MB (for file uploads)
- Max request body size: 200 MB
### Logging
- Serilog: console + rolling file (`logs/log.txt`)
### CORS
- Allowed origins: `https://admin.azaion.com`, `http://admin.azaion.com`
- All methods and headers allowed
- Credentials allowed
- Allowed origin: `https://admin.azaion.com` (the cleartext `http://` origin was dropped by AZ-538)
- All methods and headers allowed; credentials allowed
### Helpers
Local static helpers used by logout / mission endpoints:
- `ParseSidClaim(ClaimsPrincipal)` — extracts the `sid` claim; throws `InvalidRefreshToken` (401) if missing/malformed.
- `ParseUserIdClaim(ClaimsPrincipal)` — extracts `NameIdentifier`; same error semantics.
- `IssueDualTokens(...)` — shared by `/login` and `/login/mfa`; calls `IRefreshTokenService.IssueForNewLogin`, `IAuthService.CreateToken`, plus `ISessionService.RevokeMissionsForAircraft` when the caller is `RoleEnum.CompanionPC` (AZ-533 AC-4 reconnect trigger).
## Dependencies
All services, configs, entities, and request types from Azaion.Common and Azaion.Services.
All services, configs, entities, and request types from `Azaion.Common` and `Azaion.Services`. New dependencies wired in cycle 2: `Microsoft.AspNetCore.RateLimiting`, `Microsoft.AspNetCore.DataProtection`.
## Consumers
None — this is the application entry point.
None — application entry point.
## Data Models
None defined here.
## Configuration
Reads `JwtConfig`, `ConnectionStrings`, `ResourcesConfig` from `IConfiguration`.
Reads `JwtConfig`, `SessionConfig`, `AuthConfig`, `ConnectionStrings`, `ResourcesConfig` from `IConfiguration`. Optional `DataProtection:KeysFolder` for MFA-secret durability.
## External Integrations
- PostgreSQL (via DI-registered `DbFactory`)
- Local filesystem (via `ResourcesService`)
- Local filesystem (via `ResourcesService` and `JwtSigningKeyProvider` for PEM keys)
## Security
- JWT Bearer authentication with full validation (issuer, audience, lifetime, signing key)
- Role-based authorization policies
- CORS restricted to `admin.azaion.com`
- Request body limit of 200 MB
- Antiforgery disabled for resource upload endpoint
- Password sent via POST body (not URL)
- JWT Bearer with full validation: `ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey`, `ValidAlgorithms = [ES256]` (AZ-532 AC-5).
- Issuer signing keys resolved per-`kid` via `IJwtSigningKeyProvider`; supports rotation overlap.
- Public JWKS endpoint exposes only public components (`x`/`y` for EC); `Cache-Control: public, max-age=3600`.
- Per-IP sliding-window rate limit on `/login` and `/login/mfa` (AZ-537).
- HSTS (1 year, includeSubDomains, preload) + HTTPS redirect in non-Development envs (AZ-538).
- CORS restricted to HTTPS origin only (AZ-538).
- DataProtection key folder must be a persistent volume in Production so encrypted MFA secrets survive restarts (AZ-534 known operational requirement; **carry-forward F3** asks for a startup warning when running in Production with the folder unset).
- Role-based authorization for admin endpoints; new `Service` role gates the verifier-poll feed.
## Tests
None directly; tested indirectly through integration tests.
None directly; tested through `e2e/Azaion.E2E/Tests/` (Login, RefreshToken, RateLimitLockout, Logout, Jwks, MissionToken, MfaEnrollment, MfaLogin, PasswordHashing).
@@ -13,31 +13,54 @@ Custom exception type for domain-level errors, paired with an `ExceptionEnum` ca
| `GetMessage` | `static string GetMessage(ExceptionEnum exEnum)` | Looks up human-readable message for an error code |
### ExceptionEnum
| Value | Code | Description |
|-------|------|-------------|
| `NoEmailFound` | 10 | No such email found |
| `EmailExists` | 20 | Email already exists |
| `WrongPassword` | 30 | Passwords do not match |
| `PasswordLengthIncorrect` | 32 | Password should be at least 12 characters (description text — actual validator threshold is 8 chars per `RegisterUserValidator`) |
| `EmailLengthIncorrect` | 35 | Email is empty or invalid |
| `WrongEmail` | 37 | (no description attribute) |
| `UserDisabled` | 38 | User account is disabled |
| `NoFileProvided` | 60 | No file provided |
| Value | Code | Description | HTTP Status |
|-------|------|-------------|-------------|
| `NoEmailFound` | 10 | No such email found | 409 |
| `EmailExists` | 20 | Email already exists | 409 |
| `WrongPassword` | 30 | Passwords do not match | 409 |
| `PasswordLengthIncorrect` | 32 | Password should be at least 12 characters | 409 |
| `EmailLengthIncorrect` | 35 | Email is empty or invalid | 409 |
| `WrongEmail` | 37 | (no description attribute) | 409 |
| `UserDisabled` | 38 | User account is disabled | 409 |
| `AccountLocked` | 50 | AZ-537 — account temporarily locked due to too many failed login attempts (carries `RetryAfterSeconds`) | **423 Locked** |
| `LoginRateLimited` | 51 | AZ-537 — too many login attempts per account; try again later (carries `RetryAfterSeconds`) | **429 Too Many Requests** |
| `InvalidRefreshToken` | 52 | AZ-531 — refresh token invalid / expired / revoked / reuse-detected | **401 Unauthorized** |
| `SessionNotFound` | 53 | AZ-535 — admin tried to revoke a non-existent session | **404 Not Found** |
| `InvalidMissionRequest` | 54 | AZ-533 — mission_id pattern fail or planned_duration_h out of bounds | **400 Bad Request** |
| `AircraftNotFound` | 55 | AZ-533 — aircraft id missing or not a `CompanionPC` user | **400 Bad Request** |
| `MfaAlreadyEnabled` | 56 | AZ-534 — `/users/me/mfa/enroll` called for a user that already has MFA on | **409 Conflict** |
| `MfaNotEnrolling` | 57 | AZ-534 — confirm called without a prior enroll | **409 Conflict** |
| `MfaNotEnabled` | 58 | AZ-534 — disable / verify-for-login called for a user without MFA | **409 Conflict** |
| `InvalidMfaCode` | 59 | AZ-534 — TOTP code (and recovery code) failed to verify | **401 Unauthorized** |
| `NoFileProvided` | 60 | No file provided | 409 |
| `InvalidMfaToken` | 61 | AZ-534 — step-1 MFA token failed to validate (signature / audience / expiry) | **401 Unauthorized** |
> **Cycle 1 (2026-05-13) note** — `HardwareIdMismatch = 40` and `BadHardware = 45` were removed by AZ-197 (admin-side hardware-binding cleanup). Codes 40 and 45 should NOT be reused for a different meaning — older clients may still surface "Hardware mismatch" UX strings keyed on the integer. `UserDisabled = 38` was added earlier (still part of the baseline). See `_docs/03_implementation/batch_06_report.md`.
### RetryAfterSeconds
| Member | Type | Description |
|--------|------|-------------|
| Constructor | `BusinessException(ExceptionEnum exEnum, int retryAfterSeconds)` | Cycle 2 (AZ-537) — sets `RetryAfterSeconds`, surfaced by `BusinessExceptionHandler` as a `Retry-After` response header. Used by `AccountLocked` (returns remaining lockout seconds) and `LoginRateLimited` (returns the window seconds). |
| `RetryAfterSeconds` | `int?` | Optional cooldown hint; null when the exception was constructed without a window. |
> **Cycle 1 (2026-05-13) note** — `HardwareIdMismatch = 40` and `BadHardware = 45` were removed by AZ-197. Codes 40 and 45 should NOT be reused.
>
> **Cycle 2 (2026-05-14) note** — `WrongResourceName = 50` was removed along with the `GetResourceRequest` validator (the only consumer). Code 50 should NOT be reused — gap kept per the cycle-1 lesson on retired numeric codes.
> **Cycle 2 (2026-05-14) note** — `WrongResourceName = 50` was removed early in the cycle along with the `GetResourceRequest` validator. The integer 50 has since been **reused for `AccountLocked`** as part of AZ-537 (since the previous user-facing string "Wrong resource name" is no longer surfaced anywhere). This is the one deliberate exception to the "gap kept" lesson — the old code had no remaining client surface and the auth modernization wanted a tightly-clustered range of new codes.
## Internal Logic
Static constructor eagerly loads all `ExceptionEnum` descriptions into a dictionary via `EnumExtensions.GetDescriptions<ExceptionEnum>()`. Messages are retrieved by dictionary lookup with fallback to `ToString()`.
Static constructor eagerly loads all `ExceptionEnum` descriptions into a dictionary via `EnumExtensions.GetDescriptions<ExceptionEnum>()`. Messages are retrieved by dictionary lookup with fallback to `ToString()`. The two-arg constructor sets `RetryAfterSeconds` for the lockout / rate-limit paths.
## Dependencies
- `EnumExtensions` — for `GetDescriptions<T>()`
## Consumers
- `BusinessExceptionHandler` — catches and serializes to HTTP 409 response
- `UserService` — throws for email/password validation failures (`NoEmailFound`, `WrongPassword`, `EmailExists`, `UserDisabled`)
- `BusinessExceptionHandler` — catches and maps via `MapStatusCode`. The default mapping is 409; cycle 2 codes use a per-enum status map (`AccountLocked` → 423, `LoginRateLimited` → 429, refresh/MFA validation failures → 401, `SessionNotFound` → 404, mission validation failures → 400, MFA conflict states → 409). When `RetryAfterSeconds > 0` the handler also stamps a `Retry-After` response header.
- `UserService` — throws for the auth path (`NoEmailFound`, `WrongPassword`, `EmailExists`, `UserDisabled`, `AccountLocked`, `LoginRateLimited`)
- `RefreshTokenService` — throws `InvalidRefreshToken` on bad/expired/reuse-detected
- `SessionService` — throws `SessionNotFound` for admin-revoke of missing sids
- `MissionTokenService` — throws `InvalidMissionRequest`, `AircraftNotFound`
- `MfaService` — throws `MfaAlreadyEnabled`, `MfaNotEnrolling`, `MfaNotEnabled`, `InvalidMfaCode`, `InvalidMfaToken`, `NoEmailFound`, `WrongPassword`
- `ResourcesService` — throws `NoFileProvided` for missing file uploads
- `Program.cs` `ParseSidClaim` / `ParseUserIdClaim` helpers — throw `InvalidRefreshToken` (401) on missing or malformed claims
- FluentValidation validators — reference `ExceptionEnum` codes in `.WithErrorCode()`
## Data Models
@@ -50,7 +73,7 @@ None.
None.
## Security
Error codes are returned to the client via `BusinessExceptionHandler`. Codes are numeric and messages are user-facing.
Error codes are returned to the client via `BusinessExceptionHandler` along with the per-enum HTTP status. The `Retry-After` header on lockout / rate-limit responses lets well-behaved clients back off without blind retries.
## Tests
None.
@@ -0,0 +1,58 @@
# Module: Azaion.Common.Configs.AuthConfig
## Purpose
Configuration POCO bundling the per-IP / per-account login rate-limit knobs and the consecutive-failure account-lockout policy. Bound from `appsettings.json` section `AuthConfig`.
> Added in cycle 2 (2026-05-14) by AZ-537 (Epic AZ-530, CMMC AC.L2-3.1.8).
## Public Interface
### AuthConfig
| Property | Type | Description |
|----------|------|-------------|
| `RateLimit` | `RateLimitOptions` | Per-IP and per-account login rate-limit windows. |
| `Lockout` | `LockoutOptions` | Consecutive-failure threshold and lockout duration. |
### RateLimitOptions
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `PerIpPermitLimit` | `int` | 10 | Allowed login attempts per IP per `PerIpWindowSeconds`. Enforced by ASP.NET Core's built-in sliding-window limiter on `/login` (and `/login/mfa`). |
| `PerIpWindowSeconds` | `int` | 60 | Window length for the per-IP limiter. |
| `PerAccountPermitLimit` | `int` | 5 | Allowed *failed* login attempts per email per `PerAccountWindowSeconds`. Enforced by `UserService.ValidateUser` against `AuditLog.CountRecentFailedLogins`. |
| `PerAccountWindowSeconds` | `int` | 300 | Window length for the per-account limiter (5 min). |
### LockoutOptions
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `MaxAttempts` | `int` | 10 | Consecutive failed logins that trigger lockout. Counter lives on `users.failed_login_count`. |
| `DurationSeconds` | `int` | 900 | Lockout duration (15 min). Sets `users.lockout_until = now() + DurationSeconds`. |
## Internal Logic
None — pure data class.
## Dependencies
None.
## Consumers
- `Program.cs` — registers via `builder.Services.Configure<AuthConfig>(...)` and reads it eagerly to build the per-IP `SlidingWindowLimiter` partition.
- `UserService.ValidateUser` — reads `RateLimit.PerAccountPermitLimit` / `PerAccountWindowSeconds` for the per-account rate limit and `Lockout.MaxAttempts` / `DurationSeconds` for lockout enforcement.
## Data Models
None.
## Configuration
Bound via `builder.Configuration.GetSection(nameof(AuthConfig))`. Override via env vars like `AuthConfig__Lockout__MaxAttempts=15`.
## External Integrations
None.
## Security
- Per-IP limit is in-memory (process-local); a multi-instance admin deployment would either need sticky-sessions on `/login` or a Redis-backed limiter (called out as a known upgrade path in `_docs/05_security/security_report.md`).
- Per-account limit is DB-backed (via `audit_events`) so it survives process restarts and is consistent across instances.
- Lockout precedence: a locked account returns 423 Locked even for a correct password until `lockout_until` passes (CMMC AC.L2-3.1.8 requires this).
## Tests
- `e2e/Azaion.E2E/Tests/RateLimitLockoutTests.cs` — covers AC-1..AC-6 of AZ-537 with the default values from this config.
@@ -1,38 +1,68 @@
# Module: Azaion.Common.Configs.JwtConfig
# Module: Azaion.Common.Configs.JwtConfig + SessionConfig
## Purpose
Configuration POCO for JWT token generation parameters, bound from `appsettings.json` section `JwtConfig`.
Configuration POCOs for JWT signing/validation and refresh-token TTLs. Bound from `appsettings.json` sections `JwtConfig` and `SessionConfig`. Both classes live in `Azaion.Common/Configs/JwtConfig.cs`.
> **Cycle 2 (2026-05-14) note (AZ-531 / AZ-532)** — major reshape:
> - HS256 shared-secret signing is gone. `Secret` is no longer read by any code path; the property is retained only as a temporary rollback escape hatch (AZ-532 spec).
> - New: `KeysFolder` (PEM directory) and `ActiveKid` (currently-signing key id) for ES256.
> - New: `AccessTokenLifetimeMinutes` (default 15) replaces the old `TokenLifetimeHours` (default 4) — short-lived access tokens are now paired with refresh-token rotation.
> - New companion class `SessionConfig` carries refresh-token TTLs.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `Issuer` | `string` | Token issuer claim |
| `Audience` | `string` | Token audience claim |
| `Secret` | `string` | HMAC-SHA256 signing key |
| `TokenLifetimeHours` | `double` | Token expiry duration in hours |
### JwtConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Issuer` | `string` | (required) | Token `iss` claim. Validated by JwtBearer middleware. |
| `Audience` | `string` | (required) | Token `aud` claim for interactive sessions. (Mission tokens override to `satellite-provider`; MFA step-1 tokens override to `azaion-mfa-step2`.) |
| `KeysFolder` | `string` | `secrets/jwt-keys` | Directory containing one ES256 PEM per key. The kid is the filename without `.pem`. |
| `ActiveKid` | `string?` | `null` | Kid currently used to sign new tokens. If null, falls back to the first PEM by ordinal filename order with a startup log warning. |
| `AccessTokenLifetimeMinutes` | `int` | 15 | Access-token TTL. |
### SessionConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `RefreshSlidingHours` | `int` | 8 | Each rotation extends `expires_at` by this many hours from `now`. |
| `RefreshAbsoluteHours` | `int` | 12 | Family is rejected past this many hours since `family_started_at`, regardless of sliding rotations. |
## Internal Logic
None — pure data class.
None — pure data classes.
## Dependencies
None.
## Consumers
- `Program.cs` — reads `JwtConfig` to configure JWT Bearer authentication middleware
- `AuthService.CreateToken` — uses Issuer, Audience, Secret, TokenLifetimeHours to build JWT tokens
- `Program.cs`
- reads `JwtConfig` eagerly to fail-fast on missing Issuer/Audience and to construct the `JwtSigningKeyProvider` before `app.Build()`
- registers `Configure<JwtConfig>` and `Configure<SessionConfig>` for downstream injection
- `JwtSigningKeyProvider` — reads `KeysFolder`, `ActiveKid`
- `AuthService.CreateToken` — reads `Issuer`, `Audience`, `AccessTokenLifetimeMinutes`
- `RefreshTokenService` — reads `SessionConfig.RefreshSlidingHours`, `RefreshAbsoluteHours`
- `MfaService.IssueMfaStepToken` / `ValidateMfaStepToken` — reads `Issuer` (audience is hard-coded to `azaion-mfa-step2`)
- `MissionTokenService.MintToken` — reads `Issuer` (audience is hard-coded to `satellite-provider`)
## Data Models
None.
## Configuration
Bound via `builder.Configuration.GetSection(nameof(JwtConfig))`. Expected env var: `ASPNETCORE_JwtConfig__Secret`.
Bound via `builder.Configuration.GetSection(nameof(JwtConfig))` and `Configure<SessionConfig>`. Override via env vars:
- `JwtConfig__Issuer=…`, `JwtConfig__Audience=…`, `JwtConfig__KeysFolder=/var/lib/azaion/jwt-keys`, `JwtConfig__ActiveKid=kid-2026-05-14`
- `SessionConfig__RefreshSlidingHours=8`, `SessionConfig__RefreshAbsoluteHours=12`
## External Integrations
None.
Filesystem (read-only on `KeysFolder`).
## Security
`Secret` is the symmetric signing key for all JWT tokens. Must be kept secret and sufficiently long for HMAC-SHA256.
- Private signing keys live on disk only; the JWKS endpoint exports only public components. `chmod 600` is applied by `scripts/generate-jwt-key.sh`.
- The legacy `Secret` field is retained but unused; remove on a follow-up cleanup ticket once the rollback window has closed.
- `RefreshAbsoluteHours` is the hard cap on session lifetime — no rotation can extend past it. Bumping above 12 h needs a security review because it directly extends the leak-window of any one refresh token.
## Tests
None.
- `e2e/Azaion.E2E/Tests/JwksTests.cs` — exercises the rotation overlap (AC-3) by manipulating `KeysFolder` and `ActiveKid`.
- `e2e/Azaion.E2E/Tests/RefreshTokenTests.cs` — exercises both the sliding and absolute caps (AC-4).
@@ -3,34 +3,42 @@
## Purpose
linq2db `DataConnection` subclass representing the application's database context.
> **Cycle 1 (2026-05-13)** — `DetectionClasses` ITable added (AZ-513).
>
> **Cycle 2 (2026-05-14)** — `AuditEvents` ITable added (AZ-537+534), `Sessions` ITable added (AZ-531+535+533+534).
## Public Interface
| Member | Type | Description |
|--------|------|-------------|
| Constructor | `AzaionDb(DataOptions dataOptions)` | Initializes connection with pre-configured options |
| `Users` | `ITable<User>` | Typed table accessor for the `users` table |
| `Users` | `ITable<User>` | Typed accessor for `public.users` |
| `DetectionClasses` | `ITable<DetectionClass>` | Typed accessor for `public.detection_classes` |
| `AuditEvents` | `ITable<AuditEvent>` | **AZ-537+534** — typed accessor for `public.audit_events` |
| `Sessions` | `ITable<Session>` | **AZ-531+535+533+534** — typed accessor for `public.sessions` (one row per refresh-token rotation; mission tokens live here too) |
## Internal Logic
Delegates all connection management to the base `DataConnection` class. `Users` property calls `this.GetTable<User>()`.
Delegates all connection management to the base `DataConnection` class. Each property calls `this.GetTable<T>()`. The actual column mapping and conversions live in `AzaionDbShemaHolder`.
## Dependencies
- `User` entity
- `User`, `DetectionClass`, `AuditEvent`, `Session` entities
- linq2db (`LinqToDB.Data.DataConnection`, `LinqToDB.ITable<T>`)
## Consumers
- `DbFactory` — creates `AzaionDb` instances inside `Run`/`RunAdmin` methods
- `DbFactory` — creates `AzaionDb` instances inside `Run`/`RunAdmin`
- `UserService`, `DetectionClassService`, `RefreshTokenService`, `SessionService`, `MissionTokenService`, `MfaService`, `AuditLog` — all consume the ITables via `IDbFactory.Run`/`RunAdmin` lambdas
## Data Models
Provides access to the `users` table.
Provides access to four tables: `users`, `detection_classes`, `audit_events`, `sessions`.
## Configuration
Receives `DataOptions` (containing connection string + mapping schema) from `DbFactory`.
Receives `DataOptions` (containing connection string + mapping schema) from `DbFactory`. The schema instance is shared between read and write `DataOptions` — produced by `AzaionDbShemaHolder.GetSchema()` once and reused.
## External Integrations
PostgreSQL database via Npgsql.
PostgreSQL via Npgsql.
## Security
None at this level; connection string security is handled by `DbFactory`.
None at this level. `IDbFactory.Run` selects the read-only connection (`AzaionDb` connection string), `RunAdmin` selects the read/write one (`AzaionDbAdmin`). The grant set on each table determines what each connection can do — see `data_model.md` §Permissions.
## Tests
Indirectly used by `UserServiceTest`.
Exercised end-to-end via the e2e suite (`e2e/Azaion.E2E/Tests/*`). All cycle-2 services have dedicated test files (`RefreshTokenFlowTests`, `LogoutRevocationTests`, `MissionTokenTests`, `MfaLoginTests`, `LoginRateLimitTests`, `PasswordHashingTests`, `AsymmetricSigningTests`, `CorsHttpsTests`).
@@ -3,6 +3,10 @@
## Purpose
Static holder for the linq2db `MappingSchema` that maps C# entities to PostgreSQL table/column naming conventions and handles custom type conversions.
> **Cycle 1 (2026-05-13)** — `DetectionClass` mapping added (AZ-513).
>
> **Cycle 2 (2026-05-14)** — `AuditEvent` and `Session` mappings added; `User.MfaRecoveryCodes` mapped as `DataType.BinaryJson` (jsonb) to satisfy Npgsql's strict OID matching for jsonb columns (AZ-534).
## Public Interface
| Member | Type | Description |
@@ -12,26 +16,27 @@ Static holder for the linq2db `MappingSchema` that maps C# entities to PostgreSQ
## Internal Logic
Static constructor:
1. Creates a `MappingSchema` with a global callback that converts all column names to snake_case via `StringExtensions.ToSnakeCase`.
2. Uses `FluentMappingBuilder` to configure the `User` entity:
- Table name: `"users"`
- `Id`: primary key, `DataType.Guid`
- `Role`: stored as text, with custom conversion to/from `RoleEnum` via `Enum.Parse`
- `UserConfig`: stored as nullable JSON text, serialized/deserialized via `Newtonsoft.Json`
2. Uses `FluentMappingBuilder` to configure the entities:
- **`User`** — table `"users"`, `Id` PK (Guid), `Role` text with `Enum.Parse` round-trip, `UserConfig` JSON via `Newtonsoft.Json` round-trip, **`MfaRecoveryCodes`** (AZ-534) as `DataType.BinaryJson` so Npgsql sends the jsonb OID instead of text (otherwise inserts fail with "column is of type jsonb but expression is of type text").
- **`DetectionClass`** — table `"detection_classes"`, `Id` PK + identity (DB-assigned).
- **`AuditEvent`** (AZ-537+534) — table `"audit_events"`, `Id` PK + identity.
- **`Session`** (AZ-531+535+533+534) — table `"sessions"`, `Id` PK (Guid). All other columns rely on the snake_case auto-mapping.
## Dependencies
- `User`, `RoleEnum` entities
- `User`, `RoleEnum`, `DetectionClass`, `AuditEvent`, `Session` entities
- `UserConfig` (for the JSON conversion)
- `StringExtensions.ToSnakeCase`
- linq2db `MappingSchema`, `FluentMappingBuilder`
- `Newtonsoft.Json`
## Consumers
- `DbFactory.LoadOptions` — passes `MappingSchema` to `DataOptions.UseMappingSchema()`
- `DbFactory.LoadOptions` — passes `MappingSchema` to `DataOptions.UseMappingSchema()` for both read and write `DataOptions` (single shared instance).
## Data Models
Defines the ORM mapping for the `users` table.
Defines the ORM mapping for `users`, `detection_classes`, `audit_events`, `sessions` tables.
## Configuration
None — all mappings are compile-time.
None — all mappings are compile-time. The `MappingSchema` is built once at first use of the static class and shared across the entire process.
## External Integrations
None directly; mappings are used when queries execute against PostgreSQL.
@@ -40,4 +45,4 @@ None directly; mappings are used when queries execute against PostgreSQL.
None.
## Tests
None.
Exercised end-to-end via the e2e suite. Misconfigured jsonb mapping would surface as a `42804` Postgres error (`column is of type jsonb but expression is of type text`) on the first MFA confirm — covered by `e2e/Azaion.E2E/Tests/MfaLoginTests.cs`.
@@ -0,0 +1,73 @@
# Module: Azaion.Common.Entities.AuditEvent
## Purpose
Append-only audit row for security-relevant events: login outcomes, lockouts, and the MFA enrollment / login lifecycle. Drives both the per-account sliding-window rate limit (AZ-537) and the human-readable security trail.
> Added in cycle 2 (2026-05-14). Initial event types from AZ-537 (login_failed / login_success / login_lockout); MFA event types added by AZ-534 in the same cycle.
## Public Interface
### AuditEvent
| Property | Type | Description |
|----------|------|-------------|
| `Id` | `long` | DB-assigned identity. |
| `EventType` | `string` | One of `AuditEventTypes`. |
| `OccurredAt` | `DateTime` | `now()` at insert. |
| `Email` | `string?` | Normalised lowercase. NULL for system events without a subject. |
| `Ip` | `string?` | Caller IP from `HttpContext.Connection.RemoteIpAddress`. NULL for background tasks. |
| `Metadata` | `string?` | Reserved for future structured payload. Not used today. |
### AuditEventTypes (constants)
| Value | When |
|-------|------|
| `login_failed` | Wrong password, locked account, or rate-limit reject. |
| `login_lockout` | Account just hit `MaxAttempts` and was locked. |
| `login_success` | Password verified, MFA not required. |
| `mfa_enroll` | `/users/me/mfa/enroll` succeeded. |
| `mfa_confirm` | `/users/me/mfa/confirm` succeeded; MFA now active. |
| `mfa_disable` | `/users/me/mfa/disable` succeeded. |
| `mfa_login_success` | `/login/mfa` succeeded with TOTP. |
| `mfa_login_failed` | `/login/mfa` rejected (bad TOTP and bad recovery code). |
| `mfa_recovery_used` | `/login/mfa` succeeded with a recovery code (also burns the code). |
## Internal Logic
None — pure data class. All write/read logic lives in `AuditLog`.
## Dependencies
None.
## Consumers
- `AuditLog` — produces every row; reads via `CountRecentFailedLogins`.
- `AzaionDb.AuditEvents``ITable<AuditEvent>` access.
- `AzaionDbSchemaHolder` — maps `AuditEvent` to the `audit_events` table.
## Data Models
Maps to PostgreSQL table `audit_events` (defined in `env/db/07_auth_lockout_and_audit.sql`).
Columns: `id (bigserial PK)`, `event_type (varchar(64))`, `occurred_at (timestamp default now())`, `email (varchar(160) NULL)`, `ip (varchar(64) NULL)`, `metadata (text NULL)`.
Index: `audit_events_event_type_email_idx (event_type, email, occurred_at DESC)` — supports the per-account sliding-window failed-login count in O(window-rows).
## Configuration
None.
## External Integrations
None.
## Security
- Append-only by convention — `azaion_admin` only has `INSERT, SELECT` on the table.
- Stores PII (email, IP); access is gated to `azaion_admin` and `azaion_reader` only. No public endpoint surfaces audit rows.
- The table backs CMMC AC.L2-3.1.8 ("limit unsuccessful logon attempts") — tampering with it bypasses the rate limit + lockout enforcement.
## Tests
Indirectly tested via `RateLimitLockoutTests`, `MfaEnrollmentTests`, `MfaLoginTests` (assertions on the resulting `audit_events` rows).
@@ -3,6 +3,8 @@
## Purpose
Defines the authorization role hierarchy for the system.
> **Cycle 2 (2026-05-14) note** — `Service = 60` added by AZ-535 for service-to-service verifier identities (satellite-provider, gps-denied, ui). Each verifier deployment provisions one `Role=Service` user; the role is gated to read `/sessions/revoked` only (via `revocationReaderPolicy`) and is not valid for any user-facing endpoint.
## Public Interface
| Enum Value | Int Value | Description |
@@ -10,9 +12,10 @@ Defines the authorization role hierarchy for the system.
| `None` | 0 | No role assigned |
| `Operator` | 10 | Annotator access only; can send annotations to queue |
| `Validator` | 20 | Annotator + dataset explorer; can receive annotations from queue |
| `CompanionPC` | 30 | Companion PC role |
| `CompanionPC` | 30 | Companion PC role (UAV / aircraft identities; AZ-533 mission tokens are bound to these via `aircraft_id`) |
| `Admin` | 40 | Admin role |
| `ResourceUploader` | 50 | Can upload DLLs and AI models |
| `ResourceUploader` | 50 | Data-only — `apiUploaderPolicy` was removed in the post-cycle-1 AZ-183 revert. The seed `uploader@azaion.com` user keeps this role for negative-auth tests. |
| `Service` | 60 | AZ-535 — service-to-service identity for verifiers polling `/sessions/revoked`. NOT valid for any user-facing endpoint. |
| `ApiAdmin` | 1000 | Full access to all operations |
## Internal Logic
@@ -24,11 +27,13 @@ None.
## Consumers
- `User.Role` property type
- `RegisterUserRequest.Role` property type
- `Program.cs` — authorization policies (`apiAdminPolicy`, `apiUploaderPolicy`)
- `Program.cs` — authorization policies (`apiAdminPolicy`, `revocationReaderPolicy` cycle 2)
- `AuthService.CreateToken` — embeds role as claim
- `AzaionDbSchemaHolder` — maps Role to/from text in DB
- `AzaionDbSchemaHolder` — maps Role to/from text in DB (text enum → `Enum.Parse(typeof(RoleEnum), v)`; the new `Service` value parses through the existing converter without migration)
- `UserService.GetUsers` — filters by role
- `UserService.ChangeRole` — updates user role
- `MissionTokenService.Issue` — validates `aircraft_id` resolves to a `CompanionPC` user
- `Program.cs` `IssueDualTokens` — fires `RevokeMissionsForAircraft` when the authenticated user has `Role = CompanionPC`
## Data Models
Part of the `User` entity.
@@ -40,7 +45,7 @@ None.
None.
## Security
Core to the RBAC authorization model. `ApiAdmin` has unrestricted access; `ResourceUploader` can upload resources; other roles have endpoint-level restrictions.
Core to the RBAC authorization model. `ApiAdmin` has unrestricted access; `Service` is narrowly scoped to the `/sessions/revoked` verifier-poll feed; `ResourceUploader` is data-only after AZ-183 was reverted; other roles have endpoint-level restrictions.
## Tests
None.
@@ -0,0 +1,85 @@
# Module: Azaion.Common.Entities.Session
## Purpose
Domain entity representing one issued refresh token (interactive sessions) or one mission token (long-lived UAV sessions). One row per issued token; rotated rows chain via `ParentSessionId` and share a `FamilyId` so reuse-detection and family-wide revocation can key off it.
> Added in cycle 2 (2026-05-14). Initial shape from AZ-531 (interactive refresh-token sessions); extended in the same cycle by AZ-535 (`RevokedByUserId`), AZ-533 (`Class`, `AircraftId`), and AZ-534 (`MfaAuthenticated`).
## Public Interface
### Session
| Property | Type | Description |
|----------|------|-------------|
| `Id` | `Guid` | Primary key. |
| `UserId` | `Guid` | FK to `users.id`. |
| `RefreshHash` | `string?` | SHA-256 hex of the opaque refresh token. NULL for mission sessions (they have no refresh value). Unique-indexed. |
| `FamilyId` | `Guid` | All rotations of the same login share this id. For interactive root rows and for mission rows, `FamilyId == Id`. |
| `IssuedAt` | `DateTime` | Row creation time. |
| `LastUsedAt` | `DateTime` | Updated on rotation; informational. |
| `ExpiresAt` | `DateTime` | Sliding (interactive) or absolute (mission) expiry. |
| `RevokedAt` | `DateTime?` | Set on rotation, reuse-detection, logout, admin revoke, post-flight reconnect. |
| `RevokedReason` | `string?` | One of `SessionRevokedReasons`. |
| `ParentSessionId` | `Guid?` | The previous row in the family (set on rotation). |
| `FamilyStartedAt` | `DateTime` | First-issue time of the family — used for the absolute expiry check. |
| `RevokedByUserId` | `Guid?` | AZ-535 — audit trail of who revoked the session. NULL for system revocations (rotation, reuse, post-flight). |
| `Class` | `string` | AZ-533 — `"interactive"` (default) or `"mission"`. |
| `AircraftId` | `Guid?` | AZ-533 — for mission sessions, the `CompanionPC` user the mission token belongs to. Used by `RevokeMissionsForAircraft`. |
| `MfaAuthenticated` | `bool` | AZ-534 — pinned at issue; refresh rotation inherits the original AMR strength even if MFA is enabled/disabled mid-session. |
### SessionRevokedReasons (constants)
| Value | When |
|-------|------|
| `rotated` | Old row marked as superseded by a successful refresh rotation. |
| `reuse_detected` | OAuth 2.1 §6.1 — already-rotated refresh re-presented; whole family killed. |
| `logged_out` | User called `POST /logout`. |
| `logged_out_all` | User called `POST /logout/all`. |
| `admin_revoked` | Admin called `POST /sessions/{sid}/revoke`. |
| `post_flight_reconnect` | Aircraft reconnected; mission auto-revoked. |
| `family_revoked` | Reserved (manual family-wide revocation; not currently emitted). |
### SessionClasses (constants)
| Value | Meaning |
|-------|---------|
| `interactive` | Refresh-backed user session (AZ-531 default). |
| `mission` | Long-lived no-refresh UAV mission token (AZ-533). |
## Internal Logic
None — pure data class. All session lifecycle logic lives in `RefreshTokenService`, `SessionService`, `MissionTokenService`.
## Dependencies
None.
## Consumers
- `RefreshTokenService` — inserts root/family rows, updates on rotation/reuse-detection
- `SessionService` — revocation paths and the verifier-poll snapshot
- `MissionTokenService` — inserts mission-class rows
- `AzaionDb.Sessions``ITable<Session>` access
- `AzaionDbSchemaHolder` — maps `Session` to the `sessions` table
## Data Models
Maps to PostgreSQL table `sessions` (defined in `env/db/08_sessions.sql`, extended by `09_sessions_logout_and_mission.sql` and `10_users_mfa.sql`).
## Configuration
None.
## External Integrations
None.
## Security
- `refresh_hash` stores SHA-256 of the opaque token; the plaintext is never persisted.
- The `family_id` partial index `sessions_family_active_idx WHERE revoked_at IS NULL` keeps reuse-detection and `RevokeAllForUser` cheap even as the revoked tail grows.
- Auto-revoke-on-reconnect (`RevokeMissionsForAircraft`) closes the mission-token "lost UAV" risk when the aircraft phones home again; the partial index `sessions_aircraft_active_idx (aircraft_id, class) WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL` keeps that check O(active mission rows).
## Tests
Indirectly tested via `RefreshTokenTests`, `LogoutTests`, `MissionTokenTests`, and `MfaLoginTests` (which all exercise the entity through the service layer).
@@ -5,18 +5,33 @@ Domain entity representing a system user, plus related value objects `UserConfig
## Public Interface
> **Cycle 2 (2026-05-14) note** — six new properties:
> - **AZ-537 (CMMC AC.L2-3.1.8)**: `FailedLoginCount` (consecutive failed-login counter) and `LockoutUntil` (active lockout deadline). Both reset on successful login.
> - **AZ-534 (TOTP 2FA)**: `MfaEnabled`, `MfaSecret` (encrypted via `IDataProtector`), `MfaRecoveryCodes` (JSONB array of `{ hash, used_at }`), `MfaEnrolledAt`, `MfaLastUsedWindow` (RFC 6238 time-step counter — defends in-window replay).
>
> `MfaEnabled`, `MfaSecret`, `MfaRecoveryCodes`, and `MfaLastUsedWindow` are `[JsonIgnore]` — they never leave the server in API responses. `PasswordHash` is also `[JsonIgnore]` (this attribute was always there).
>
> The `PasswordHash` column now holds an Argon2id PHC string for new + rehashed users (AZ-536); legacy SHA-384 entries still validate and are transparently upgraded on next successful login.
### User
| Property | Type | Description |
|----------|------|-------------|
| `Id` | `Guid` | Primary key |
| `Email` | `string` | Unique user email |
| `PasswordHash` | `string` | SHA-384 hash of plaintext password |
| `Hardware` | `string?` | Raw hardware fingerprint string (set on first resource access) |
| `PasswordHash` | `string` | Argon2id PHC string (`$argon2id$…`) for new users; legacy 64-char Base64 SHA-384 still accepted by `Security.VerifyPassword` |
| `Hardware` | `string?` | TOMBSTONED — kept nullable, not read or written by any code path (AZ-197 removed the hardware-binding feature) |
| `Role` | `RoleEnum` | Authorization role |
| `CreatedAt` | `DateTime` | Account creation timestamp |
| `LastLogin` | `DateTime?` | Last successful resource-check/hardware-check timestamp |
| `LastLogin` | `DateTime?` | Currently unused — left for forward compatibility |
| `UserConfig` | `UserConfig?` | JSON-serialized user configuration |
| `IsEnabled` | `bool` | Account active flag |
| `FailedLoginCount` | `int` | AZ-537 — consecutive failed-login counter; resets to 0 on success |
| `LockoutUntil` | `DateTime?` | AZ-537 — active lockout deadline (UTC). `>= now()` blocks login even with correct password |
| `MfaEnabled` | `bool` | AZ-534 — true after `/users/me/mfa/confirm` succeeds |
| `MfaSecret` | `string?` | AZ-534 — base32 TOTP secret encrypted at rest via `IDataProtector` (purpose `Azaion.Mfa.Secret.v1`) |
| `MfaRecoveryCodes` | `string?` | AZ-534 — JSONB array of `{ Hash, UsedAt }` |
| `MfaEnrolledAt` | `DateTime?` | AZ-534 — set by `Confirm` |
| `MfaLastUsedWindow` | `long?` | AZ-534 — RFC 6238 time-step counter of the most recently accepted code; rejects in-window replay |
| Method | Signature | Description |
|--------|-----------|-------------|
@@ -41,22 +56,30 @@ Domain entity representing a system user, plus related value objects `UserConfig
- `RoleEnum`
## Consumers
- All services (`UserService`, `AuthService`, `ResourcesService`) work with `User`
- All services (`UserService`, `AuthService`, `ResourcesService`, `MfaService`, `MissionTokenService`) work with `User`
- `AzaionDb` exposes `ITable<User>`
- `AzaionDbSchemaHolder` maps `User` to the `users` PostgreSQL table
- `AzaionDbSchemaHolder` maps `User` to the `users` PostgreSQL table; `MfaRecoveryCodes` carries an explicit `DataType.BinaryJson` mapping so Npgsql sends the JSON oid (otherwise inserts fail with "column is of type jsonb but expression is of type text")
- `SetUserQueueOffsetsRequest` uses `UserQueueOffsets`
- `Session` rows reference `User` via `UserId` (and via `AircraftId` for mission sessions targeting `RoleEnum.CompanionPC` users)
## Data Models
Maps to PostgreSQL table `users` with columns: `id`, `email`, `password_hash`, `hardware`, `role`, `user_config` (JSON text), `created_at`, `last_login`, `is_enabled`.
Maps to PostgreSQL table `users` with columns: `id`, `email`, `password_hash`, `hardware`, `role`, `user_config` (JSON text), `created_at`, `last_login`, `is_enabled`, `failed_login_count` (AZ-537), `lockout_until` (AZ-537), `mfa_enabled` (AZ-534), `mfa_secret` (AZ-534), `mfa_recovery_codes` (jsonb, AZ-534), `mfa_enrolled_at` (AZ-534), `mfa_last_used_window` (AZ-534).
Migration files: `env/db/02_structure.sql` (initial), `03_add_timestamp_columns.sql`, `06_users_email_unique.sql` (UNIQUE INDEX on email), `07_auth_lockout_and_audit.sql` (AZ-537 lockout columns + `audit_events` table), `10_users_mfa.sql` (AZ-534 MFA columns).
## Configuration
None.
None directly. `MfaSecret` encryption depends on the application-level `DataProtection:KeysFolder` setting (Production must point this at a persistent volume).
## External Integrations
None.
None directly — but `MfaSecret` depends on ASP.NET Core DataProtection for at-rest encryption.
## Security
`PasswordHash` stores SHA-384 hash. `Hardware` stores raw hardware fingerprint (hashed for comparison via `Security.GetHWHash`).
- `PasswordHash` stores Argon2id PHC strings for new + rehashed users; legacy SHA-384 still accepted (lazy-migrated on next successful login).
- `MfaSecret` is encrypted at rest via `IDataProtector` (purpose `Azaion.Mfa.Secret.v1`).
- `MfaRecoveryCodes` are SHA-256-hashed at rest; the plaintext list is shown only in the `/users/me/mfa/enroll` response.
- `MfaLastUsedWindow` defends against in-window replay of the same TOTP code.
- `FailedLoginCount` + `LockoutUntil` enforce CMMC AC.L2-3.1.8 (lockout after 10 consecutive failed logins; 15-min default duration).
- `Hardware` is a tombstone (no application code reads or writes it) per AZ-197.
## Tests
Indirectly tested end-to-end via `e2e/Azaion.E2E/Tests/LoginTests.cs`, `UserManagementTests.cs`, and `DeviceTests.cs`. (The previous in-process `Azaion.Test/UserServiceTest` and `SecurityTest` were both removed by cycle 2 along with the `Azaion.Test` project.)
Indirectly tested end-to-end via `e2e/Azaion.E2E/Tests/LoginTests.cs`, `UserManagementTests.cs`, `DeviceTests.cs`, `RateLimitLockoutTests.cs`, `MfaEnrollmentTests.cs`, `MfaLoginTests.cs`.
@@ -3,6 +3,8 @@
## Purpose
Request DTO for the `/login` endpoint.
> **Cycle 2 (2026-05-14) note** — the `/login` response shape changed (AZ-531 added refresh tokens; AZ-534 added the MFA two-step branch), but the **request** body is unchanged. The new response DTOs live in companion files: see `common_requests_login_response.md` (`LoginResponse`, `RefreshTokenRequest`) and `common_requests_mfa_requests.md` (`MfaRequiredResponse`, `MfaLoginRequest`). The `Token` legacy single-token response is preserved via `LoginResponse.Token` for backward compatibility.
## Public Interface
| Property | Type | Description |
@@ -17,8 +19,8 @@ None — pure data class. No FluentValidation validator defined for this request
None.
## Consumers
- `Program.cs` `/login` endpoint — receives as request body
- `UserService.ValidateUser` — accepts as parameter
- `Program.cs` `/login` endpoint — receives as request body; the response is either `LoginResponse` (no MFA) or `MfaRequiredResponse` (MFA enabled)
- `UserService.ValidateUser` — accepts as parameter; throws lockout/rate-limit/wrong-password/disabled exceptions per AZ-537 + AZ-536
## Data Models
None.
@@ -0,0 +1,53 @@
# Module: Azaion.Common.Requests.LoginResponse + RefreshTokenRequest
## Purpose
Response DTO for `/login`, `/login/mfa`, and `/token/refresh` (dual-token shape), plus the request DTO for `/token/refresh`.
> Added in cycle 2 (2026-05-14) by AZ-531 (Epic AZ-529, Refresh-token Flow). The pre-AZ-531 single-token `{ token }` shape is preserved via the `Token` accessor for backward compatibility — pre-AZ-531 clients see the same value via `Token` even though new clients consume `AccessToken` / `RefreshToken`.
## Public Interface
### LoginResponse
| Property | Type | Description |
|----------|------|-------------|
| `AccessToken` | `string` | The 15-min ES256 JWT to be sent as `Authorization: Bearer <…>` on subsequent requests. |
| `AccessExp` | `DateTime` | Absolute expiry of `AccessToken` (UTC). |
| `RefreshToken` | `string` | Opaque base64url string (43 chars). Send to `/token/refresh` to rotate. NEVER decode — it is not a JWT. |
| `RefreshExp` | `DateTime` | Sliding expiry of the refresh token (UTC). |
| `Token` (read-only) | `string` | Backward-compat accessor returning `AccessToken`. Pre-AZ-531 clients that read `Token` keep working. |
### RefreshTokenRequest
| Property | Type | Description |
|----------|------|-------------|
| `RefreshToken` | `string` | The opaque token returned in the previous `LoginResponse.RefreshToken` (or in the previous successful `/token/refresh` response). |
## Internal Logic
None — pure data classes. The `Token` getter is a read-only alias.
## Dependencies
None.
## Consumers
- `Program.cs` `/login` — returns `LoginResponse` (when MFA is not required) via the shared `IssueDualTokens` helper.
- `Program.cs` `/login/mfa` — returns `LoginResponse` via `IssueDualTokens` after second-factor success.
- `Program.cs` `/token/refresh` — accepts `RefreshTokenRequest`, returns `LoginResponse`.
- `RefreshTokenService.IssueForNewLogin` / `Rotate` — supplies the values that populate `LoginResponse`.
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
- `RefreshToken` is high-entropy (256 bits) and opaque. It is never logged and only ever returned in this response shape (HTTPS is mandatory in Production — see AZ-538 HSTS / HTTPS-redirect).
- `AccessToken` is a JWT carrying `sid`, `jti`, `amr`, role and email claims. Validation is configured in `Program.cs` (`ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey`, `ValidAlgorithms = [ES256]`).
- Backward-compat note — the `Token` accessor exists so pre-AZ-531 UI builds keep working during the transition. New clients should use `AccessToken` so they can also pick up `AccessExp` for proactive refresh scheduling.
## Tests
- `e2e/Azaion.E2E/Tests/RefreshTokenTests.cs` — assertions on the shape (AC-1) and on rotation behaviour (AC-2..AC-5).
@@ -0,0 +1,83 @@
# Module: Azaion.Common.Requests.MfaRequests
## Purpose
Request and response DTOs for the MFA enrollment / login surface introduced in cycle 2 by AZ-534 (Epic AZ-529, TOTP-based 2FA at credential login). All DTOs live in a single `MfaRequests.cs` file.
## Public Interface
### MfaEnrollRequest
| Property | Type | Description |
|----------|------|-------------|
| `Password` | `string` | Re-auth required for enrollment (defends a stolen access token from silently flipping MFA on). |
### MfaEnrollResponse
| Property | Type | Description |
|----------|------|-------------|
| `Secret` | `string` | 32-char base32 TOTP shared secret. Shown once. |
| `OtpAuthUrl` | `string` | Standard `otpauth://` URL the authenticator app consumes. |
| `QrPngBase64` | `string` | PNG encoding of `OtpAuthUrl` (base64). UI inlines as `data:image/png;base64,…`. |
| `RecoveryCodes` | `string[]` | 10 single-use base32 codes (each ≥12 chars). Stored hashed in `users.mfa_recovery_codes`; the plaintext list is unrecoverable after this response. |
### MfaConfirmRequest
| Property | Type | Description |
|----------|------|-------------|
| `Code` | `string` | TOTP code that validates the enrolled secret. On success `users.mfa_enabled` flips to true. |
### MfaDisableRequest
| Property | Type | Description |
|----------|------|-------------|
| `Password` | `string` | Re-auth (same defence as enroll). |
| `Code` | `string` | A valid TOTP code (recovery codes are NOT accepted here — disable should be deliberate). |
### MfaRequiredResponse
Returned by `POST /login` when the user has MFA enabled instead of `LoginResponse`.
| Property | Type | Description |
|----------|------|-------------|
| `MfaRequired` | `bool` | Always `true`. Lets dual-shape clients branch on a single field. |
| `MfaToken` | `string` | Short-lived (5 min) ES256 JWT with audience `azaion-mfa-step2`. Carry to `/login/mfa`. |
| `ExpiresIn` | `int` | Step-1 token TTL in seconds (300). |
### MfaLoginRequest
| Property | Type | Description |
|----------|------|-------------|
| `MfaToken` | `string` | The step-1 token from `MfaRequiredResponse`. |
| `Code` | `string` | A valid TOTP code OR a single-use recovery code. |
## Internal Logic
None — pure data classes.
## Dependencies
None.
## Consumers
- `Program.cs` `/users/me/mfa/enroll``MfaEnrollRequest``MfaEnrollResponse`.
- `Program.cs` `/users/me/mfa/confirm``MfaConfirmRequest`.
- `Program.cs` `/users/me/mfa/disable``MfaDisableRequest`.
- `Program.cs` `/login` — returns `MfaRequiredResponse` when `user.MfaEnabled`.
- `Program.cs` `/login/mfa``MfaLoginRequest``LoginResponse`.
- `MfaService` — consumes every request type and produces the responses.
## Data Models
None directly.
## Configuration
None.
## External Integrations
None — but `MfaToken` validation depends on `IJwtSigningKeyProvider` (ES256 keys) and `JwtConfig.Issuer`.
## Security
- `Password` fields carry plaintext credentials; HTTPS is mandatory in Production (AZ-538 HSTS / HTTPS-redirect).
- `Secret` and `RecoveryCodes` are returned ONCE in `MfaEnrollResponse` — the client must show them immediately and never send them back.
- `MfaToken` is narrowly-scoped (audience `azaion-mfa-step2`) so it cannot be used against any non-MFA endpoint even if leaked.
## Tests
- `e2e/Azaion.E2E/Tests/MfaEnrollmentTests.cs` — AC-1 (enroll shape), AC-2 (confirm), AC-5 (disable), AC-6 (encrypted at rest).
- `e2e/Azaion.E2E/Tests/MfaLoginTests.cs` — AC-3 (two-step + AMR claim), AC-4 (recovery code single-use).
@@ -0,0 +1,59 @@
# Module: Azaion.Common.Requests.MissionSessionRequest + ValidRegion + MissionSessionResponse
## Purpose
Request / response DTOs for `POST /sessions/mission` — pilot asks admin to mint a long-lived no-refresh access token for a single UAV flight.
> Added in cycle 2 (2026-05-14) by AZ-533 (Epic AZ-529, Mission-token issuance for disconnected UAV operations).
## Public Interface
### MissionSessionRequest
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `MissionId` | `string` | Yes | Must match `^M-\d{4}-\d{2}-\d{2}-\d{3}$` (validated server-side; HTTP 400 with `InvalidMissionRequest` on miss). |
| `AircraftId` | `Guid` | Yes | The user id of the `CompanionPC` user representing the aircraft. Must exist; otherwise HTTP 400 with `AircraftNotFound`. |
| `PlannedDurationH` | `double` | Yes | ∈ `[0.1, 12.0]`. Outside range → 400 `InvalidMissionRequest`. The minted token's `exp` is `now + PlannedDurationH + 1.0 h` (the buffer covers post-flight reconnect grace). |
| `RequestedScope` | `IList<string>?` | No | Optional permission strings stamped as multi-valued `permissions` claim. |
| `ValidRegion` | `ValidRegion?` | No | Optional bbox stamped as JSON-typed `valid_region` claim; informational until `satellite-provider` enforces it. |
### ValidRegion
| Property | Type |
|----------|------|
| `MinLat` / `MaxLat` / `MinLon` / `MaxLon` | `double` |
### MissionSessionResponse
| Property | Type | Description |
|----------|------|-------------|
| `AccessToken` | `string` | The ES256 JWT bound to the mission. Audience `satellite-provider`. |
| `AccessExp` | `DateTime` | Token expiry (UTC). |
| `TokenClass` | `string` | Always `"mission"`. |
| `SessionId` | `Guid` | The `sessions.id` row backing this token; verifiers see this in the `sid` claim. |
## Internal Logic
None — pure data classes. Validation runs in `MissionTokenService.Validate`; the regex is compiled-once per process.
## Dependencies
- `System.ComponentModel.DataAnnotations.Required` — surfaces 400 from minimal-API model binding when `MissionId` / `AircraftId` / `PlannedDurationH` are missing or unset.
## Consumers
- `Program.cs` `/sessions/mission` — receives `MissionSessionRequest`, returns `MissionSessionResponse`.
- `MissionTokenService.Issue` — accepts `MissionSessionRequest`, returns `MissionSessionResponse`.
## Data Models
None directly — `MissionTokenService` translates these DTOs into a `Session` row + JWT claims.
## Configuration
None.
## External Integrations
None directly. The minted token is consumed by the `satellite-provider` workspace; cross-workspace ticket coordinates verifier-side enforcement of the `mission_id` / `aircraft_id` / `valid_region` claims.
## Security
- The `MissionId` regex defends against injection of arbitrary text into a claim that downstream verifiers may use for log correlation or ABAC decisions.
- The 12-hour upper bound on `PlannedDurationH` is a hard cap — any future expansion needs a deliberate config change with a security-review trigger because it directly extends the leak-window of any one mission token.
## Tests
- `e2e/Azaion.E2E/Tests/MissionTokenTests.cs` — AC-1..AC-5 (lifetime, cap, claims, auto-revoke, auth required).
@@ -0,0 +1,60 @@
# Module: Azaion.Services.AuditLog
## Purpose
Append-only audit trail for security-relevant events (login attempts, lockouts, MFA lifecycle). Also exposes the per-account sliding-window failed-login count consumed by `UserService.ValidateUser`'s rate limit.
> Added in cycle 2 (2026-05-14). Initially shipped with AZ-537 (login lockout + per-account rate-limit feed); MFA event types added by AZ-534 in the same cycle.
## Public Interface
### IAuditLog
| Method | Signature | Description |
|--------|-----------|-------------|
| `RecordLoginFailed` | `Task RecordLoginFailed(string email, CancellationToken ct = default)` | Inserts `audit_events` row with `event_type='login_failed'`. |
| `RecordLoginLockout` | `Task RecordLoginLockout(string email, CancellationToken ct = default)` | Inserts `event_type='login_lockout'` (AZ-537 AC-6). |
| `RecordLoginSuccess` | `Task RecordLoginSuccess(string email, CancellationToken ct = default)` | Inserts `event_type='login_success'`. |
| `RecordMfaEnroll` / `RecordMfaConfirm` / `RecordMfaDisable` | `Task ...(string email, CancellationToken ct = default)` | MFA enrollment lifecycle. |
| `RecordMfaLoginSuccess` / `RecordMfaLoginFailed` / `RecordMfaRecoveryUsed` | `Task ...(string email, CancellationToken ct = default)` | MFA login outcomes. |
| `CountRecentFailedLogins` | `Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default)` | Number of `login_failed` rows for the email within the last `windowSeconds`. Drives the per-account sliding-window rate limit (AZ-537 AC-2). |
## Internal Logic
- **Email normalisation** — every insert and read lowercases the email (`ToLowerInvariant`) so case-variant addresses can't bypass the rate limit.
- **IP capture** — pulls `HttpContext.Connection.RemoteIpAddress` via `IHttpContextAccessor`. Null when there is no current request (background task). Null IPs are persisted as null, not omitted.
- **Insert path** uses `dbFactory.RunAdmin` (write privilege required); count uses `dbFactory.Run` (read-only).
- **Backing table** — `public.audit_events`, defined by `env/db/07_auth_lockout_and_audit.sql`. Supporting index `audit_events_event_type_email_idx (event_type, email, occurred_at DESC)` makes the per-account sliding-window count O(window-rows).
## Dependencies
- `IDbFactory` — read + admin connections
- `IHttpContextAccessor` — for the request IP
- `AuditEvent` entity, `AuditEventTypes` constants
## Consumers
- `UserService.ValidateUser` — calls `CountRecentFailedLogins` (per-account rate limit), `RecordLoginFailed`, `RecordLoginSuccess`, `RecordLoginLockout`.
- `MfaService` — calls every `RecordMfa*` method along the enroll/confirm/disable/login paths.
## Data Models
Operates on the `AuditEvent` entity via `AzaionDb.AuditEvents` table.
## Configuration
None directly. The window/threshold constants live on `AuthConfig.RateLimit` and `AuthConfig.Lockout`, consumed by the caller (`UserService.ValidateUser`).
## External Integrations
PostgreSQL via `IDbFactory`.
## Security
- Append-only by convention — no UPDATE/DELETE in code, and `azaion_admin` only has `INSERT, SELECT` on the table.
- The IP and email are PII; access to the table is gated to `azaion_admin` (insert + read) and `azaion_reader` (read-only). No public endpoint surfaces audit rows directly.
- The per-account sliding-window count is the foundation of CMMC AC.L2-3.1.8 enforcement; tampering with `audit_events` bypasses the rate limit.
## Tests
- `e2e/Azaion.E2E/Tests/RateLimitLockoutTests.cs` — exercises `RecordLoginFailed` + `CountRecentFailedLogins` end-to-end via the lockout/rate-limit ACs.
- `e2e/Azaion.E2E/Tests/MfaEnrollmentTests.cs` and `MfaLoginTests.cs` — assert the corresponding MFA `audit_events` rows after each lifecycle event.
@@ -1,48 +1,81 @@
# Module: Azaion.Services.AuthService
## Purpose
JWT token creation and current-user resolution from HTTP context claims.
Mints short-lived (15 min) ES256 access tokens and resolves the current user from HTTP context claims.
> **Cycle 2 (2026-05-14) note (AZ-531 / AZ-532 / AZ-534)** — `CreateToken` was completely reshaped:
> - Signing switched from HMAC-HS256 (`JwtConfig.Secret`) to ES256 via `IJwtSigningKeyProvider` (AZ-532).
> - Lifetime is now `JwtConfig.AccessTokenLifetimeMinutes` (default 15) instead of the old `TokenLifetimeHours` (default 4).
> - Tokens stamp two new claims required by the refresh / logout flow: `sid` (session id) and `jti` (per-token unique id).
> - Tokens stamp the RFC 8176 `amr` claim (multi-valued; defaults to `["pwd"]`, becomes `["pwd","mfa"]` after `/login/mfa`, with `"recovery"` appended when a recovery code was used).
> - Returns an `AccessToken` record (`Jwt` + `ExpiresAt`) so callers can populate `LoginResponse.AccessExp` directly.
## Public Interface
### IAuthService
| Method | Signature | Description |
|--------|-----------|-------------|
| `GetCurrentUser` | `Task<User?> GetCurrentUser()` | Extracts email from JWT claims, returns full User entity |
| `CreateToken` | `string CreateToken(User user)` | Generates a signed JWT token for the given user |
| `GetCurrentUser` | `Task<User?> GetCurrentUser()` | Reads `ClaimTypes.Name` from `HttpContext.User`, delegates to `IUserService.GetByEmail`. |
| `CreateToken` | `AccessToken CreateToken(User user, Guid sessionId, Guid jti, IEnumerable<string>? amr = null)` | Mints a 15-min ES256 access token bound to `sessionId`/`jti`, with the supplied `amr` values. |
### `record AccessToken(string Jwt, DateTime ExpiresAt)`
The token string + its absolute expiry (UTC). `Program.cs` packs this into `LoginResponse.AccessToken` / `LoginResponse.AccessExp`.
## Internal Logic
- **GetCurrentUser**: reads `ClaimTypes.Name` from `HttpContext.User.Claims`, then delegates to `IUserService.GetByEmail`.
- **CreateToken**: builds a `SecurityTokenDescriptor` with claims (NameIdentifier = user ID, Name = email, Role = role), signs with HMAC-SHA256 using the configured secret, sets expiry from `JwtConfig.TokenLifetimeHours`.
Private method:
- `GetCurrentUserEmail` — extracts email from claims dictionary.
- **CreateToken** builds claims:
- `ClaimTypes.NameIdentifier` = `user.Id`
- `ClaimTypes.Name` = `user.Email`
- `ClaimTypes.Role` = `user.Role.ToString()`
- `JwtRegisteredClaimNames.Sid` = `sessionId.ToString()`
- `JwtRegisteredClaimNames.Jti` = `jti.ToString()`
- One `amr` claim per element of the `amr` parameter (defaults to `["pwd"]`).
- Signs with `SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256)` using the active key from `IJwtSigningKeyProvider`. The `kid` JWT header is auto-stamped because `ECDsaSecurityKey.KeyId` is set per loaded key.
- Lifetime: `now + JwtConfig.AccessTokenLifetimeMinutes`.
- **GetCurrentUser**: reads `ClaimTypes.Name` from `HttpContext.User.Claims` and delegates to `IUserService.GetByEmail` (which is cached).
## Dependencies
- `IHttpContextAccessor` — for accessing current HTTP context
- `IOptions<JwtConfig>`JWT configuration
- `IOptions<JwtConfig>``Issuer`, `Audience`, `AccessTokenLifetimeMinutes`
- `IJwtSigningKeyProvider` (cycle 2 — ES256 active key)
- `IUserService` — for `GetByEmail` lookup
- `System.IdentityModel.Tokens.Jwt`
- `Microsoft.IdentityModel.Tokens`
## Consumers
- `Program.cs` `/login` endpoint — calls `CreateToken` after successful validation
- `Program.cs` `/users/current` — calls `GetCurrentUser` (the previously listed `/resources/get`, `/resources/get-installer`, `/resources/check` consumers were removed in cycle 2 / by AZ-197 along with their endpoints)
- `Program.cs` `/login` (after `UserService.ValidateUser`) → calls `CreateToken` via the shared `IssueDualTokens` helper.
- `Program.cs` `/login/mfa` → calls `CreateToken` with `amr` from `MfaService.VerifyForLogin`.
- `Program.cs` `/token/refresh` → calls `CreateToken` with `amr` reconstructed from the session's `MfaAuthenticated` flag.
- `Program.cs` `/users/current` → calls `GetCurrentUser`.
- `MfaService.IssueMfaStepToken` and `MissionTokenService.MintToken` mint their own tokens directly (separate audiences); they bypass `AuthService.CreateToken` on purpose.
## Data Models
None.
## Configuration
Uses `JwtConfig` (Issuer, Audience, Secret, TokenLifetimeHours).
`JwtConfig`:
- `Issuer`, `Audience` — claim values
- `AccessTokenLifetimeMinutes` (default 15) — access TTL
- `KeysFolder`, `ActiveKid` — signing key selection (consumed via `IJwtSigningKeyProvider`)
The legacy `JwtConfig.Secret` field is **no longer read** — the codebase keeps the property only as a temporary rollback escape hatch and to avoid breaking any environment that still binds it.
## External Integrations
None.
None directly. Signing key material lives on disk in `JwtConfig.KeysFolder` (default `secrets/jwt-keys/`).
## Security
- Token includes user ID, email, and role as claims
- Signed with HMAC-SHA256
- Expiry controlled by `TokenLifetimeHours` config
- Token validation parameters are configured in `Program.cs` (ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey)
- Asymmetric ES256 signing — verifiers hold only the public key set (served at `/.well-known/jwks.json`). A compromised verifier can no longer mint admin tokens.
- `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` is pinned in `Program.cs` JwtBearer config to defeat the alg-confusion attack (forging a token with `alg=HS256` using the public key as the HMAC secret).
- Every token now carries `sid` and `jti`. `sid` is the AZ-535 logout / family-revocation key; `jti` reserves the option of a per-access denylist if revocation latency ever needs to drop below the verifier-poll interval.
- The 15-min access TTL plus refresh-token rotation (AZ-531) constrains the leak-window of a stolen access token to <15 min.
## Tests
None.
- `e2e/Azaion.E2E/Tests/RefreshTokenTests.cs` (AC-1, AC-2) — verifies `AccessExp ≈ now + 15m` and that rotation produces a fresh access token.
- `e2e/Azaion.E2E/Tests/JwksTests.cs` (AC-1) — verifies `alg=ES256` and `kid` header on issued tokens.
- `e2e/Azaion.E2E/Tests/MfaLoginTests.cs` (AC-3) — verifies the `amr` claim ordering across the two-step login.
- `e2e/Azaion.E2E/Tests/LogoutTests.cs` — exercises the `sid` claim path.
@@ -0,0 +1,75 @@
# Module: Azaion.Services.JwtSigningKeyProvider
## Purpose
Loads ES256 JWT signing keys from a directory of `*.pem` files. One key is "active" (used to sign new tokens); the rest stay in the JWKS feed so in-flight tokens minted with older kids still verify during a rotation overlap window.
> Added in cycle 2 (2026-05-14) by AZ-532 (Epic AZ-529, Auth Mechanism Modernization). Replaces the HS256 shared-secret path; `JwtConfig.Secret` is no longer read by the codebase.
## Public Interface
### IJwtSigningKeyProvider
| Member | Type | Description |
|--------|------|-------------|
| `Active` | `JwtSigningKey` | The key the codebase uses to sign new tokens. Selected by `JwtConfig.ActiveKid`; falls back to the first key by filename (sorted ordinal) with a startup log warning if no `ActiveKid` is set. |
| `All` | `IReadOnlyList<JwtSigningKey>` | Every loaded key, ordered by `Kid`. Surfaced through `/.well-known/jwks.json`. |
### JwtSigningKey
| Property | Type | Description |
|----------|------|-------------|
| `Kid` | `string` | Filename without `.pem` extension. |
| `Ecdsa` | `ECDsa` | Underlying ECDSA instance (P-256). |
| `SecurityKey` | `ECDsaSecurityKey` | Microsoft.IdentityModel wrapper with `KeyId = Kid`. |
## Internal Logic
- **Eager construction** — built at host construction time in `Program.cs` (before DI is finalized) so `JwtBearer` can resolve issuer signing keys via the same instance DI registers as a singleton. Failures are fail-fast at startup, not at first-request.
- **Discovery** — `Directory.EnumerateFiles(folder, "*.pem")`, sorted ordinal. Empty folder or missing folder throws `InvalidOperationException` with a pointer to `scripts/generate-jwt-key.sh`.
- **Curve enforcement** — `EnsureP256` rejects any key whose curve OID is not `1.2.840.10045.3.1.7` / `nistP256` / `ECDSA_P256`. ES256 ⇒ P-256; the wrong curve would silently break verifiers expecting ES256.
- **Disposal** — `IDisposable` releases every loaded `ECDsa` instance.
## Dependencies
- `IOptions<JwtConfig>``KeysFolder` and `ActiveKid`
- `ILogger<JwtSigningKeyProvider>` — fallback warning when `ActiveKid` is unset
- System.Security.Cryptography (ECDsa, PEM import)
- Microsoft.IdentityModel.Tokens (ECDsaSecurityKey)
## Consumers
- `Program.cs` — registered as singleton; supplies the `IssuerSigningKeyResolver` for `JwtBearer` and is shared with `AuthService` / `MfaService` / `MissionTokenService` for signing.
- `AuthService.CreateToken` — uses `Active.SecurityKey` for `SigningCredentials`.
- `MfaService.IssueMfaStepToken` / `ValidateMfaStepToken` — same.
- `MissionTokenService.MintToken` — same.
- `Program.cs` `/.well-known/jwks.json` — exposes `All` as the JWKS feed.
## Data Models
None.
## Configuration
`JwtConfig.KeysFolder` (default `secrets/jwt-keys`) — directory containing one PEM per key.
`JwtConfig.ActiveKid` — kid of the currently-signing key. If unset, the first key by filename wins (with a startup log warning).
## External Integrations
Filesystem (read-only on `KeysFolder`).
## Security
- Private key material lives only on disk and in process memory. The JWKS endpoint exports public components only (`x`, `y` for EC).
- Keys are loaded with `chmod 600` set by `scripts/generate-jwt-key.sh` (the generator script chmods after `openssl ecparam`).
- Curve pinning prevents accidental signing with a non-P-256 key that would silently break ES256 verifiers.
- Rotation procedure (per AZ-532 spec, also documented in `scripts/generate-jwt-key.sh`):
1. Generate a new PEM with `scripts/generate-jwt-key.sh <new-kid>` next to the existing one.
2. Restart admin — JWKS now exposes both kids; the OLD kid is still active for signing.
3. Wait verifier-cache TTL (`Cache-Control: max-age=3600` = 1 h).
4. Set `JwtConfig__ActiveKid=<new-kid>` and restart admin.
5. Wait until all old-kid access tokens have expired (TTL = 15 min).
6. Delete the old PEM and restart admin — JWKS now lists only the new kid.
## Tests
- `e2e/Azaion.E2E/Tests/JwksTests.cs` — AC-1 (alg=ES256, kid present), AC-2 (JWKS shape + max-age=3600), AC-3 (two-key overlap during rotation), AC-4 (no private fields in JWKS), AC-5 (alg-confusion attack rejected via pinned `ValidAlgorithms`).
@@ -0,0 +1,73 @@
# Module: Azaion.Services.MfaService
## Purpose
RFC 6238 TOTP-based 2FA at credential login. Manages enrollment, confirmation, disable, and second-factor verification, and issues the short-lived step-1 JWT carried between `/login` and `/login/mfa`.
> Added in cycle 2 (2026-05-14) by AZ-534 (Epic AZ-529). Per-user opt-in initially; no policy yet enforces MFA by role. AZ-533 mission-token issuance has a TODO to require `amr=["pwd","mfa"]` once MFA adoption is established.
## Public Interface
### IMfaService
| Method | Signature | Description |
|--------|-----------|-------------|
| `Enroll` | `Task<MfaEnrollResponse> Enroll(Guid userId, string password, CancellationToken ct = default)` | Generates a TOTP secret + 10 single-use recovery codes, persists the encrypted secret + hashed recovery codes, returns the secret/otpauth-url/QR/recovery codes (ONCE — recovery codes are unrecoverable after this response). Requires fresh password re-auth. `mfa_enabled` stays false until `Confirm`. |
| `Confirm` | `Task Confirm(Guid userId, string code, CancellationToken ct = default)` | Validates one TOTP code against the enrolled secret; on success sets `mfa_enabled=true`. |
| `Disable` | `Task Disable(Guid userId, string password, string code, CancellationToken ct = default)` | Removes MFA; requires both password re-auth and a valid TOTP code (no recovery-code substitution here — disable should be deliberate). |
| `IssueMfaStepToken` | `string IssueMfaStepToken(Guid userId)` | Mints a 5-minute ES256 JWT (audience `azaion-mfa-step2`) returned at `/login` step-1 when the user has MFA enabled. The client carries it back to `/login/mfa`. |
| `ValidateMfaStepToken` | `Guid ValidateMfaStepToken(string token)` | Decodes a step-1 token, returns the userId. Throws `BusinessException(InvalidMfaToken)` on bad signature, audience mismatch, or expiry. |
| `VerifyForLogin` | `Task<string[]> VerifyForLogin(Guid userId, string code, CancellationToken ct = default)` | Step-2 verification at login. Returns the AMR array the access token should carry — `["pwd","mfa"]` for TOTP success, `["pwd","mfa","recovery"]` if a recovery code was consumed. Throws `BusinessException(InvalidMfaCode)` on failure. |
## Internal Logic
- **Secret generation**: 20-byte (160-bit) random key per RFC 6238 §3, encoded as 32-char base32. Stored encrypted at rest via `IDataProtector` (purpose `Azaion.Mfa.Secret.v1`).
- **otpauth URL**: built via `OtpUri` (Otp.NET) with SHA-1 / 6 digits / 30-sec period — RFC 6238 defaults.
- **QR**: PNG generated via `QRCoder.QRCodeGenerator` (ECCLevel.M), returned as base64. The endpoint hands the raw PNG bytes back; the UI inlines the data URL.
- **Recovery codes**: 10 codes, each 10 random bytes → 16-char base32. Stored as `{ Hash, UsedAt }` JSON array; hash is SHA-256 hex (high-entropy secret → fast hash is appropriate, same reasoning as the refresh-token store). Single-use enforcement via the `UsedAt` field plus a conditional update on the prior JSON to defend against concurrent-use races.
- **TOTP verification** uses Otp.NET's `Totp.VerifyTotp` with `VerificationWindow.RfcSpecifiedNetworkDelay` (±1 step). Each successful verification persists the matched time-step counter to `users.mfa_last_used_window`; subsequent codes with `matched_window <= last_used_window` are rejected to prevent in-window replay.
- **Step-1 token**: ES256 JWT with audience `azaion-mfa-step2` (intentionally distinct from the main `JwtConfig.Audience` so the main JwtBearer middleware rejects it). Lifetime 5 min — matches AZ-534 AC-3.
- **Disable's raw SQL** — setting `mfa_recovery_codes` (jsonb) back to NULL via the LinqToDB UPDATE expression API sends an untyped NULL literal that Postgres parses as text and rejects (42804). A small parameterized SQL avoids the type-inference dance.
## Dependencies
- `IDbFactory` — admin connection for user updates
- `IUserService` — user lookup by id
- `IDataProtectionProvider` — encrypts `mfa_secret` at rest (key storage configured via `DataProtection:KeysFolder`; defaults to per-machine ephemeral)
- `IJwtSigningKeyProvider` — ES256 signing for the step-1 token
- `IOptions<JwtConfig>` — issuer for the step-1 token
- `IAuditLog` — emits `mfa_enroll` / `mfa_confirm` / `mfa_disable` / `mfa_login_success` / `mfa_login_failed` / `mfa_recovery_used`
- `Security` — password verification (Argon2id) for re-auth on enroll/disable
- Otp.NET (TOTP), QRCoder (PNG generation)
## Consumers
- `Program.cs` `/users/me/mfa/enroll`, `/users/me/mfa/confirm`, `/users/me/mfa/disable`
- `Program.cs` `/login` — calls `IssueMfaStepToken` when `user.MfaEnabled`
- `Program.cs` `/login/mfa` — calls `ValidateMfaStepToken` then `VerifyForLogin`
## Data Models
Operates on the `User` entity (`mfa_enabled`, `mfa_secret`, `mfa_recovery_codes`, `mfa_enrolled_at`, `mfa_last_used_window` columns added by `env/db/10_users_mfa.sql`).
## Configuration
- `JwtConfig.Issuer` — used as the `iss` of the step-1 token.
- `DataProtection:KeysFolder` (production must set this to a persistent volume so encrypted MFA secrets survive container restarts; without it the per-machine ephemeral key store will lose every MFA secret on first deploy).
## External Integrations
PostgreSQL via `IDbFactory`. ASP.NET Core DataProtection for at-rest encryption.
## Security
- TOTP secret is base32 (32 chars) encrypted at rest with `IDataProtector`. Plaintext only exists in memory during enroll/verify.
- Recovery codes are SHA-256-hashed in the DB; the plaintext list is shown ONCE in `MfaEnrollResponse` and unrecoverable thereafter.
- `mfa_last_used_window` defends against in-window replay (a code presented twice within 30 s is rejected the second time).
- Step-1 JWT carries a narrowed audience (`azaion-mfa-step2`); the main JwtBearer middleware accepts only `JwtConfig.Audience` and rejects this token for any non-MFA endpoint.
- Re-auth with password is required for enroll and disable; this defends against a stolen access token being used to silently flip MFA state.
- **Known follow-up F2 (carried forward from Cycle 2 batch 4 review)**: `TryConsumeRecoveryCode` returns `true` even when the conditional update affects 0 rows — concurrent double-spend of the same recovery code is possible (low practical risk, but a real correctness gap).
## Tests
- `e2e/Azaion.E2E/Tests/MfaEnrollmentTests.cs` — AC-1 (enroll shape), AC-2 (confirm), AC-5 (disable), AC-6 (encrypted at rest).
- `e2e/Azaion.E2E/Tests/MfaLoginTests.cs` — AC-3 (two-step flow + AMR claim), AC-4 (recovery-code single-use).
@@ -0,0 +1,69 @@
# Module: Azaion.Services.MissionTokenService
## Purpose
Issues long-lived (≤ 12 h) single-use access tokens for offline UAV missions. Distinct from `AuthService.CreateToken` because:
- Lifetime is per-mission (`planned_duration_h + 1 h` buffer), not the 15-minute interactive policy.
- Audience is narrowed to `satellite-provider`, not the broad admin audience.
- No refresh: a single token covers the entire flight, then dies.
- Carries mission-specific claims (`mission_id`, `aircraft_id`, `valid_region`, `permissions`).
> Added in cycle 2 (2026-05-14) by AZ-533 (Epic AZ-529). Solves the "10 h offline UAV vs. 15 min interactive access token" tension without weakening interactive-session security.
## Public Interface
### IMissionTokenService
| Method | Signature | Description |
|--------|-----------|-------------|
| `Issue` | `Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default)` | Validates the request, persists a `class='mission'` row in `sessions`, mints an ES256 access token bound to that session id, returns the token + expiry + session id. |
## Internal Logic
- **Validation**:
- `mission_id` must match `^M-\d{4}-\d{2}-\d{2}-\d{3}$` (compiled regex).
- `planned_duration_h``[0.1, 12.0]` — anything outside throws `BusinessException(InvalidMissionRequest)` (HTTP 400).
- `aircraft_id` must exist in `users` with `Role=CompanionPC`; otherwise `BusinessException(AircraftNotFound)`.
- **Session row first, then token** — the row is inserted *before* the JWT is minted so revocation lookups can never miss a token already in the wild.
- **Lifetime** = `planned_duration_h + 1.0` (the 1-hour buffer covers post-flight reconnect grace).
- **Family handling** — mission sessions are their own family (`family_id = id`); they never rotate.
- **Token claims**: `sub` = pilotUserId, `sid` = session id, `jti` = unique token id, `mission_id`, `aircraft_id`, `token_class="mission"`, optional `permissions` (multi-valued), optional `valid_region` (JSON-typed claim). Audience pinned to `satellite-provider`.
- **Auto-revoke on reconnect** is implemented in `Program.cs` via `ISessionService.RevokeMissionsForAircraft`, fired from `/login` and `/token/refresh` whenever the caller is a `CompanionPC` user.
## Dependencies
- `IDbFactory` — admin connection for inserting the mission session row, read connection for the aircraft existence check
- `IJwtSigningKeyProvider` — ES256 active key
- `IOptions<JwtConfig>` — issuer
- `Session` entity, `SessionClasses.Mission` constant
- `MissionSessionRequest` / `MissionSessionResponse` DTOs
## Consumers
- `Program.cs` `/sessions/mission` (requires interactive auth; per AZ-533 the AC-6 step-up MFA gate is a TODO until org-wide MFA adoption)
## Data Models
Operates on the `Session` entity (`class='mission'`, `aircraft_id` set, `refresh_hash` null).
## Configuration
- `JwtConfig.Issuer` — issuer claim of the minted token.
- Hard-coded constants:
- `MissionAudience = "satellite-provider"` — verifier-side audience gate.
- `MaxDurationHours = 12.0`, `MinDurationHours = 0.1`.
- `LifetimeBufferHours = 1.0`.
## External Integrations
PostgreSQL via `IDbFactory`. Token consumed by the `satellite-provider` workspace (verifier-side enforcement of `mission_id`/`aircraft_id`/`valid_region` is filed under that workspace).
## Security
- Long-lived tokens are inherently dangerous if leaked. Hardware binding (mTLS / DPoP / `cnf`) is the long-term answer; documented as a known risk in `_docs/05_security/security_report.md`.
- The narrowed audience (`satellite-provider`) prevents a stolen mission token from being usable against the admin API itself; admin endpoints still require `JwtConfig.Audience`.
- The `valid_region` bbox is informational until `satellite-provider` enforces it (cross-workspace coordination ticket).
- Mission tokens are auto-revoked the moment the aircraft reconnects (`/login` or `/token/refresh` from a `CompanionPC` user). Verifiers polling `/sessions/revoked` see the revocation within their poll interval (≤ 30 s).
## Tests
- `e2e/Azaion.E2E/Tests/MissionTokenTests.cs` — AC-1 (correct lifetime + claims), AC-2 (12h cap), AC-3 (scope claims), AC-4 (auto-revoke on reconnect), AC-5 (auth required).
@@ -0,0 +1,63 @@
# Module: Azaion.Services.RefreshTokenService
## Purpose
Issues, rotates, and validates opaque refresh tokens for interactive sessions. Implements OAuth 2.1 §6.1 reuse-detection: presenting an already-rotated refresh token kills the entire session family.
> Added in cycle 2 (2026-05-14) by AZ-531 (Epic AZ-529, Auth Mechanism Modernization). Foundation for AZ-535 (logout/revocation) and AZ-534 (MFA — pins `mfa_authenticated` to the session so refresh rotation inherits the original AMR strength).
## Public Interface
### IRefreshTokenService
| Method | Signature | Description |
|--------|-----------|-------------|
| `IssueForNewLogin` | `Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default)` | Mint a fresh refresh token at login; starts a new session family. The opaque token is returned to the caller; only its SHA-256 hash is persisted. `mfaAuthenticated` is pinned to the session row so rotation preserves AMR strength. |
| `Rotate` | `Task<(string OpaqueToken, Session Session)> Rotate(string opaqueToken, CancellationToken ct = default)` | Rotate the supplied refresh token. On success returns a new opaque token + the new session row. Throws `BusinessException(InvalidRefreshToken)` on bad/expired/revoked input; on reuse-detection (already-rotated token presented again) the entire session family is revoked first. |
## Internal Logic
- **Token format**: 32 random bytes (256 bits) base64url-encoded → 43-char string (no padding). Persisted as the SHA-256 hex digest in `sessions.refresh_hash`. The opaque value is never logged.
- **Family semantics**: each `IssueForNewLogin` creates a new family (`family_id == id`). Each `Rotate` inserts a new row in the same family with `parent_session_id` chained to the previous row, then marks the previous row `revoked_reason='rotated'`.
- **Reuse detection**: if a presented token is found with `revoked_reason='rotated'`, every active row in the same family is set to `revoked_reason='reuse_detected'` (per OAuth 2.1 §6.1) — even the row that succeeded last cycle stops working.
- **Sliding expiry**: each rotation moves `expires_at` to `now + RefreshSlidingHours` (default 8 h).
- **Absolute cap**: a family older than `RefreshAbsoluteHours` (default 12 h) since `family_started_at` is rejected even if every individual rotation stayed within the sliding window.
- **Concurrency**: rotation runs in a `Serializable` transaction so two concurrent refreshes of the same token can't both succeed.
## Dependencies
- `IDbFactory` — admin connection for inserts/updates
- `IOptions<SessionConfig>` — sliding/absolute window TTLs (defined alongside `JwtConfig` in `Azaion.Common/Configs/JwtConfig.cs`)
- `Session` entity, `SessionRevokedReasons` constants
- `BusinessException` / `ExceptionEnum.InvalidRefreshToken`
- `System.Security.Cryptography.RandomNumberGenerator` + `SHA256`
## Consumers
- `Program.cs` `/login` → calls `IssueForNewLogin` after `UserService.ValidateUser` succeeds
- `Program.cs` `/login/mfa` → calls `IssueForNewLogin` after MFA second factor
- `Program.cs` `/token/refresh` → calls `Rotate`
## Data Models
Operates on the `Session` entity via `AzaionDb.Sessions` table.
## Configuration
`SessionConfig` (bound from `appsettings.json` section `SessionConfig`):
- `RefreshSlidingHours` (default 8)
- `RefreshAbsoluteHours` (default 12)
## External Integrations
PostgreSQL via `IDbFactory`.
## Security
- Refresh tokens are opaque random strings, never JWTs — verifiers cannot decode or alter them.
- The plaintext token leaves the server only at issue/rotation; the DB stores only the SHA-256 hash.
- Reuse-detection is the primary defence against stolen-refresh-token attacks: the legitimate user's next refresh will be rejected and they'll be forced to re-authenticate, but the attacker's token also dies.
- Rotation is transactional (`Serializable`) so concurrent refresh races cannot leak two valid descendants.
## Tests
- `e2e/Azaion.E2E/Tests/RefreshTokenTests.cs` — covers AC-1 (login dual tokens), AC-2 (rotation invalidates old), AC-3 (reuse kills family), AC-4 (sliding + absolute expiry), AC-5 (opaque, not JWT).
+51 -11
View File
@@ -1,39 +1,79 @@
# Module: Azaion.Services.Security
## Purpose
Static utility class providing the SHA-384 password hashing helper used by `UserService`.
Static utility class providing password hashing and verification. As of cycle 2, hashes new passwords with **Argon2id (RFC 9106)** and transparently re-hashes legacy SHA-384 entries on the next successful login.
> **Cycle 1 (2026-05-13) note** — `GetHWHash` was deleted and `GetApiEncryptionKey` was simplified from `(email, password, hardwareHash)` to `(email, password)` by AZ-197.
> **Cycle 1 (2026-05-13) note** — `GetHWHash` deleted; `GetApiEncryptionKey` simplified by AZ-197.
>
> **Cycle 2 (2026-05-14) note** — `GetApiEncryptionKey`, `EncryptTo`, and `DecryptTo` were all removed along with the encrypted-download endpoint. Only `ToHash` remains; it still backs SHA-384 password hashing in `UserService` (`PasswordHash = request.Password.ToHash()`). The `Azaion.Test/SecurityTest.cs` unit tests went with the removed methods, leaving the `Azaion.Test` project empty (also removed from the solution). See `_docs/06_metrics/retro_2026-05-14.md` once cycle 2's retro lands.
> **Cycle 2 (2026-05-14) note A** — `GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` removed with the encrypted-download endpoint. The `Azaion.Test` project went with them.
>
> **Cycle 2 (2026-05-14) note B (AZ-536)** — `ToHash` was removed and replaced with `HashPassword` + `VerifyPassword`. Hash format is now PHC: `$argon2id$v=19$m=65536,t=3,p=1$<salt-b64>$<hash-b64>`. Legacy SHA-384 hashes (64-char Base64, no `$` prefix) are still accepted for verification and the verify path returns `NeedsRehash=true` so `UserService.ValidateUser` can rewrite them on the success path. Epic AZ-530, CMMC IA.L2-3.5.10.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `ToHash` | `static string ToHash(this string str)` | Extension: SHA-384 hash of input, returned as Base64 |
| `HashPassword` | `static string HashPassword(string plaintext)` | Generates a 16-byte salt, computes Argon2id with the conservative defaults below, returns a PHC string. |
| `VerifyPassword` | `static VerifyResult VerifyPassword(string plaintext, string stored)` | Detects format by prefix. Argon2id PHC → re-derives + constant-time compare; legacy SHA-384 → re-hashes + constant-time compare. Returns `Valid`, plus `NeedsRehash=true` when (a) the stored hash is legacy SHA-384, or (b) the stored Argon2 parameters are weaker than current defaults. |
### `record VerifyResult(bool Valid, bool NeedsRehash)`
Carries the verification outcome. `NeedsRehash` is the trigger for `UserService.RegisterSuccessfulLogin` to write a fresh Argon2id hash back to the row.
## Internal Logic
- `ToHash` uses SHA-384 with UTF-8 encoding, outputting Base64.
**Defaults (RFC 9106 §4 conservative profile)**:
- Memory: 65536 KiB (64 MiB)
- Iterations: 3
- Parallelism: 1
- Salt: 16 bytes (128 bits) per RFC §3.1 minimum
- Hash output: 32 bytes (256 bits)
**Format detection**:
- Argon2id PHC string starts with `$argon2id$`.
- Legacy SHA-384: exactly 64 base64 characters and does NOT start with `$`.
- Anything else fails verify with `Valid=false, NeedsRehash=false`.
**PHC encoding** uses base64 *without* padding (PHC convention):
```
$argon2id$v=19$m=<KiB>,t=<iters>,p=<lanes>$<salt-b64-nopad>$<hash-b64-nopad>
```
**Constant-time comparison** uses `CryptographicOperations.FixedTimeEquals` for both formats — addresses AZ-536 AC-5 (no remotely-observable timing leak).
## Dependencies
- `System.Security.Cryptography` (SHA384)
- `System.Text.Encoding`
- `Konscious.Security.Cryptography.Argon2` (Argon2id implementation, pure C#)
- `System.Security.Cryptography.SHA384` (legacy verify path)
- `System.Security.Cryptography.RandomNumberGenerator` (salt entropy)
- `System.Security.Cryptography.CryptographicOperations` (constant-time compare)
## Consumers
- `Azaion.Services/UserService.cs``RegisterUser` (password storage) and `ValidateUser` (login comparison) both call `request.Password.ToHash()`
- `Azaion.Services/UserService.cs`
- `RegisterUser` — calls `HashPassword(request.Password)`
- `ValidateUser``RegisterSuccessfulLogin` — calls `VerifyPassword`; on `NeedsRehash` writes a fresh Argon2id hash back transactionally (conditional on the original hash to avoid clobbering a parallel rehash)
- `Azaion.Services/MfaService.cs`
- `Enroll` and `Disable` — re-auth via `VerifyPassword(password, user.PasswordHash)`
## Data Models
None.
## Configuration
None.
None directly. The defaults are class-level constants. Bumping them later automatically surfaces `NeedsRehash=true` for any older stored hash, so the upgrade is lazy and transparent.
## External Integrations
None.
## Security
- Password hashing uses SHA-384 with no per-user salt and no key stretching. Not resistant to rainbow-table attacks (security audit F-7 — open). Unchanged by cycles 1 and 2.
- Argon2id memory cost (64 MiB) makes GPU bruteforce attacks orders of magnitude slower than the previous SHA-384 path. Each verify costs ~50200 ms on commodity hardware (intentional latency floor).
- Legacy SHA-384 hashes are migrated on next successful login (lazy migration). Service accounts that never log in interactively (CompanionPC devices) need an admin-side bulk-reset rotation cycle to upgrade.
- The verify path is constant-time end-to-end via `FixedTimeEquals` — defends AZ-536 AC-5.
- The "needs rehash" flag also covers future parameter bumps: raising `Argon2MemoryKib`/`Argon2Iterations` here will make all weaker stored hashes upgrade themselves on the next login.
## Tests
None at the unit-test level after the `Azaion.Test` project was removed in cycle 2. `ToHash` is exercised end-to-end through every login / register e2e test (`e2e/Azaion.E2E/Tests/`).
- `e2e/Azaion.E2E/Tests/PasswordHashingTests.cs` — AC-1 (PHC format), AC-2 (legacy SHA-384 still validates), AC-3 (transparent re-hash), AC-4 (wrong password fails for both formats), AC-5 (constant-time verify).
- **Known follow-up** (carried from cycle 2 batch 4 review) — `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak` is intermittently flaky under suite-level concurrency; widen the assertion bound or warm Argon2 with a non-test login first.
- `Azaion.Services` is exercised end-to-end through every login / register / MFA flow in `e2e/Azaion.E2E/Tests/`.
@@ -0,0 +1,65 @@
# Module: Azaion.Services.SessionService
## Purpose
Logout / revocation surface and the verifier-poll snapshot. Distinct from `RefreshTokenService` (which rotates and reuse-detects); this service expresses the human / admin / system intent to kill a session and exposes the cross-service denylist feed.
> Added in cycle 2 (2026-05-14) by AZ-535 (Epic AZ-529). The `RevokeMissionsForAircraft` path was added the same day for AZ-533 (mission-token auto-revoke on reconnect).
## Public Interface
### ISessionService
| Method | Signature | Description |
|--------|-----------|-------------|
| `RevokeBySid` | `Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default)` | Revoke a single session by id. Returns `true` if the session was already revoked (no-op), `false` if this call performed the revocation. Throws `BusinessException(SessionNotFound)` if no row exists. |
| `RevokeAllForUser` | `Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default)` | Revoke every active session for a user. Returns the number of rows newly revoked. |
| `RevokeMissionsForAircraft` | `Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default)` | AZ-533 — auto-revoke every open mission session for an aircraft. Fired on successful `/login` or `/token/refresh` from a `CompanionPC` user. |
| `GetRevokedSince` | `Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default)` | Verifier-poll snapshot. Returns sessions revoked after `since` whose `exp` is still in the future (auto-prunes already-expired entries). |
### `record RevokedSession(Guid Sid, DateTime Exp, DateTime RevokedAt, string? Reason)`
Shape returned by `GetRevokedSince`. Field names match the JSON the `/sessions/revoked` endpoint serializes to verifiers.
## Internal Logic
- **Revocation reasons** are constants on `SessionRevokedReasons` (`logged_out`, `logged_out_all`, `admin_revoked`, `post_flight_reconnect`, `rotated`, `reuse_detected`, `family_revoked`).
- **Idempotency** — `RevokeBySid` reads first, then writes only if `revoked_at IS NULL`. The boolean return signals which side of the race the caller was on.
- **Mission auto-revoke** uses the partial index `sessions_aircraft_active_idx` (defined in `09_sessions_logout_and_mission.sql`) — O(active mission rows for that aircraft).
- **Snapshot pruning** — `GetRevokedSince` filters `expires_at > now()` so the response stays bounded even if revocation history grows large; the endpoint additionally clamps `since` to `now - 12 h` to prevent unbounded historical scans.
## Dependencies
- `IDbFactory` — admin connection for updates, read connection for the snapshot
- `Session` entity, `SessionRevokedReasons`, `SessionClasses` constants
- `BusinessException` / `ExceptionEnum.SessionNotFound`
## Consumers
- `Program.cs` `/logout``RevokeBySid`
- `Program.cs` `/logout/all``RevokeAllForUser`
- `Program.cs` `/sessions/{sid}/revoke` (admin-only) → `RevokeBySid`
- `Program.cs` `/sessions/revoked` (verifier-poll, gated by `revocationReaderPolicy`) → `GetRevokedSince`
- `Program.cs` `/login` and `/token/refresh` (when caller is `RoleEnum.CompanionPC`) → `RevokeMissionsForAircraft`
## Data Models
Operates on the `Session` entity via `AzaionDb.Sessions` table.
## Configuration
None directly. The `/sessions/revoked` endpoint hard-codes the 12-hour `since` floor; review if mission TTL is ever raised above 12 h.
## External Integrations
PostgreSQL via `IDbFactory`.
## Security
- The verifier-poll endpoint is gated by `revocationReaderPolicy` (`Service` or `ApiAdmin` role). Each verifier deployment (satellite-provider, gps-denied, ui) provisions one `Role=Service` user.
- The `Cache-Control: no-cache` header on `/sessions/revoked` prevents intermediaries from staleing the denylist.
- The `revoked_by_user_id` column gives an audit trail of "who revoked this session" for admin and user-initiated revocations; system revocations (rotation, reuse, post-flight) leave it null on purpose.
## Tests
- `e2e/Azaion.E2E/Tests/LogoutTests.cs` — covers AC-1 (logout revokes session), AC-2 (logout/all), AC-3 (admin revoke), AC-4 (snapshot recent + prune expired), AC-5 (idempotent logout).
- `e2e/Azaion.E2E/Tests/MissionTokenTests.cs` — exercises `RevokeMissionsForAircraft` via AC-4 (auto-revoke on reconnect).
@@ -1,69 +1,92 @@
# Module: Azaion.Services.UserService
## Purpose
Core business logic for user management: registration (web users + provisioned devices), authentication, role management, and account lifecycle.
Core business logic for user management: registration (web users + provisioned devices), authentication (with rate-limit + lockout enforcement), role management, and account lifecycle.
> **Cycle 1 (2026-05-13) note** — hardware-binding methods (`UpdateHardware`, `CheckHardwareHash`, private `UpdateLastLoginDate`) and the bound `IUserService` declarations were removed by AZ-197 (admin-side hardware-binding cleanup). Device auto-provisioning (`RegisterDevice`) was added by AZ-196. **Post-cycle-1 (security audit F-3)**: `RegisterDevice` was refactored to delegate the row insert to `RegisterUser`, and `RegisterUser` itself now relies on the new `users_email_uidx` UNIQUE INDEX (`env/db/06_users_email_unique.sql`) — the check-then-insert race is gone; `Npgsql.PostgresException(SqlState=23505)` is translated to `BusinessException(EmailExists)`. See `_docs/03_implementation/batch_05_report.md` and `batch_06_report.md`.
> **Cycle 1 (2026-05-13) note** — hardware-binding methods removed by AZ-197; device auto-provisioning (`RegisterDevice`) added by AZ-196. Post-cycle-1: `RegisterUser` now relies on the `users_email_uidx` UNIQUE INDEX; `Npgsql.PostgresException(SqlState=23505)` is translated to `BusinessException(EmailExists)`.
>
> **Cycle 2 (2026-05-14) note A (AZ-536)** — password hashing switched to Argon2id. `RegisterUser` calls `Security.HashPassword`; `ValidateUser` calls `Security.VerifyPassword`. On a `NeedsRehash=true` outcome the user's row is updated transactionally with a fresh Argon2id hash (conditional on the original `password_hash` to avoid clobbering a parallel rehash from a concurrent login).
>
> **Cycle 2 (2026-05-14) note B (AZ-537)** — `ValidateUser` now enforces account lockout (423) and per-account sliding-window rate limit (429-equivalent via `BusinessException(LoginRateLimited)`). The lockout state lives on `users.failed_login_count` / `users.lockout_until`; the rate-limit feed is `audit_events` rows of type `login_failed`. `IAuditLog` and `IOptions<AuthConfig>` are new constructor dependencies.
## Public Interface
### IUserService
| Method | Signature | Description |
|--------|-----------|-------------|
| `RegisterUser` | `Task RegisterUser(RegisterUserRequest request, CancellationToken ct)` | Creates a new user with hashed password |
| `RegisterDevice` | `Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct)` | Creates a new `CompanionPC` user with auto-assigned `azj-NNNN` serial / email and a 32-char hex password (returned plaintext exactly once) |
| `ValidateUser` | `Task<User> ValidateUser(LoginRequest request, CancellationToken ct)` | Validates email + password, returns user. Throws `NoEmailFound`, `WrongPassword`, or `UserDisabled` |
| `GetByEmail` | `Task<User?> GetByEmail(string? email, CancellationToken ct)` | Cached user lookup by email |
| `UpdateQueueOffsets` | `Task UpdateQueueOffsets(string email, UserQueueOffsets offsets, CancellationToken ct)` | Updates user's annotation queue offsets |
| `GetUsers` | `Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct)` | Lists users with optional email/role filters |
| `ChangeRole` | `Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct)` | Changes a user's role |
| `SetEnableStatus` | `Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct)` | Enables or disables a user account |
| `RemoveUser` | `Task RemoveUser(string email, CancellationToken ct)` | Permanently deletes a user |
| `RegisterUser` | `Task RegisterUser(RegisterUserRequest request, CancellationToken ct)` | Creates a new user with Argon2id-hashed password. Translates `users_email_uidx` 23505 violations to `BusinessException(EmailExists)`. |
| `RegisterDevice` | `Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct)` | Creates a new `CompanionPC` user with auto-assigned `azj-NNNN` serial / email and a 32-char hex password (returned plaintext exactly once). |
| `ValidateUser` | `Task<User> ValidateUser(LoginRequest request, CancellationToken ct)` | Validates email + password; enforces account lockout and per-account rate limit. Returns the user on success (with `failed_login_count` zeroed and any legacy SHA-384 hash transparently upgraded). Throws `NoEmailFound`, `AccountLocked` (with retry-after seconds), `LoginRateLimited` (with retry-after window), `WrongPassword`, or `UserDisabled`. |
| `GetByEmail` | `Task<User?> GetByEmail(string? email, CancellationToken ct)` | Cached user lookup by email. |
| `GetById` | `Task<User?> GetById(Guid userId, CancellationToken ct)` | Direct DB lookup by id (used by token-bound flows: refresh, MFA, mission). Not cached. |
| `UpdateQueueOffsets` | `Task UpdateQueueOffsets(string email, UserQueueOffsets offsets, CancellationToken ct)` | Updates user's annotation queue offsets. |
| `GetUsers` | `Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct)` | Lists users with optional email/role filters. |
| `ChangeRole` | `Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct)` | Changes a user's role. |
| `SetEnableStatus` | `Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct)` | Enables or disables a user account. |
| `RemoveUser` | `Task RemoveUser(string email, CancellationToken ct)` | Permanently deletes a user. |
## Internal Logic
- **RegisterUser**: hashes password via `Security.ToHash`, inserts via `RunAdmin`. Catches `Npgsql.PostgresException` with `SqlState == PostgresErrorCodes.UniqueViolation` (23505) on the `users_email_uidx` UNIQUE INDEX and rethrows as `BusinessException(EmailExists)`. The previous check-then-insert pattern was removed (race-prone before the index existed; redundant after).
- **RegisterDevice**: calls private `NextDeviceIdentity` (read-only) to compute the next `azj-NNNN` serial + matching email, generates a 32-char hex password from `RandomNumberGenerator.GetBytes(16)`, then delegates the row insert to `RegisterUser` (so any future change to user-creation policy applies here too). Returns `{Serial, Email, Password}` (plaintext password exposed exactly once at provisioning time). On a serial-allocation race, the second caller's insert hits the UNIQUE INDEX and surfaces `BusinessException(EmailExists)`; the caller can retry.
- **NextDeviceIdentity** (private): queries the most recent `RoleEnum.CompanionPC` user via `dbFactory.Run` (read connection), parses the `azj-NNNN` suffix (chars `[SerialNumberStart, SerialNumberLength)` of the email, constants on the class), increments by 1, returns `(serial, email)`.
- **ValidateUser**: finds user by email, compares password hash. Throws `NoEmailFound`, `WrongPassword`, or `UserDisabled`.
- **GetByEmail**: uses `ICache.GetFromCacheAsync` with key `User.{email}`.
- **UpdateQueueOffsets**: writes via `RunAdmin`, then invalidates the user cache.
- **GetUsers**: uses `WhereIf` for optional filter predicates.
- **RegisterUser**: hashes password via `Security.HashPassword` (Argon2id), inserts via `RunAdmin`. Catches `PostgresException(23505)` on `users_email_uidx` and rethrows as `BusinessException(EmailExists)`.
- **RegisterDevice**: queries the most recent `RoleEnum.CompanionPC` user via `dbFactory.Run`, parses the `azj-NNNN` suffix, increments by 1, generates a 32-char hex password from `RandomNumberGenerator.GetBytes(16)`, then delegates the row insert to `RegisterUser` (so future user-creation policy changes apply here too).
- **ValidateUser** (sequence — order matters):
1. Lookup by email; missing → `NoEmailFound`.
2. **Lockout gate** — if `lockout_until > now()`, throw `AccountLocked` with the remaining seconds as `RetryAfterSeconds`. This precedes the password check (CMMC AC.L2-3.1.8 — even a correct password is rejected during lockout).
3. **Per-account rate limit**`IAuditLog.CountRecentFailedLogins` over `AuthConfig.RateLimit.PerAccountWindowSeconds`; if ≥ `PerAccountPermitLimit`, throw `LoginRateLimited` with the window as `RetryAfterSeconds`.
4. **Password verify** via `Security.VerifyPassword`. Failure → `RegisterFailedLogin` (audit row + counter increment + maybe lockout) → throw `WrongPassword` (or `AccountLocked` if the failure crossed the threshold).
5. `IsEnabled` check (after verify so wrong-password and disabled-account look identical to attackers from the outside).
6. **Success path**`RegisterSuccessfulLogin`: lazy Argon2id rehash if `NeedsRehash=true` (conditional on the original hash to avoid clobbering a parallel rehash), zero `failed_login_count`, clear `lockout_until`, invalidate cache, write `login_success` audit row.
- **RegisterFailedLogin**: writes `login_failed` audit row, increments `failed_login_count`. If the new count reaches `Lockout.MaxAttempts`, sets `lockout_until = now() + DurationSeconds`, writes a `login_lockout` audit row, and throws `AccountLocked` immediately so the caller learns the threshold was crossed.
- **GetByEmail**: cached via `ICache.GetFromCacheAsync` keyed `User.{email}`.
- **GetById**: not cached (used by token-bound flows where the user id is already authenticated).
Private constants (device provisioning):
- `DeviceEmailPrefix = "azj-"`, `DeviceEmailDomain = "@azaion.com"`, `SerialNumberStart = 4`, `SerialNumberLength = 4`, `DevicePasswordBytes = 16`.
## Dependencies
- `IDbFactory` (database access)
- `ICache` (user caching)
- `Security` (hashing — `ToHash`)
- `IAuditLog` (cycle 2 — audit row writes + per-account rate-limit feed)
- `IOptions<AuthConfig>` (cycle 2 — `RateLimit.*`, `Lockout.*` thresholds)
- `Security` (Argon2id hashing — `HashPassword` / `VerifyPassword`)
- `System.Security.Cryptography.RandomNumberGenerator` (device password entropy)
- `Npgsql` (`PostgresException`, `PostgresErrorCodes.UniqueViolation` — used to translate UNIQUE-INDEX violations to `BusinessException(EmailExists)`)
- `BusinessException` (domain errors)
- `Npgsql` (`PostgresException`, `PostgresErrorCodes.UniqueViolation`)
- `BusinessException` / `ExceptionEnum` (`NoEmailFound`, `WrongPassword`, `EmailExists`, `UserDisabled`, `AccountLocked`, `LoginRateLimited`)
- `QueryableExtensions.WhereIf`
- `User`, `UserConfig`, `UserQueueOffsets`, `RoleEnum`
- `RegisterUserRequest`, `LoginRequest`, `RegisterDeviceResponse`
## Consumers
- `Program.cs``/users/*` endpoints delegate to `IUserService`
- `Program.cs` `POST /devices` calls `RegisterDevice` (added by AZ-196)
- `Program.cs` `/users/*` endpoints — delegate to `IUserService`
- `Program.cs` `POST /devices` — calls `RegisterDevice`
- `Program.cs` `/login` — calls `ValidateUser` then either short-circuits to MFA step-1 or issues dual tokens
- `Program.cs` `/login/mfa`, `/token/refresh`, `/sessions/mission` — call `GetById` after token-side identity is established
- `AuthService.GetCurrentUser` — calls `GetByEmail`
- `MfaService` — calls `GetById` for re-auth in `Enroll` / `Confirm` / `Disable` / `VerifyForLogin`
## Data Models
Operates on `User` entity via `AzaionDb.Users` table. The `User.Hardware` column is left in place (nullable, unused) per AZ-197 — see the entity doc.
Operates on `User` entity via `AzaionDb.Users`. Reads `failed_login_count` / `lockout_until` (AZ-537) and `mfa_enabled` (AZ-534). Writes `password_hash`, `failed_login_count`, `lockout_until` along the lockout/rehash paths. The `User.Hardware` column remains a tombstone (nullable, unused) per AZ-197.
## Configuration
None.
- `AuthConfig.RateLimit.PerAccountPermitLimit` / `PerAccountWindowSeconds` — sliding-window thresholds.
- `AuthConfig.Lockout.MaxAttempts` / `DurationSeconds` — consecutive-failure lockout.
## External Integrations
PostgreSQL via `IDbFactory`.
## Security
- Passwords hashed with SHA-384 (via `Security.ToHash`) before storage.
- Device passwords are returned plaintext to the caller exactly once at provisioning; the persisted form is the SHA-384 hash. The plaintext is never re-derivable.
- Passwords hashed with Argon2id (post-AZ-536). Legacy SHA-384 entries still validate and are transparently upgraded on next successful login.
- Device passwords are returned plaintext to the caller exactly once at provisioning; the persisted form is the Argon2id hash. The plaintext is never re-derivable.
- Lockout precedence (CMMC AC.L2-3.1.8): a locked account returns 423 even for a correct password until `lockout_until` passes.
- The per-account rate limit is DB-backed (via `audit_events`) so it survives process restarts — distinct from the in-memory per-IP limiter that lives in `Program.cs`.
- Read operations use the read-only DB connection; writes use the admin connection.
## Tests
- `e2e/Azaion.E2E/Tests/DeviceTests.cs` — e2e for AZ-196 device-provisioning ACs
- `e2e/Azaion.E2E/Tests/UserManagementTests.cs` and `LoginTests.cs` — e2e coverage for the rest of the user lifecycle (login, register, role change, enable/disable, delete, queue offsets)
(Unit-test coverage in `Azaion.Test/UserServiceTest.cs` was removed earlier with the AZ-197 hardware-binding cleanup; the `Azaion.Test` project itself was removed from the solution in cycle 2 once its only remaining file — `SecurityTest.cs` — was deleted with the encrypted-download stack.)
- `e2e/Azaion.E2E/Tests/RateLimitLockoutTests.cs` — AZ-537 ACs (per-IP 429, per-account 429, lockout 423, counter reset, lockout auto-expires, audit_events row on lockout).
- `e2e/Azaion.E2E/Tests/PasswordHashingTests.cs` — AZ-536 ACs (Argon2id format, legacy verify, transparent re-hash, wrong-password fail, constant-time verify).
- `e2e/Azaion.E2E/Tests/DeviceTests.cs` — AZ-196 device-provisioning ACs.
- `e2e/Azaion.E2E/Tests/UserManagementTests.cs` and `LoginTests.cs` — broader user lifecycle coverage.