mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 13:41:10 +00:00
refactor: remove obsolete resource download and installer endpoints
- Deleted the `POST /resources/get/{dataFolder?}` and `GET /resources/get-installer` endpoints as part of the architectural shift towards simplified resource management.
- Removed associated methods and configurations, including `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, and related properties in `ResourcesConfig`.
- Cleaned up environment variables and configuration files to reflect the removal of installer-related settings.
- Eliminated the `GetResourceRequest` DTO and its validator, along with the `WrongResourceName` error code.
- Updated documentation to clarify the changes in resource handling and the retirement of per-user file encryption.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Dependencies Table
|
||||
|
||||
**Date**: 2026-05-13 (refreshed; original 2026-04-16)
|
||||
**Total Tasks**: 11 (7 done test tasks + 4 active product tasks)
|
||||
**Total Complexity Points**: 40
|
||||
**Date**: 2026-05-14 (refreshed; previous 2026-05-13)
|
||||
**Total Tasks**: 19 (7 done test tasks + 12 active product tasks)
|
||||
**Total Complexity Points**: 71
|
||||
|
||||
| Task | Name | Complexity | Dependencies | Epic | Status |
|
||||
|--------|-------------------------------|-----------:|-------------------------|--------|--------|
|
||||
@@ -17,9 +17,21 @@
|
||||
| AZ-196 | register_device_endpoint | 2 | None | AZ-181 | todo |
|
||||
| AZ-197 | remove_hardware_id | 3 | None | AZ-181 | todo |
|
||||
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | todo |
|
||||
| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | todo |
|
||||
| AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | todo |
|
||||
| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | todo |
|
||||
| AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | todo |
|
||||
| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | todo |
|
||||
| AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | todo |
|
||||
| AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | todo |
|
||||
| AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | todo |
|
||||
|
||||
## Notes
|
||||
|
||||
- AZ-513 added 2026-05-13 (cross-workspace prerequisite from `ui/` workspace AZ-512). Filed under epic AZ-509 (Cycle 3 — Auth bootstrap fix + classColors carve-out + admin class edit).
|
||||
- **AZ-529 / AZ-530 added 2026-05-14**: two new epics covering the auth-mechanism modernization and a focused CMMC compliance pass.
|
||||
- **AZ-529 — Auth Mechanism Modernization** (5 tasks, 23 pts): refresh-token flow, asymmetric signing + JWKS, mission tokens for UAV, TOTP 2FA, logout/revocation. AZ-531 is the foundation that AZ-533 and AZ-535 build on; AZ-532 is independent and can land first or in parallel.
|
||||
- **AZ-530 — CMMC Compliance Hardening** (3 tasks, 8 pts): Argon2id password hashing, /login rate limit + lockout, CORS https-only + HSTS. All three are independent and shippable now; AZ-536 + AZ-537 both touch `UserService.ValidateUser` so land AZ-536 first.
|
||||
- **MFA scope**: TOTP enrollment + login validation lives in admin only (AZ-534). Other services (satellite-provider, gps-denied, ui) consume the `amr` claim if they need step-up checks — they do NOT enforce MFA themselves.
|
||||
- **Cross-workspace verifier work** (satellite-provider, gps-denied, ui must switch from HS256 shared secret to JWKS verification, plus add denylist polling) is intentionally **deferred** to per-workspace tickets, to be filed once admin's AZ-529 epic is close to shipping.
|
||||
- AZ-513 added 2026-05-13 (cross-workspace prerequisite from `ui/` workspace AZ-512). Filed under epic AZ-509.
|
||||
- AZ-197 originally listed `Component: Admin API, Loader`; the Loader workspace was architecturally retired (see `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`) and the spec was adapted on 2026-05-13 to be admin-only.
|
||||
- All four active tasks (AZ-183, AZ-196, AZ-197, AZ-513) are independent — no inter-task dependencies in this active set.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Refresh-Token Flow with Rotation + Reuse Detection
|
||||
|
||||
**Task**: AZ-531_refresh_token_flow
|
||||
**Name**: Refresh-token flow with rotation + reuse detection
|
||||
**Description**: Replace single 4h JWT with short-lived (15m) access + opaque refresh token. Rotate refresh on every use; kill the session family on reuse-detection per OAuth 2.1 §6.1. Persists session state in a new `sessions` table — the foundation logout/revocation will build on.
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: None
|
||||
**Component**: Admin API + Services + DataAccess
|
||||
**Tracker**: AZ-531
|
||||
**Epic**: AZ-529
|
||||
|
||||
## Problem
|
||||
|
||||
`/login` today returns a single 4-hour HS256 JWT (`AuthService.CreateToken`). There is no refresh, no logout, and no way to shorten the access lifetime without forcing users to re-enter credentials every few minutes. Stolen tokens are valid for the full 4 h with no remediation.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `POST /login` returns `{ access_token, access_exp, refresh_token, refresh_exp }`. Access TTL = 15 min. Refresh TTL = 8 h sliding, 12 h absolute.
|
||||
- `POST /token/refresh` accepts an opaque refresh token, **rotates** it (issues new access + new refresh, invalidates old refresh), and returns the same shape.
|
||||
- Refresh-reuse detection: if an already-rotated refresh token is presented again, the entire session family is killed (per OAuth 2.1 §6.1).
|
||||
- Refresh tokens are opaque random 32-byte base64url strings stored hashed in `sessions` table — never JWTs.
|
||||
- Existing single-token `/login` callers (UI) get an additive shape; older clients that ignore the new fields keep working until they're updated.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- New `sessions` table (id, user_id, refresh_hash, family_id, issued_at, last_used_at, expires_at, revoked_at, revoked_reason, parent_session_id).
|
||||
- `IRefreshTokenService` + impl in `Azaion.Services/`.
|
||||
- `/token/refresh` minimal-API handler in `Azaion.AdminApi/Program.cs`.
|
||||
- Update `AuthService.CreateToken` to take refresh-context and stamp `jti` + `sid` claims on access tokens (needed by AZ-535 logout ticket).
|
||||
- Update `LoginRequest`/`LoginResponse` DTO shape in `Azaion.Common/Requests/`.
|
||||
- Migration script for the `sessions` table.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Asymmetric signing — see AZ-532.
|
||||
- Logout endpoint — see AZ-535. This ticket only persists session state.
|
||||
- 2FA enforcement on `/login` — see AZ-534.
|
||||
- UI changes to consume the new shape — cross-workspace ticket filed once admin lands.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: /login returns dual tokens**
|
||||
Given valid credentials
|
||||
When `POST /login` is called
|
||||
Then response body has non-empty `access_token` (JWT, exp ≈ now+15m ±60s) AND `refresh_token` (opaque ≥43 chars), and a session row exists.
|
||||
|
||||
**AC-2: /token/refresh rotates the refresh token**
|
||||
Given a valid refresh token
|
||||
When `POST /token/refresh` is called with it
|
||||
Then response returns a new access + new refresh; the old refresh becomes invalid; session row's `refresh_hash` is updated; `parent_session_id` chains to the previous row.
|
||||
|
||||
**AC-3: Reuse-detection kills family**
|
||||
Given refresh token R1 was rotated to R2
|
||||
When R1 is presented again
|
||||
Then `POST /token/refresh` returns 401, every session in R1's family is marked `revoked_reason='reuse_detected'`, and R2 also stops working.
|
||||
|
||||
**AC-4: Sliding + absolute expiry**
|
||||
Given a refresh token issued 7 h 50 min ago
|
||||
When used
|
||||
Then rotation succeeds, sliding window extended; if same family is older than 12 h absolute since first issue, refresh fails 401.
|
||||
|
||||
**AC-5: Refresh tokens are opaque, not JWT**
|
||||
Given any refresh token from `/login` or `/token/refresh`
|
||||
When decoded
|
||||
Then it is not a JWT (no dot-separated base64url segments parse as a header/payload). Stored as SHA-256 hash, raw value never logged.
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Seed user | POST /login | 200 with both tokens, exp ≈ now+15m | — |
|
||||
| AC-2 | Refresh R1 from AC-1 | POST /token/refresh with R1 | New access + new refresh; R1 invalid | — |
|
||||
| AC-3 | R1 rotated to R2 | POST /token/refresh with R1 again | 401; R2 also dead | — |
|
||||
| AC-4 | Refresh issued 11h59m ago | POST /token/refresh | Rotation succeeds; same family at 12h+ → 401 | — |
|
||||
| AC-5 | Refresh token from any path | Decode/parse | Not a JWT; DB stores SHA-256 | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- `sessions` table needs an index on `(refresh_hash)` for O(1) lookup.
|
||||
- Rotation must be transactional (insert new + invalidate old in one tx) to prevent race where two parallel refreshes both succeed.
|
||||
- Coordinate with AZ-535 (logout) for shared session-table schema.
|
||||
- Coordinate with AZ-534 (2FA) for which `amr` value gets stamped into the access token's claims.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Asymmetric Signing (RS256/ES256) + JWKS Endpoint
|
||||
|
||||
**Task**: AZ-532_asymmetric_signing_jwks
|
||||
**Name**: Asymmetric signing (RS256/ES256) + JWKS endpoint
|
||||
**Description**: Switch admin's JWT signing from shared-secret HS256 to ES256 (preferred) so verifiers hold only public keys. Expose a standard `GET /.well-known/jwks.json`. Verifiers can no longer mint tokens even if compromised; new verifiers can be added without secret distribution.
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: None (independent of AZ-531; can land before or after)
|
||||
**Component**: Admin API + Services
|
||||
**Tracker**: AZ-532
|
||||
**Epic**: AZ-529
|
||||
|
||||
## Problem
|
||||
|
||||
Access tokens are signed with HS256 using a shared symmetric secret (`JWT_SECRET`). Every verifier (satellite-provider today, gps-denied + ui tomorrow) holds material that can mint valid admin tokens — a breach of any one verifier compromises the whole auth domain. Adding a new verifier requires distributing the secret out-of-band.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Admin signs access tokens with a **private key** (ES256 preferred for small signatures + speed; RS256 acceptable). Public key lives nowhere outside the JWKS endpoint.
|
||||
- `GET /.well-known/jwks.json` returns the active public key set with `kid` per key. Cache headers: `Cache-Control: public, max-age=3600` (verifiers cache, refresh hourly).
|
||||
- Tokens carry `kid` in the header so verifiers select the right key during rotation overlap.
|
||||
- Key material lives in admin's secrets dir (`secrets/jwt_signing_key.pem`) — NOT in env vars.
|
||||
- Documented rotation procedure: generate new key → add to JWKS as second entry → wait verifier-cache TTL → switch signing to new `kid` → wait until all old-kid tokens expire → remove old from JWKS.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- ES256 keypair generation script in `scripts/` (one-time setup + rotation tool).
|
||||
- `IJwtSigningKeyProvider` interface + file-backed impl loading from `secrets/`.
|
||||
- Update `AuthService.CreateToken` to use asymmetric signing.
|
||||
- New `GET /.well-known/jwks.json` minimal-API handler (anonymous, cacheable, `.AllowAnonymous()`).
|
||||
- Update `appsettings.json` / `.env.example` to drop `JWT_SECRET` (keep temporarily as fallback for one release for rollback safety).
|
||||
- Tests: round-trip sign/verify, JWKS payload shape, kid header presence, alg-confusion attack rejection.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Verifier-side migration in satellite-provider / gps-denied / ui (filed under those workspaces once admin ships).
|
||||
- Hardware HSM / KMS integration (file-backed PEM is sufficient for now; HSM is a future ticket).
|
||||
- Mission-token specific signing path (handled in AZ-533; uses same key).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Admin signs with ES256**
|
||||
Given admin is configured with an ES256 keypair
|
||||
When `POST /login` succeeds
|
||||
Then the returned access token's header has `alg=ES256` and `kid` matching the active key.
|
||||
|
||||
**AC-2: JWKS endpoint serves the public key**
|
||||
Given a fresh admin instance
|
||||
When `GET /.well-known/jwks.json` is called (no auth)
|
||||
Then response is 200 with body `{ "keys": [ { "kty":"EC", "crv":"P-256", "kid":"...", "x":"...", "y":"...", "alg":"ES256", "use":"sig" } ] }`. `Cache-Control: public, max-age=3600`.
|
||||
|
||||
**AC-3: Two-key overlap during rotation**
|
||||
Given two valid signing keys are configured (kid-A active, kid-B inactive but kept)
|
||||
When JWKS is fetched
|
||||
Then both keys appear; tokens signed with kid-A still verify; switching active to kid-B starts producing kid-B tokens; both verify until kid-A is removed.
|
||||
|
||||
**AC-4: Private key never leaves admin**
|
||||
Given the JWKS endpoint
|
||||
When response is inspected
|
||||
Then no `d` field (private scalar for EC) or `p`/`q` (RSA private primes) appears. Only public components.
|
||||
|
||||
**AC-5: alg-confusion attack rejected**
|
||||
Given a forged token with `alg=HS256` and signature computed with the public key as the HMAC secret
|
||||
When presented to a verifier configured for ES256
|
||||
Then verification fails. (Pin expected algorithm explicitly in `TokenValidationParameters.ValidAlgorithms`.)
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | ES256 key configured | POST /login → decode header | alg=ES256, kid present | — |
|
||||
| AC-2 | Fresh admin | GET /.well-known/jwks.json | 200, JWKS shape, max-age=3600 | — |
|
||||
| AC-3 | Two keys configured | GET JWKS twice across rotation | Both keys present in overlap | — |
|
||||
| AC-4 | JWKS response | Inspect for private fields | No `d`/`p`/`q` present | — |
|
||||
| AC-5 | Forged HS256-as-ES256-pubkey token | POST any protected endpoint | 401 | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- HS256 → ES256 is a breaking change for verifiers. Coordinate the cutover: admin keeps signing HS256 in parallel for one release while verifiers add ES256 verification, then admin flips to ES256-only.
|
||||
- Document the cutover in `_docs/02_document/architecture.md` (suite-level).
|
||||
@@ -0,0 +1,102 @@
|
||||
# Mission-Token Issuance for Disconnected UAV Operations
|
||||
|
||||
**Task**: AZ-533_mission_token_uav
|
||||
**Name**: Mission-token issuance for disconnected UAV operations
|
||||
**Description**: New `POST /sessions/mission` endpoint that issues a single long-lived (≤11 h) access token for one specific flight. Narrowly scoped (`mission_id`, `aircraft_id`, `aud`), one-shot, auto-revoked on aircraft reconnect. Solves the "10 h offline UAV vs 15 min ground access token" tension without weakening interactive-session security.
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: AZ-531 (needs `sessions` table for revocation tracking). Can implement in parallel; final wiring depends on AZ-531.
|
||||
**Component**: Admin API + Services + DataAccess
|
||||
**Tracker**: AZ-533
|
||||
**Epic**: AZ-529
|
||||
|
||||
## Problem
|
||||
|
||||
UAV missions can fly up to 10 h fully offline (no Starlink, no admin reachability). Standard short-lived access tokens (15 min) plus refresh-on-network are physically impossible during flight. Today's solution would be "set JWT lifetime to 4 h and pray", which is both too short for full missions and too long for ground operations — a single lifetime can't satisfy both.
|
||||
|
||||
## Outcome
|
||||
|
||||
- New endpoint `POST /sessions/mission` (auth: existing interactive access token, MFA proven within last 15 min by virtue of refresh chain).
|
||||
- Body: `{ mission_id, aircraft_id, planned_duration_h, requested_scope }`.
|
||||
- Returns: a single long-lived access token (no refresh) with custom claims:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "<pilot-or-aircraft-user-id>",
|
||||
"iss": "AzaionApi",
|
||||
"aud": "satellite-provider",
|
||||
"exp": "now + planned_duration_h + 1h",
|
||||
"mission_id": "M-2026-05-14-042",
|
||||
"aircraft_id": "UAV-117",
|
||||
"valid_region": { "...bbox..." : "..." },
|
||||
"permissions": ["GPS"],
|
||||
"sid": "<session-id>",
|
||||
"jti": "<token-id>",
|
||||
"token_class": "mission"
|
||||
}
|
||||
```
|
||||
|
||||
- Mission tokens are recorded in `sessions` table with `class='mission'` so logout/revocation works.
|
||||
- On post-flight reconnect (any successful auth call from the same `aircraft_id`), all open mission sessions for that aircraft are auto-revoked.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- `MissionSessionRequest` / `MissionSessionResponse` DTOs in `Azaion.Common/Requests/`.
|
||||
- Validation: `planned_duration_h` ∈ [0.1, 12]; `mission_id` matches `M-YYYY-MM-DD-NNN`; `aircraft_id` exists in users table with `Role=CompanionPC`.
|
||||
- Auto-revoke-on-reconnect logic in middleware (cheap: index on `sessions(aircraft_id, class, revoked_at)`).
|
||||
- Tests: happy path, scope-narrowing, max-duration cap, auto-revoke on next call.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Hardware binding (mTLS / DPoP / `cnf` claim) — separate future ticket. This ticket gets the lifetime + scope right; hardware binding is a hardening pass.
|
||||
- Verifier-side enforcement of `mission_id`/`valid_region`/`aircraft_id` claims — filed under satellite-provider once admin ships.
|
||||
- Pre-flight ground station UX (file/load mission token onto UAV) — client/UI concern.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Mission token issued with correct lifetime**
|
||||
Given an authenticated pilot session and `planned_duration_h=9`
|
||||
When `POST /sessions/mission` is called
|
||||
Then response includes a single access token with `exp ≈ now + 10h` (±60s), no refresh token, `token_class="mission"`.
|
||||
|
||||
**AC-2: Hard cap enforced**
|
||||
Given `planned_duration_h=15`
|
||||
When called
|
||||
Then 400 with detail `"planned_duration_h must be ≤ 12"`.
|
||||
|
||||
**AC-3: Scope claims present**
|
||||
Given a request with `mission_id` and `aircraft_id`
|
||||
When the returned token is decoded
|
||||
Then `mission_id`, `aircraft_id`, `aud="satellite-provider"`, `permissions`, `sid`, `jti` all present.
|
||||
|
||||
**AC-4: Auto-revoke on reconnect**
|
||||
Given aircraft UAV-117 has an open mission session M-001
|
||||
When UAV-117 calls any `/token/refresh` or `/login` endpoint successfully
|
||||
Then the M-001 mission session is marked `revoked_reason='post_flight_reconnect'` and that token stops working.
|
||||
|
||||
**AC-5: Issued only against an authenticated session**
|
||||
Given no auth header
|
||||
When `POST /sessions/mission` is called
|
||||
Then 401.
|
||||
|
||||
**AC-6: Auth claim chain proven (MFA step-up)**
|
||||
Given the requesting access token has `amr=["pwd"]` only (no MFA)
|
||||
When `POST /sessions/mission` is called (after AZ-534 ships)
|
||||
Then 403 with detail `"mission tokens require step-up MFA"`. Until AZ-534 ships, AC-6 is enforced as a TODO comment in code; do not block this ticket on AZ-534.
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Pilot session, 9h request | POST /sessions/mission | exp ≈ now+10h, no refresh, class=mission | — |
|
||||
| AC-2 | 15h request | POST /sessions/mission | 400 with cap message | — |
|
||||
| AC-3 | Mission token from AC-1 | Decode claims | mission_id, aircraft_id, aud, sid, jti present | — |
|
||||
| AC-4 | Open mission for UAV-117 | UAV-117 calls /token/refresh | Mission revoked, token dead | — |
|
||||
| AC-5 | No auth header | POST /sessions/mission | 401 | — |
|
||||
| AC-6 | amr=["pwd"] token (post-AZ-534) | POST /sessions/mission | 403 step-up required | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- Long-lived tokens are dangerous if leaked. Hardware binding is the right long-term answer; document this as known-risk in `_docs/05_security/security_report.md`.
|
||||
- The `valid_region` bbox is informational until satellite-provider enforces it. Document the planned enforcement in the cross-workspace coordination note.
|
||||
@@ -0,0 +1,93 @@
|
||||
# TOTP-Based 2FA at Credential Login
|
||||
|
||||
**Task**: AZ-534_totp_2fa_login
|
||||
**Name**: TOTP-based 2FA at credential login
|
||||
**Description**: Add RFC 6238 TOTP enrollment, two-step `/login` flow, and recovery codes. MFA validated only at credential login (not on each refresh); access tokens stamp `amr` claim so future verifiers can require step-up MFA. Per-user opt-in initially; can be made mandatory by role via config.
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: None (touches `/login` so coordinate merge with AZ-537 rate-limit + AZ-531 dual-token)
|
||||
**Component**: Admin API + Services + DataAccess
|
||||
**Tracker**: AZ-534
|
||||
**Epic**: AZ-529
|
||||
|
||||
## Problem
|
||||
|
||||
`/login` accepts password-only auth. CMMC requires multi-factor authentication for privileged accounts. There is no second-factor support today.
|
||||
|
||||
## Outcome
|
||||
|
||||
- TOTP (RFC 6238) enrollment + validation flow at credential login. No SMS, no email codes — TOTP only (offline-friendly, phishing-resistant against bulk SMS attacks).
|
||||
- Recovery codes (10 single-use codes shown once at enrollment) for device-loss recovery.
|
||||
- 2FA validated **only at credential login** — NOT on every refresh. The refresh chain proves "MFA was done in this session" via the `amr` claim.
|
||||
- Access tokens stamp `amr: ["pwd","mfa"]` when MFA was completed; `amr: ["pwd"]` if password-only (e.g. CompanionPC service accounts that don't have MFA enrolled).
|
||||
- Per-user opt-in initially; admin policy can require MFA for `Role in (Admin, ApiAdmin)` from a config flag.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- New columns on `users`: `mfa_enabled (bool)`, `mfa_secret (text, encrypted)`, `mfa_recovery_codes (jsonb of hashed codes)`, `mfa_enrolled_at (timestamptz)`.
|
||||
- `POST /users/me/mfa/enroll` — returns `{ secret, otpauth_url, qr_png_base64, recovery_codes }`. Requires authenticated session, requires re-auth via password in same request body.
|
||||
- `POST /users/me/mfa/confirm` — body `{ code }`, completes enrollment by validating one TOTP code.
|
||||
- `POST /users/me/mfa/disable` — body `{ password, code }`, removes secret and recovery codes.
|
||||
- `/login` flow change: if user has `mfa_enabled=true`, return `{ mfa_required: true, mfa_token: <short-lived JWT> }` instead of access+refresh; client then calls `POST /login/mfa` with `{ mfa_token, code }` to get the real tokens.
|
||||
- Recovery-code consumption: a recovery code may substitute for a TOTP code at `/login/mfa`; consumed code is marked used (single-use).
|
||||
- Audit log entries for: enroll, confirm, disable, login-via-MFA, login-via-recovery-code.
|
||||
- TOTP library: prefer `Otp.NET` (mature, no transitive deps). Verify version compatibility with .NET 10.
|
||||
|
||||
### Excluded
|
||||
|
||||
- WebAuthn / FIDO2 / hardware-key MFA — future ticket.
|
||||
- Per-action step-up MFA (re-prompt for sensitive operations) — future ticket. AZ-533 mission-token issuance will start enforcing `amr=["pwd","mfa"]` after this lands.
|
||||
- Admin-side reset of another user's MFA ("my user lost their phone") — future ticket; for now they go through recovery codes or DB intervention.
|
||||
- UI changes — cross-workspace ticket later.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Enrollment returns a usable TOTP secret**
|
||||
Given an authenticated user without MFA
|
||||
When `POST /users/me/mfa/enroll` is called with the user's password
|
||||
Then response includes a 32-character base32 `secret`, a valid `otpauth://` URL, a PNG QR (base64), and 10 recovery codes (≥12 chars each, base32). DB has `mfa_enabled=false` until confirm.
|
||||
|
||||
**AC-2: Confirm completes enrollment**
|
||||
Given the user scanned the QR and got a 6-digit code
|
||||
When `POST /users/me/mfa/confirm` is called with the code
|
||||
Then MFA is activated (`mfa_enabled=true`); subsequent logins require step 2.
|
||||
|
||||
**AC-3: Login two-step flow**
|
||||
Given a user with MFA enabled
|
||||
When `POST /login` is called with valid credentials
|
||||
Then response is 200 with `{ mfa_required: true, mfa_token, expires_in: 300 }` — no access/refresh yet.
|
||||
When `POST /login/mfa` is called with `mfa_token` + valid TOTP code
|
||||
Then access + refresh tokens are issued; access token's `amr=["pwd","mfa"]`.
|
||||
|
||||
**AC-4: Recovery code works once**
|
||||
Given the same MFA-enabled user
|
||||
When `POST /login/mfa` is called with a recovery code instead of TOTP
|
||||
Then login succeeds; `amr=["pwd","mfa","recovery"]`; the same code on the next login fails.
|
||||
|
||||
**AC-5: Disable requires password + current code**
|
||||
Given a user with MFA enabled
|
||||
When `POST /users/me/mfa/disable` is called with password + a valid TOTP code
|
||||
Then MFA is disabled; subsequent `/login` returns access+refresh directly without step 2.
|
||||
|
||||
**AC-6: TOTP secret is encrypted at rest**
|
||||
Given an enrolled user
|
||||
When the `users.mfa_secret` column is read directly from Postgres
|
||||
Then the value is ciphertext (uses the same encryption infra already in admin for sensitive fields).
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Auth user, no MFA | POST /users/me/mfa/enroll | 200 with secret, otpauth, QR, 10 recovery codes | — |
|
||||
| AC-2 | Enrollment in progress | POST /users/me/mfa/confirm with valid code | MFA activated | — |
|
||||
| AC-3 | MFA-enabled user | POST /login then POST /login/mfa | Two-step flow; amr=[pwd,mfa] | — |
|
||||
| AC-4 | MFA-enabled user | POST /login/mfa with recovery code | Success once; amr=[pwd,mfa,recovery]; second use fails | — |
|
||||
| AC-5 | MFA-enabled user | POST /users/me/mfa/disable | MFA off; /login returns tokens directly | — |
|
||||
| AC-6 | Enrolled user | Read users.mfa_secret directly from DB | Ciphertext, not plaintext base32 | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- TOTP code reuse: a single 30-second code window can be replayed within those 30 seconds. Mitigate by tracking last-used-time per user (small DB write per login). Optional in this ticket; flag for next hardening pass if not in scope.
|
||||
- The two-step `/login` change is a wire-shape change for clients. Coordinate with UI workspace via cross-workspace ticket once admin lands.
|
||||
- Touches the same `/login` code path as AZ-537 (rate limit) and AZ-531 (dual-token). Land AZ-531 first (changes response shape), then AZ-537 (adds limiter middleware), then this ticket (adds the two-step branch).
|
||||
@@ -0,0 +1,82 @@
|
||||
# Logout Endpoint + Revocation Surface for Verifiers
|
||||
|
||||
**Task**: AZ-535_logout_revocation
|
||||
**Name**: Logout endpoint + revocation surface for verifiers
|
||||
**Description**: Add `POST /logout`, `POST /logout/all`, admin-only `POST /sessions/{sid}/revoke`, and a `GET /sessions/revoked?since=<ts>` snapshot endpoint that verifiers (satellite-provider, gps-denied, ui) poll to maintain a local denylist. Without this, JWTs cannot be revoked before `exp`.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-531 (needs the `sessions` table); coordinate `jti`/`sid` claim stamping
|
||||
**Component**: Admin API + Services + DataAccess
|
||||
**Tracker**: AZ-535
|
||||
**Epic**: AZ-529
|
||||
|
||||
## Problem
|
||||
|
||||
With stateless JWT validation, logout doesn't actually exist. Calling `/logout` on admin can clear admin's session, but satellite-provider, gps-denied, and any other verifier keep accepting the same token until `exp`. There is no way to forcibly kick a session in real time (e.g. "GPS permission revoked, end the flight").
|
||||
|
||||
## Outcome
|
||||
|
||||
- `POST /logout` endpoint: revokes the caller's current session (refresh + all access tokens minted from it). Idempotent.
|
||||
- `POST /logout/all` endpoint: revokes every session for the caller's user (full "sign out everywhere").
|
||||
- `POST /sessions/{sid}/revoke` (admin-only): revoke any session by id ("GPS permission revoked, kill flight UAV-117 mission M-042").
|
||||
- Verifiers consume revocation via either:
|
||||
- **Pull mode (default)**: `GET /sessions/revoked?since=<unix-ts>` returns `[{ jti, sid, exp }]`. Verifiers poll every 30 s and maintain a local denylist with TTL = token's remaining lifetime.
|
||||
- **Push mode (optional)**: a Redis pub/sub channel `auth:revoked` for sub-second propagation. Pull is mandatory; push is best-effort acceleration.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- `POST /logout`, `POST /logout/all`, `POST /sessions/{sid}/revoke` handlers in `Azaion.AdminApi/Program.cs`.
|
||||
- `GET /sessions/revoked?since=<ts>` endpoint authenticated via service-to-service JWT issued to each verifier identity (each verifier has a dedicated `Role=Service` user).
|
||||
- Update `sessions` table with `revoked_at`, `revoked_reason`, `revoked_by_user_id` (these columns may already be present from AZ-531; if so, this ticket only adds `revoked_by_user_id`).
|
||||
- Snapshot endpoint must auto-prune entries whose `exp < now()` so the response stays bounded.
|
||||
- Tests: logout works, all-logout works, admin-revoke works, revoked endpoint returns recent revocations and excludes expired.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Verifier-side denylist consumption (per-verifier ticket, filed when admin ships).
|
||||
- Redis pub/sub push channel — nice-to-have; pull-based snapshot is the contract.
|
||||
- Per-permission revocation in real time (e.g. "revoke just GPS, keep session alive") — architecturally requires moving permissions out of the JWT; future ticket.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: /logout revokes the session**
|
||||
Given a valid access + refresh token pair
|
||||
When `POST /logout` is called with the access token
|
||||
Then the session row is marked `revoked_at=now()`, `revoked_reason='user_logout'`. The refresh token stops working.
|
||||
|
||||
**AC-2: /logout/all revokes every session for the user**
|
||||
Given user U has 3 active sessions
|
||||
When `POST /logout/all` is called from any one of them
|
||||
Then all 3 sessions are revoked.
|
||||
|
||||
**AC-3: Admin can revoke any session by id**
|
||||
Given user U has session SID-X
|
||||
When an Admin-role JWT calls `POST /sessions/SID-X/revoke`
|
||||
Then SID-X is marked revoked with `revoked_by_user_id` = the admin's id.
|
||||
|
||||
**AC-4: /sessions/revoked snapshot returns recent revocations**
|
||||
Given 5 sessions revoked in the last hour, 2 of which already expired
|
||||
When `GET /sessions/revoked?since=<1h-ago>` is called by an authenticated verifier
|
||||
Then response is the 3 non-expired ones, with `[{ jti, sid, exp }]`. `Cache-Control: no-cache` (this is real-time data).
|
||||
|
||||
**AC-5: Idempotent logout**
|
||||
Given a session already revoked
|
||||
When `POST /logout` is called again with the same token
|
||||
Then 200 with `{ already_revoked: true }`. No DB write.
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Active session | POST /logout | Session revoked, refresh dead | — |
|
||||
| AC-2 | User with 3 sessions | POST /logout/all from any | All 3 revoked | — |
|
||||
| AC-3 | Admin JWT, target SID-X | POST /sessions/SID-X/revoke | SID-X revoked with admin id | — |
|
||||
| AC-4 | 5 revoked (2 expired) | GET /sessions/revoked?since=… | Returns 3 non-expired | — |
|
||||
| AC-5 | Already-revoked session | POST /logout again | 200 already_revoked, no DB write | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- The pull endpoint must NOT leak revocations across users to non-admin callers. Verifier identity is service-level (each verifier has a dedicated `Role=Service` user with read-revocations permission); they get the global feed. Regular users only see their own sessions if a future endpoint is added.
|
||||
- 30 s polling means up to 30 s of "stale token works" after logout. Documented as acceptable; for sub-second, deploy the optional Redis push.
|
||||
- Coordinate auto-prune cadence to keep snapshot < 5 KB even at high revocation rates.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Replace SHA-384 Password Hashing with Argon2id (Salted)
|
||||
|
||||
**Task**: AZ-536_argon2id_password_hashing
|
||||
**Name**: Replace SHA-384 password hashing with Argon2id (salted)
|
||||
**Description**: Replace the unsalted single-pass SHA-384 in `Azaion.Services/Security.cs::ToHash` with Argon2id (RFC 9106), salted, memory-hard. PHC string format (self-describing — no separate salt column needed). Lazy migration: existing SHA-384 hashes re-hash on next successful login.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None
|
||||
**Component**: Services + DataAccess
|
||||
**Tracker**: AZ-536
|
||||
**Epic**: AZ-530
|
||||
**CMMC ref**: IA.L2-3.5.10 (cryptographic mechanisms to protect passwords)
|
||||
|
||||
## Problem
|
||||
|
||||
`Azaion.Services/Security.cs::ToHash` does:
|
||||
|
||||
```csharp
|
||||
public static string ToHash(this string str) =>
|
||||
Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str)));
|
||||
```
|
||||
|
||||
Used at `UserService.cs:43` (registration) and `UserService.cs:115` (login validation). This is **unsalted**, **fast**, **single-pass** SHA-384.
|
||||
|
||||
Problems:
|
||||
- Trivially attacked with rainbow tables (no salt).
|
||||
- GPU bruteforce ≈ billions of guesses/sec.
|
||||
- Identical passwords across users produce identical hashes (visible in DB dumps).
|
||||
- Affects every `users` row in the central admin DB — including operator, admin, and CompanionPC device passwords.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Replace `ToHash` with Argon2id (RFC 9106), salted, with conservative parameters (memory ≥ 64 MiB, iterations ≥ 3, parallelism ≥ 1).
|
||||
- Each password hash stored in PHC string format: `$argon2id$v=19$m=65536,t=3,p=1$<salt-b64>$<hash-b64>` — self-describing, no separate salt column needed.
|
||||
- **Lazy migration**: existing SHA-384 hashes stay in the DB. On next successful login (verified by re-hashing the submitted plaintext with SHA-384 and matching), the password is re-hashed with Argon2id and the row updated. Detect format by prefix (`$argon2id$` vs base64).
|
||||
- For service accounts that never log in interactively (CompanionPC devices), provide an admin-side bulk-reset script that rotates their passwords during next provisioning cycle.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- Add `Konscious.Security.Cryptography.Argon2` (or `Isopoh.Cryptography.Argon2` — both pure C#) as a `Azaion.Services` dependency. Pin a specific version.
|
||||
- Refactor `Security.cs`: `HashPassword(string)` returns PHC string; `VerifyPassword(string plaintext, string stored)` handles both formats and triggers re-hash for legacy SHA-384.
|
||||
- Update `UserService.RegisterUser` to call `HashPassword`.
|
||||
- Update `UserService.ValidateUser` to call `VerifyPassword` and on legacy-hash match, write the new Argon2id hash back transactionally before returning success.
|
||||
- Update `_docs/05_security/security_report.md` to reflect the new state and the migration plan.
|
||||
- Tests: hash format, verify happy path, verify legacy hash transparently re-hashes, verify wrong password fails for both formats, parameter sanity (m ≥ 64 MiB).
|
||||
|
||||
### Excluded
|
||||
|
||||
- Forced password reset on next login (not required — lazy migration covers humans; service accounts via separate provisioning).
|
||||
- Pepper / HSM-bound hashing — future hardening pass.
|
||||
- Algorithm agility framework ("add bcrypt support too") — not needed; Argon2id is the answer for the next 5+ years.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: New users get Argon2id hashes**
|
||||
Given a fresh registration
|
||||
When the row is inspected
|
||||
Then `password_hash` starts with `$argon2id$v=19$m=`… and parameter parses confirm m ≥ 65536, t ≥ 3, p ≥ 1.
|
||||
|
||||
**AC-2: Legacy SHA-384 hashes still validate**
|
||||
Given a seed user with a SHA-384 hash from before this change
|
||||
When they log in with the correct password
|
||||
Then 200 — login succeeds.
|
||||
|
||||
**AC-3: Successful legacy login transparently re-hashes**
|
||||
After AC-2, when the same user's row is re-read
|
||||
Then `password_hash` is now in Argon2id PHC format. The same plaintext continues to validate.
|
||||
|
||||
**AC-4: Wrong password fails for both formats**
|
||||
Given a user with a SHA-384 hash and a user with an Argon2id hash
|
||||
When each tries to log in with the wrong password
|
||||
Then both return 409 ExceptionEnum=WrongPassword (existing error semantics preserved).
|
||||
|
||||
**AC-5: Verify is constant-time**
|
||||
Given any stored hash
|
||||
When `VerifyPassword` is called with various wrong passwords of different lengths
|
||||
Then timing variance is not observable to a remote attacker (rely on the library's constant-time comparator; do NOT use `string ==`).
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Fresh registration | Read users.password_hash | Starts with $argon2id$v=19$, m ≥ 65536 | NFT-SEC-NEW |
|
||||
| AC-2 | Seed user with legacy SHA-384 hash | POST /login with correct pwd | 200 | — |
|
||||
| AC-3 | After AC-2 | Read users.password_hash | Now Argon2id PHC format | — |
|
||||
| AC-4 | Both hash formats | POST /login with wrong pwd | 409 WrongPassword | — |
|
||||
| AC-5 | Various-length wrong pwds | Time the verify | No remotely-observable timing leak | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- Argon2id with 64 MiB × 3 iterations costs ≈ 50-200 ms per verify on commodity hardware. Login latency increases noticeably (was ≈ 1 ms with SHA-384). This is the point — it makes bruteforce expensive. Document the new latency in security report.
|
||||
- AZ-537 (rate limit + lockout) and this ticket touch the same code path (`UserService.ValidateUser`). Coordinate merge order — land Argon2id (this ticket) first since it changes the success path semantics, then AZ-537 layers on top.
|
||||
@@ -0,0 +1,99 @@
|
||||
# /login Rate Limit + Account Lockout
|
||||
|
||||
**Task**: AZ-537_login_rate_limit_lockout
|
||||
**Name**: /login rate limit + account lockout
|
||||
**Description**: Add ASP.NET Core sliding-window rate limiter on `/login` (per-IP and per-account) plus an account-lockout policy after 10 consecutive failures. Closes the unbounded credential-stuffing / password-spray surface.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None functionally; coordinate merge order with AZ-536 (both touch `UserService.ValidateUser`)
|
||||
**Component**: Admin API + Services + DataAccess
|
||||
**Tracker**: AZ-537
|
||||
**Epic**: AZ-530
|
||||
**CMMC ref**: AC.L2-3.1.8 (limit unsuccessful logon attempts)
|
||||
|
||||
## Problem
|
||||
|
||||
`Azaion.AdminApi/Program.cs:177` (`POST /login`) has no rate limiting and no account lockout. An attacker can:
|
||||
- **Credential stuffing**: spray leaked username/password pairs from other breaches at unlimited RPS.
|
||||
- **Password spray**: try one common password against every known account.
|
||||
- **Targeted bruteforce**: hammer one account.
|
||||
|
||||
Nothing in the request path slows them down. Combined with the SHA-384 hashing flaw (sister ticket AZ-536), this is high-severity.
|
||||
|
||||
## Outcome
|
||||
|
||||
- ASP.NET Core built-in rate limiter (`AddRateLimiter`) attached to `/login`:
|
||||
- **Per-IP**: 10 attempts / 60 s (sliding window). Burst of 3.
|
||||
- **Per-account** (keyed by submitted email, normalised lowercase): 5 attempts / 5 min.
|
||||
- Both limits return 429 with `Retry-After` header when exceeded.
|
||||
- **Account lockout**: after 10 consecutive failed logins for a single account, lock it for 15 min (configurable). Lockout state stored on `users` row (`lockout_until timestamptz`, `failed_login_count int`). Successful login resets the counter.
|
||||
- Lockout takes precedence over rate limit (if account is locked, return 423 Locked even if request is within rate budget).
|
||||
- Counters reset on successful login.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- New columns on `users`: `failed_login_count int default 0`, `lockout_until timestamptz null`.
|
||||
- Migration script for the schema change.
|
||||
- `RateLimiter` configuration in `Program.cs` (use built-in `AddSlidingWindowLimiter` for IP + account partitions).
|
||||
- Update `UserService.ValidateUser` to:
|
||||
- Reject early with 423 if `lockout_until > now()`.
|
||||
- On wrong password: increment `failed_login_count`; if it hits the threshold, set `lockout_until = now() + 15min`.
|
||||
- On success: zero the counter and clear `lockout_until`.
|
||||
- `appsettings.json` keys for thresholds (`Auth:RateLimit:*`, `Auth:Lockout:MaxAttempts`, `Auth:Lockout:DurationMinutes`).
|
||||
- Tests: rate-limit triggers 429, lockout triggers 423 even for correct password, success resets counter, lockout auto-expires after duration.
|
||||
- Audit log entries for each lockout event (security-relevant).
|
||||
|
||||
### Excluded
|
||||
|
||||
- CAPTCHA challenge — not in scope; rate-limit + lockout is sufficient for CMMC L2.
|
||||
- Distributed rate-limit store (Redis-backed limiter for multi-instance admin) — in-memory limiter is acceptable for current single-instance deploy. Document the upgrade path.
|
||||
- Admin-side "unlock user" API — separate small ticket if needed; for now wait out the 15-min window or DB intervention.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Per-IP rate limit triggers 429**
|
||||
Given 11 `/login` requests from the same IP within 60 s
|
||||
When the 11th is sent
|
||||
Then response is 429 with a `Retry-After` header.
|
||||
|
||||
**AC-2: Per-account rate limit triggers 429**
|
||||
Given 6 `/login` requests for `alice@x.com` from 6 different IPs within 5 min
|
||||
When the 6th is sent
|
||||
Then response is 429 (account-key partition triggered).
|
||||
|
||||
**AC-3: Account lockout after 10 failures**
|
||||
Given `alice@x.com` has 9 consecutive wrong-password attempts (across IPs / time)
|
||||
When the 10th wrong attempt arrives
|
||||
Then `users.lockout_until = now() + 15min`. Subsequent attempts — even with the correct password — return 423 Locked until that time.
|
||||
|
||||
**AC-4: Successful login resets the counter**
|
||||
Given `alice@x.com` has 5 failed attempts
|
||||
When she submits the correct password (within the rate-limit budget)
|
||||
Then login succeeds and `failed_login_count = 0`, `lockout_until = NULL`.
|
||||
|
||||
**AC-5: Lockout auto-expires**
|
||||
Given `alice@x.com` is locked with `lockout_until = T`
|
||||
When she submits the correct password at `T + 1s`
|
||||
Then login succeeds.
|
||||
|
||||
**AC-6: Audit log on lockout**
|
||||
Given AC-3 fires
|
||||
When the audit log is inspected
|
||||
Then there is a `login_lockout` entry with `email`, `ip`, `timestamp`.
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | 11 requests from same IP in 60s | 11th POST /login | 429 with Retry-After | NFT-SEC-NEW |
|
||||
| AC-2 | 6 requests for alice from 6 IPs in 5min | 6th POST /login | 429 | NFT-SEC-NEW |
|
||||
| AC-3 | 10 wrong-pwd attempts | 11th attempt with correct pwd | 423 Locked | NFT-SEC-NEW |
|
||||
| AC-4 | 5 failed attempts | Successful login | counter=0, lockout_until=NULL | — |
|
||||
| AC-5 | Locked until T | Login at T+1s with correct pwd | 200 | — |
|
||||
| AC-6 | AC-3 fires | Inspect audit log | login_lockout entry present | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- DoS-as-a-service: an attacker can lock out a known target's account by spraying wrong passwords from many IPs. The per-account counter intentionally allows this (CMMC requires lockout regardless of source). Mitigate operationally with admin-side unlock; do not weaken the rule.
|
||||
- AZ-536 (Argon2id hashing) and this ticket both modify `UserService.ValidateUser`. Coordinate merge order — land AZ-536 first since it changes the success path semantics; this ticket layers on top.
|
||||
@@ -0,0 +1,95 @@
|
||||
# CORS — Drop HTTP Origin, Enforce HTTPS-Only + HSTS
|
||||
|
||||
**Task**: AZ-538_cors_https_only_hsts
|
||||
**Name**: CORS — drop http origin, enforce HTTPS-only + HSTS
|
||||
**Description**: Remove `http://admin.azaion.com` from the CORS allow-list (currently combined with `AllowCredentials()`, which permits credentialed traffic over cleartext), enable HSTS in non-Development envs, and add HTTPS redirection as defence in depth.
|
||||
**Complexity**: 2 points
|
||||
**Dependencies**: None
|
||||
**Component**: Admin API
|
||||
**Tracker**: AZ-538
|
||||
**Epic**: AZ-530
|
||||
**CMMC ref**: SC.L2-3.13.8 (encrypt CUI in transit), SC.L2-3.13.11 (FIPS-validated cryptography)
|
||||
|
||||
## Problem
|
||||
|
||||
`Azaion.AdminApi/Program.cs` lines 117-127:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AdminCorsPolicy", policy =>
|
||||
{
|
||||
policy.WithOrigins("https://admin.azaion.com", "http://admin.azaion.com")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Allowing the `http://` origin together with `AllowCredentials()` means a browser will send cookies / `Authorization` headers to the admin API over cleartext from `http://admin.azaion.com`. Any LAN MITM (coffee shop wifi, compromised AP, ARP spoof) can capture the session.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Drop `"http://admin.azaion.com"` from `WithOrigins`. Only `https://admin.azaion.com` remains.
|
||||
- Enable HSTS via `app.UseHsts()` in non-Development environments. `max-age=31536000; includeSubDomains; preload`.
|
||||
- Add `app.UseHttpsRedirection()` to bounce any cleartext request to HTTPS at the protocol layer (defence in depth — even if someone re-adds the http origin by accident, the redirect kicks in first).
|
||||
- Verify dev workflow: any contributor who relied on `http://admin.azaion.com` locally must switch to `https://localhost:<port>` (devcert is already in `secrets/`).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- One-line `WithOrigins` change.
|
||||
- `UseHsts` + `UseHttpsRedirection` in `Program.cs`, gated to non-Development env to keep `dotnet watch` flow on http://localhost intact.
|
||||
- Update `_docs/05_security/security_report.md` (close the finding).
|
||||
- Update `_docs/02_document/architecture.md` if it documents the http allowance.
|
||||
- Smoke test: cleartext origin returns CORS rejection in browser preflight.
|
||||
|
||||
### Excluded
|
||||
|
||||
- mTLS between services — separate ticket, larger scope.
|
||||
- Cert pinning at clients — separate ticket.
|
||||
- TLS 1.3 enforcement — already the Kestrel default in .NET 10; no action needed.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: http origin rejected by CORS**
|
||||
Given a browser preflight `OPTIONS /login` with `Origin: http://admin.azaion.com`
|
||||
When the response is inspected
|
||||
Then no `Access-Control-Allow-Origin` header is returned (CORS denies the request).
|
||||
|
||||
**AC-2: https origin still works**
|
||||
Given a browser preflight `OPTIONS /login` with `Origin: https://admin.azaion.com`
|
||||
When the response is inspected
|
||||
Then `Access-Control-Allow-Origin: https://admin.azaion.com` is present and `Access-Control-Allow-Credentials: true`.
|
||||
|
||||
**AC-3: HSTS header on prod responses**
|
||||
Given the app runs with `ASPNETCORE_ENVIRONMENT=Production`
|
||||
When any HTTPS request returns
|
||||
Then response includes `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`.
|
||||
|
||||
**AC-4: HTTP requests redirect to HTTPS**
|
||||
Given the app runs with `ASPNETCORE_ENVIRONMENT=Production`
|
||||
When `GET http://admin.azaion.com/health/live` is called
|
||||
Then response is 307 to `https://admin.azaion.com/health/live`.
|
||||
|
||||
**AC-5: Development env unchanged**
|
||||
Given `ASPNETCORE_ENVIRONMENT=Development`
|
||||
When `GET http://localhost:8080/health/live` is called
|
||||
Then 200 (no HTTPS redirect, no HSTS).
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Origin: http://admin.azaion.com | OPTIONS preflight | No ACAO header | NFT-SEC-NEW |
|
||||
| AC-2 | Origin: https://admin.azaion.com | OPTIONS preflight | ACAO present, ACAC: true | — |
|
||||
| AC-3 | Production env | Any HTTPS response | HSTS header present | NFT-SEC-NEW |
|
||||
| AC-4 | Production env | GET http:// URL | 307 to https:// | — |
|
||||
| AC-5 | Development env | GET http://localhost:8080/health/live | 200, no HSTS | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- If any deployed UI build is pinned to `http://admin.azaion.com`, this change will break it. Verify the UI build's API base URL before merging.
|
||||
- If a reverse proxy / load balancer terminates TLS upstream, ensure `app.UseForwardedHeaders` is correctly configured so `UseHttpsRedirection` doesn't loop. Document expected header config in `_docs/04_deploy/`.
|
||||
Reference in New Issue
Block a user