mirror of
https://github.com/azaion/admin.git
synced 2026-06-22 08:01:22 +00:00
Compare commits
18 Commits
c7b297de83
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b0fe6582 | |||
| 6e1e147562 | |||
| 837b1f2374 | |||
| 5224a12589 | |||
| 8b7d8a4275 | |||
| 4bf2e689cb | |||
| ebde2b2d25 | |||
| f369153149 | |||
| d2b5308b45 | |||
| 1bdbe8c96d | |||
| a77b3f8a59 | |||
| c2c659ef62 | |||
| 1e1ded73f5 | |||
| 8e7c602f51 | |||
| 51a293dbcc | |||
| 491993f9c1 | |||
| 9679b5636f | |||
| 3a925b9b0f |
@@ -39,6 +39,7 @@ alwaysApply: true
|
||||
- When you think you are done with changes, run the full test suite. Every failure in tests that cover code you modified or that depend on code you modified is a **blocking gate**. For pre-existing failures in unrelated areas, report them to the user but do not block on them. Never silently ignore or skip a failure without reporting it. On any blocking failure, stop and ask the user to choose one of:
|
||||
- **Investigate and fix** the failing test or source code
|
||||
- **Remove the test** if it is obsolete or no longer relevant
|
||||
- **Iterative-skill exception**: when an iterative loop skill is active (e.g. autodev / `implement/SKILL.md` batch loop, `refactor/SKILL.md` batch loop), the skill governs full-suite cadence — typically focused tests per task/batch and a single full-suite gate at the very end of the implementation phase, NOT after each batch. "Done with changes" means done with the entire implementation phase the skill is running, not done with one batch. Do not run the full suite per batch unless the skill explicitly says to.
|
||||
- Do not rename any databases or tables or table columns without confirmation. Avoid such renaming if possible.
|
||||
|
||||
- Make sure we don't commit binaries, create and keep .gitignore up to date and delete binaries after you are done with the task
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: "Use chunked writes (Write + StrReplace marker pattern) for large generated files, especially after a monolithic Write fails"
|
||||
alwaysApply: true
|
||||
---
|
||||
# Large File Writes — Chunk on Failure
|
||||
|
||||
When a `Write` call to a single file fails (timeout, payload limit, "Invalid arguments", or any tool error) and the intended content is large (>~500 lines or >~50 KB), do NOT retry the same monolithic Write. Switch to chunked writes:
|
||||
|
||||
1. **First Write** — create the file with header + table of contents (if applicable) + an explicit append marker, e.g.
|
||||
|
||||
```
|
||||
<!-- INSERTION_POINT do-not-remove-until-final-chunk -->
|
||||
```
|
||||
|
||||
2. **Each subsequent chunk** — use `StrReplace` to replace the marker with `<new content>\n<marker>` so the marker stays at the end. This is idempotent: if a chunk fails, retry it without losing earlier chunks.
|
||||
|
||||
3. **Final chunk** — `StrReplace` removes the marker.
|
||||
|
||||
## Why
|
||||
|
||||
- Tool argument size limits and transient failures hit large monolithic writes hardest. Retrying the same large payload typically fails for the same reason.
|
||||
- Chunked writes are recoverable per chunk. The earlier chunks are durable on disk.
|
||||
- A unique marker is greppable, visible in diffs, and stops accidental insertion in the wrong place.
|
||||
|
||||
## Triggers
|
||||
|
||||
- Generated documentation that aggregates per-component content (epics, design docs, multi-section architecture summaries, traceability dumps).
|
||||
- Large fixture or test-data files written from a template.
|
||||
- Any single-file artifact you can pre-estimate at >~500 lines.
|
||||
|
||||
## Do NOT chunk
|
||||
|
||||
- Files under ~200 lines — a single `Write` is faster, clearer, and easier to review.
|
||||
- Source code files where appending breaks module structure (functions, classes, imports). Split into multiple files instead.
|
||||
- Files where ordering of sections is computed late and inserting in the middle is required — use a single `Write` once the full content is known.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Retrying the same failed monolithic `Write` more than once. Twice is the limit; on the second failure, switch strategies.
|
||||
- Using `Shell` with heredoc (`cat <<EOF`) or `echo >>` to append — these bypass the editor diff view and break the StrReplace contract for the next chunk.
|
||||
- Embedding the marker so deep inside structured content that a chunk's `StrReplace` becomes ambiguous. Place the marker on its own line at the very end of the file.
|
||||
@@ -14,11 +14,14 @@ alwaysApply: true
|
||||
- Issue types: Epic, Story, Task, Bug, Subtask
|
||||
|
||||
## Tracker Availability Gate
|
||||
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, or any non-success response: **STOP** tracker operations and notify the user via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
|
||||
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, **timeout**, a non-2xx status code, an empty body, or any response shape that does not clearly confirm the requested change: **STOP IMMEDIATELY** — no automatic retry, no silent continuation. Surface the full raw error/response to the user verbatim and notify via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
|
||||
- A minimal `{"success": true}` body with no echoed issue state is NOT a confirmed transition. When a transition's success matters (status moves, ticket creation, blocking link), follow it with a read-back call (`getJiraIssue` or equivalent) and confirm the new state matches what you asked for. If the read-back disagrees → STOP and ASK.
|
||||
- Do NOT loop "retry up to N times before asking". One call, one verification. On failure, the user decides whether to retry.
|
||||
- The user may choose to:
|
||||
- **Retry authentication** — preferred; the tracker remains the source of truth.
|
||||
- **Retry the same operation** — once, after the user authorizes it. If it fails again, surface both responses.
|
||||
- **Retry authentication** — preferred when the failure looks like an auth/credentials problem; the tracker remains the source of truth.
|
||||
- **Continue in `tracker: local` mode** — only when the user explicitly accepts this option. In that mode all tasks keep numeric prefixes and a `Tracker: pending` marker is written into each task header. The state file records `tracker: local`. The mode is NOT silent — the user has been asked and has acknowledged the trade-off.
|
||||
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. If the user is unreachable (e.g., non-interactive run), stop and wait.
|
||||
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. Do not paper over an opaque response by moving on. If the user is unreachable (e.g., non-interactive run), stop and wait.
|
||||
- When the tracker becomes available again, any `Tracker: pending` tasks should be synced — this is done at the start of the next `/autodev` invocation via the Leftovers Mechanism below.
|
||||
|
||||
## Leftovers Mechanism (non-user-input blockers only)
|
||||
|
||||
@@ -67,8 +67,9 @@ B3. Read state — `_docs/_autodev_state.md` (if it exists).
|
||||
B4. Read File Index — `state.md`, `protocols.md`, and the active flow file.
|
||||
|
||||
### Resolve (once per invocation, after Bootstrap)
|
||||
R1. Reconcile state — verify state file against `_docs/` contents; on disagreement, trust the folders
|
||||
and update the state file (rules: `state.md` → "State File Rules" #4).
|
||||
R1. Reconcile state — verify state file against `_docs/` contents; probe `<workspace-root>/../docs`
|
||||
(parent suite `docs/` — see `state.md` → "State File Rules" #4); on disagreement,
|
||||
trust the folders and update the state file (rules: `state.md` → "State File Rules" #4).
|
||||
After this step, `state.step` / `state.status` are authoritative.
|
||||
R2. Resolve flow — see §Flow Resolution above.
|
||||
R3. Resolve current step — when a state file exists, `state.step` drives detection.
|
||||
|
||||
@@ -5,7 +5,8 @@ Workflow for **meta-repositories** — repos that aggregate multiple components
|
||||
This flow differs fundamentally from `greenfield` and `existing-code`:
|
||||
|
||||
- **No problem/research/plan phases** — meta-repos don't build features, they coordinate existing ones
|
||||
- **No test spec / implement / run tests** — the meta-repo has no code to test
|
||||
- **No test spec / run tests** — the meta-repo has no code to test
|
||||
- **`implement` is scoped to suite-level work only** — cross-repo concerns, repo/folder renames, suite-root infra additions (e.g., `.gitmodules`, `_infra/`, suite `e2e/`). Per-component implementation lives in each component's own workspace `/autodev` cycle. The meta-repo's implement step (Step 3.5) executes only when `_docs/tasks/todo/` is non-empty AND the user explicitly opts in; placement is **before** the sync skills so subsequent Doc/E2E/CICD sync propagates the post-implementation state.
|
||||
- **No `_docs/00_problem/` artifacts** — documentation target is `_docs/*.md` unified docs, not per-feature `_docs/NN_feature/` folders
|
||||
- **Primary artifact is `_docs/_repo-config.yaml`** — generated by `monorepo-discover`, read by every other step
|
||||
|
||||
@@ -17,6 +18,7 @@ This flow differs fundamentally from `greenfield` and `existing-code`:
|
||||
| 2 | Config Review | (human checkpoint, no sub-skill) | — |
|
||||
| 2.5 | Glossary & Architecture Vision | (inline, no sub-skill) | Steps 1–5 |
|
||||
| 3 | Status | monorepo-status/SKILL.md | Sections 1–5 |
|
||||
| 3.5 | Suite Implement | implement/SKILL.md (suite-level invocation context) | Steps 1–14 + 16 (Step 14.5 + Step 15 skipped); conditional on `_docs/tasks/todo/` non-empty AND user opt-in |
|
||||
| 4 | Document Sync | monorepo-document/SKILL.md | Phase 1–7 (conditional on doc drift) |
|
||||
| 4.5 | Integration Test Sync | monorepo-e2e/SKILL.md | Phase 1–6 (conditional on suite-e2e drift; skipped if `suite_e2e:` block absent in config) |
|
||||
| 5 | CICD Sync | monorepo-cicd/SKILL.md | Phase 1–7 (conditional on CI drift) |
|
||||
@@ -184,11 +186,16 @@ The status report identifies:
|
||||
- Registry/config mismatches
|
||||
- Unresolved questions
|
||||
|
||||
Based on the report, auto-chain branches:
|
||||
Based on the report, auto-chain branches in this evaluation order (first match wins):
|
||||
|
||||
- If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
|
||||
- Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
||||
- Else if **registry mismatch** found (new components not in config) → present Choose format:
|
||||
1. **Registry mismatch** (new components not in config, or config component not in registry) → present the Choose format below FIRST. After the user resolves it (A: refresh discover, B: onboard, C: continue with mismatch acknowledged), proceed to the next rule. This rule has priority because a stale config would mislead Step 3.5's ownership-envelope synthesis and any sync skill's component scope.
|
||||
2. **Pre-routing gate (Step 3.5 detection)** — check `_docs/tasks/todo/` for suite-level task files (`*.md` excluding files starting with `_`). If ≥1 task is present, auto-chain to **Step 3.5 (Suite Implement)**. After Step 3.5 returns (regardless of A/B outcome), the post-implement re-status applies rules 3–6 below to the post-implementation state.
|
||||
3. If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
|
||||
4. Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
||||
5. Else if **suite-e2e drift** (only) found → auto-chain to **Step 4.5 (Integration Test Sync)** (only when `suite_e2e:` block exists in config)
|
||||
6. Else → **workflow done for this cycle**.
|
||||
|
||||
**Registry mismatch Choose format** (rule 1):
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
@@ -205,7 +212,134 @@ Based on the report, auto-chain branches:
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
- Else → **workflow done for this cycle**. Report "No drift. Meta-repo is in sync." Loop waits for next invocation.
|
||||
When rule 6 fires (no drift, no todo tasks), report "No drift. Meta-repo is in sync." and end the cycle. Loop waits for next invocation.
|
||||
|
||||
---
|
||||
|
||||
**Step 3.5 — Suite Implement**
|
||||
|
||||
Condition (folder fallback): `_docs/tasks/todo/` exists AND contains ≥1 file matching `*.md` excluding files starting with `_` (e.g., `_dependencies_table.md` is excluded by convention).
|
||||
|
||||
State-driven: reached by auto-chain from Step 3 when the pre-routing gate detected todo tasks. Inserted **before** the sync skills (Step 4 / 4.5 / 5) by deliberate design: implementing renames + cross-repo edits first means the subsequent sync skills propagate the actual landed state rather than the pre-change state, avoiding a second cycle to fix downstream drift.
|
||||
|
||||
**Skip condition**: `_docs/tasks/todo/` is empty, missing, or contains only `_*` files. In that case Step 3.5 is skipped entirely and the cycle proceeds with Step 3's existing drift-based routing.
|
||||
|
||||
**Goal**: Execute suite-level implementation tasks — cross-repo concerns (e.g., `autopilot` + `ui` + suite `e2e/` cutover in a coordinated change-set), folder renames (e.g., `git mv flights missions` + `.gitmodules` edit + `_infra/` path refs), and suite-root infrastructure additions (e.g., `_infra/dev/docker-compose.dev.yml`). Per-component implementation work stays in each component's own workspace `/autodev` cycle.
|
||||
|
||||
**Why this exists**: the meta-repo's existing sync skills (`monorepo-document`, `monorepo-cicd`, `monorepo-e2e`) only **propagate** changes that already landed. They cannot **execute** a task spec. Without Step 3.5, suite-level tickets like AZ-543 (B4 repo rename) or AZ-506 (new dev compose) have no flow path forward — they require operator action outside autodev.
|
||||
|
||||
**Inputs**:
|
||||
|
||||
- `_docs/tasks/todo/*.md` (excluding `_*`) — task specs in the existing format (`Task` / `Component` / `Dependencies` / `Acceptance criteria` headers)
|
||||
- `_docs/_repo-config.yaml` — `components[].path` list, used to compute the suite-level OWNED envelope (workspace root EXCLUDING any path under a component's folder)
|
||||
- `_docs/tasks/_dependencies_table.md` — synthesized by this step if missing (see Procedure)
|
||||
- `_docs/tasks/_suite_module_layout.md` — synthesized by this step if missing (see Procedure)
|
||||
|
||||
**Procedure**:
|
||||
|
||||
1. **Detection (already done by Step 3 pre-routing gate)**. List task files in `_docs/tasks/todo/` (excluding `_*`). If 0 → skip Step 3.5. If ≥1 → continue.
|
||||
|
||||
2. **Present Choose**:
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
DECISION REQUIRED: <N> suite-level task(s) in _docs/tasks/todo/
|
||||
══════════════════════════════════════
|
||||
Task(s) detected:
|
||||
- AZ-XXX: <title> (deps: <list or "—">)
|
||||
- AZ-YYY: <title> (deps: <list or "—">)
|
||||
...
|
||||
|
||||
A) Run implement skill on these task(s) now (then continue to Doc / E2E / CICD sync)
|
||||
B) Skip implement this cycle — continue to Doc / E2E / CICD sync without executing tasks
|
||||
C) Pause — review the tasks before deciding (end session, no state changes)
|
||||
══════════════════════════════════════
|
||||
Recommendation: A — running implement BEFORE syncs means subsequent
|
||||
sync skills propagate the post-implementation state.
|
||||
B is appropriate when tasks are blocked on user input
|
||||
or external coordination. C when the tasks themselves
|
||||
need owner clarification before execution.
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
3. **On user A — Pre-flight**:
|
||||
|
||||
a. **Working tree clean check**. Run `git status --porcelain`. If non-empty, surface to the user with a Choose A/B/C identical to the implement skill's prerequisite gate (commit/stash manually; agent commits as `chore: WIP pre-implement`; abort).
|
||||
|
||||
b. **Synthesize `_docs/tasks/_dependencies_table.md`** if missing. Parse each in-scope task's `Dependencies:` field. Write a minimal table of the form:
|
||||
|
||||
```markdown
|
||||
# Suite-Level Task Dependencies
|
||||
|
||||
| Task ID | Depends on | Notes |
|
||||
|---------|------------|-------|
|
||||
| AZ-XXX | (none) | — |
|
||||
| AZ-YYY | AZ-XXX | — |
|
||||
```
|
||||
|
||||
If a task lists a dependency that is neither in `todo/` nor `done/`, log a warning in the synthesized file but do not block — implement skill's Step 1 (Parse) will surface the issue if it actually blocks execution.
|
||||
|
||||
c. **Synthesize `_docs/tasks/_suite_module_layout.md`** if missing. Default content:
|
||||
|
||||
```markdown
|
||||
# Suite-Level Module Layout (synthetic)
|
||||
|
||||
Generated by autodev meta-repo Step 3.5. The suite root has no per-feature decomposition; ownership is defined at the component-boundary level only.
|
||||
|
||||
## Per-Component Mapping
|
||||
|
||||
| Component | Owns | Imports from |
|
||||
|-----------|----------------------------------|--------------|
|
||||
| suite | (workspace root) excluding any path listed under `_repo-config.yaml.components[].path` | (read-only) every component's primary doc + `_docs/*.md` |
|
||||
|
||||
Suite-level tasks operate on: `.gitmodules`, `_infra/**`, `_docs/**` (excluding `_docs/tasks/_*` regenerated files), root `README.md`, `e2e/**` (suite e2e harness only).
|
||||
|
||||
Forbidden paths for suite-level tasks: `<component>/**` for every component listed in `_repo-config.yaml.components[].path` — those edits live in the component's own workspace `/autodev` cycle.
|
||||
```
|
||||
|
||||
d. **Prepare invocation context**:
|
||||
|
||||
```
|
||||
suite_level: true
|
||||
TASKS_DIR: _docs/tasks/
|
||||
module_layout_path: _docs/tasks/_suite_module_layout.md
|
||||
```
|
||||
|
||||
4. **Invoke implement skill**. Read and execute `.cursor/skills/implement/SKILL.md` with the prepared context. The skill's "Suite-level invocation context" subsection (added in tandem with this flow change) honors the three flags above and skips:
|
||||
|
||||
- Step 14.5 (cumulative code review) — no `architecture_compliance_baseline.md` exists at the suite level; cross-task drift is captured by the next `monorepo-status` cycle instead.
|
||||
- Step 15 (Product Implementation Completeness Gate) — the gate's inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite tasks are infrastructure / coordination work, not feature implementation.
|
||||
|
||||
All other implement skill steps (1–14, 16) execute unchanged. Tracker integration (Step 5: In Progress, Step 12: In Testing) runs normally.
|
||||
|
||||
5. **Post-implement re-status**. After the implement skill completes (last batch committed, all originally-todo tasks moved to `_docs/tasks/done/`), silently re-run Step 3's drift detection logic — do NOT re-render the full Status report; just re-evaluate the drift signals against the post-implementation tree. Then auto-chain per the post-implementation drift findings:
|
||||
|
||||
- Doc drift → Step 4 (Document Sync)
|
||||
- Suite-e2e drift only → Step 4.5
|
||||
- CI drift only → Step 5
|
||||
- No drift → cycle complete
|
||||
|
||||
Note: the post-implement re-status is exactly why Step 3.5 is placed before sync. A repo rename will typically introduce doc + CI drift; the next invocation of Step 4 / Step 5 catches it on the same cycle.
|
||||
|
||||
6. **On user B (skip)** → mark Step 3.5 `skipped` in state file. Apply Step 3's original drift-based routing (compute from the pre-Step-3.5 Status report).
|
||||
|
||||
7. **On user C (pause)** → end session. Update state to `step: 3.5, status: in_progress, sub_step: {phase: 0, name: awaiting-task-review, detail: "<N> tasks pending review"}`. Tell the user to invoke `/autodev` again after deciding. **Do NOT modify any files** — pre-flight has not run yet.
|
||||
|
||||
**Self-verification** (executed before invoking implement):
|
||||
|
||||
- [ ] Working tree is clean (or user explicitly chose B in the WIP-stash sub-Choose)
|
||||
- [ ] `_docs/tasks/_dependencies_table.md` exists (synthesized if it didn't)
|
||||
- [ ] `_docs/tasks/_suite_module_layout.md` exists (synthesized if it didn't)
|
||||
- [ ] All in-scope task files have a `Component:` field (skip + report any that don't — don't guess ownership)
|
||||
- [ ] Tracker availability gate satisfied per `protocols.md` (or `tracker: local` previously chosen)
|
||||
|
||||
**Failure handling**:
|
||||
|
||||
- If implement returns FAILED → standard Failure Handling (`protocols.md`): retry up to 3 times, then escalate.
|
||||
- If implement is interrupted mid-batch → next invocation re-detects via the implement skill's resumability protocol (read latest `_docs/03_implementation/suite_batch_*.md`). Step 3.5 itself is reentrant: on re-entry, if `todo/` still has tasks, it presents the Choose again with the remaining set.
|
||||
- **Half-applied state risk** (acknowledged): if implement is interrupted between commits, the working tree is clean at the last commit boundary but the in-flight batch is lost. The user is responsible for inspecting and re-invoking. This is intentional — automated rollback of suite-level renames + `.gitmodules` edits is more dangerous than a human-driven recovery.
|
||||
|
||||
**Idempotency**: if `_docs/tasks/todo/` becomes empty after this step (all tasks moved to `done/`), the next `/autodev` invocation skips Step 3.5 entirely and proceeds with normal Status → sync flow.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,11 +421,16 @@ After onboarding completes, the config is updated. Auto-chain back to **Step 3 (
|
||||
| Config Review (2, user picked A, confirmed_by_user: true) | Auto-chain → Glossary & Architecture Vision (2.5) |
|
||||
| Config Review (2, user picked B) | **Session boundary** — end session, await re-invocation |
|
||||
| Glossary & Architecture Vision (2.5) | Auto-chain → Status (3) |
|
||||
| Status (3, doc drift) | Auto-chain → Document Sync (4) |
|
||||
| Status (3, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Status (3, CI drift only) | Auto-chain → CICD Sync (5) |
|
||||
| Status (3, no drift) | **Cycle complete** — end session, await re-invocation |
|
||||
| Status (3, todo tasks present) | Auto-chain → Suite Implement (3.5) — pre-routing gate fires before drift-based routing |
|
||||
| Status (3, no todo tasks, doc drift) | Auto-chain → Document Sync (4) |
|
||||
| Status (3, no todo tasks, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Status (3, no todo tasks, CI drift only) | Auto-chain → CICD Sync (5) |
|
||||
| Status (3, no todo tasks, no drift) | **Cycle complete** — end session, await re-invocation |
|
||||
| Status (3, registry mismatch) | Ask user (A: discover, B: onboard, C: continue) |
|
||||
| Suite Implement (3.5, user picked A, success) | Silent re-status; auto-chain per post-implementation drift (Step 4 / 4.5 / 5 / cycle complete) |
|
||||
| Suite Implement (3.5, user picked B) | Mark `skipped`; auto-chain per Step 3's original drift findings |
|
||||
| Suite Implement (3.5, user picked C) | **Session boundary** — end session, await re-invocation |
|
||||
| Suite Implement (3.5, FAILED ×3) | Standard Failure Handling escalation (`protocols.md`) |
|
||||
| Document Sync (4) + suite-e2e drift pending | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Document Sync (4) + CI drift only pending | Auto-chain → CICD Sync (5) |
|
||||
| Document Sync (4) + no further drift | **Cycle complete** |
|
||||
@@ -317,11 +456,12 @@ Flow-specific slot values:
|
||||
| 2 | Config Review | `IN PROGRESS (awaiting human)` |
|
||||
| 2.5 | Glossary & Architecture Vision | `SKIPPED (already captured)` |
|
||||
| 3 | Status | `DONE (no drift)`, `DONE (N drifts)` |
|
||||
| 3.5 | Suite Implement | `DONE (N tasks)`, `SKIPPED (no todo tasks)`, `SKIPPED (user picked B)`, `IN PROGRESS (batch M of ~N)`, `IN PROGRESS (awaiting-task-review)` |
|
||||
| 4 | Document Sync | `DONE (N docs)`, `SKIPPED (no doc drift)` |
|
||||
| 4.5 | Integration Test Sync | `DONE (N files)`, `SKIPPED (no suite-e2e drift)`, `SKIPPED (no suite_e2e config block)` |
|
||||
| 5 | CICD Sync | `DONE (N files)`, `SKIPPED (no CI drift)` |
|
||||
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 3.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
|
||||
|
||||
Row rendering format:
|
||||
|
||||
@@ -330,6 +470,7 @@ Row rendering format:
|
||||
Step 2 Config Review [<state token>]
|
||||
Step 2.5 Glossary & Architecture Vision [<state token>]
|
||||
Step 3 Status [<state token>]
|
||||
Step 3.5 Suite Implement [<state token>]
|
||||
Step 4 Document Sync [<state token>]
|
||||
Step 4.5 Integration Test Sync [<state token>]
|
||||
Step 5 CICD Sync [<state token>]
|
||||
@@ -337,8 +478,12 @@ Row rendering format:
|
||||
|
||||
## Notes for the meta-repo flow
|
||||
|
||||
- **No session boundary except Step 2 and Step 2.5**: unlike existing-code flow (which has boundaries around decompose), meta-repo flow only pauses at config review and the one-shot glossary/vision capture. Once both are confirmed, syncing is fast enough to complete in one session and Step 2.5 idempotently no-ops on every subsequent invocation.
|
||||
- **Session boundaries**: Step 2 (Config Review pending), Step 2.5 (one-shot glossary/vision review), and Step 3.5 (when user picks C "Pause"). Step 3.5's A/B picks do NOT cross a session boundary — they auto-chain to syncs in the same session.
|
||||
- **Cyclical, not terminal**: no "done forever" state. Each invocation completes a drift cycle; next invocation starts fresh.
|
||||
- **No tracker integration**: this flow does NOT create Jira/ADO tickets. Maintenance is not a feature — if a feature-level ticket spans the meta-repo's concerns, it lives in the per-component workspace.
|
||||
- **Tracker integration scope**: this flow does NOT create Jira/ADO tickets in its sync skills (Status / Document Sync / E2E / CICD). Step 3.5 (Suite Implement) IS tracker-integrated — it transitions existing tickets In Progress → In Testing per the implement skill's standard tracker handling. Suite-level tickets are authored manually by the operator (typically as children of an Epic that spans multiple components, like AZ-539); the flow doesn't auto-create them.
|
||||
- **Per-component vs. suite-level work**:
|
||||
- Tickets that touch component source code (`<component>/src/**`) belong in that component's own workspace `/autodev` cycle. The meta-repo flow does NOT execute them.
|
||||
- Tickets that touch suite-root paths only (`.gitmodules`, `_infra/**`, suite `e2e/**`, root `README.md`, suite `_docs/**` outside `tasks/_*`) are eligible for Step 3.5.
|
||||
- Tickets that span both (e.g., AZ-550 B11 consumer cutover, which touches `autopilot/`, `ui/`, AND suite `e2e/`) are NOT executable from a single workspace by design — split the ticket so the suite-level slice can run in Step 3.5 and the component slices run in their owning workspaces.
|
||||
- **Onboarding is opt-in**: never auto-onboarded. User must explicitly request.
|
||||
- **Failure handling**: uses the same retry/escalation protocol as other flows (see `protocols.md`).
|
||||
|
||||
@@ -114,6 +114,7 @@ Before entering a step from this table for the first time in a session, verify t
|
||||
| greenfield | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
|
||||
| existing-code | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
|
||||
| existing-code | New Task | Step 7 — Ticket | Create ticket per task, link to epic |
|
||||
| meta-repo | Suite Implement | Step 3.5 — implement skill Step 5 / Step 12 | Transition existing tickets In Progress → In Testing per implement skill (does NOT create new tickets — operator authors them) |
|
||||
|
||||
### State File Marker
|
||||
|
||||
@@ -388,7 +389,7 @@ The banner shell is defined here once. Each flow file contributes only its step-
|
||||
where `<state token>` comes from the state-token set defined per row in the flow's step-list table.
|
||||
- `<current-suffix>` — optional, flow-specific. The existing-code flow appends ` (cycle <N>)` when `state.cycle > 1`; other flows leave it empty.
|
||||
- `Retry:` row — omit entirely when `retry_count` is 0. Include it with `<N>/3` otherwise.
|
||||
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty.
|
||||
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty unless **parent suite docs** apply: if `<workspace-root>/../docs` exists and is a directory, append `Suite docs (parent): <absolute path>` on its own line (or `Suite docs (parent): absent` is **not** required — omit when missing). This line is orthogonal to flow-specific footer lines; both may appear.
|
||||
|
||||
### State token set (shared)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The autodev persists its position to `_docs/_autodev_state.md`. This is a lightw
|
||||
|
||||
## Current Step
|
||||
flow: [greenfield | existing-code | meta-repo]
|
||||
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo, or "done"]
|
||||
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo (incl. fractional 2.5 and 3.5), or "done"]
|
||||
name: [step name from the active flow's Step Reference Table]
|
||||
status: [not_started / in_progress / completed / skipped / failed]
|
||||
sub_step:
|
||||
@@ -82,6 +82,19 @@ retry_count: 0
|
||||
cycle: 1
|
||||
```
|
||||
|
||||
```
|
||||
flow: meta-repo
|
||||
step: 3.5
|
||||
name: Suite Implement
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 7
|
||||
name: batch-loop
|
||||
detail: "AZ-543 batch 1 of 1; suite-level"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
```
|
||||
|
||||
```
|
||||
flow: existing-code
|
||||
step: 10
|
||||
@@ -100,7 +113,7 @@ cycle: 3
|
||||
1. **Create** on the first autodev invocation (after state detection determines Step 1)
|
||||
2. **Update** after every change — this includes: batch completion, sub-step progress, step completion, session boundary, failed retry, or any meaningful state transition. The state file must always reflect the current reality.
|
||||
3. **Read** as the first action on every invocation — before folder scanning
|
||||
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file
|
||||
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file. **Parent suite `docs/`**: on every invocation, also probe `<workspace-root>/../docs` (the parent directory’s `docs` folder — typical suite-level shared documentation next to a component repo). If it exists, mention it in the Status Summary footer per `protocols.md`; use it only as supplemental reading context unless a flow step explicitly ties detection to it. It never replaces workspace `_docs/` for step detection by default.
|
||||
5. **Never delete** the state file
|
||||
6. **Retry tracking**: increment `retry_count` on each failed auto-retry; reset to `0` on success. If `retry_count` reaches 3, set `status: failed`
|
||||
7. **Failed state on re-entry**: if `status: failed` with `retry_count: 3`, do NOT auto-retry — present the issue to the user first
|
||||
|
||||
@@ -64,6 +64,27 @@ TASKS_DIR/
|
||||
└── done/ ← completed tasks (moved here after implementation)
|
||||
```
|
||||
|
||||
### Suite-level invocation context (meta-repo flow)
|
||||
|
||||
When invoked from `.cursor/skills/autodev/flows/meta-repo.md` Step 3.5 (or any caller that supplies the same context envelope), the skill receives:
|
||||
|
||||
```
|
||||
suite_level: true
|
||||
TASKS_DIR: <override> # e.g., _docs/tasks/ (vs. default _docs/02_tasks/)
|
||||
module_layout_path: <override> # e.g., _docs/tasks/_suite_module_layout.md
|
||||
```
|
||||
|
||||
When `suite_level: true` is present, the following gate adjustments apply — and ONLY these. All other steps (1–14, 16) execute unchanged:
|
||||
|
||||
1. **TASKS_DIR override** is honored throughout the skill (Step 1 Parse, Step 13 Archive, Step 15 input paths if it ran). Default `_docs/02_tasks/` is replaced by the supplied path.
|
||||
2. **module_layout_path override** is read instead of the hardcoded `_docs/02_document/module-layout.md` in Step 4 (Assign File Ownership). The supplied file uses the same `Per-Component Mapping` schema. If both the override and the hardcoded path are missing, behavior is unchanged from default mode (STOP and instruct).
|
||||
3. **Step 14.5 (Cumulative Code Review) — SKIPPED**. The meta-repo has no `_docs/02_document/architecture_compliance_baseline.md`; cross-task drift is captured by the next `monorepo-status` cycle instead.
|
||||
4. **Step 15 (Product Implementation Completeness Gate) — SKIPPED**. The gate's hard inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite-level tasks are infrastructure / coordination work (renames, cross-repo edits, suite-root infra additions), not feature implementation; the equivalent completeness signal is the next `monorepo-status` drift report (which the meta-repo flow re-runs immediately after Step 3.5 returns).
|
||||
5. **Final report filename**: `_docs/03_implementation/suite_implementation_report_{run_name}.md` (in addition to the existing feature/test/refactor variants). Batch reports follow `_docs/03_implementation/suite_batch_{NN}_report.md`.
|
||||
6. **Tracker integration** (Step 5: In Progress, Step 12: In Testing) runs unchanged — suite-level tickets follow the same tracker rules as any other.
|
||||
|
||||
Without `suite_level: true`, none of these adjustments apply and the skill runs exactly as documented in default mode.
|
||||
|
||||
## Prerequisite Checks (BLOCKING)
|
||||
|
||||
1. `TASKS_DIR/todo/` exists and contains at least one task file for the selected context — **STOP if missing**
|
||||
@@ -103,7 +124,7 @@ TASKS_DIR/
|
||||
|
||||
### 4. Assign File Ownership
|
||||
|
||||
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
|
||||
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5), unless `suite_level: true` was supplied in the invocation context — in which case the `module_layout_path` override is read instead (see "Suite-level invocation context" above). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
|
||||
|
||||
For each task in the batch:
|
||||
- Read the task spec's **Component** field.
|
||||
@@ -222,6 +243,8 @@ For product implementation, this archive means "batch implementation accepted."
|
||||
|
||||
### 14.5. Cumulative Code Review (every K batches)
|
||||
|
||||
**Skipped entirely when `suite_level: true`** (see "Suite-level invocation context" above) — the meta-repo has no `architecture_compliance_baseline.md` to evaluate against; cross-task drift is captured by the next `monorepo-status` cycle.
|
||||
|
||||
- **Trigger**: every K completed batches (default `K = 3`; configurable per run via a `cumulative_review_interval` knob in the invocation context)
|
||||
- **Purpose**: per-batch review (Step 9) catches batch-local issues; cumulative review catches issues that only appear when tasks are combined — architecture drift, cross-task inconsistency, duplicate symbols introduced across different batches, contracts that drifted across producer/consumer batches
|
||||
- **Scope**: the union of files changed since the **last** cumulative review (or since the start of the run if this is the first)
|
||||
@@ -239,7 +262,7 @@ For product implementation, this archive means "batch implementation accepted."
|
||||
|
||||
### 15. Product Implementation Completeness Gate
|
||||
|
||||
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate only when the remaining context is explicitly test implementation or refactoring, as determined by the task files and report filename rules.
|
||||
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate when (a) the remaining context is explicitly test implementation or refactoring (as determined by the task files and report filename rules), OR (b) `suite_level: true` was supplied in the invocation context (the gate's inputs do not exist in the meta-repo artifact layout — see "Suite-level invocation context" above).
|
||||
|
||||
**Goal**: catch the failure mode where narrow tests validate scaffold behavior while the task's actual outcome, included scope, architecture promise, or named integration remains unimplemented.
|
||||
|
||||
@@ -309,8 +332,9 @@ After each batch completes, save the batch report to `_docs/03_implementation/ba
|
||||
- **Test implementation** (tasks from test decomposition): `_docs/03_implementation/implementation_report_tests.md`
|
||||
- **Feature implementation**: `_docs/03_implementation/implementation_report_{feature_slug}_cycle{N}.md` where `{feature_slug}` is derived from the batch task names (e.g., `implementation_report_core_api_cycle2.md`) and `{N}` is the current `state.cycle` from `_docs/_autodev_state.md`. If `state.cycle` is absent (pre-migration), default to `cycle1`.
|
||||
- **Refactoring**: `_docs/03_implementation/implementation_report_refactor_{run_name}.md`
|
||||
- **Suite-level** (when `suite_level: true` was supplied — see "Suite-level invocation context" above): `_docs/03_implementation/suite_implementation_report_{run_name}.md`. Batch reports use `_docs/03_implementation/suite_batch_{NN}_report.md`. `{run_name}` is derived from the batch task IDs (e.g., `suite_implementation_report_az543_az549_az550.md`).
|
||||
|
||||
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; otherwise derive the feature slug from the component names and append the cycle suffix.
|
||||
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; if `suite_level: true` was supplied, use the suite filename; otherwise derive the feature slug from the component names and append the cycle suffix.
|
||||
|
||||
Batch report filenames must also include the cycle counter when running feature implementation: `_docs/03_implementation/batch_{NN}_cycle{N}_report.md` (test and refactor runs may use the plain `batch_{NN}_report.md` form since they are not cycle-scoped).
|
||||
|
||||
|
||||
+32
-5
@@ -14,16 +14,43 @@ ASPNETCORE_URLS=http://+:8080 # Kestrel bind address inside the
|
||||
ASPNETCORE_ConnectionStrings__AzaionDb=Host=localhost;Port=4312;Database=azaion;Username=azaion_reader;Password=CHANGE_ME
|
||||
ASPNETCORE_ConnectionStrings__AzaionDbAdmin=Host=localhost;Port=4312;Database=azaion;Username=azaion_admin;Password=CHANGE_ME
|
||||
|
||||
# ---------- JWT (HMAC-SHA256, 4 h TTL) --------------------------------------
|
||||
ASPNETCORE_JwtConfig__Secret=CHANGE_ME_TO_A_RANDOM_STRING_AT_LEAST_32_BYTES
|
||||
# ---------- JWT (ES256, 15 min access, 8/12 h refresh — AZ-531/AZ-532) ------
|
||||
# AZ-532 — admin signs access tokens with ES256. Keys live as PEM files in the
|
||||
# folder named by KeysFolder (the kid is the filename without `.pem`); generate
|
||||
# with scripts/generate-jwt-key.sh. The cycle-1 symmetric secret was removed in
|
||||
# cycle 2; verifiers now fetch the public key from /.well-known/jwks.json.
|
||||
ASPNETCORE_JwtConfig__Issuer=AzaionApi
|
||||
ASPNETCORE_JwtConfig__Audience=Annotators/OrangePi/Admins
|
||||
ASPNETCORE_JwtConfig__TokenLifetimeHours=4
|
||||
ASPNETCORE_JwtConfig__KeysFolder=/etc/azaion/jwt-keys
|
||||
# AZ-552/AZ-553 — ActiveKid is REQUIRED in production deployments. The
|
||||
# preflight in scripts/start-services.sh fails fast if it is unset.
|
||||
ASPNETCORE_JwtConfig__ActiveKid=kid-20260514-000000
|
||||
ASPNETCORE_JwtConfig__AccessTokenLifetimeMinutes=15
|
||||
|
||||
# AZ-553 — host-side directory holding the ES256 PEMs. Bind-mounted RO into
|
||||
# the container at $JwtConfig__KeysFolder. Must be owned by (or readable by)
|
||||
# the container's `app` UID. See secrets/README.md "Host-side directories".
|
||||
DEPLOY_HOST_JWT_KEYS_DIR=/var/lib/azaion/jwt-keys
|
||||
|
||||
# AZ-531 — refresh-token windows. Sliding extends on every rotation; absolute
|
||||
# caps the family lifetime regardless of activity.
|
||||
ASPNETCORE_SessionConfig__RefreshSlidingHours=8
|
||||
ASPNETCORE_SessionConfig__RefreshAbsoluteHours=12
|
||||
|
||||
# ---------- DataProtection (AZ-554) -----------------------------------------
|
||||
# AZ-554 — DataProtection master keys MUST persist across container restarts;
|
||||
# otherwise the cycle-2 MFA secret ciphertexts become unreadable and every
|
||||
# MFA-enrolled user is locked out at the next deploy. Production deployments
|
||||
# MUST set this; non-prod uses the ephemeral default if unset.
|
||||
ASPNETCORE_DataProtection__KeysFolder=/var/lib/azaion/dp-keys
|
||||
|
||||
# AZ-554 — host-side directory holding the DataProtection key ring. Bind-mounted
|
||||
# RW into the container at $DataProtection__KeysFolder. Must be writable by the
|
||||
# container's `app` UID. NEVER world-readable (chmod 0700).
|
||||
DEPLOY_HOST_DP_KEYS_DIR=/var/lib/azaion/dp-keys
|
||||
|
||||
# ---------- Resource storage (filesystem) -----------------------------------
|
||||
ASPNETCORE_ResourcesConfig__ResourcesFolder=Content
|
||||
ASPNETCORE_ResourcesConfig__SuiteInstallerFolder=suite
|
||||
ASPNETCORE_ResourcesConfig__SuiteStageInstallerFolder=suite-stage
|
||||
|
||||
# ---------- Container build / image label ------------------------------------
|
||||
# Injected at build time as --build-arg CI_COMMIT_SHA=… by Woodpecker.
|
||||
|
||||
+5
-1
@@ -10,4 +10,8 @@ Content/
|
||||
.env
|
||||
.DS_Store
|
||||
e2e/test-results/*
|
||||
!e2e/test-results/.gitkeep
|
||||
!e2e/test-results/.gitkeep
|
||||
|
||||
# AZ-532 — never commit production JWT signing keys.
|
||||
secrets/jwt-keys/*
|
||||
!secrets/jwt-keys/.gitkeep
|
||||
+4
-7
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.AdminApi", "Azaion.AdminApi\Azaion.AdminApi.csproj", "{03A56CF2-A57F-4631-8454-C08B804B8903}"
|
||||
EndProject
|
||||
@@ -6,8 +6,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Common", "Azaion.Com
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Services", "Azaion.Services\Azaion.Services.csproj", "{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Test", "Azaion.Test\Azaion.Test.csproj", "{2F4F0EA9-0645-4917-8D21-F317E815EB9E}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{49FBE419-D2FA-4D7C-8419-D3AD5B44DD58}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
Dockerfile = Dockerfile
|
||||
@@ -32,9 +30,8 @@ Global
|
||||
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -13,9 +13,12 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
|
||||
if (exception is BusinessException ex)
|
||||
{
|
||||
logger.LogWarning(exception, ex.Message);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
httpContext.Response.StatusCode = MapStatusCode(ex.ExceptionEnum);
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
|
||||
if (ex.RetryAfterSeconds is { } retry && retry > 0)
|
||||
httpContext.Response.Headers.RetryAfter = retry.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var err = JsonConvert.SerializeObject(new
|
||||
{
|
||||
ErrorCode = ex.ExceptionEnum,
|
||||
@@ -42,4 +45,24 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int MapStatusCode(ExceptionEnum kind) => kind switch
|
||||
{
|
||||
// AZ-556 — `InvalidCredentials` covers unknown email, wrong password, disabled
|
||||
// account, lockout, and per-account rate-limit. Same 401 for all five so the
|
||||
// wire response carries no signal beyond the optional Retry-After header.
|
||||
ExceptionEnum.InvalidCredentials => StatusCodes.Status401Unauthorized,
|
||||
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
|
||||
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
|
||||
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
|
||||
ExceptionEnum.SessionNotFound => StatusCodes.Status404NotFound,
|
||||
ExceptionEnum.InvalidMissionRequest => StatusCodes.Status400BadRequest,
|
||||
ExceptionEnum.AircraftNotFound => StatusCodes.Status400BadRequest,
|
||||
ExceptionEnum.MfaAlreadyEnabled => StatusCodes.Status409Conflict,
|
||||
ExceptionEnum.MfaNotEnrolling => StatusCodes.Status409Conflict,
|
||||
ExceptionEnum.MfaNotEnabled => StatusCodes.Status409Conflict,
|
||||
ExceptionEnum.InvalidMfaCode => StatusCodes.Status401Unauthorized,
|
||||
ExceptionEnum.InvalidMfaToken => StatusCodes.Status401Unauthorized,
|
||||
_ => StatusCodes.Status409Conflict
|
||||
};
|
||||
}
|
||||
|
||||
+410
-58
@@ -1,4 +1,6 @@
|
||||
using System.Text;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
@@ -9,8 +11,12 @@ using FluentValidation;
|
||||
using LinqToDB.Data;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Rewrite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using Serilog;
|
||||
@@ -30,9 +36,15 @@ builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||
o.MultipartBodyLengthLimit = 209715200);
|
||||
|
||||
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
|
||||
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Secret))
|
||||
throw new Exception("Missing configuration section: JwtConfig");
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Secret));
|
||||
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Issuer) || string.IsNullOrEmpty(jwtConfig.Audience))
|
||||
throw new Exception("Missing configuration section: JwtConfig (Issuer + Audience required)");
|
||||
|
||||
// AZ-532 — load ES256 signing keys eagerly so JwtBearer can resolve issuer signing
|
||||
// keys via the same provider DI registers below for AuthService.
|
||||
var signingKeyLoggerFactory = LoggerFactory.Create(c => c.AddSerilog(Log.Logger));
|
||||
var jwtSigningKeyProvider = new JwtSigningKeyProvider(
|
||||
Options.Create(jwtConfig),
|
||||
signingKeyLoggerFactory.CreateLogger<JwtSigningKeyProvider>());
|
||||
|
||||
// Fail-fast for DB connection strings — surfaces a missing env var at startup
|
||||
// instead of on the first request to a DB-backed endpoint.
|
||||
@@ -51,13 +63,22 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
{
|
||||
o.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtConfig.Issuer,
|
||||
ValidAudience = jwtConfig.Audience,
|
||||
IssuerSigningKey = signingKey
|
||||
ValidIssuer = jwtConfig.Issuer,
|
||||
ValidAudience = jwtConfig.Audience,
|
||||
// AZ-532 AC-5 — pin algorithms so a token forged with alg=HS256 using the
|
||||
// public key as the HMAC secret cannot pass validation.
|
||||
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
|
||||
IssuerSigningKeyResolver = (_, _, kid, _) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(kid))
|
||||
return jwtSigningKeyProvider.All.Select(k => (SecurityKey)k.SecurityKey);
|
||||
var hit = jwtSigningKeyProvider.All.FirstOrDefault(k => k.Kid == kid);
|
||||
return hit != null ? [hit.SecurityKey] : [];
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,9 +87,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
|
||||
|
||||
// AZ-535 — verifiers (satellite-provider, gps-denied, ui) authenticate as
|
||||
// service-role identities and are the only callers (besides ApiAdmin) allowed
|
||||
// to read the global revocation snapshot.
|
||||
var revocationReaderPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole(RoleEnum.Service.ToString(), RoleEnum.ApiAdmin.ToString()).Build();
|
||||
|
||||
builder.Services.AddAuthorization(o =>
|
||||
{
|
||||
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
||||
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
|
||||
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
|
||||
});
|
||||
|
||||
#endregion Policies
|
||||
@@ -101,11 +129,79 @@ builder.Services.AddSwaggerGen(c =>
|
||||
builder.Services.Configure<ResourcesConfig>(builder.Configuration.GetSection(nameof(ResourcesConfig)));
|
||||
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection(nameof(JwtConfig)));
|
||||
builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(nameof(ConnectionStrings)));
|
||||
builder.Services.Configure<AuthConfig>(builder.Configuration.GetSection(nameof(AuthConfig)));
|
||||
builder.Services.Configure<SessionConfig>(builder.Configuration.GetSection(nameof(SessionConfig)));
|
||||
|
||||
var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get<AuthConfig>() ?? new AuthConfig();
|
||||
|
||||
// AZ-532 — share the eagerly-built provider so JwtBearer and AuthService both
|
||||
// hold the same set of loaded keys.
|
||||
builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
|
||||
builder.Services.AddScoped<IMfaService, MfaService>();
|
||||
|
||||
// AZ-534 / AZ-554 — DataProtection encrypts mfa_secret at rest. Production
|
||||
// MUST persist the key ring to a bind-mounted host folder; otherwise every
|
||||
// container restart rotates the master key and locks every MFA-enrolled user
|
||||
// out at the next deploy. Development falls back to the ephemeral default.
|
||||
{
|
||||
var dpBuilder = builder.Services.AddDataProtection();
|
||||
dpBuilder.SetApplicationName("Azaion.AdminApi");
|
||||
var keyFolder = builder.Configuration["DataProtection:KeysFolder"];
|
||||
var isProduction = builder.Environment.IsProduction();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyFolder))
|
||||
{
|
||||
if (isProduction)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"DataProtection.KeysFolder is required in Production. " +
|
||||
"Set ASPNETCORE_DataProtection__KeysFolder to a persistent bind-mounted path " +
|
||||
"(e.g. /var/lib/azaion/dp-keys backed by DEPLOY_HOST_DP_KEYS_DIR). " +
|
||||
"Without this, MFA secret ciphertexts become unreadable after the next container restart.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(keyFolder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"DataProtection.KeysFolder '{keyFolder}' is not writable: {ex.Message}. " +
|
||||
"Ensure the bind-mounted host directory is owned by the container user.",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (isProduction)
|
||||
{
|
||||
var probe = Path.Combine(keyFolder, ".dp-writable-probe");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(probe, "ok");
|
||||
File.Delete(probe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"DataProtection.KeysFolder '{keyFolder}' exists but is not writable by the current process: {ex.Message}. " +
|
||||
"Check host-side ownership/permissions of DEPLOY_HOST_DP_KEYS_DIR (must be writable by the container user).",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
dpBuilder.PersistKeysToFileSystem(new DirectoryInfo(keyFolder));
|
||||
}
|
||||
}
|
||||
builder.Services.AddScoped<IResourcesService, ResourcesService>();
|
||||
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
|
||||
builder.Services.AddScoped<IAuditLog, AuditLog>();
|
||||
builder.Services.AddSingleton<IDbFactory, DbFactory>();
|
||||
|
||||
builder.Services.AddLazyCache();
|
||||
@@ -114,18 +210,61 @@ builder.Services.AddScoped<ICache, MemoryCache>();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
|
||||
builder.Services.AddExceptionHandler<BusinessExceptionHandler>();
|
||||
|
||||
// Add CORS configuration
|
||||
// AZ-537 — per-IP sliding window rate limit on /login. Per-account rate limit and
|
||||
// account lockout live in UserService.ValidateUser (DB-backed) so they survive
|
||||
// process restarts and feed the audit_events table.
|
||||
const string LoginPerIpPolicy = "login-per-ip";
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = (ctx, _) =>
|
||||
{
|
||||
if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
ctx.HttpContext.Response.Headers.RetryAfter =
|
||||
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
options.AddPolicy(LoginPerIpPolicy, httpContext =>
|
||||
{
|
||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetSlidingWindowLimiter(ip, _ => new SlidingWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = authConfig.RateLimit.PerIpPermitLimit,
|
||||
Window = TimeSpan.FromSeconds(authConfig.RateLimit.PerIpWindowSeconds),
|
||||
SegmentsPerWindow = 6,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// AZ-538 — only the HTTPS origin is allowed; the legacy http:// origin combined with
|
||||
// AllowCredentials() permitted credentialed cleartext traffic and is now removed.
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AdminCorsPolicy", policy =>
|
||||
{
|
||||
policy.WithOrigins("https://admin.azaion.com", "http://admin.azaion.com")
|
||||
policy.WithOrigins("https://admin.azaion.com")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
// AZ-538 — HSTS: 1 year, includeSubDomains, preload eligible. Only attached in
|
||||
// non-Development envs; Development skips both HSTS and HTTPS redirection so
|
||||
// `dotnet watch` on http://localhost keeps working.
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddHsts(o =>
|
||||
{
|
||||
o.MaxAge = TimeSpan.FromDays(365);
|
||||
o.IncludeSubDomains = true;
|
||||
o.Preload = true;
|
||||
});
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -133,11 +272,19 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
else
|
||||
{
|
||||
// AZ-538 — defence in depth: even if the http origin is re-added by accident
|
||||
// the protocol-layer redirect kicks in first.
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
app.UseCors("AdminCorsPolicy");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseRewriter(new RewriteOptions().AddRedirect("^$", "/swagger"));
|
||||
|
||||
@@ -175,12 +322,259 @@ app.MapGet("/health/ready", async (IDbFactory dbFactory, HttpContext http, Cance
|
||||
#endregion Health endpoints
|
||||
|
||||
app.MapPost("/login",
|
||||
async (LoginRequest request, IUserService userService, IAuthService authService, CancellationToken cancellationToken) =>
|
||||
async (LoginRequest request,
|
||||
IUserService userService,
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
IMfaService mfaService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var user = await userService.ValidateUser(request, ct: cancellationToken);
|
||||
return Results.Ok(new { Token = authService.CreateToken(user)});
|
||||
|
||||
// AZ-534 AC-3 — MFA-enabled users get short-circuited to a step-1 token; the
|
||||
// real access+refresh pair is minted only after /login/mfa.
|
||||
if (user.MfaEnabled)
|
||||
{
|
||||
return Results.Ok(new MfaRequiredResponse
|
||||
{
|
||||
MfaRequired = true,
|
||||
MfaToken = mfaService.IssueMfaStepToken(user.Id),
|
||||
ExpiresIn = 300,
|
||||
});
|
||||
}
|
||||
|
||||
return await IssueDualTokens(user, authService, refreshTokens, sessionService, amr: null, cancellationToken);
|
||||
})
|
||||
.WithSummary("Login");
|
||||
.RequireRateLimiting(LoginPerIpPolicy)
|
||||
.WithSummary("Login (returns access + refresh token, OR mfa_required if MFA is enabled)");
|
||||
|
||||
// AZ-534 AC-3 / AC-4 — second factor at credential login. Anonymous because the
|
||||
// step-1 mfa_token is itself the proof the caller is mid-flow.
|
||||
app.MapPost("/login/mfa",
|
||||
async (MfaLoginRequest request,
|
||||
IMfaService mfaService,
|
||||
IUserService userService,
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var userId = mfaService.ValidateMfaStepToken(request.MfaToken);
|
||||
// AZ-556 — keep the wire response opaque even on the unlikely state where the
|
||||
// step-1 token resolves to a userId that no longer exists. MfaService applies
|
||||
// the same opaque response for missing MfaSecret / disabled user.
|
||||
var user = await userService.GetById(userId, cancellationToken)
|
||||
?? throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
|
||||
var amr = await mfaService.VerifyForLogin(userId, request.Code, cancellationToken);
|
||||
return await IssueDualTokens(user, authService, refreshTokens, sessionService, amr, cancellationToken);
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.RequireRateLimiting(LoginPerIpPolicy)
|
||||
.WithSummary("AZ-534 — second-factor verification; returns access + refresh token");
|
||||
|
||||
static async Task<IResult> IssueDualTokens(
|
||||
User user,
|
||||
IAuthService authService,
|
||||
IRefreshTokenService refreshTokens,
|
||||
ISessionService sessionService,
|
||||
string[]? amr,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// AZ-534 — pin AMR strength to the session so refresh rotation inherits it.
|
||||
var mfaAuthenticated = amr != null && amr.Contains("mfa");
|
||||
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, mfaAuthenticated, ct);
|
||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid(), amr: amr);
|
||||
|
||||
// AZ-533 AC-4 — post-flight reconnect: if the just-authenticated user is an
|
||||
// aircraft (CompanionPC), kill any open mission session bound to it.
|
||||
if (user.Role == RoleEnum.CompanionPC)
|
||||
await sessionService.RevokeMissionsForAircraft(user.Id, ct);
|
||||
|
||||
return Results.Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access.Jwt,
|
||||
AccessExp = access.ExpiresAt,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshExp = session.ExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// AZ-531 — refresh-token rotation. Anonymous: clients pass the opaque refresh
|
||||
// in the request body so an expired access token doesn't block the refresh.
|
||||
app.MapPost("/token/refresh",
|
||||
async (RefreshTokenRequest request,
|
||||
IRefreshTokenService refreshTokens,
|
||||
IUserService userService,
|
||||
IAuthService authService,
|
||||
ISessionService sessionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
|
||||
var user = await userService.GetById(session.UserId, cancellationToken);
|
||||
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
// AZ-534 — preserve the original AMR strength across rotations.
|
||||
var amr = session.MfaAuthenticated ? new[] { "pwd", "mfa" } : new[] { "pwd" };
|
||||
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid(), amr: amr);
|
||||
|
||||
// AZ-533 AC-4 — same auto-revoke trigger as /login.
|
||||
if (user.Role == RoleEnum.CompanionPC)
|
||||
await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken);
|
||||
|
||||
return Results.Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access.Jwt,
|
||||
AccessExp = access.ExpiresAt,
|
||||
RefreshToken = newRefresh,
|
||||
RefreshExp = session.ExpiresAt,
|
||||
});
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
|
||||
|
||||
// AZ-535 — logout: revoke the caller's current session (the sid claim on their
|
||||
// access token). Idempotent.
|
||||
app.MapPost("/logout",
|
||||
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
var sid = ParseSidClaim(http.User);
|
||||
var caller = ParseUserIdClaim(http.User);
|
||||
var alreadyRevoked = await sessions.RevokeBySid(sid, caller, SessionRevokedReasons.LoggedOut, ct);
|
||||
return Results.Ok(new { alreadyRevoked });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-535 — revoke the caller's current session");
|
||||
|
||||
// AZ-535 AC-2 — sign out everywhere: revoke every active session for the caller.
|
||||
app.MapPost("/logout/all",
|
||||
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
var caller = ParseUserIdClaim(http.User);
|
||||
var revoked = await sessions.RevokeAllForUser(caller, caller, SessionRevokedReasons.LoggedOutAll, ct);
|
||||
return Results.Ok(new { revoked });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-535 — revoke every session for the caller's user");
|
||||
|
||||
// AZ-535 AC-3 — admin-only revoke-by-sid.
|
||||
app.MapPost("/sessions/{sid:guid}/revoke",
|
||||
async (Guid sid, HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
var admin = ParseUserIdClaim(http.User);
|
||||
var alreadyRevoked = await sessions.RevokeBySid(sid, admin, SessionRevokedReasons.AdminRevoked, ct);
|
||||
return Results.Ok(new { alreadyRevoked });
|
||||
})
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("AZ-535 — admin revoke-by-session-id");
|
||||
|
||||
// AZ-535 AC-4 — verifier-poll snapshot of revoked-but-not-yet-expired sessions.
|
||||
app.MapGet("/sessions/revoked",
|
||||
async (DateTime? since, HttpContext http, ISessionService sessions, CancellationToken ct) =>
|
||||
{
|
||||
// Cap "since" to the longest plausible token TTL (12 h, matches mission cap)
|
||||
// so a buggy verifier asking for "everything since 1970" doesn't cost us a
|
||||
// multi-million-row table scan.
|
||||
var floor = DateTime.UtcNow.AddHours(-12);
|
||||
var effective = since.HasValue && since.Value > floor ? since.Value : floor;
|
||||
|
||||
var rows = await sessions.GetRevokedSince(effective, ct);
|
||||
http.Response.Headers.CacheControl = "no-cache";
|
||||
return Results.Ok(rows.Select(r => new
|
||||
{
|
||||
sid = r.Sid,
|
||||
exp = r.Exp,
|
||||
revokedAt = r.RevokedAt,
|
||||
reason = r.Reason
|
||||
}));
|
||||
})
|
||||
.RequireAuthorization(revocationReaderPolicy)
|
||||
.WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL");
|
||||
|
||||
// AZ-534 — TOTP MFA enrollment + management.
|
||||
app.MapPost("/users/me/mfa/enroll",
|
||||
async (MfaEnrollRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
|
||||
{
|
||||
var userId = ParseUserIdClaim(http.User);
|
||||
var resp = await mfa.Enroll(userId, request.Password, ct);
|
||||
return Results.Ok(resp);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-534 — start TOTP enrollment (pre-confirm)");
|
||||
|
||||
app.MapPost("/users/me/mfa/confirm",
|
||||
async (MfaConfirmRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
|
||||
{
|
||||
var userId = ParseUserIdClaim(http.User);
|
||||
await mfa.Confirm(userId, request.Code, ct);
|
||||
return Results.Ok(new { mfaEnabled = true });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-534 — confirm TOTP enrollment with a valid code");
|
||||
|
||||
app.MapPost("/users/me/mfa/disable",
|
||||
async (MfaDisableRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
|
||||
{
|
||||
var userId = ParseUserIdClaim(http.User);
|
||||
await mfa.Disable(userId, request.Password, request.Code, ct);
|
||||
return Results.Ok(new { mfaEnabled = false });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-534 — disable MFA (requires password + valid code)");
|
||||
|
||||
// AZ-533 — mission token issuance for offline UAV ops. Pilot calls with their
|
||||
// interactive access token; admin returns a long-lived no-refresh token bound
|
||||
// to one aircraft + one mission.
|
||||
app.MapPost("/sessions/mission",
|
||||
async (MissionSessionRequest request, HttpContext http, IMissionTokenService missions, CancellationToken ct) =>
|
||||
{
|
||||
var pilot = ParseUserIdClaim(http.User);
|
||||
// TODO (AZ-534): require amr=["pwd","mfa"]; until MFA ships this is a code
|
||||
// comment per the AZ-533 spec, not an enforced gate.
|
||||
var resp = await missions.Issue(pilot, request, ct);
|
||||
return Results.Ok(resp);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithSummary("AZ-533 — issue a long-lived mission token for one UAV flight");
|
||||
|
||||
static Guid ParseSidClaim(System.Security.Claims.ClaimsPrincipal user) =>
|
||||
Guid.TryParse(user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value, out var s)
|
||||
? s
|
||||
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
static Guid ParseUserIdClaim(System.Security.Claims.ClaimsPrincipal user) =>
|
||||
Guid.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var u)
|
||||
? u
|
||||
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
|
||||
app.MapGet("/.well-known/jwks.json",
|
||||
(IJwtSigningKeyProvider keys, HttpContext http) =>
|
||||
{
|
||||
http.Response.Headers.CacheControl = "public, max-age=3600";
|
||||
var jwks = new
|
||||
{
|
||||
keys = keys.All.Select(k =>
|
||||
{
|
||||
var p = k.Ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
return new
|
||||
{
|
||||
kty = "EC",
|
||||
crv = "P-256",
|
||||
kid = k.Kid,
|
||||
use = "sig",
|
||||
alg = "ES256",
|
||||
x = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.X!),
|
||||
y = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.Y!)
|
||||
};
|
||||
}).ToArray()
|
||||
};
|
||||
return Results.Json(jwks);
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.ExcludeFromDescription()
|
||||
.WithSummary("JWKS — public verification keys");
|
||||
|
||||
app.MapPost("/users",
|
||||
async (RegisterUserRequest registerUserRequest, IValidator<RegisterUserRequest> validator,
|
||||
@@ -261,48 +655,6 @@ app.MapPost("/resources/clear/{dataFolder?}",
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Clear folder");
|
||||
|
||||
app.MapPost("/resources/get/{dataFolder?}", //Need to have POST method for secure password
|
||||
async ([FromBody]GetResourceRequest request, [FromRoute]string? dataFolder, IAuthService authService,
|
||||
IResourcesService resourcesService, CancellationToken ct) =>
|
||||
{
|
||||
var user = await authService.GetCurrentUser();
|
||||
if (user == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
|
||||
var key = Security.GetApiEncryptionKey(user.Email, request.Password);
|
||||
var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct);
|
||||
|
||||
return Results.File(stream, "application/octet-stream", request.FileName);
|
||||
}).RequireAuthorization()
|
||||
.WithSummary("Gets encrypted by user's Password resource. POST method for secure password");
|
||||
|
||||
app.MapGet("/resources/get-installer",
|
||||
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
||||
{
|
||||
var user = await authService.GetCurrentUser();
|
||||
if (user == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
var (name, stream) = resourcesService.GetInstaller(isStage: false);
|
||||
if (stream == null)
|
||||
throw new FileNotFoundException("Installer file was not found!");
|
||||
return Results.File(stream, "application/octet-stream", name);
|
||||
}).RequireAuthorization()
|
||||
.WithSummary("Gets latest installer");
|
||||
|
||||
app.MapGet("/resources/get-installer/stage",
|
||||
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
||||
{
|
||||
var user = await authService.GetCurrentUser();
|
||||
if (user == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
var (name, stream) = resourcesService.GetInstaller(isStage: true);
|
||||
if (stream == null)
|
||||
throw new FileNotFoundException("Installer file was not found!");
|
||||
return Results.File(stream, "application/octet-stream", name);
|
||||
}).RequireAuthorization()
|
||||
.WithSummary("Gets latest installer");
|
||||
|
||||
|
||||
app.MapPost("/classes",
|
||||
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
||||
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
||||
|
||||
@@ -4,5 +4,10 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AuthConfig": {
|
||||
"RateLimit": {
|
||||
"PerIpPermitLimit": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,31 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ResourcesConfig": {
|
||||
"ResourcesFolder": "Content",
|
||||
"SuiteInstallerFolder": "suite",
|
||||
"SuiteStageInstallerFolder": "suite-stage"
|
||||
"ResourcesFolder": "Content"
|
||||
},
|
||||
"JwtConfig": {
|
||||
"Issuer": "AzaionApi",
|
||||
"Audience": "Annotators/OrangePi/Admins",
|
||||
"TokenLifetimeHours": 4
|
||||
"KeysFolder": "secrets/jwt-keys",
|
||||
"AccessTokenLifetimeMinutes": 15
|
||||
},
|
||||
"SessionConfig": {
|
||||
"RefreshSlidingHours": 8,
|
||||
"RefreshAbsoluteHours": 12
|
||||
},
|
||||
"AuthConfig": {
|
||||
"RateLimit": {
|
||||
"PerIpPermitLimit": 10,
|
||||
"PerIpWindowSeconds": 60,
|
||||
"PerAccountPermitLimit": 5,
|
||||
"PerAccountWindowSeconds": 300
|
||||
},
|
||||
"Lockout": {
|
||||
"MaxAttempts": 10,
|
||||
"DurationSeconds": 900
|
||||
}
|
||||
},
|
||||
"DataProtection": {
|
||||
"KeysFolder": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,34 @@ public class BusinessException(ExceptionEnum exEnum) : Exception(GetMessage(exEn
|
||||
|
||||
public ExceptionEnum ExceptionEnum { get; set; } = exEnum;
|
||||
|
||||
/// <summary>
|
||||
/// Optional cooldown hint surfaced as a Retry-After response header by the exception
|
||||
/// handler. Used by AccountLocked and LoginRateLimited (AZ-537).
|
||||
/// </summary>
|
||||
public int? RetryAfterSeconds { get; init; }
|
||||
|
||||
public BusinessException(ExceptionEnum exEnum, int retryAfterSeconds) : this(exEnum)
|
||||
{
|
||||
RetryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public static string GetMessage(ExceptionEnum exEnum) => ExceptionDescriptions.GetValueOrDefault(exEnum) ?? exEnum.ToString();
|
||||
}
|
||||
|
||||
public enum ExceptionEnum
|
||||
{
|
||||
// AZ-556 — DEPRECATED: no longer thrown by `UserService.ValidateUser`. The login
|
||||
// path now uses `InvalidCredentials` (70) for all rejection categories to close the
|
||||
// user-enumeration leak (F-AUTH-1 + F-AUTH-3). Kept defined for any cross-workspace
|
||||
// verifier that still pattern-matches on the old codes. Removal is scheduled in a
|
||||
// separate ticket after the deprecation window.
|
||||
[Description("No such email found.")]
|
||||
NoEmailFound = 10,
|
||||
|
||||
[Description("Email already exists.")]
|
||||
EmailExists = 20,
|
||||
|
||||
// AZ-556 — DEPRECATED: see the `NoEmailFound` deprecation note above.
|
||||
[Description("Passwords do not match.")]
|
||||
WrongPassword = 30,
|
||||
|
||||
@@ -36,12 +53,55 @@ public enum ExceptionEnum
|
||||
|
||||
WrongEmail = 37,
|
||||
|
||||
// AZ-556 — DEPRECATED: see the `NoEmailFound` deprecation note above.
|
||||
[Description("User account is disabled.")]
|
||||
UserDisabled = 38,
|
||||
|
||||
[Description("Wrong resource file name.")]
|
||||
WrongResourceName = 50,
|
||||
// AZ-556 — DEPRECATED: cycle-2 unifies the lockout response under
|
||||
// `InvalidCredentials` + Retry-After header (AC-7). Kept defined for cross-workspace
|
||||
// verifier compatibility; will be removed alongside `NoEmailFound`/`WrongPassword`.
|
||||
[Description("Account is temporarily locked due to too many failed login attempts.")]
|
||||
AccountLocked = 50,
|
||||
|
||||
// AZ-556 — DEPRECATED: see the `AccountLocked` deprecation note above.
|
||||
[Description("Too many login attempts. Try again later.")]
|
||||
LoginRateLimited = 51,
|
||||
|
||||
[Description("Refresh token is invalid, expired, or has been revoked.")]
|
||||
InvalidRefreshToken = 52,
|
||||
|
||||
[Description("Session not found.")]
|
||||
SessionNotFound = 53,
|
||||
|
||||
[Description("Mission token request is invalid.")]
|
||||
InvalidMissionRequest = 54,
|
||||
|
||||
[Description("Aircraft not found or wrong role.")]
|
||||
AircraftNotFound = 55,
|
||||
|
||||
[Description("MFA is already enabled for this user.")]
|
||||
MfaAlreadyEnabled = 56,
|
||||
|
||||
[Description("MFA enrollment is not in progress for this user.")]
|
||||
MfaNotEnrolling = 57,
|
||||
|
||||
[Description("MFA is not enabled for this user.")]
|
||||
MfaNotEnabled = 58,
|
||||
|
||||
[Description("Invalid MFA code or recovery code.")]
|
||||
InvalidMfaCode = 59,
|
||||
|
||||
[Description("MFA token is invalid or expired.")]
|
||||
InvalidMfaToken = 61,
|
||||
|
||||
[Description("No file provided.")]
|
||||
NoFileProvided = 60,
|
||||
|
||||
// AZ-556 — single opaque login-failure code. Replaces the wire-side use of
|
||||
// `NoEmailFound`, `WrongPassword`, `UserDisabled`, `AccountLocked`, and
|
||||
// `LoginRateLimited`. The audit log preserves the actual category for SecOps.
|
||||
// Lockout / rate-limit responses additionally carry a Retry-After header via
|
||||
// `BusinessException.RetryAfterSeconds`.
|
||||
[Description("Invalid credentials.")]
|
||||
InvalidCredentials = 70,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Azaion.Common.Configs;
|
||||
|
||||
public class AuthConfig
|
||||
{
|
||||
public RateLimitOptions RateLimit { get; set; } = new();
|
||||
public LockoutOptions Lockout { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RateLimitOptions
|
||||
{
|
||||
public int PerIpPermitLimit { get; set; } = 10;
|
||||
public int PerIpWindowSeconds { get; set; } = 60;
|
||||
public int PerAccountPermitLimit { get; set; } = 5;
|
||||
public int PerAccountWindowSeconds { get; set; } = 300;
|
||||
}
|
||||
|
||||
public class LockoutOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 10;
|
||||
public int DurationSeconds { get; set; } = 900; // 15 min
|
||||
}
|
||||
@@ -2,8 +2,41 @@ namespace Azaion.Common.Configs;
|
||||
|
||||
public class JwtConfig
|
||||
{
|
||||
public string Issuer { get; set; } = null!;
|
||||
public string Issuer { get; set; } = null!;
|
||||
public string Audience { get; set; } = null!;
|
||||
public string Secret { get; set; } = null!;
|
||||
public double TokenLifetimeHours { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AZ-532 — directory containing ES256 private keys (PEM, *.pem). The kid is
|
||||
/// the filename without extension. Production: <c>secrets/jwt-keys</c>.
|
||||
/// </summary>
|
||||
public string KeysFolder { get; set; } = "secrets/jwt-keys";
|
||||
|
||||
/// <summary>
|
||||
/// AZ-532 — kid of the key currently used to SIGN new tokens. Other keys in
|
||||
/// <see cref="KeysFolder"/> remain in JWKS for the rotation overlap window so
|
||||
/// in-flight tokens still verify.
|
||||
/// </summary>
|
||||
public string? ActiveKid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AZ-531 — access-token TTL in minutes (default 15). Refresh-token TTLs live
|
||||
/// on <see cref="SessionConfig"/>.
|
||||
/// </summary>
|
||||
public int AccessTokenLifetimeMinutes { get; set; } = 15;
|
||||
}
|
||||
|
||||
public class SessionConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// AZ-531 — sliding window. Each refresh extends expires_at by this many
|
||||
/// hours from "now"; family-level absolute cap below.
|
||||
/// </summary>
|
||||
public int RefreshSlidingHours { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-531 — absolute cap. A session family older than this many hours since
|
||||
/// the family's first issue is rejected even if every individual rotation
|
||||
/// stayed within the sliding window.
|
||||
/// </summary>
|
||||
public int RefreshAbsoluteHours { get; set; } = 12;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,4 @@ namespace Azaion.Common.Configs;
|
||||
public class ResourcesConfig
|
||||
{
|
||||
public string ResourcesFolder { get; set; } = null!;
|
||||
public string SuiteInstallerFolder { get; set; } = null!;
|
||||
public string SuiteStageInstallerFolder { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@ public class AzaionDb(DataOptions dataOptions) : DataConnection(dataOptions)
|
||||
{
|
||||
public ITable<User> Users => this.GetTable<User>();
|
||||
public ITable<DetectionClass> DetectionClasses => this.GetTable<DetectionClass>();
|
||||
public ITable<AuditEvent> AuditEvents => this.GetTable<AuditEvent>();
|
||||
public ITable<Session> Sessions => this.GetTable<Session>();
|
||||
}
|
||||
@@ -34,7 +34,12 @@ public static class AzaionDbSchemaHolder
|
||||
.HasConversion(
|
||||
v => v == null ? null : JsonConvert.SerializeObject(v),
|
||||
p => string.IsNullOrEmpty(p) ? new UserConfig() : JsonConvert.DeserializeObject<UserConfig>(p))
|
||||
.IsNullable();
|
||||
.IsNullable()
|
||||
// AZ-534 — mfa_recovery_codes is JSONB; tell the provider so Npgsql sends
|
||||
// the JSON type oid instead of text (otherwise inserts fail with
|
||||
// "column is of type jsonb but expression is of type text").
|
||||
.Property(x => x.MfaRecoveryCodes)
|
||||
.HasDataType(DataType.BinaryJson);
|
||||
|
||||
builder.Entity<DetectionClass>()
|
||||
.HasTableName("detection_classes")
|
||||
@@ -42,6 +47,18 @@ public static class AzaionDbSchemaHolder
|
||||
.IsPrimaryKey()
|
||||
.IsIdentity();
|
||||
|
||||
builder.Entity<AuditEvent>()
|
||||
.HasTableName("audit_events")
|
||||
.Property(x => x.Id)
|
||||
.IsPrimaryKey()
|
||||
.IsIdentity();
|
||||
|
||||
builder.Entity<Session>()
|
||||
.HasTableName("sessions")
|
||||
.Property(x => x.Id)
|
||||
.IsPrimaryKey()
|
||||
.HasDataType(DataType.Guid);
|
||||
|
||||
builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Azaion.Common.Entities;
|
||||
|
||||
public class AuditEvent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string EventType { get; set; } = null!;
|
||||
public DateTime OccurredAt { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
|
||||
public static class AuditEventTypes
|
||||
{
|
||||
public const string LoginFailed = "login_failed";
|
||||
public const string LoginLockout = "login_lockout";
|
||||
public const string LoginSuccess = "login_success";
|
||||
|
||||
// AZ-556 — per-category internal forensics for unified `InvalidCredentials` wire
|
||||
// response. SecOps can distinguish these in the audit_events table even though the
|
||||
// /login response cannot be distinguished by an attacker.
|
||||
public const string LoginFailedUnknownEmail = "login_failed_unknown_email";
|
||||
public const string LoginFailedDisabled = "login_failed_disabled";
|
||||
|
||||
// AZ-534 — MFA lifecycle + login events.
|
||||
public const string MfaEnroll = "mfa_enroll";
|
||||
public const string MfaConfirm = "mfa_confirm";
|
||||
public const string MfaDisable = "mfa_disable";
|
||||
public const string MfaLoginSuccess = "mfa_login_success";
|
||||
public const string MfaLoginFailed = "mfa_login_failed";
|
||||
public const string MfaRecoveryUsed = "mfa_recovery_used";
|
||||
}
|
||||
@@ -8,5 +8,10 @@ public enum RoleEnum
|
||||
CompanionPC = 30,
|
||||
Admin = 40, //
|
||||
ResourceUploader = 50, //Uploading dll and ai models
|
||||
// AZ-535 — service-to-service identity (one per verifier: satellite-provider,
|
||||
// gps-denied, ui). Only authorized to read /sessions/revoked snapshot; not
|
||||
// valid for any user-facing endpoint. Each verifier deployment gets one
|
||||
// dedicated Service user.
|
||||
Service = 60,
|
||||
ApiAdmin = 1000 //everything
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Azaion.Common.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-531 — refresh-token session row. One row per issued refresh token. A
|
||||
/// "session family" is the chain of rotated sessions that all share the same
|
||||
/// <see cref="FamilyId"/>; reuse-detection keys off it.
|
||||
/// </summary>
|
||||
public class Session
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
/// <summary>
|
||||
/// AZ-531 — sha256(opaque refresh) for interactive sessions. AZ-533 mission
|
||||
/// sessions have no refresh value and store NULL here.
|
||||
/// </summary>
|
||||
public string? RefreshHash { get; set; }
|
||||
public Guid FamilyId { get; set; }
|
||||
public DateTime IssuedAt { get; set; }
|
||||
public DateTime LastUsedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public string? RevokedReason { get; set; }
|
||||
public Guid? ParentSessionId { get; set; }
|
||||
public DateTime FamilyStartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 — audit trail for who revoked the session (user id of the admin or
|
||||
/// the user themselves on /logout). Null for system revocations (rotation,
|
||||
/// reuse detection, post-flight reconnect).
|
||||
/// </summary>
|
||||
public Guid? RevokedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — session class. <see cref="SessionClasses.Interactive"/> is the
|
||||
/// default refresh-backed interactive session (AZ-531); <see cref="SessionClasses.Mission"/>
|
||||
/// is a long-lived no-refresh token issued for a single UAV mission.
|
||||
/// </summary>
|
||||
public string Class { get; set; } = SessionClasses.Interactive;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — for mission sessions: the aircraft (CompanionPC user) the mission
|
||||
/// token belongs to. Used by the auto-revoke-on-reconnect middleware. Null for
|
||||
/// interactive sessions.
|
||||
/// </summary>
|
||||
public Guid? AircraftId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AZ-534 — true iff the session was created via an MFA-validated /login/mfa
|
||||
/// call. Refresh-token rotation reads this to keep the AMR claim stable across
|
||||
/// the session lifetime.
|
||||
/// </summary>
|
||||
public bool MfaAuthenticated { get; set; }
|
||||
}
|
||||
|
||||
public static class SessionRevokedReasons
|
||||
{
|
||||
public const string Rotated = "rotated";
|
||||
public const string ReuseDetected = "reuse_detected";
|
||||
public const string LoggedOut = "logged_out";
|
||||
public const string LoggedOutAll = "logged_out_all";
|
||||
public const string AdminRevoked = "admin_revoked";
|
||||
public const string PostFlightReconnect = "post_flight_reconnect";
|
||||
public const string FamilyRevoked = "family_revoked";
|
||||
}
|
||||
|
||||
public static class SessionClasses
|
||||
{
|
||||
public const string Interactive = "interactive";
|
||||
public const string Mission = "mission";
|
||||
}
|
||||
@@ -16,6 +16,24 @@ public class User
|
||||
public UserConfig? UserConfig { get; set; } = null!;
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
// AZ-537 — consecutive failed-login counter and active lockout deadline.
|
||||
public int FailedLoginCount { get; set; }
|
||||
public DateTime? LockoutUntil { get; set; }
|
||||
|
||||
// AZ-534 — TOTP-based 2FA. mfa_secret is encrypted at rest; recovery codes are
|
||||
// stored as a JSONB array of { hash, used_at } objects. mfa_last_used_window
|
||||
// is the RFC 6238 time-step counter of the most recently accepted code,
|
||||
// used to reject in-window replays.
|
||||
[JsonIgnore]
|
||||
public bool MfaEnabled { get; set; }
|
||||
[JsonIgnore]
|
||||
public string? MfaSecret { get; set; }
|
||||
[JsonIgnore]
|
||||
public string? MfaRecoveryCodes { get; set; }
|
||||
public DateTime? MfaEnrolledAt { get; set; }
|
||||
[JsonIgnore]
|
||||
public long? MfaLastUsedWindow { get; set; }
|
||||
|
||||
public static string GetCacheKey(string email) =>
|
||||
string.IsNullOrEmpty(email) ? "" : $"{nameof(User)}.{email}";
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Azaion.Common.Extensions;
|
||||
|
||||
public static class StreamExtensions
|
||||
{
|
||||
public static string ConvertToString(this Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
var result = reader.ReadToEnd();
|
||||
stream.Position = 0;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
public class GetResourceRequest
|
||||
{
|
||||
public string Password { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class GetResourceRequestValidator : AbstractValidator<GetResourceRequest>
|
||||
{
|
||||
public GetResourceRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Password)
|
||||
.MinimumLength(8)
|
||||
.WithErrorCode(nameof(ExceptionEnum.PasswordLengthIncorrect))
|
||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.PasswordLengthIncorrect));
|
||||
|
||||
RuleFor(r => r.FileName)
|
||||
.NotEmpty()
|
||||
.WithErrorCode(nameof(ExceptionEnum.WrongResourceName))
|
||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.WrongResourceName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-531 — dual-token login response. <see cref="Token"/> is kept for
|
||||
/// backwards compatibility with pre-AZ-531 clients (UI ignores extra fields);
|
||||
/// it carries the same value as <see cref="AccessToken"/>.
|
||||
/// </summary>
|
||||
public class LoginResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = null!;
|
||||
public DateTime AccessExp { get; set; }
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
public DateTime RefreshExp { get; set; }
|
||||
|
||||
public string Token => AccessToken;
|
||||
}
|
||||
|
||||
public class RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
/// <summary>AZ-534 — body for <c>POST /users/me/mfa/enroll</c>.</summary>
|
||||
public class MfaEnrollRequest
|
||||
{
|
||||
public string Password { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>AZ-534 — response of /enroll (also surfaces recovery codes ONCE; they are
|
||||
/// hashed at rest and unrecoverable after this response).</summary>
|
||||
public class MfaEnrollResponse
|
||||
{
|
||||
public string Secret { get; set; } = null!;
|
||||
public string OtpAuthUrl { get; set; } = null!;
|
||||
public string QrPngBase64 { get; set; } = null!;
|
||||
public string[] RecoveryCodes { get; set; } = [];
|
||||
}
|
||||
|
||||
public class MfaConfirmRequest
|
||||
{
|
||||
public string Code { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class MfaDisableRequest
|
||||
{
|
||||
public string Password { get; set; } = null!;
|
||||
public string Code { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>AZ-534 AC-3 — response of step-1 /login when the user has MFA enabled.
|
||||
/// The mfa_token is a short-lived JWT carried into <c>POST /login/mfa</c>.</summary>
|
||||
public class MfaRequiredResponse
|
||||
{
|
||||
public bool MfaRequired { get; set; } = true;
|
||||
public string MfaToken { get; set; } = null!;
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public class MfaLoginRequest
|
||||
{
|
||||
public string MfaToken { get; set; } = null!;
|
||||
public string Code { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — body for <c>POST /sessions/mission</c>. Pilot (interactive session)
|
||||
/// asks admin to mint a long-lived no-refresh token for a single UAV flight.
|
||||
/// </summary>
|
||||
public class MissionSessionRequest
|
||||
{
|
||||
[Required] public string MissionId { get; set; } = null!;
|
||||
[Required] public Guid AircraftId { get; set; }
|
||||
[Required] public double PlannedDurationH { get; set; }
|
||||
public IList<string>? RequestedScope { get; set; }
|
||||
/// <summary>
|
||||
/// Optional bbox of the operating area. Informational until the verifier
|
||||
/// (satellite-provider) enforces it; included verbatim in the token claim.
|
||||
/// </summary>
|
||||
public ValidRegion? ValidRegion { get; set; }
|
||||
}
|
||||
|
||||
public class ValidRegion
|
||||
{
|
||||
public double MinLat { get; set; }
|
||||
public double MinLon { get; set; }
|
||||
public double MaxLat { get; set; }
|
||||
public double MaxLon { get; set; }
|
||||
}
|
||||
|
||||
public class MissionSessionResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = null!;
|
||||
public DateTime AccessExp { get; set; }
|
||||
public string TokenClass { get; set; } = "mission";
|
||||
public Guid SessionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
public interface IAuditLog
|
||||
{
|
||||
Task RecordLoginFailed (string email, CancellationToken ct = default);
|
||||
Task RecordLoginLockout(string email, CancellationToken ct = default);
|
||||
Task RecordLoginSuccess(string email, CancellationToken ct = default);
|
||||
|
||||
// AZ-556 — per-category internal forensics. Wire response is uniformly
|
||||
// `InvalidCredentials`; these recorders keep SecOps's audit trail honest.
|
||||
Task RecordLoginFailedUnknownEmail(string email, CancellationToken ct = default);
|
||||
Task RecordLoginFailedDisabled (string email, CancellationToken ct = default);
|
||||
|
||||
// AZ-534 — MFA lifecycle + login auth-event audit.
|
||||
Task RecordMfaEnroll (string email, CancellationToken ct = default);
|
||||
Task RecordMfaConfirm (string email, CancellationToken ct = default);
|
||||
Task RecordMfaDisable (string email, CancellationToken ct = default);
|
||||
Task RecordMfaLoginSuccess (string email, CancellationToken ct = default);
|
||||
Task RecordMfaLoginFailed (string email, CancellationToken ct = default);
|
||||
Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count of failure-audit rows for the given email within the last
|
||||
/// <paramref name="windowSeconds"/> that feed the per-account sliding-window rate
|
||||
/// limit. Includes BOTH password (<c>login_failed</c>) and TOTP
|
||||
/// (<c>mfa_login_failed</c>) failures (AZ-537 AC-2 + AZ-557 AC-3). Disabled-account
|
||||
/// and unknown-email rejections are intentionally excluded — they don't reflect an
|
||||
/// account-credential attack that the lockout/rate-limit policy should escalate.
|
||||
/// </summary>
|
||||
Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class AuditLog(IDbFactory dbFactory, IHttpContextAccessor httpContextAccessor) : IAuditLog
|
||||
{
|
||||
public Task RecordLoginFailed (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginFailed, email, ct);
|
||||
|
||||
public Task RecordLoginLockout(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginLockout, email, ct);
|
||||
|
||||
public Task RecordLoginSuccess(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginSuccess, email, ct);
|
||||
|
||||
public Task RecordLoginFailedUnknownEmail(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginFailedUnknownEmail, email, ct);
|
||||
|
||||
public Task RecordLoginFailedDisabled(string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.LoginFailedDisabled, email, ct);
|
||||
|
||||
public Task RecordMfaEnroll (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaEnroll, email, ct);
|
||||
public Task RecordMfaConfirm (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaConfirm, email, ct);
|
||||
public Task RecordMfaDisable (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaDisable, email, ct);
|
||||
public Task RecordMfaLoginSuccess (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaLoginSuccess, email, ct);
|
||||
public Task RecordMfaLoginFailed (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaLoginFailed, email, ct);
|
||||
public Task RecordMfaRecoveryUsed (string email, CancellationToken ct = default)
|
||||
=> Insert(AuditEventTypes.MfaRecoveryUsed, email, ct);
|
||||
|
||||
public async Task<int> CountRecentFailedLogins(string email, int windowSeconds, CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddSeconds(-windowSeconds);
|
||||
var normalised = email.ToLowerInvariant();
|
||||
// AZ-557 — MFA failures feed the same per-account sliding-window count as
|
||||
// password failures so an attacker who got past factor 1 can't brute-force
|
||||
// factor 2 from rotating IPs without tripping the per-account throttle.
|
||||
return await dbFactory.Run(async db =>
|
||||
await db.AuditEvents
|
||||
.Where(e => (e.EventType == AuditEventTypes.LoginFailed
|
||||
|| e.EventType == AuditEventTypes.MfaLoginFailed)
|
||||
&& e.Email == normalised
|
||||
&& e.OccurredAt >= cutoff)
|
||||
.CountAsync(token: ct));
|
||||
}
|
||||
|
||||
private async Task Insert(string eventType, string email, CancellationToken ct)
|
||||
{
|
||||
var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
var normalised = email.ToLowerInvariant();
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
await db.InsertAsync(new AuditEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
Email = normalised,
|
||||
Ip = ip
|
||||
}, token: ct);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -12,11 +11,27 @@ namespace Azaion.Services;
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<User?> GetCurrentUser();
|
||||
string CreateToken(User user);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-531 / AZ-532 — mint a 15-minute ES256 access token. <paramref name="sessionId"/>
|
||||
/// is stamped as the <c>sid</c> claim (logout / family-revocation key in AZ-535)
|
||||
/// and <paramref name="jti"/> is the per-token unique id (AZ-535 access denylist).
|
||||
/// AZ-534 — <paramref name="amr"/> values are stamped as repeated <c>amr</c>
|
||||
/// claims so verifiers can require step-up MFA. Defaults to <c>["pwd"]</c>.
|
||||
/// </summary>
|
||||
AccessToken CreateToken(User user, Guid sessionId, Guid jti, IEnumerable<string>? amr = null);
|
||||
}
|
||||
|
||||
public class AuthService(IHttpContextAccessor httpContextAccessor, IOptions<JwtConfig> jwtConfig, IUserService userService) : IAuthService
|
||||
public sealed record AccessToken(string Jwt, DateTime ExpiresAt);
|
||||
|
||||
public class AuthService(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<JwtConfig> jwtConfig,
|
||||
IJwtSigningKeyProvider signingKeys,
|
||||
IUserService userService) : IAuthService
|
||||
{
|
||||
private readonly JwtConfig _jwt = jwtConfig.Value;
|
||||
|
||||
private string? GetCurrentUserEmail()
|
||||
{
|
||||
var claims = httpContextAccessor.HttpContext?.User.Claims.ToDictionary(x => x.Type);
|
||||
@@ -29,25 +44,38 @@ public class AuthService(IHttpContextAccessor httpContextAccessor, IOptions<JwtC
|
||||
return await userService.GetByEmail(email);
|
||||
}
|
||||
|
||||
public string CreateToken(User user)
|
||||
public AccessToken CreateToken(User user, Guid sessionId, Guid jti, IEnumerable<string>? amr = null)
|
||||
{
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.Value.Secret));
|
||||
var active = signingKeys.Active;
|
||||
var signingCredentials = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
|
||||
|
||||
var expires = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenLifetimeMinutes);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.Email),
|
||||
new(ClaimTypes.Role, user.Role.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sid, sessionId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, jti.ToString())
|
||||
};
|
||||
|
||||
// AZ-534 — stamp authentication-methods-reference per RFC 8176. Multi-valued:
|
||||
// password+TOTP login produces ["pwd","mfa"]; recovery-code login adds "recovery".
|
||||
var amrValues = amr?.ToArray() ?? ["pwd"];
|
||||
foreach (var v in amrValues)
|
||||
claims.Add(new Claim("amr", v));
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Email),
|
||||
new Claim(ClaimTypes.Role, user.Role.ToString())
|
||||
]),
|
||||
Expires = DateTime.UtcNow.AddHours(jwtConfig.Value.TokenLifetimeHours),
|
||||
Issuer = jwtConfig.Value.Issuer,
|
||||
Audience = jwtConfig.Value.Audience,
|
||||
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = expires,
|
||||
Issuer = _jwt.Issuer,
|
||||
Audience = _jwt.Audience,
|
||||
SigningCredentials = signingCredentials
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
return new AccessToken(tokenHandler.WriteToken(token), expires);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="LazyCache.AspNetCore" Version="2.4.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Security.Cryptography;
|
||||
using Azaion.Common.Configs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-532 — loads ES256 signing keys from <see cref="JwtConfig.KeysFolder"/>.
|
||||
/// One key is "active" (used to sign new tokens); the rest stay in JWKS so
|
||||
/// in-flight tokens minted with older keys still verify during the rotation
|
||||
/// overlap window. The kid of each key is its filename without <c>.pem</c>.
|
||||
/// </summary>
|
||||
public interface IJwtSigningKeyProvider
|
||||
{
|
||||
JwtSigningKey Active { get; }
|
||||
IReadOnlyList<JwtSigningKey> All { get; }
|
||||
}
|
||||
|
||||
public sealed class JwtSigningKey
|
||||
{
|
||||
public string Kid { get; }
|
||||
public ECDsa Ecdsa { get; }
|
||||
public ECDsaSecurityKey SecurityKey { get; }
|
||||
|
||||
public JwtSigningKey(string kid, ECDsa ecdsa)
|
||||
{
|
||||
Kid = kid;
|
||||
Ecdsa = ecdsa;
|
||||
SecurityKey = new ECDsaSecurityKey(ecdsa) { KeyId = kid };
|
||||
}
|
||||
}
|
||||
|
||||
public class JwtSigningKeyProvider : IJwtSigningKeyProvider, IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, JwtSigningKey> _byKid;
|
||||
private readonly JwtSigningKey _active;
|
||||
|
||||
public JwtSigningKeyProvider(IOptions<JwtConfig> jwtConfig, ILogger<JwtSigningKeyProvider> logger)
|
||||
{
|
||||
var folder = jwtConfig.Value.KeysFolder;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||||
throw new InvalidOperationException(
|
||||
$"JwtConfig.KeysFolder '{folder}' does not exist. " +
|
||||
"Generate a key with scripts/generate-jwt-key.sh and ensure the folder is mounted into the container.");
|
||||
|
||||
var pemFiles = Directory.EnumerateFiles(folder, "*.pem").OrderBy(p => p).ToList();
|
||||
if (pemFiles.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"No *.pem keys found in '{folder}'. Generate a key with scripts/generate-jwt-key.sh.");
|
||||
|
||||
_byKid = new Dictionary<string, JwtSigningKey>(StringComparer.Ordinal);
|
||||
foreach (var path in pemFiles)
|
||||
{
|
||||
var kid = Path.GetFileNameWithoutExtension(path);
|
||||
var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(File.ReadAllText(path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ecdsa.Dispose();
|
||||
throw new InvalidOperationException($"Failed to load JWT signing key from '{path}'.", ex);
|
||||
}
|
||||
|
||||
EnsureP256(ecdsa, path);
|
||||
_byKid[kid] = new JwtSigningKey(kid, ecdsa);
|
||||
}
|
||||
|
||||
var requestedActive = jwtConfig.Value.ActiveKid;
|
||||
if (!string.IsNullOrEmpty(requestedActive))
|
||||
{
|
||||
if (!_byKid.TryGetValue(requestedActive, out var resolved))
|
||||
throw new InvalidOperationException(
|
||||
$"JwtConfig.ActiveKid '{requestedActive}' is not present in '{folder}'.");
|
||||
_active = resolved;
|
||||
}
|
||||
else
|
||||
{
|
||||
_active = _byKid[Path.GetFileNameWithoutExtension(pemFiles[0])];
|
||||
logger.LogInformation(
|
||||
"JwtConfig.ActiveKid not set; falling back to first key by filename: {Kid}", _active.Kid);
|
||||
}
|
||||
}
|
||||
|
||||
public JwtSigningKey Active => _active;
|
||||
public IReadOnlyList<JwtSigningKey> All => _byKid.Values.OrderBy(k => k.Kid, StringComparer.Ordinal).ToList();
|
||||
|
||||
private static void EnsureP256(ECDsa ecdsa, string path)
|
||||
{
|
||||
// ES256 ⇒ P-256 (prime256v1 / secp256r1). Reject anything else so we don't
|
||||
// silently sign with the wrong curve and break verifiers expecting ES256.
|
||||
var p = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var oid = p.Curve.Oid?.Value ?? p.Curve.Oid?.FriendlyName;
|
||||
if (oid is not ("1.2.840.10045.3.1.7" or "nistP256" or "ECDSA_P256"))
|
||||
throw new InvalidOperationException(
|
||||
$"Key '{path}' is not on the P-256 curve (got '{oid ?? "unknown"}'). ES256 requires P-256.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var k in _byKid.Values) k.Ecdsa.Dispose();
|
||||
_byKid.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using Azaion.Common.Requests;
|
||||
using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OtpNet;
|
||||
using QRCoder;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-534 — RFC 6238 TOTP enrollment + login validation, with single-use recovery codes.
|
||||
/// MfaSecret is encrypted at rest via <see cref="IDataProtector"/>; recovery codes are
|
||||
/// stored as SHA-256 hashes (high-entropy secrets need a fast hash, not Argon2id —
|
||||
/// same reasoning the refresh-token store uses).
|
||||
/// </summary>
|
||||
public interface IMfaService
|
||||
{
|
||||
Task<MfaEnrollResponse> Enroll(Guid userId, string password, CancellationToken ct = default);
|
||||
Task Confirm(Guid userId, string code, CancellationToken ct = default);
|
||||
Task Disable(Guid userId, string password, string code, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Issued at /login when the user has MFA enabled — a 5-minute JWT (aud=azaion-mfa-step2)
|
||||
/// the client carries to /login/mfa for the second-factor verification.
|
||||
/// </summary>
|
||||
string IssueMfaStepToken(Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// Decode the step-1 token, returning the userId. Throws BusinessException(InvalidMfaToken)
|
||||
/// on bad signature, audience mismatch, or expired token.
|
||||
/// </summary>
|
||||
Guid ValidateMfaStepToken(string token);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-534 AC-3 + AC-4 — second-factor verification at login. Returns the
|
||||
/// <c>amr</c> values the access token should carry (always includes <c>"pwd"</c>
|
||||
/// and <c>"mfa"</c>; <c>"recovery"</c> is added when a recovery code was used).
|
||||
/// </summary>
|
||||
Task<string[]> VerifyForLogin(Guid userId, string code, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class MfaService(
|
||||
IDbFactory dbFactory,
|
||||
IUserService userService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IJwtSigningKeyProvider signingKeys,
|
||||
IOptions<JwtConfig> jwtConfig,
|
||||
IOptions<AuthConfig> authConfig,
|
||||
IAuditLog auditLog) : IMfaService
|
||||
{
|
||||
private const string MfaSecretPurpose = "Azaion.Mfa.Secret.v1";
|
||||
private const string MfaStepAudience = "azaion-mfa-step2";
|
||||
private const int MfaStepLifetimeSeconds = 300; // 5 min — matches AC-3
|
||||
private const int SecretBytes = 20; // 160 bits — RFC 6238 §3
|
||||
private const int RecoveryCodeCount = 10;
|
||||
private const int RecoveryCodeBytes = 10; // base32(10) = 16 chars (≥12 per AC-1)
|
||||
|
||||
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector(MfaSecretPurpose);
|
||||
private readonly JwtConfig _jwt = jwtConfig.Value;
|
||||
private readonly AuthConfig _auth = authConfig.Value;
|
||||
|
||||
public async Task<MfaEnrollResponse> Enroll(Guid userId, string password, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
// Re-auth with password — AC-1 requires this to defend a stolen access token
|
||||
// from being usable to silently flip the user into MFA.
|
||||
var verify = Security.VerifyPassword(password, user.PasswordHash);
|
||||
if (!verify.Valid)
|
||||
throw new BusinessException(ExceptionEnum.WrongPassword);
|
||||
|
||||
if (user.MfaEnabled)
|
||||
throw new BusinessException(ExceptionEnum.MfaAlreadyEnabled);
|
||||
|
||||
var secretBytes = KeyGeneration.GenerateRandomKey(SecretBytes);
|
||||
var secretBase32 = Base32Encoding.ToString(secretBytes); // 32 chars (per AC-1)
|
||||
|
||||
var otpAuthUrl = new OtpUri(
|
||||
schema: OtpType.Totp,
|
||||
secret: secretBase32,
|
||||
user: user.Email,
|
||||
issuer: _jwt.Issuer,
|
||||
algorithm: OtpHashMode.Sha1,
|
||||
digits: 6,
|
||||
period: 30).ToString();
|
||||
|
||||
var qrPng = GenerateQrPng(otpAuthUrl);
|
||||
|
||||
var recoveryPlain = new string[RecoveryCodeCount];
|
||||
var recoveryStore = new RecoveryCodeStore[RecoveryCodeCount];
|
||||
for (var i = 0; i < RecoveryCodeCount; i++)
|
||||
{
|
||||
var code = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(RecoveryCodeBytes));
|
||||
recoveryPlain[i] = code;
|
||||
recoveryStore[i] = new RecoveryCodeStore { Hash = HashRecoveryCode(code), UsedAt = null };
|
||||
}
|
||||
|
||||
var encryptedSecret = _protector.Protect(secretBase32);
|
||||
var recoveryJson = JsonSerializer.Serialize(recoveryStore);
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User
|
||||
{
|
||||
MfaSecret = encryptedSecret,
|
||||
MfaRecoveryCodes = recoveryJson,
|
||||
MfaEnabled = false, // confirm step flips this true
|
||||
MfaEnrolledAt = null
|
||||
},
|
||||
token: ct));
|
||||
|
||||
await auditLog.RecordMfaEnroll(user.Email, ct);
|
||||
|
||||
return new MfaEnrollResponse
|
||||
{
|
||||
Secret = secretBase32,
|
||||
OtpAuthUrl = otpAuthUrl,
|
||||
QrPngBase64 = qrPng,
|
||||
RecoveryCodes = recoveryPlain
|
||||
};
|
||||
}
|
||||
|
||||
public async Task Confirm(Guid userId, string code, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
if (user.MfaEnabled)
|
||||
throw new BusinessException(ExceptionEnum.MfaAlreadyEnabled);
|
||||
|
||||
if (string.IsNullOrEmpty(user.MfaSecret))
|
||||
throw new BusinessException(ExceptionEnum.MfaNotEnrolling);
|
||||
|
||||
var secret = _protector.Unprotect(user.MfaSecret);
|
||||
if (!VerifyTotpCode(secret, code, lastUsedWindow: null, out _))
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaCode);
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User
|
||||
{
|
||||
MfaEnabled = true,
|
||||
MfaEnrolledAt = DateTime.UtcNow
|
||||
},
|
||||
token: ct));
|
||||
|
||||
await auditLog.RecordMfaConfirm(user.Email, ct);
|
||||
}
|
||||
|
||||
public async Task Disable(Guid userId, string password, string code, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
if (!user.MfaEnabled)
|
||||
throw new BusinessException(ExceptionEnum.MfaNotEnabled);
|
||||
|
||||
var verify = Security.VerifyPassword(password, user.PasswordHash);
|
||||
if (!verify.Valid)
|
||||
throw new BusinessException(ExceptionEnum.WrongPassword);
|
||||
|
||||
var secret = _protector.Unprotect(user.MfaSecret!);
|
||||
if (!VerifyTotpCode(secret, code, lastUsedWindow: null, out _))
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaCode);
|
||||
|
||||
// Raw SQL: setting mfa_recovery_codes (jsonb) to NULL via the LinqToDB UPDATE
|
||||
// expression sends an untyped NULL literal that Postgres parses as text and
|
||||
// rejects (42804). A small parameterized SQL avoids the type-inference dance.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.ExecuteAsync(
|
||||
@"UPDATE public.users
|
||||
SET mfa_enabled = false,
|
||||
mfa_secret = NULL,
|
||||
mfa_recovery_codes = NULL::jsonb,
|
||||
mfa_enrolled_at = NULL,
|
||||
mfa_last_used_window = NULL
|
||||
WHERE id = @id",
|
||||
new DataParameter("id", userId, DataType.Guid)));
|
||||
|
||||
await auditLog.RecordMfaDisable(user.Email, ct);
|
||||
}
|
||||
|
||||
public string IssueMfaStepToken(Guid userId)
|
||||
{
|
||||
var active = signingKeys.Active;
|
||||
var creds = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
|
||||
var expires = DateTime.UtcNow.AddSeconds(MfaStepLifetimeSeconds);
|
||||
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("token_use", "mfa_step")
|
||||
]),
|
||||
Expires = expires,
|
||||
Issuer = _jwt.Issuer,
|
||||
// AZ-534 — narrow audience: this token is ONLY usable at /login/mfa.
|
||||
// The main JwtBearer middleware accepts _jwt.Audience and rejects this one.
|
||||
Audience = MfaStepAudience,
|
||||
SigningCredentials = creds
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return handler.WriteToken(handler.CreateToken(descriptor));
|
||||
}
|
||||
|
||||
public Guid ValidateMfaStepToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = _jwt.Issuer,
|
||||
ValidAudience = MfaStepAudience,
|
||||
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
|
||||
IssuerSigningKeyResolver = (_, _, _, _) =>
|
||||
signingKeys.All.Select(k => (SecurityKey)k.SecurityKey)
|
||||
}, out _);
|
||||
|
||||
var sub = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? throw new BusinessException(ExceptionEnum.InvalidMfaToken);
|
||||
return Guid.Parse(sub);
|
||||
}
|
||||
catch (BusinessException) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
throw new BusinessException(ExceptionEnum.InvalidMfaToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string[]> VerifyForLogin(Guid userId, string code, CancellationToken ct = default)
|
||||
{
|
||||
var user = await userService.GetById(userId, ct)
|
||||
?? throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
|
||||
if (!user.MfaEnabled || string.IsNullOrEmpty(user.MfaSecret))
|
||||
throw new BusinessException(ExceptionEnum.MfaNotEnabled);
|
||||
|
||||
// AZ-557 — active lockout from EITHER the password or the MFA side rejects
|
||||
// the request before the TOTP verify runs, with the same wire shape the
|
||||
// password path uses (`InvalidCredentials` + Retry-After).
|
||||
if (user.LockoutUntil is { } until && until > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials, Math.Max(remaining, 1));
|
||||
}
|
||||
|
||||
// AZ-557 — per-account sliding-window rate limit applies to MFA failures too
|
||||
// (CountRecentFailedLogins counts login_failed + mfa_login_failed). Without
|
||||
// this an attacker with a leaked password could brute-force the 6-digit TOTP
|
||||
// from rotating IPs without ever tripping the per-account throttle.
|
||||
var recentFailures = await auditLog.CountRecentFailedLogins(
|
||||
user.Email, _auth.RateLimit.PerAccountWindowSeconds, ct);
|
||||
if (recentFailures >= _auth.RateLimit.PerAccountPermitLimit)
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials, _auth.RateLimit.PerAccountWindowSeconds);
|
||||
|
||||
var secret = _protector.Unprotect(user.MfaSecret);
|
||||
if (VerifyTotpCode(secret, code, user.MfaLastUsedWindow, out var window))
|
||||
{
|
||||
// Persist last-used window so a re-presented code in the same 30 s
|
||||
// step is rejected even if the attacker presents it before the next step.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User { MfaLastUsedWindow = window },
|
||||
token: ct));
|
||||
// AZ-557 — TOTP success also resets the failure counter so a user who
|
||||
// fat-fingered a few codes before getting it right doesn't drift toward
|
||||
// lockout. Mirrors the password-side reset in RegisterSuccessfulLogin.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == userId,
|
||||
u => new User { FailedLoginCount = 0, LockoutUntil = null },
|
||||
token: ct));
|
||||
await auditLog.RecordMfaLoginSuccess(user.Email, ct);
|
||||
return ["pwd", "mfa"];
|
||||
}
|
||||
|
||||
// TOTP failed — try recovery code (single-use). Recovery codes are
|
||||
// high-entropy and intentionally NOT counted by the lockout pipeline; a
|
||||
// locked-out user can still escape via a recovery code.
|
||||
if (await TryConsumeRecoveryCode(user, code, ct))
|
||||
{
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == user.Id,
|
||||
u => new User { FailedLoginCount = 0, LockoutUntil = null },
|
||||
token: ct));
|
||||
await auditLog.RecordMfaRecoveryUsed(user.Email, ct);
|
||||
return ["pwd", "mfa", "recovery"];
|
||||
}
|
||||
|
||||
// AZ-557 — feed the shared failure-accounting helper. It records the audit
|
||||
// row (mfa_login_failed), bumps failed_login_count, and on threshold-crossing
|
||||
// throws InvalidCredentials + Retry-After (which we let propagate). If it
|
||||
// does NOT throw, we fall through and throw the bare InvalidCredentials so
|
||||
// the wire response is uniform with the password path.
|
||||
await userService.RegisterMfaFailedLogin(user, ct);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
}
|
||||
|
||||
private static bool VerifyTotpCode(string secretBase32, string code, long? lastUsedWindow, out long matchedWindow)
|
||||
{
|
||||
matchedWindow = 0;
|
||||
var totp = new Totp(Base32Encoding.ToBytes(secretBase32));
|
||||
if (!totp.VerifyTotp(code, out matchedWindow, VerificationWindow.RfcSpecifiedNetworkDelay))
|
||||
return false;
|
||||
if (lastUsedWindow.HasValue && matchedWindow <= lastUsedWindow.Value)
|
||||
return false; // replay within or before the last accepted window
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> TryConsumeRecoveryCode(User user, string code, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.MfaRecoveryCodes)) return false;
|
||||
|
||||
var codes = JsonSerializer.Deserialize<RecoveryCodeStore[]>(user.MfaRecoveryCodes)
|
||||
?? Array.Empty<RecoveryCodeStore>();
|
||||
var candidateHash = HashRecoveryCode(code);
|
||||
|
||||
var matchIdx = -1;
|
||||
for (var i = 0; i < codes.Length; i++)
|
||||
{
|
||||
if (codes[i].UsedAt != null) continue;
|
||||
if (CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.ASCII.GetBytes(codes[i].Hash),
|
||||
System.Text.Encoding.ASCII.GetBytes(candidateHash)))
|
||||
{
|
||||
matchIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchIdx < 0) return false;
|
||||
|
||||
codes[matchIdx] = codes[matchIdx] with { UsedAt = DateTime.UtcNow };
|
||||
var updated = JsonSerializer.Serialize(codes);
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
// Conditional update on the prior JSON to avoid a race where two
|
||||
// concurrent /login/mfa calls both consume the same code.
|
||||
u => u.Id == user.Id && u.MfaRecoveryCodes == user.MfaRecoveryCodes,
|
||||
u => new User { MfaRecoveryCodes = updated },
|
||||
token: ct));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GenerateQrPng(string text)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
|
||||
var pngBytes = new PngByteQRCode(data).GetGraphic(pixelsPerModule: 6);
|
||||
return Convert.ToBase64String(pngBytes);
|
||||
}
|
||||
|
||||
private static string HashRecoveryCode(string code)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(code);
|
||||
var digest = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(digest);
|
||||
}
|
||||
|
||||
private sealed record RecoveryCodeStore
|
||||
{
|
||||
public string Hash { get; init; } = "";
|
||||
public DateTime? UsedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using Azaion.Common.Requests;
|
||||
using LinqToDB;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — issues long-lived single-use access tokens for offline UAV missions.
|
||||
/// Distinct from <see cref="IAuthService"/> because:
|
||||
/// <list type="bullet">
|
||||
/// <item>Lifetime is per-mission (≤ 12 h), not per-session policy.</item>
|
||||
/// <item>Audience is narrowed to <c>satellite-provider</c>, not the broad admin audience.</item>
|
||||
/// <item>No refresh: a single token covers the entire flight, then dies.</item>
|
||||
/// <item>Carries mission-specific claims (mission_id, aircraft_id, valid_region).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IMissionTokenService
|
||||
{
|
||||
Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class MissionTokenService(
|
||||
IDbFactory dbFactory,
|
||||
IJwtSigningKeyProvider signingKeys,
|
||||
IOptions<JwtConfig> jwtConfig) : IMissionTokenService
|
||||
{
|
||||
private const string MissionAudience = "satellite-provider";
|
||||
private const double MaxDurationHours = 12.0;
|
||||
private const double MinDurationHours = 0.1;
|
||||
private const double LifetimeBufferHours = 1.0; // covers post-flight reconnect grace
|
||||
|
||||
private static readonly Regex MissionIdPattern =
|
||||
new(@"^M-\d{4}-\d{2}-\d{2}-\d{3}$", RegexOptions.Compiled);
|
||||
|
||||
private readonly JwtConfig _jwt = jwtConfig.Value;
|
||||
|
||||
public async Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Validate(request);
|
||||
|
||||
// Aircraft must exist with Role=CompanionPC. Anything else is a config error.
|
||||
var aircraft = await dbFactory.Run(async db =>
|
||||
await db.Users.FirstOrDefaultAsync(u => u.Id == request.AircraftId, token: ct));
|
||||
if (aircraft == null || aircraft.Role != RoleEnum.CompanionPC)
|
||||
throw new BusinessException(ExceptionEnum.AircraftNotFound);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var expAt = now.AddHours(request.PlannedDurationH + LifetimeBufferHours);
|
||||
var sid = Guid.NewGuid();
|
||||
var jti = Guid.NewGuid();
|
||||
|
||||
// Persist the session BEFORE we mint the token so revocation lookups can
|
||||
// never miss a token that's already in the wild.
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.InsertAsync(new Session
|
||||
{
|
||||
Id = sid,
|
||||
UserId = pilotUserId,
|
||||
FamilyId = sid, // mission sessions are their own family — no rotation
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = expAt,
|
||||
FamilyStartedAt = now,
|
||||
Class = SessionClasses.Mission,
|
||||
AircraftId = request.AircraftId,
|
||||
// RefreshHash null — no refresh value backs a mission token.
|
||||
}, token: ct));
|
||||
|
||||
var token = MintToken(pilotUserId, request, sid, jti, expAt);
|
||||
|
||||
return new MissionSessionResponse
|
||||
{
|
||||
AccessToken = token,
|
||||
AccessExp = expAt,
|
||||
TokenClass = SessionClasses.Mission,
|
||||
SessionId = sid,
|
||||
};
|
||||
}
|
||||
|
||||
private static void Validate(MissionSessionRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.MissionId) || !MissionIdPattern.IsMatch(request.MissionId))
|
||||
throw new BusinessException(ExceptionEnum.InvalidMissionRequest);
|
||||
|
||||
if (request.PlannedDurationH < MinDurationHours || request.PlannedDurationH > MaxDurationHours)
|
||||
throw new BusinessException(ExceptionEnum.InvalidMissionRequest);
|
||||
}
|
||||
|
||||
private string MintToken(Guid pilotUserId, MissionSessionRequest request, Guid sid, Guid jti, DateTime expAt)
|
||||
{
|
||||
var active = signingKeys.Active;
|
||||
var creds = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, pilotUserId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sid, sid.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, jti.ToString()),
|
||||
new("mission_id", request.MissionId),
|
||||
new("aircraft_id", request.AircraftId.ToString()),
|
||||
new("token_class", SessionClasses.Mission),
|
||||
};
|
||||
|
||||
if (request.RequestedScope is { Count: > 0 })
|
||||
foreach (var p in request.RequestedScope)
|
||||
claims.Add(new Claim("permissions", p));
|
||||
|
||||
if (request.ValidRegion != null)
|
||||
claims.Add(new Claim(
|
||||
"valid_region",
|
||||
JsonSerializer.Serialize(request.ValidRegion),
|
||||
JsonClaimValueTypes.Json));
|
||||
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = expAt,
|
||||
Issuer = _jwt.Issuer,
|
||||
// AZ-533 — narrowed audience: satellite-provider only, not the broad
|
||||
// interactive audience. Verifiers downstream gate on this claim.
|
||||
Audience = MissionAudience,
|
||||
SigningCredentials = creds
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var token = handler.CreateToken(descriptor);
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using LinqToDB;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-531 — issues, rotates, and validates opaque refresh tokens. Reuse-detection
|
||||
/// kills the entire session family per OAuth 2.1 §6.1.
|
||||
/// </summary>
|
||||
public interface IRefreshTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Mint a fresh refresh token at login; starts a new session family. Returns
|
||||
/// the opaque token (NEVER persisted; only its sha256 lands in the DB) and
|
||||
/// the session row that backs it. <paramref name="mfaAuthenticated"/> is pinned
|
||||
/// to the session so refresh-token rotation inherits the original AMR strength.
|
||||
/// </summary>
|
||||
Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rotate <paramref name="opaqueToken"/>. On success returns the new token +
|
||||
/// the new session row. On reuse-detection or invalid token throws
|
||||
/// <see cref="BusinessException"/> with <see cref="ExceptionEnum.InvalidRefreshToken"/>;
|
||||
/// reuse also revokes every active row in the same family.
|
||||
/// </summary>
|
||||
Task<(string OpaqueToken, Session Session)> Rotate(string opaqueToken, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RefreshTokenService(IDbFactory dbFactory, IOptions<SessionConfig> sessionConfig) : IRefreshTokenService
|
||||
{
|
||||
private const int OpaqueTokenBytes = 32; // 256 bits → 43-char base64url string.
|
||||
|
||||
private readonly SessionConfig _cfg = sessionConfig.Value;
|
||||
|
||||
public async Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default)
|
||||
{
|
||||
var (opaque, hash) = GenerateToken();
|
||||
var now = DateTime.UtcNow;
|
||||
var session = new Session
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
RefreshHash = hash,
|
||||
FamilyId = Guid.NewGuid(), // self-rooted family
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours),
|
||||
FamilyStartedAt = now,
|
||||
MfaAuthenticated = mfaAuthenticated,
|
||||
};
|
||||
// family_id should equal id for the root row so SELECT family_id from
|
||||
// any row returns a stable handle even if id is renamed later.
|
||||
session.FamilyId = session.Id;
|
||||
|
||||
await dbFactory.RunAdmin(async db => await db.InsertAsync(session, token: ct));
|
||||
return (opaque, session);
|
||||
}
|
||||
|
||||
public async Task<(string OpaqueToken, Session Session)> Rotate(string opaqueToken, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(opaqueToken))
|
||||
throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
var hash = HashToken(opaqueToken);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
// Use a serializable transaction so two concurrent refreshes can't both
|
||||
// observe the row as un-rotated and both succeed.
|
||||
await using var tx = await db.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||||
|
||||
var current = await db.Sessions.FirstOrDefaultAsync(s => s.RefreshHash == hash, token: ct);
|
||||
if (current == null)
|
||||
throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
// Reuse detection: presenting an already-rotated token kills the family.
|
||||
if (current.RevokedAt.HasValue)
|
||||
{
|
||||
if (current.RevokedReason == SessionRevokedReasons.Rotated)
|
||||
{
|
||||
await db.Sessions
|
||||
.Where(s => s.FamilyId == current.FamilyId && s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, SessionRevokedReasons.ReuseDetected)
|
||||
.UpdateAsync(token: ct);
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
}
|
||||
|
||||
// Sliding expiry — each rotation restarts the window from `now`.
|
||||
if (current.ExpiresAt < now)
|
||||
throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
// Absolute expiry — the family cannot live past this regardless of rotations.
|
||||
if ((now - current.FamilyStartedAt).TotalHours > _cfg.RefreshAbsoluteHours)
|
||||
throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
|
||||
|
||||
var (newOpaque, newHash) = GenerateToken();
|
||||
var newSession = new Session
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = current.UserId,
|
||||
RefreshHash = newHash,
|
||||
FamilyId = current.FamilyId,
|
||||
IssuedAt = now,
|
||||
LastUsedAt = now,
|
||||
ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours),
|
||||
FamilyStartedAt = current.FamilyStartedAt,
|
||||
ParentSessionId = current.Id,
|
||||
MfaAuthenticated = current.MfaAuthenticated,
|
||||
};
|
||||
|
||||
await db.Sessions
|
||||
.Where(s => s.Id == current.Id && s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, SessionRevokedReasons.Rotated)
|
||||
.Set(s => s.LastUsedAt, now)
|
||||
.UpdateAsync(token: ct);
|
||||
|
||||
await db.InsertAsync(newSession, token: ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return (newOpaque, newSession);
|
||||
});
|
||||
}
|
||||
|
||||
private static (string Opaque, string Hash) GenerateToken()
|
||||
{
|
||||
var raw = RandomNumberGenerator.GetBytes(OpaqueTokenBytes);
|
||||
var opaque = Base64Url(raw);
|
||||
var hash = HashToken(opaque);
|
||||
return (opaque, hash);
|
||||
}
|
||||
|
||||
private static string HashToken(string opaque)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(opaque);
|
||||
var digest = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(digest);
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -8,8 +8,6 @@ namespace Azaion.Services;
|
||||
|
||||
public interface IResourcesService
|
||||
{
|
||||
(string?, Stream?) GetInstaller(bool isStage);
|
||||
Task<Stream> GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken cancellationToken = default);
|
||||
Task SaveResource(string? dataFolder, IFormFile data, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<string>> ListResources(string? dataFolder, string? search, CancellationToken cancellationToken = default);
|
||||
void ClearFolder(string? dataFolder);
|
||||
@@ -24,29 +22,6 @@ public class ResourcesService(IOptions<ResourcesConfig> resourcesConfig, ILogger
|
||||
: Path.Combine(resourcesConfig.Value.ResourcesFolder, dataFolder);
|
||||
}
|
||||
|
||||
public (string?, Stream?) GetInstaller(bool isStage)
|
||||
{
|
||||
var suiteFolder = Path.Combine(resourcesConfig.Value.ResourcesFolder, isStage
|
||||
? resourcesConfig.Value.SuiteStageInstallerFolder
|
||||
: resourcesConfig.Value.SuiteInstallerFolder);
|
||||
var installer = new DirectoryInfo(suiteFolder).GetFiles("AzaionSuite.Iterative*").FirstOrDefault();
|
||||
if (installer == null)
|
||||
return (null, null);
|
||||
|
||||
var fileStream = new FileStream(installer.FullName, FileMode.Open, FileAccess.Read);
|
||||
return (installer.Name, fileStream);
|
||||
}
|
||||
|
||||
public async Task<Stream> GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fileStream = new FileStream(Path.Combine(GetResourceFolder(dataFolder), fileName), FileMode.Open, FileAccess.Read);
|
||||
|
||||
var ms = new MemoryStream();
|
||||
await fileStream.EncryptTo(ms, key, cancellationToken);
|
||||
ms.Seek(0, SeekOrigin.Begin);
|
||||
return ms;
|
||||
}
|
||||
|
||||
public async Task SaveResource(string? dataFolder, IFormFile data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (data == null)
|
||||
|
||||
+135
-44
@@ -1,61 +1,152 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Azaion.Common.Entities;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
// Password hashing — Argon2id (RFC 9106) for new + lazy migration of legacy SHA-384.
|
||||
// Stored format: PHC string `$argon2id$v=19$m=<KiB>,t=<iters>,p=<lanes>$<salt-b64>$<hash-b64>`.
|
||||
// Legacy format: 64-char base64 of unsalted SHA-384 (no `$` prefix). Detected by prefix.
|
||||
//
|
||||
// AZ-536 (Epic AZ-530, CMMC IA.L2-3.5.10).
|
||||
public static class Security
|
||||
{
|
||||
private const int BUFFER_SIZE = 524288; // 512 KB buffer size
|
||||
// Conservative defaults per RFC 9106 §4. Bump in the future and the verify path
|
||||
// will surface NeedsRehash=true for any hash whose params are weaker.
|
||||
private const int Argon2MemoryKib = 65536; // 64 MiB
|
||||
private const int Argon2Iterations = 3;
|
||||
private const int Argon2Parallelism = 1;
|
||||
private const int SaltLengthBytes = 16; // 128 bits — RFC 9106 recommended minimum
|
||||
private const int HashLengthBytes = 32; // 256 bits
|
||||
private const string PhcPrefix = "$argon2id$";
|
||||
private const int LegacySha384B64Length = 64; // Convert.ToBase64String(48 bytes) == 64 chars
|
||||
|
||||
public static string ToHash(this string str) =>
|
||||
Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str)));
|
||||
public sealed record VerifyResult(bool Valid, bool NeedsRehash);
|
||||
|
||||
public static string GetApiEncryptionKey(string email, string password) =>
|
||||
$"{email}-{password}-#%@AzaionKey@%#---".ToHash();
|
||||
// AZ-556 — timing equalizer for unknown-email and disabled-account branches of
|
||||
// `UserService.ValidateUser`. Pre-computed once with the same Argon2id parameters
|
||||
// as a real hash so a `VerifyDummy(plaintext)` call costs ~the same wall-clock as
|
||||
// a real `VerifyPassword(plaintext, user.PasswordHash)`. The result is always
|
||||
// discarded — this is a side-channel mitigation, not a control-flow path.
|
||||
private static readonly string DummyHashForTiming = HashPassword(
|
||||
"az-556-timing-equalizer-dummy-do-not-store-in-db");
|
||||
|
||||
public static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// AZ-556 — run the same Argon2id work a real verify would do, then discard the
|
||||
/// result. Used to keep the unknown-email and disabled-account login branches
|
||||
/// timing-indistinguishable from a wrong-password branch.
|
||||
/// </summary>
|
||||
public static void VerifyDummy(string plaintext)
|
||||
{
|
||||
inputStream.Seek(0, SeekOrigin.Begin);
|
||||
if (inputStream is { CanRead: false }) throw new ArgumentNullException(nameof(inputStream));
|
||||
if (key is not { Length: > 0 }) throw new ArgumentNullException(nameof(key));
|
||||
|
||||
using var aes = Aes.Create();
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
aes.GenerateIV();
|
||||
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
|
||||
|
||||
await using var cs = new CryptoStream(toStream, encryptor, CryptoStreamMode.Write, leaveOpen: true);
|
||||
|
||||
await toStream.WriteAsync(aes.IV.AsMemory(0, aes.IV.Length), cancellationToken);
|
||||
|
||||
var buffer = new byte[BUFFER_SIZE];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await inputStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
await cs.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
_ = VerifyPassword(plaintext, DummyHashForTiming);
|
||||
}
|
||||
|
||||
public static async Task DecryptTo(this Stream encryptedStream, Stream toStream, string key, CancellationToken cancellationToken = default)
|
||||
public static string HashPassword(string plaintext)
|
||||
{
|
||||
encryptedStream.Seek(0, SeekOrigin.Begin);
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
if (plaintext == null) throw new ArgumentNullException(nameof(plaintext));
|
||||
|
||||
var iv = new byte[aes.BlockSize / 8];
|
||||
_ = await encryptedStream.ReadAsync(iv, cancellationToken);
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
|
||||
|
||||
await using var cryptoStream = new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read, leaveOpen: true);
|
||||
|
||||
var buffer = new byte[BUFFER_SIZE];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await cryptoStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
await toStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
toStream.Seek(0, SeekOrigin.Begin);
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltLengthBytes);
|
||||
var hash = ComputeArgon2id(plaintext, salt, Argon2MemoryKib, Argon2Iterations, Argon2Parallelism);
|
||||
return EncodePhc(Argon2MemoryKib, Argon2Iterations, Argon2Parallelism, salt, hash);
|
||||
}
|
||||
|
||||
public static VerifyResult VerifyPassword(string plaintext, string stored)
|
||||
{
|
||||
if (plaintext == null) throw new ArgumentNullException(nameof(plaintext));
|
||||
if (string.IsNullOrEmpty(stored)) return new VerifyResult(Valid: false, NeedsRehash: false);
|
||||
|
||||
if (stored.StartsWith(PhcPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (!TryDecodePhc(stored, out var p))
|
||||
return new VerifyResult(Valid: false, NeedsRehash: false);
|
||||
|
||||
var candidate = ComputeArgon2id(plaintext, p.Salt, p.MemoryKib, p.Iterations, p.Parallelism);
|
||||
var valid = CryptographicOperations.FixedTimeEquals(candidate, p.Hash);
|
||||
// NeedsRehash true if defaults are stronger than the stored params — supports later upgrades.
|
||||
var needsRehash = valid && (p.MemoryKib < Argon2MemoryKib
|
||||
|| p.Iterations < Argon2Iterations
|
||||
|| p.Parallelism < Argon2Parallelism);
|
||||
return new VerifyResult(valid, needsRehash);
|
||||
}
|
||||
|
||||
if (IsLegacySha384(stored))
|
||||
{
|
||||
var legacyHash = SHA384.HashData(Encoding.UTF8.GetBytes(plaintext));
|
||||
var legacyB64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(legacyHash));
|
||||
var storedBytes = Encoding.ASCII.GetBytes(stored);
|
||||
var valid = storedBytes.Length == legacyB64Bytes.Length
|
||||
&& CryptographicOperations.FixedTimeEquals(storedBytes, legacyB64Bytes);
|
||||
return new VerifyResult(valid, NeedsRehash: valid);
|
||||
}
|
||||
|
||||
return new VerifyResult(Valid: false, NeedsRehash: false);
|
||||
}
|
||||
|
||||
private static bool IsLegacySha384(string stored) =>
|
||||
stored.Length == LegacySha384B64Length && !stored.StartsWith('$');
|
||||
|
||||
private static byte[] ComputeArgon2id(string plaintext, byte[] salt, int memoryKib, int iterations, int parallelism)
|
||||
{
|
||||
using var argon = new Argon2id(Encoding.UTF8.GetBytes(plaintext))
|
||||
{
|
||||
Salt = salt,
|
||||
MemorySize = memoryKib,
|
||||
Iterations = iterations,
|
||||
DegreeOfParallelism = parallelism
|
||||
};
|
||||
return argon.GetBytes(HashLengthBytes);
|
||||
}
|
||||
|
||||
private static string EncodePhc(int memoryKib, int iterations, int parallelism, byte[] salt, byte[] hash) =>
|
||||
$"$argon2id$v=19$m={memoryKib},t={iterations},p={parallelism}${ToB64NoPad(salt)}${ToB64NoPad(hash)}";
|
||||
|
||||
private static bool TryDecodePhc(string stored, out PhcParams parsed)
|
||||
{
|
||||
parsed = default!;
|
||||
// $argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
|
||||
var parts = stored.Split('$');
|
||||
if (parts.Length != 6) return false;
|
||||
if (parts[1] != "argon2id") return false;
|
||||
if (parts[2] != "v=19") return false;
|
||||
|
||||
var paramFields = parts[3].Split(',');
|
||||
if (paramFields.Length != 3) return false;
|
||||
if (!TryParseKv(paramFields[0], "m", out var m)) return false;
|
||||
if (!TryParseKv(paramFields[1], "t", out var t)) return false;
|
||||
if (!TryParseKv(paramFields[2], "p", out var p)) return false;
|
||||
|
||||
if (!TryFromB64NoPad(parts[4], out var salt)) return false;
|
||||
if (!TryFromB64NoPad(parts[5], out var hash)) return false;
|
||||
|
||||
parsed = new PhcParams(m, t, p, salt, hash);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseKv(string field, string key, out int value)
|
||||
{
|
||||
value = 0;
|
||||
var eq = field.IndexOf('=');
|
||||
if (eq <= 0 || field[..eq] != key) return false;
|
||||
return int.TryParse(field.AsSpan(eq + 1), out value) && value > 0;
|
||||
}
|
||||
|
||||
private static string ToB64NoPad(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=');
|
||||
|
||||
private static bool TryFromB64NoPad(string s, out byte[] bytes)
|
||||
{
|
||||
var padded = s.Length % 4 == 0 ? s : s + new string('=', 4 - s.Length % 4);
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(padded);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct PhcParams(int MemoryKib, int Iterations, int Parallelism, byte[] Salt, byte[] Hash);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using LinqToDB;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 — logout/revocation surface. Distinct from <see cref="IRefreshTokenService"/>:
|
||||
/// refresh-token service rotates and reuse-detects; this service expresses the
|
||||
/// human / admin / system intent to kill a session and exposes the verifier-poll
|
||||
/// snapshot that powers cross-service denylists.
|
||||
/// </summary>
|
||||
public interface ISessionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Revoke a single session by id. Returns the revocation status BEFORE this
|
||||
/// call: <c>true</c> if it was already revoked (idempotent no-op),
|
||||
/// <c>false</c> if this call is the one that revoked it.
|
||||
/// </summary>
|
||||
Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke every active session for a user. Returns the count of rows newly
|
||||
/// revoked by this call.
|
||||
/// </summary>
|
||||
Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — auto-revoke every open mission session belonging to <paramref name="aircraftId"/>.
|
||||
/// Fired on successful /login or /token/refresh from the aircraft's own user.
|
||||
/// </summary>
|
||||
Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 AC-4 — verifier-poll snapshot. Returns sessions revoked since
|
||||
/// <paramref name="since"/> whose <c>exp</c> is still in the future, so the
|
||||
/// list stays bounded.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record RevokedSession(Guid Sid, DateTime Exp, DateTime RevokedAt, string? Reason);
|
||||
|
||||
public class SessionService(IDbFactory dbFactory) : ISessionService
|
||||
{
|
||||
public async Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default)
|
||||
{
|
||||
return await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var existing = await db.Sessions.FirstOrDefaultAsync(s => s.Id == sessionId, token: ct);
|
||||
if (existing == null)
|
||||
throw new BusinessException(ExceptionEnum.SessionNotFound);
|
||||
|
||||
if (existing.RevokedAt.HasValue)
|
||||
return true; // idempotent — already revoked, no DB write needed
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await db.Sessions
|
||||
.Where(s => s.Id == sessionId && s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, reason)
|
||||
.Set(s => s.RevokedByUserId, byUserId)
|
||||
.UpdateAsync(token: ct);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await db.Sessions
|
||||
.Where(s => s.UserId == userId && s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, reason)
|
||||
.Set(s => s.RevokedByUserId, byUserId)
|
||||
.UpdateAsync(token: ct);
|
||||
});
|
||||
|
||||
public async Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await db.Sessions
|
||||
.Where(s => s.AircraftId == aircraftId
|
||||
&& s.Class == SessionClasses.Mission
|
||||
&& s.RevokedAt == null)
|
||||
.Set(s => s.RevokedAt, now)
|
||||
.Set(s => s.RevokedReason, SessionRevokedReasons.PostFlightReconnect)
|
||||
.UpdateAsync(token: ct);
|
||||
});
|
||||
|
||||
public async Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await dbFactory.Run(async db =>
|
||||
(await db.Sessions
|
||||
.Where(s => s.RevokedAt != null
|
||||
&& s.RevokedAt > since
|
||||
&& s.ExpiresAt > now) // AZ-535 AC-4: prune expired
|
||||
.Select(s => new RevokedSession(s.Id, s.ExpiresAt, s.RevokedAt!.Value, s.RevokedReason))
|
||||
.ToListAsync(token: ct)));
|
||||
}
|
||||
}
|
||||
+176
-12
@@ -1,10 +1,12 @@
|
||||
using System.Security.Cryptography;
|
||||
using Azaion.Common;
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using Azaion.Common.Extensions;
|
||||
using Azaion.Common.Requests;
|
||||
using LinqToDB;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace Azaion.Services;
|
||||
@@ -15,15 +17,34 @@ public interface IUserService
|
||||
Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default);
|
||||
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
|
||||
Task<User?> GetByEmail(string? email, CancellationToken ct = default);
|
||||
Task<User?> GetById(Guid userId, CancellationToken ct = default);
|
||||
Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default);
|
||||
Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct = default);
|
||||
Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default);
|
||||
Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default);
|
||||
Task RemoveUser(string email, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// AZ-557 — shared failure-accounting path for MFA-side failures. Mirrors what the
|
||||
/// password-side path in <see cref="ValidateUser"/> does on a wrong-password event:
|
||||
/// records the appropriate audit row, increments <c>failed_login_count</c>,
|
||||
/// crosses-the-threshold trips <c>lockout_until</c>, and signals lockout by throwing
|
||||
/// <see cref="BusinessException"/> with <see cref="ExceptionEnum.InvalidCredentials"/>
|
||||
/// + <see cref="BusinessException.RetryAfterSeconds"/>. Callers (e.g.,
|
||||
/// <c>MfaService.VerifyForLogin</c>) MUST handle the throw branch and rethrow their
|
||||
/// own opaque error if the threshold was not crossed.
|
||||
/// </summary>
|
||||
Task RegisterMfaFailedLogin(User user, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
||||
public class UserService(
|
||||
IDbFactory dbFactory,
|
||||
ICache cache,
|
||||
IAuditLog auditLog,
|
||||
IOptions<AuthConfig> authConfig) : IUserService
|
||||
{
|
||||
private readonly AuthConfig _auth = authConfig.Value;
|
||||
|
||||
private const string DeviceEmailPrefix = "azj-";
|
||||
private const string DeviceEmailDomain = "@azaion.com";
|
||||
private const int SerialNumberStart = 4; // index of NNNN inside "azj-NNNN..." (length of DeviceEmailPrefix)
|
||||
@@ -40,7 +61,7 @@ public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = request.Email,
|
||||
PasswordHash = request.Password.ToHash(),
|
||||
PasswordHash = Security.HashPassword(request.Password),
|
||||
Role = request.Role,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsEnabled = true
|
||||
@@ -104,23 +125,166 @@ public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
||||
await db.Users.FirstOrDefaultAsync(x => x.Email == email, ct)));
|
||||
}
|
||||
|
||||
public async Task<User?> GetById(Guid userId, CancellationToken ct = default) =>
|
||||
await dbFactory.Run(async db => await db.Users.FirstOrDefaultAsync(x => x.Id == userId, token: ct));
|
||||
|
||||
public async Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default) =>
|
||||
await dbFactory.Run(async db =>
|
||||
|
||||
public async Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await dbFactory.Run(async db =>
|
||||
await db.Users.FirstOrDefaultAsync(x => x.Email == request.Email, token: ct));
|
||||
|
||||
// AZ-556 — unknown email: equalize timing with a dummy Argon2id verify so a
|
||||
// wall-clock observer can't distinguish "no such email" from "wrong password".
|
||||
// No counter to increment (there is no row), so this path skips lockout
|
||||
// accounting entirely; the audit row preserves the attempted email for SecOps.
|
||||
if (user == null)
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(x => x.Email == request.Email, token: ct);
|
||||
if (user == null)
|
||||
throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
Security.VerifyDummy(request.Password);
|
||||
await auditLog.RecordLoginFailedUnknownEmail(request.Email, ct);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
}
|
||||
|
||||
if (request.Password.ToHash() != user.PasswordHash)
|
||||
throw new BusinessException(ExceptionEnum.WrongPassword);
|
||||
// AZ-537 AC-3 — active lockout takes precedence over the password check; even
|
||||
// a correct password is rejected until the lockout expires. AZ-556 collapses
|
||||
// the response code to `InvalidCredentials` while keeping the Retry-After
|
||||
// header so legitimate clients can self-throttle.
|
||||
if (user.LockoutUntil is { } until && until > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials, Math.Max(remaining, 1));
|
||||
}
|
||||
|
||||
if (!user.IsEnabled)
|
||||
throw new BusinessException(ExceptionEnum.UserDisabled);
|
||||
// AZ-537 AC-2 — per-account sliding-window rate limit. Counts only failure
|
||||
// events in the recent window (login_failed + mfa_login_failed per AZ-557) so
|
||||
// legitimate retries after a success aren't punished.
|
||||
var recentFailures = await auditLog.CountRecentFailedLogins(
|
||||
user.Email, _auth.RateLimit.PerAccountWindowSeconds, ct);
|
||||
if (recentFailures >= _auth.RateLimit.PerAccountPermitLimit)
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials, _auth.RateLimit.PerAccountWindowSeconds);
|
||||
|
||||
return user;
|
||||
// AZ-556 F-AUTH-3 — disabled-account check moved BEFORE password verify. An
|
||||
// attacker who knows the password of a disabled account no longer learns that
|
||||
// fact via a distinct error code (or via the missing-Argon2id timing tell).
|
||||
// Still run the dummy verify so the wall-clock equalises against a real
|
||||
// wrong-password branch.
|
||||
if (!user.IsEnabled)
|
||||
{
|
||||
Security.VerifyDummy(request.Password);
|
||||
await auditLog.RecordLoginFailedDisabled(user.Email, ct);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
}
|
||||
|
||||
var verify = Security.VerifyPassword(request.Password, user.PasswordHash);
|
||||
if (!verify.Valid)
|
||||
{
|
||||
// RegisterFailedLogin may itself throw InvalidCredentials + Retry-After
|
||||
// when the threshold trips; otherwise we fall through and throw the
|
||||
// non-Retry-After variant below.
|
||||
await RegisterFailedLogin(user, ct);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
}
|
||||
|
||||
await RegisterSuccessfulLogin(user, request.Password, verify.NeedsRehash, ct);
|
||||
return user;
|
||||
}
|
||||
|
||||
// Lazy migration of legacy SHA-384 hashes (and future Argon2 param upgrades).
|
||||
// Conditional on the original hash to avoid clobbering a concurrent rehash from
|
||||
// a parallel login of the same account.
|
||||
private async Task RegisterSuccessfulLogin(User user, string plaintext, bool rehash, CancellationToken ct)
|
||||
{
|
||||
var newHash = rehash ? Security.HashPassword(plaintext) : null;
|
||||
var oldHash = user.PasswordHash;
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
if (newHash != null)
|
||||
{
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == user.Id && u.PasswordHash == oldHash,
|
||||
u => new User
|
||||
{
|
||||
PasswordHash = newHash,
|
||||
FailedLoginCount = 0,
|
||||
LockoutUntil = null
|
||||
},
|
||||
token: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == user.Id,
|
||||
u => new User
|
||||
{
|
||||
FailedLoginCount = 0,
|
||||
LockoutUntil = null
|
||||
},
|
||||
token: ct);
|
||||
}
|
||||
});
|
||||
|
||||
if (newHash != null)
|
||||
user.PasswordHash = newHash;
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockoutUntil = null;
|
||||
cache.Invalidate(User.GetCacheKey(user.Email));
|
||||
|
||||
await auditLog.RecordLoginSuccess(user.Email, ct);
|
||||
}
|
||||
|
||||
private Task RegisterFailedLogin(User user, CancellationToken ct) =>
|
||||
RegisterFailedLoginCore(user, FailureKind.Password, ct);
|
||||
|
||||
public Task RegisterMfaFailedLogin(User user, CancellationToken ct = default) =>
|
||||
RegisterFailedLoginCore(user, FailureKind.Mfa, ct);
|
||||
|
||||
// AZ-557 — single accounting path shared by the password-side (`ValidateUser`) and
|
||||
// the MFA-side (`MfaService.VerifyForLogin`) failure branches. The audit row type
|
||||
// diverges (`login_failed` vs `mfa_login_failed`) so SecOps can analyse the two
|
||||
// categories separately, but the counter / lockout / Retry-After semantics are
|
||||
// identical. On lockout-trip we throw `InvalidCredentials` + Retry-After so the
|
||||
// caller can rethrow its opaque wire response without losing the cooldown hint.
|
||||
private async Task RegisterFailedLoginCore(User user, FailureKind kind, CancellationToken ct)
|
||||
{
|
||||
if (kind == FailureKind.Password)
|
||||
await auditLog.RecordLoginFailed(user.Email, ct);
|
||||
else
|
||||
await auditLog.RecordMfaLoginFailed(user.Email, ct);
|
||||
|
||||
var newCount = user.FailedLoginCount + 1;
|
||||
var triggersLock = newCount >= _auth.Lockout.MaxAttempts;
|
||||
DateTime? newLockoutUntil = triggersLock
|
||||
? DateTime.UtcNow.AddSeconds(_auth.Lockout.DurationSeconds)
|
||||
: user.LockoutUntil;
|
||||
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(
|
||||
u => u.Id == user.Id,
|
||||
u => new User
|
||||
{
|
||||
FailedLoginCount = newCount,
|
||||
LockoutUntil = newLockoutUntil
|
||||
},
|
||||
token: ct));
|
||||
|
||||
cache.Invalidate(User.GetCacheKey(user.Email));
|
||||
|
||||
if (triggersLock)
|
||||
{
|
||||
await auditLog.RecordLoginLockout(user.Email, ct);
|
||||
// AZ-556 — promote a threshold-crossing failure into the unified lockout
|
||||
// response. The caller sees `InvalidCredentials` + Retry-After regardless
|
||||
// of whether the threshold was crossed by a password or an MFA attempt.
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials, _auth.Lockout.DurationSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
private enum FailureKind
|
||||
{
|
||||
Password,
|
||||
Mfa,
|
||||
}
|
||||
|
||||
public async Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Azaion.Services\Azaion.Services.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,110 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Azaion.Common.Extensions;
|
||||
using Azaion.Services;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Test;
|
||||
|
||||
public class SecurityTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task EncryptDecryptTest()
|
||||
{
|
||||
var testString = "Hello World Test dfvjkhsdbfvkljh sabdljsdafv asdvsad vsadfjv hbsadfkujv hgasdkvhgaksdjhvbsdv sdvsdjfhvb skdajfhb vskldfvhb lsdkfbv lsdb v" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
"sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" +
|
||||
|
||||
" sakdhvb kasjdhbv kjasdhv kjhas";
|
||||
var email = "user@azaion.com";
|
||||
var password = "testpw";
|
||||
|
||||
var key = Security.GetApiEncryptionKey(email, password);
|
||||
|
||||
var encryptedStream = new MemoryStream();
|
||||
await StringToStream(testString).EncryptTo(encryptedStream, key);
|
||||
|
||||
await using var decryptedStream = new MemoryStream();
|
||||
await encryptedStream.DecryptTo(decryptedStream, key);
|
||||
encryptedStream.Close();
|
||||
|
||||
var str = decryptedStream.ConvertToString();
|
||||
str.Should().Be(testString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EncryptDecryptLargeFileTest()
|
||||
{
|
||||
var username = "user@azaion.com";
|
||||
var password = "testpw";
|
||||
|
||||
var key = Security.GetApiEncryptionKey(username, password);
|
||||
|
||||
var largeFilePath = "large.txt";
|
||||
var largeFileDecryptedPath = "large_decrypted.txt";
|
||||
|
||||
var stream = await CreateLargeFile(largeFilePath);
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var encryptedStream = new MemoryStream();
|
||||
|
||||
await stream.EncryptTo(encryptedStream, key);
|
||||
encryptedStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
File.Delete(largeFileDecryptedPath);
|
||||
await using var decryptedStream = new FileStream(largeFileDecryptedPath, FileMode.OpenOrCreate, FileAccess.Write);
|
||||
await encryptedStream.DecryptTo(decryptedStream, key);
|
||||
|
||||
encryptedStream.Close();
|
||||
stream.Close();
|
||||
decryptedStream.Close();
|
||||
|
||||
await CompareFiles(largeFilePath, largeFileDecryptedPath);
|
||||
File.Delete(largeFilePath);
|
||||
File.Delete(largeFileDecryptedPath);
|
||||
}
|
||||
|
||||
private async Task CompareFiles(string largeFilePath, string largeFileDecryptedPath)
|
||||
{
|
||||
await using var stream1 = new FileStream(largeFilePath, FileMode.Open, FileAccess.Read);
|
||||
await using var stream2 = new FileStream(largeFileDecryptedPath, FileMode.Open, FileAccess.Read);
|
||||
|
||||
var sha256Bytes1 = Encoding.UTF8.GetString(await SHA256.HashDataAsync(stream1));
|
||||
var sha256Bytes2 = Encoding.UTF8.GetString(await SHA256.HashDataAsync(stream2));
|
||||
sha256Bytes1.Should().Be(sha256Bytes2);
|
||||
}
|
||||
|
||||
private async Task<Stream> CreateLargeFile(string largeTxtPath)
|
||||
{
|
||||
var max = 4000000;
|
||||
File.Delete(largeTxtPath);
|
||||
var stream = new FileStream(largeTxtPath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
var numbersList = Enumerable.Range(1, max).Chunk(100000);
|
||||
foreach (var numbers in numbersList)
|
||||
{
|
||||
var dict = numbers.ToDictionary(x => x, _ => DateTime.UtcNow);
|
||||
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(dict, Formatting.Indented));
|
||||
await stream.WriteAsync(bytes);
|
||||
Console.WriteLine($"Writing numbers from {(numbers.FirstOrDefault()*100 / (double)max):F1} %");
|
||||
}
|
||||
await stream.FlushAsync();
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static Stream StringToStream(string src)
|
||||
{
|
||||
var byteArray = Encoding.UTF8.GetBytes(src);
|
||||
return new MemoryStream(byteArray);
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,36 @@
|
||||
|
||||
## 1. System Context
|
||||
|
||||
**Problem being solved**: Azaion Suite requires a centralized admin API to manage users, assign roles, and securely distribute encrypted software resources (DLLs, AI models, installers) to authorized devices and SaaS users.
|
||||
**Problem being solved**: Azaion Suite requires a centralized admin API to manage users + roles, authenticate humans (with optional second factor), authenticate UAVs for offline missions, and broker token revocation across a fleet of verifier services.
|
||||
|
||||
**System boundaries**:
|
||||
- **Inside**: User management, authentication (JWT), role-based authorization, file-based resource storage with per-user AES encryption.
|
||||
- **Outside**: Client applications (admin web panel at admin.azaion.com, fTPM-secured Jetson edge devices), PostgreSQL database, server filesystem for resource storage.
|
||||
- **Inside**: user management, password hashing (Argon2id), authentication (ES256 JWT + opaque refresh tokens with rotation + reuse detection), TOTP MFA, mission-token issuance, session revocation + verifier-poll snapshot, account lockout + per-IP and per-account rate limiting, JWKS publication, role-based authorization, file-based resource storage (upload / list / clear), HSTS + HTTPS redirect.
|
||||
- **Outside**: admin web panel (`admin.azaion.com`), fTPM-secured Jetson edge devices (CompanionPC), verifier fleet (satellite-provider, gps-denied, ui — service-role identities), PostgreSQL, server filesystem.
|
||||
|
||||
> **Note (AZ-197, 2026-05-13)**: hardware-fingerprint binding (`User.Hardware`, `CheckHardwareHash`, `PUT /users/hardware/set`, `POST /resources/check`, `HardwareIdMismatch`/`BadHardware` error codes) was removed. Edge devices now ship as fTPM-secured Jetsons; server/desktop access is SaaS-only. The `User.Hardware` DB column remains as a nullable tombstone (no migration in AZ-197).
|
||||
> **Note (AZ-197, cycle 1)**: hardware-fingerprint binding removed.
|
||||
>
|
||||
> **Note (cycle 2 early)**: encrypted resource download + installer endpoints removed; ADR-003 retired.
|
||||
>
|
||||
> **Note (cycle 2 — Auth Modernization, 2026-05-14, AZ-531..AZ-538)**: the entire authentication layer was rebuilt:
|
||||
> - **AZ-536** — Argon2id password hashing replaced SHA-384; lazy migration on login.
|
||||
> - **AZ-531** — opaque refresh tokens with server-side rotation, family-based reuse detection, sliding + absolute lifetimes (`SessionConfig`).
|
||||
> - **AZ-532** — symmetric HS256 → asymmetric ES256 with file-system key store + JWKS endpoint.
|
||||
> - **AZ-534** — TOTP MFA (enroll/confirm/disable, recovery codes, two-step login, `IDataProtector`-encrypted secret, `amr` claim).
|
||||
> - **AZ-535** — logout (single + all) + admin revoke + verifier-poll snapshot of revoked sessions; new `Service` role for verifier identities.
|
||||
> - **AZ-533** — long-lived no-refresh mission tokens for UAV ops, with auto-revoke on aircraft reconnect.
|
||||
> - **AZ-537** — DB-backed account lockout + per-account sliding-window rate limit + per-IP token-bucket via ASP.NET `RateLimiter`; `audit_events` table.
|
||||
> - **AZ-538** — CORS narrowed to single HTTPS origin, HSTS enabled (non-Development), HTTPS redirection (non-Development).
|
||||
> - New ADRs **ADR-006** through **ADR-009** below capture the per-decision context.
|
||||
|
||||
**External systems**:
|
||||
|
||||
| System | Integration Type | Direction | Purpose |
|
||||
|--------|-----------------|-----------|---------|
|
||||
| PostgreSQL | Database (linq2db) | Both | User data persistence |
|
||||
| Server filesystem | File I/O | Both | Resource file storage and retrieval |
|
||||
| Azaion Suite client | REST API | Inbound | Resource download, login |
|
||||
| Admin web panel (admin.azaion.com) | REST API | Inbound | User management, resource upload |
|
||||
| PostgreSQL | Database (linq2db) | Both | User + session + audit_events persistence |
|
||||
| Server filesystem | File I/O | Both | Resource files; ES256 PEM key store; DataProtection key store (when `DataProtection:KeysFolder` is set) |
|
||||
| Admin web panel (admin.azaion.com) | REST API | Inbound | User management, login, MFA, refresh, resource upload |
|
||||
| Verifier fleet (Service role) | REST API | Inbound | Polls `/sessions/revoked`, fetches `/.well-known/jwks.json` |
|
||||
| CompanionPC (Jetson) edge devices | REST API | Inbound | Login + refresh; mission-token consumer |
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
@@ -28,11 +42,15 @@
|
||||
| Database | PostgreSQL | (server-side) | Open-source, robust relational DB |
|
||||
| ORM | linq2db | 5.4.1 | Lightweight, LINQ-native, no migrations overhead |
|
||||
| Cache | LazyCache (in-memory) | 2.4.0 | Simple async caching for user lookups |
|
||||
| Auth | JWT Bearer | 10.0.3 | Stateless token authentication |
|
||||
| Auth | JWT Bearer (ES256) | 10.0.3 | Stateless token auth; cycle 2 — switched from HS256 to ES256 with JWKS (AZ-532) |
|
||||
| Password hashing | Konscious.Security.Cryptography (Argon2id) | (cycle 2 add) | Replaces SHA-384 (AZ-536) |
|
||||
| MFA | OtpNet (TOTP) + QRCoder (PNG) | (cycle 2 add) | TOTP + recovery codes (AZ-534) |
|
||||
| Rate limiting | Microsoft.AspNetCore.RateLimiting | 10.0 | Per-IP sliding window (AZ-537) |
|
||||
| Data protection | Microsoft.AspNetCore.DataProtection | 10.0 | Encrypt MFA secret at rest (AZ-534) |
|
||||
| Validation | FluentValidation | 11.3.0 / 11.10.0 | Declarative request validation |
|
||||
| Logging | Serilog | 4.1.0 | Structured logging (console + file) |
|
||||
| API Docs | Swashbuckle (Swagger) | 10.1.4 | OpenAPI specification |
|
||||
| Serialization | Newtonsoft.Json | 13.0.1 | JSON for DB field mapping and responses |
|
||||
| Serialization | Newtonsoft.Json | 13.0.4 | JSON for DB field mapping and responses (bumped from 13.0.1 by audit D-1) |
|
||||
| Container | Docker | .NET 10.0 images | Multi-stage build, ARM64 support |
|
||||
| CI/CD | Woodpecker CI | — | Branch-based ARM64 builds |
|
||||
| Registry | docker.azaion.com | — | Private container registry |
|
||||
@@ -54,7 +72,11 @@
|
||||
| Secrets | Environment variables (`ASPNETCORE_*`) | Environment variables |
|
||||
| Logging | Console + file | Console + rolling file (`logs/log.txt`) |
|
||||
| Swagger | Enabled | Disabled |
|
||||
| CORS | Same as prod | `admin.azaion.com` |
|
||||
| CORS | (same policy registered, allows `https://admin.azaion.com`) | `https://admin.azaion.com` only |
|
||||
| HSTS | **Disabled** (Development bypass) | **Enabled** (1 y, includeSubDomains, preload) |
|
||||
| HTTPS redirect | **Disabled** (Development bypass) | **Enabled** |
|
||||
| ES256 keys | `JwtConfig.KeysFolder` — at least one PEM, `ActiveKid` selects | Same; persistent volume mandatory |
|
||||
| DataProtection keys | Ephemeral OK (single-instance dev) | `DataProtection:KeysFolder` MUST be a persistent volume — otherwise MFA secrets are unrecoverable after restart |
|
||||
|
||||
## 4. Data Model Overview
|
||||
|
||||
@@ -62,22 +84,25 @@
|
||||
|
||||
| Entity | Description | Owned By Component |
|
||||
|--------|-------------|--------------------|
|
||||
| User | System user with email (UNIQUE-indexed via `users_email_uidx`), password hash, role, config (legacy `Hardware` column tombstoned per AZ-197). Subset of users have `Role = CompanionPC` and are auto-provisioned via `POST /devices` (AZ-196), which delegates the insert to `UserService.RegisterUser` (post-security-audit consolidation, finding F-3). | 01 Data Layer |
|
||||
| UserConfig | JSON-serialized per-user configuration (queue offsets) | 01 Data Layer |
|
||||
| RoleEnum | Authorization role hierarchy (None → ApiAdmin); `ResourceUploader` retained as data only after the OTA endpoints were retired | 01 Data Layer |
|
||||
| DetectionClass *(AZ-513, cycle 1)* | Operator-managed detection-class catalogue (Name, ShortName, Color, MaxSizeM, PhotoMode?) backing the UI Detection Classes table | 01 Data Layer |
|
||||
| ExceptionEnum | Business error code catalog (HW-related codes 40/45 removed by AZ-197) | Common Helpers |
|
||||
| User | System user. Cycle 2 added `failed_login_count`, `lockout_until` (AZ-537) and `mfa_*` columns (AZ-534). `password_hash` is now Argon2id PHC; legacy SHA-384 base64 lazily upgraded on next login (AZ-536). | 01 Data Layer |
|
||||
| Session *(AZ-531+535+533+534)* | One row per refresh token (interactive) or per mission token. Carries `family_id` (rotation chain), `revoked_at`/`revoked_reason`/`revoked_by_user_id`, `class` ∈ {`interactive`, `mission`}, `aircraft_id`, `mfa_authenticated`. | 01 Data Layer |
|
||||
| AuditEvent *(AZ-537+534)* | Append-only `audit_events` row: login_failed/success/lockout, mfa_enroll/confirm/disable/login_success/login_failed/recovery_used. | 01 Data Layer |
|
||||
| UserConfig | JSON-serialized per-user configuration (queue offsets). | 01 Data Layer |
|
||||
| RoleEnum | Authorization role hierarchy. Cycle 2 added `Service = 60` for verifier identities (AZ-535). | 01 Data Layer |
|
||||
| DetectionClass | Operator-managed catalogue. Unchanged in cycle 2. | 01 Data Layer |
|
||||
| ExceptionEnum | Business error code catalog. Cycle 2 added codes 50–61 for the auth/MFA/refresh/mission/lockout paths. | Common Helpers |
|
||||
|
||||
> **Removed in cycle 1 / post-cycle-1**: the `Resource` entity, the `resources` table, and the OTA delivery flow (AZ-183 — F10) were reverted after the security audit (finding F-1). The data model no longer carries an OTA-artifact entity.
|
||||
|
||||
**Key relationships**:
|
||||
- User → RoleEnum: each user has exactly one role
|
||||
- User → UserConfig: optional 1:1 JSON field containing queue offsets
|
||||
**Key relationships** (cycle 2 additions):
|
||||
- User 1 — N Session (`sessions.user_id` FK, ON DELETE CASCADE)
|
||||
- User 1 — N Session (`sessions.aircraft_id` FK for mission rows, ON DELETE SET NULL)
|
||||
- User 1 — N Session (`sessions.revoked_by_user_id` FK, ON DELETE SET NULL)
|
||||
- Session 1 — N Session (`parent_session_id` rotation chain)
|
||||
|
||||
**Data flow summary**:
|
||||
- Client → API → UserService → PostgreSQL: user CRUD operations
|
||||
- Client → API → ResourcesService → Filesystem: resource upload/download
|
||||
- Client → API → Security → ResourcesService: encrypted resource retrieval (key derived from user email + password; hardware-hash component removed in AZ-197)
|
||||
- Client → API → UserService → PostgreSQL: user CRUD + Argon2id verify/hash + lazy migration
|
||||
- Client → API → RefreshTokenService / SessionService / MfaService / MissionTokenService → PostgreSQL `sessions` + `users` + `audit_events`
|
||||
- Verifier → API → SessionService → PostgreSQL `sessions` (revoked-since snapshot) + JwtSigningKeyProvider (JWKS)
|
||||
- Client → API → ResourcesService → Filesystem: resource upload / list / clear
|
||||
|
||||
## 5. Integration Points
|
||||
|
||||
@@ -85,11 +110,13 @@
|
||||
|
||||
| From | To | Protocol | Pattern | Notes |
|
||||
|------|----|----------|---------|-------|
|
||||
| Admin API | User Management | Direct DI call | Request-Response | Scoped service injection |
|
||||
| Admin API | Auth & Security | Direct DI call | Request-Response | Scoped service injection |
|
||||
| Admin API | Resource Management | Direct DI call | Request-Response | Scoped service injection |
|
||||
| User Management | Data Layer | Direct DI call | Request-Response | Singleton DbFactory |
|
||||
| Auth & Security | User Management | Direct DI call | Request-Response | IUserService.GetByEmail |
|
||||
| Admin API | User Management | Direct DI call | Request-Response | Scoped |
|
||||
| Admin API | AuthService | Direct DI call | Request-Response | Scoped — also reads `IJwtSigningKeyProvider` (singleton) |
|
||||
| Admin API | RefreshTokenService / SessionService / MfaService / MissionTokenService / AuditLog | Direct DI call | Request-Response | Scoped |
|
||||
| Admin API | Resource Management | Direct DI call | Request-Response | Scoped |
|
||||
| User Management | AuditLog | Direct DI call | Request-Response | Failed/success/lockout audit + sliding-window count |
|
||||
| MfaService | IDataProtector | Direct DI call | Request-Response | Encrypt/decrypt mfa_secret |
|
||||
| All services | Data Layer | Direct DI call | Request-Response | Singleton DbFactory |
|
||||
|
||||
### External Integrations
|
||||
|
||||
@@ -103,28 +130,40 @@
|
||||
| Requirement | Target | Measurement | Priority |
|
||||
|------------|--------|-------------|----------|
|
||||
| Max upload size | 200 MB | Kestrel MaxRequestBodySize | High |
|
||||
| File encryption | AES-256-CBC | Per-resource | High |
|
||||
| Password hashing | SHA-384 | Per-user | Medium |
|
||||
| Password hashing | Argon2id (parameters from `AuthConfig.PasswordHashing`) | Per-user, constant-time verify | High |
|
||||
| Access token lifetime | `JwtConfig.AccessTokenLifetimeMinutes` (15 default) | Per token | High |
|
||||
| Refresh token sliding lifetime | `SessionConfig.RefreshSlidingHours` | Per session row | High |
|
||||
| Refresh token absolute lifetime | `SessionConfig.RefreshAbsoluteHours` | Per family | High |
|
||||
| Mission token lifetime | `MissionSessionRequest.PlannedDurationH` (validation-bounded) | Per mission session | High |
|
||||
| Per-IP login rate | `AuthConfig.RateLimit.PerIpPermitLimit` per `PerIpWindowSeconds` | Sliding window | High |
|
||||
| Per-account login rate | `AuthConfig.RateLimit.PerAccountFailedThreshold` per `PerAccountWindowSeconds` | DB sliding window via `audit_events` | High |
|
||||
| Account lockout | `AuthConfig.Lockout.ConsecutiveFailureThreshold` failures → `LockoutSeconds` lockout | DB-backed | High |
|
||||
| HSTS | 1 y, includeSubDomains, preload (non-Development) | HTTP header | High |
|
||||
| HTTPS redirect | Enabled (non-Development) | Middleware | High |
|
||||
| Cache TTL | 4 hours | User entity cache | Low |
|
||||
|
||||
No explicit availability, latency, throughput, or recovery targets found in the codebase.
|
||||
|
||||
## 7. Security Architecture
|
||||
|
||||
**Authentication**: JWT Bearer tokens (HMAC-SHA256 signed, validated for issuer/audience/lifetime/signing key).
|
||||
**Authentication**:
|
||||
- ES256 (ECDSA P-256) JWT bearer tokens (AZ-532). `ValidAlgorithms` pinned to `ES256` to prevent the HS256-with-public-key forgery class.
|
||||
- Opaque refresh tokens with server-side rotation + reuse detection (AZ-531). Stored as SHA-256 hashes; never re-presented.
|
||||
- TOTP MFA + recovery codes (AZ-534). Step-1 token is itself an ES256 JWT with a separate audience.
|
||||
- Mission tokens (AZ-533) — long-lived, no refresh, bound to `aircraft_id`, auto-revoked on aircraft reconnect.
|
||||
|
||||
**Authorization**: Role-based (RBAC) via ASP.NET Core authorization policies:
|
||||
- `apiAdminPolicy` — requires `ApiAdmin` role
|
||||
- `apiAdminPolicy` — requires `ApiAdmin`
|
||||
- `revocationReaderPolicy` — requires `Service` OR `ApiAdmin` (verifier fleet)
|
||||
- General `[Authorize]` — any authenticated user
|
||||
|
||||
> The `apiUploaderPolicy` was added by AZ-183 and removed in the post-cycle-1 revert along with the OTA endpoints it guarded. `RoleEnum.ResourceUploader` remains as data only.
|
||||
|
||||
**Data protection**:
|
||||
- At rest: Resources encrypted with AES-256-CBC using per-user derived key (email + password). The hardware-hash component was removed in AZ-197 (sealed-Jetson + SaaS architecture).
|
||||
- In transit: HTTPS (assumed, not enforced in code)
|
||||
- Secrets management: Environment variables (`ASPNETCORE_*` prefix)
|
||||
- **At rest**: `mfa_secret` is encrypted via `IDataProtector` (purpose `Azaion.Mfa.Secret`). MFA recovery codes are individually Argon2id-hashed and single-use. Passwords are Argon2id PHC strings. ES256 PEM keys live in `JwtConfig.KeysFolder` — protect via filesystem permissions.
|
||||
- **In transit**: HSTS + HTTPS redirection in non-Development environments (AZ-538). CORS narrowed to `https://admin.azaion.com` only.
|
||||
- **Token revocation propagation**: `GET /sessions/revoked` provides a verifier-poll snapshot; verifiers are responsible for honoring it within their poll cadence (currently ~30s recommended).
|
||||
- **Secrets management**: Environment variables (`ASPNETCORE_*` prefix).
|
||||
|
||||
**Audit logging**: No explicit audit trail. Serilog logs business exceptions (WARN) and general events (INFO).
|
||||
**Audit logging**: `audit_events` table records login_success/failed/lockout and mfa_enroll/confirm/disable/login_success/login_failed/recovery_used events with normalised email + caller IP. Drives the per-account rate limit and provides forensic evidence. Serilog continues to log business exceptions (WARN) and general events (INFO).
|
||||
|
||||
## 8. Key Architectural Decisions
|
||||
|
||||
@@ -144,15 +183,13 @@ No explicit availability, latency, throughput, or recovery targets found in the
|
||||
|
||||
**Consequences**: Write operations are explicitly gated through `RunAdmin`. Prevents accidental writes through the reader connection. Requires maintaining two DB users with different privileges.
|
||||
|
||||
### ADR-003: Per-User Resource Encryption
|
||||
### ADR-003: Per-User Resource Encryption — RETIRED (cycle 2, 2026-05-14)
|
||||
|
||||
**Context**: Resources (DLLs, AI models) must be delivered only to authorized users.
|
||||
**Original context**: Resources (DLLs, AI models) had to be delivered only to authorized users via a per-download AES-256-CBC stream keyed off the user's email + password.
|
||||
|
||||
**Decision**: Resources are encrypted at download time using AES-256-CBC with a key derived from the user's email and password. The client must know both to decrypt.
|
||||
**Retirement decision**: With the OTA delivery flow (AZ-183) and the hardware-binding flow (AZ-197) both gone, the only remaining consumer of the encrypted-download path was a now-vestigial `POST /resources/get/{dataFolder?}` endpoint and the two installer endpoints. None of them are part of the target architecture (browser SaaS + fTPM Jetsons), so the entire encrypt-on-download stack — `POST /resources/get`, `GET /resources/get-installer`, `GET /resources/get-installer/stage`, `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, `Security.GetApiEncryptionKey`, `Security.EncryptTo`, `Security.DecryptTo`, `GetResourceRequest`, `WrongResourceName` (50), `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` — was removed. `Security.ToHash` is retained because it still backs SHA-384 password hashing in `UserService`.
|
||||
|
||||
**Consequences**: Strong per-user binding. However, encryption happens in memory (MemoryStream), which limits practical file sizes. Key derivation is deterministic — same inputs always produce the same key.
|
||||
|
||||
> **Update (AZ-197, 2026-05-13)**: the hardware-hash component of the derivation was removed. The new key formula is `SHA384(email + "-" + password + "-#%@AzaionKey@%#---")`. See ADR-004 for context on why the hardware binding was retired.
|
||||
**Consequences**: resource files now live on disk as plain bytes; any future at-rest encryption must come from filesystem or storage-layer features (LUKS, object-store SSE), not from application code.
|
||||
|
||||
### ADR-004: Hardware Fingerprint Binding — RETIRED (AZ-197)
|
||||
|
||||
@@ -174,3 +211,68 @@ The binding's only remaining effect was a real production failure mode (`Hardwar
|
||||
**Decision**: Use linq2db instead of Entity Framework Core.
|
||||
|
||||
**Consequences**: No migration framework — schema managed via SQL scripts (`env/db/`). Lighter runtime footprint. Manual mapping configuration in `AzaionDbSchemaHolder`.
|
||||
|
||||
### ADR-006: Asymmetric ES256 JWT signing with file-system key store + JWKS *(cycle 2 — AZ-532)*
|
||||
|
||||
**Context**: Cycle-1 JWT signing was symmetric HS256 with the secret in environment configuration. The verifier fleet (satellite-provider, gps-denied, ui) needed to validate tokens without sharing the signing secret with every service. Sharing the HS256 secret would have made any verifier compromise also a token-forgery primitive.
|
||||
|
||||
**Decision**: Switch to ES256 (ECDSA P-256). The Admin API holds the private key; verifiers fetch the public key set from `GET /.well-known/jwks.json`. Keys live as one PEM per kid in `JwtConfig.KeysFolder`. `JwtConfig.ActiveKid` selects the signer; ALL discovered keys are exposed in JWKS so existing tokens stay verifiable across rotations.
|
||||
|
||||
**Alternatives rejected**:
|
||||
- **Continue HS256 + share secret**: rejected — secret-distribution + verifier-compromise blast radius.
|
||||
- **RS256**: equivalent security, larger keys, no operational benefit at our scale.
|
||||
- **External KMS / HSM**: deferred — adds operational complexity (KMS auth, latency on every signing op) without near-term benefit. The PEM-on-disk approach is reversible to KMS later.
|
||||
|
||||
**Consequences**:
|
||||
- JwtBearer `ValidAlgorithms = [ES256]` is mandatory — without it, a token forged with `alg=HS256` using the public key as the HMAC secret would validate.
|
||||
- The PEM directory MUST be a persistent volume.
|
||||
- Key rotation is "drop a new PEM, set `ActiveKid`, restart" — the old kid keeps verifying tokens until physically removed.
|
||||
- Verifiers MUST cache the JWKS for at most 1 hour to pick up new kids quickly.
|
||||
|
||||
### ADR-007: Refresh tokens as opaque rotating server-side rows (not JWT) *(cycle 2 — AZ-531)*
|
||||
|
||||
**Context**: The dual-token model needs a refresh token. The two viable shapes are (a) signed self-describing JWT or (b) opaque server-stored value. Refresh tokens are long-lived; their threat model centres on theft + replay.
|
||||
|
||||
**Decision**: Opaque random `Base64Url(32 bytes)` stored on the server as a SHA-256 hash. Each rotation marks the previous row as `revoked_reason='rotated'` and inserts a new row in the same `family_id`. Presenting an already-rotated token revokes the entire family with `reason='reuse_detected'`.
|
||||
|
||||
**Alternatives rejected**:
|
||||
- **JWT refresh token**: server cannot revoke without a denylist (which negates the "stateless" advantage). No reuse-detection without ALSO server state.
|
||||
- **Sliding session ID alone (no rotation)**: theft is permanent until manual revocation.
|
||||
|
||||
**Consequences**:
|
||||
- Every refresh hits Postgres (one indexed lookup + one update + one insert in a transaction). Acceptable at current load; if it becomes a bottleneck, the `sessions_refresh_hash_idx` UNIQUE INDEX is the obvious caching boundary.
|
||||
- Refresh-token theft is detectable on the next legitimate refresh.
|
||||
- The session row is also the `sid` claim in the access token — the same row drives logout (F12), JWKS-independent revocation snapshots (F15), and AMR persistence across rotations (`mfa_authenticated`).
|
||||
|
||||
### ADR-008: TOTP MFA secrets encrypted via `IDataProtector` *(cycle 2 — AZ-534)*
|
||||
|
||||
**Context**: MFA secrets are TOTP shared secrets — possession of the database alone (DBA access, backup leak) must NOT yield the ability to mint TOTP codes for users.
|
||||
|
||||
**Decision**: Encrypt `mfa_secret` with ASP.NET `IDataProtector` (purpose string `Azaion.Mfa.Secret`) before persisting. The DataProtection key store is configured via `DataProtection:KeysFolder` and MUST be a persistent volume in production. Recovery codes are individually Argon2id-hashed and stored as a `jsonb` array; single-use is enforced by setting `used_at` transactionally with the rest of the login.
|
||||
|
||||
**Alternatives rejected**:
|
||||
- **Plaintext**: explicit DB-leak escalation path.
|
||||
- **Application-managed AES via env-var key**: re-introduces the very key-distribution problem ADR-006 solved for JWT signing.
|
||||
- **External KMS for MFA secrets**: deferred for the same reason as ADR-006.
|
||||
|
||||
**Consequences**:
|
||||
- Loss of the DataProtection key folder = users must re-enroll MFA (no recovery path). This MUST be backed up alongside DB backups.
|
||||
- DBA-only access does not yield MFA bypass.
|
||||
|
||||
### ADR-009: Per-account lockout + DB-backed sliding-window rate limit alongside per-IP token bucket *(cycle 2 — AZ-537)*
|
||||
|
||||
**Context**: ASP.NET `RateLimiter` is per-process and per-IP. CMMC AC.L2-3.1.8 requires per-account lockout that survives process restarts. Per-IP alone is insufficient (NAT'd attacker farm; bot rotates IPs). Per-account-only is insufficient (single IP can DoS many accounts at "just below threshold").
|
||||
|
||||
**Decision**: Both layers, both required to pass:
|
||||
1. Per-IP — ASP.NET `RateLimiter` middleware with `SlidingWindowRateLimiter` on `/login` and `/login/mfa`. In-memory; resets on restart but recovers within seconds.
|
||||
2. Per-account — DB-backed sliding window via `audit_events` (count `login_failed` rows for the email within `PerAccountWindowSeconds`).
|
||||
3. Lockout — `users.failed_login_count` + `users.lockout_until`. After `ConsecutiveFailureThreshold` failures, `lockout_until = now + LockoutSeconds`. Subsequent logins throw `AccountLocked` with `RetryAfterSeconds` until the window passes.
|
||||
|
||||
**Alternatives rejected**:
|
||||
- **Redis token bucket per account**: avoids DB load but adds a new infra dependency for a low-write workload. The DB sliding window has acceptable cost (`audit_events_event_type_email_idx`).
|
||||
- **Single combined rule**: harder to tune.
|
||||
|
||||
**Consequences**:
|
||||
- `audit_events` will grow large (~14 GB/yr at projected fleet scale); operational follow-up to time-partition.
|
||||
- The `Retry-After` header is set both by the per-IP middleware (lease metadata) and by the `BusinessExceptionHandler` (from `BusinessException.RetryAfterSeconds`), so clients see consistent backoff hints regardless of which layer rejected.
|
||||
- All gating events go through `audit_events`, providing a single auditable history.
|
||||
|
||||
@@ -5,7 +5,8 @@ Shared utility extensions used across multiple components.
|
||||
## Modules
|
||||
- `EnumExtensions` — enum description/attribute extraction (used by BusinessException)
|
||||
- `StringExtensions` — PascalCase → snake_case conversion (used by AzaionDbSchemaHolder)
|
||||
- `StreamExtensions` — Stream → string conversion (used by SecurityTest)
|
||||
<!-- StreamExtensions removed in cycle 2 (2026-05-14): only consumer was the deleted SecurityTest. -->
|
||||
|
||||
- `QueryableExtensions` — conditional LINQ Where filter (used by UserService)
|
||||
|
||||
## Consumers
|
||||
|
||||
@@ -8,14 +8,16 @@ Domain exception type with catalog of business error codes (`ExceptionEnum`).
|
||||
| NoEmailFound | 10 | No such email found |
|
||||
| EmailExists | 20 | Email already exists |
|
||||
| WrongPassword | 30 | Passwords do not match |
|
||||
| PasswordLengthIncorrect | 32 | Password should be at least 8 characters |
|
||||
| PasswordLengthIncorrect | 32 | Password should be at least 12 characters (validator threshold is 12 in `RegisterUserValidator`; the description text on the enum still reads "12 characters") |
|
||||
| EmailLengthIncorrect | 35 | Email is empty or invalid |
|
||||
| WrongEmail | 37 | (no description) |
|
||||
| HardwareIdMismatch | 40 | Hardware mismatch |
|
||||
| BadHardware | 45 | Hardware should be not empty |
|
||||
| WrongResourceName | 50 | Wrong resource file name |
|
||||
| UserDisabled | 38 | User account is disabled |
|
||||
| NoFileProvided | 60 | No file provided |
|
||||
|
||||
> **Retired numeric codes — DO NOT REUSE**:
|
||||
> - `40` (HardwareIdMismatch) and `45` (BadHardware) — removed by AZ-197 (cycle 1, 2026-05-13). Older clients may still surface "Hardware mismatch" UX strings keyed on these integers.
|
||||
> - `50` (WrongResourceName) — removed in cycle 2 (2026-05-14) along with the `GetResourceRequest` validator (its only consumer) and the `POST /resources/get/{dataFolder?}` endpoint.
|
||||
|
||||
## Consumers
|
||||
| Component | Usage |
|
||||
|-----------|-------|
|
||||
|
||||
@@ -29,41 +29,66 @@
|
||||
|
||||
### Entities
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — `DetectionClass` (AZ-513) entity was added. `Resource` (AZ-183) was added then removed in the same cycle (post-cycle-1 revert; security audit F-1 + the OTA delivery model itself was deemed obsolete). The `User.Hardware` column is left in place as a tombstone (nullable, unused) per AZ-197. A UNIQUE INDEX `users_email_uidx` was added on `users.email` (security audit F-3, `env/db/06_users_email_unique.sql`).
|
||||
> **Cycle 1 (2026-05-13) note** — `DetectionClass` (AZ-513) added; `Resource` (AZ-183) added then reverted same cycle. `User.Hardware` left as a tombstone (AZ-197). UNIQUE INDEX `users_email_uidx` added on `users.email` (security audit F-3, `env/db/06_users_email_unique.sql`).
|
||||
>
|
||||
> **Cycle 2 — early (2026-05-14)** — `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` removed with the installer endpoints; `ResourcesConfig` is now `ResourcesFolder`-only.
|
||||
>
|
||||
> **Cycle 2 — Auth Modernization (2026-05-14)** — significant data-layer changes:
|
||||
> - **`User`** gained `FailedLoginCount`, `LockoutUntil` (AZ-537) and `MfaEnabled`, `MfaSecret` (DataProtection-encrypted), `MfaRecoveryCodes` (jsonb of Argon2id-hashed codes), `MfaEnrolledAt`, `MfaLastUsedWindow` (AZ-534). `PasswordHash` column unchanged in shape but now contains Argon2id PHC strings; legacy SHA-384 base64 values are accepted by `Security.VerifyPassword` and lazily upgraded on next login (AZ-536).
|
||||
> - **New table `public.sessions`** (AZ-531 / AZ-535) — refresh-token rotation + revocation, mapped via `Common/Entities/Session`.
|
||||
> - **New table `public.audit_events`** (AZ-537 + AZ-534) — append-only login + MFA event log, mapped via `Common/Entities/AuditEvent`.
|
||||
> - **New `RoleEnum.Service = 60`** (AZ-535) — verifier-fleet identity used by the `revocationReaderPolicy`.
|
||||
> - **New configs**: `AuthConfig` (rate limit + lockout + Argon2id parameters), `SessionConfig` (refresh sliding + absolute lifetimes). `JwtConfig` rebuilt around ES256 (`KeysFolder`, `ActiveKid`, `AccessTokenLifetimeMinutes`, `MfaStepTokenLifetimeMinutes`); the legacy `Secret` and `TokenLifetimeHours` fields are no longer read.
|
||||
> - **Migrations** added: `07_auth_lockout_and_audit.sql`, `08_sessions.sql`, `09_sessions_logout_and_mission.sql`, `10_users_mfa.sql`.
|
||||
|
||||
```
|
||||
User:
|
||||
Id: Guid (PK)
|
||||
Email: string (required)
|
||||
PasswordHash: string (required)
|
||||
Hardware: string? (optional — TOMBSTONED by AZ-197; nullable, unused; no application code reads or writes)
|
||||
PasswordHash: string (required, Argon2id PHC; legacy SHA-384 base64 accepted on read, rehashed on next login — AZ-536)
|
||||
Hardware: string? (TOMBSTONED — AZ-197)
|
||||
Role: RoleEnum (required)
|
||||
CreatedAt: DateTime (required)
|
||||
LastLogin: DateTime? (optional)
|
||||
UserConfig: UserConfig? (optional, JSON-serialized)
|
||||
IsEnabled: bool (required)
|
||||
|
||||
UserConfig:
|
||||
QueueOffsets: UserQueueOffsets? (optional)
|
||||
|
||||
UserQueueOffsets:
|
||||
AnnotationsOffset: ulong
|
||||
AnnotationsConfirmOffset: ulong
|
||||
AnnotationsCommandsOffset: ulong
|
||||
|
||||
DetectionClass (AZ-513):
|
||||
Id: int (PK, DB-assigned identity)
|
||||
Name, ShortName, Color: string
|
||||
MaxSizeM: double
|
||||
PhotoMode: string?
|
||||
CreatedAt: DateTime
|
||||
LastLogin: DateTime?
|
||||
UserConfig: UserConfig?
|
||||
IsEnabled: bool
|
||||
FailedLoginCount: int (AZ-537 — reset on successful login)
|
||||
LockoutUntil: DateTime? (AZ-537 — UTC; "now < LockoutUntil" → AccountLocked)
|
||||
MfaEnabled: bool (AZ-534)
|
||||
MfaSecret: string? (AZ-534 — IDataProtector-encrypted base32 TOTP secret)
|
||||
MfaRecoveryCodes: List<string>? (AZ-534 — jsonb of Argon2id-hashed single-use codes)
|
||||
MfaEnrolledAt: DateTime?
|
||||
MfaLastUsedWindow: long? (AZ-534 — anti-replay; last consumed TOTP step)
|
||||
|
||||
// Resource entity — REMOVED post-cycle-1 (AZ-183 reverted). The `resources`
|
||||
// table no longer exists; see env/db/ for the current migration set.
|
||||
Session (AZ-531 / AZ-535):
|
||||
Id: Guid (PK — used as the JWT `sid` claim)
|
||||
UserId: Guid (FK to users)
|
||||
Class: string ("interactive" | "mission")
|
||||
RefreshTokenHash: byte[]? (SHA-256 of opaque refresh; null for mission sessions)
|
||||
RotatedFromTokenId: Guid? (chain pointer for reuse detection)
|
||||
IssuedAt: DateTime
|
||||
ExpiresAt: DateTime (sliding for interactive, absolute for mission)
|
||||
RevokedAt: DateTime?
|
||||
RevokedReason: string? (one of SessionRevokedReasons)
|
||||
RevokedByUserId: Guid?
|
||||
Ip: string?
|
||||
UserAgent: string?
|
||||
MfaAuthenticated: bool (AZ-534 — pinned at issue, inherited by rotations)
|
||||
AircraftId: Guid? (mission-only)
|
||||
MissionId: string? (mission-only)
|
||||
|
||||
RoleEnum: None=0, Operator=10, Validator=20, CompanionPC=30, Admin=40, ResourceUploader=50, ApiAdmin=1000
|
||||
// ResourceUploader is now data-only — no endpoint policy references it
|
||||
// after AZ-183 was reverted.
|
||||
AuditEvent (AZ-537 + AZ-534):
|
||||
Id: long (PK identity)
|
||||
EventType: string (one of AuditEventTypes — login_failed/success/lockout, mfa_*)
|
||||
Email: string (lowercase normalised)
|
||||
Ip: string?
|
||||
OccurredAt: DateTime (UTC)
|
||||
|
||||
DetectionClass (AZ-513): unchanged
|
||||
|
||||
RoleEnum: None=0, Operator=10, Validator=20, CompanionPC=30, Admin=40, ResourceUploader=50, Service=60 (AZ-535), ApiAdmin=1000
|
||||
// ResourceUploader is data-only since AZ-183 revert.
|
||||
// Service is the verifier-fleet identity used by revocationReaderPolicy.
|
||||
```
|
||||
|
||||
### Configuration POCOs
|
||||
@@ -73,17 +98,33 @@ ConnectionStrings:
|
||||
AzaionDb: string — read-only connection string
|
||||
AzaionDbAdmin: string — admin (read/write) connection string
|
||||
|
||||
JwtConfig:
|
||||
JwtConfig (AZ-532):
|
||||
Issuer: string
|
||||
Audience: string
|
||||
Secret: string
|
||||
TokenLifetimeHours: double
|
||||
KeysFolder: string — directory containing one PEM per kid
|
||||
ActiveKid: string — selects the signing key
|
||||
AccessTokenLifetimeMinutes: int — default 15
|
||||
MfaStepTokenLifetimeMinutes: int — default 5 (AZ-534)
|
||||
# Secret + TokenLifetimeHours: no longer read; kept only for back-compat deserialisation
|
||||
|
||||
SessionConfig (AZ-531):
|
||||
RefreshSlidingHours: int — sliding window per rotate
|
||||
RefreshAbsoluteHours: int — hard cap (no rotation past this)
|
||||
RevokedSnapshotMinutes: int — verifier-poll grace window for /sessions/revoked
|
||||
|
||||
AuthConfig (AZ-536 + AZ-537):
|
||||
PasswordHashing: { TimeCost, MemoryCostKiB, Parallelism } — Argon2id parameters
|
||||
RateLimit:
|
||||
PerIpPermitLimit: int
|
||||
PerIpWindowSeconds: int
|
||||
PerAccountWindowSeconds: int
|
||||
PerAccountFailedThreshold: int
|
||||
Lockout:
|
||||
ConsecutiveFailureThreshold: int
|
||||
LockoutSeconds: int
|
||||
|
||||
ResourcesConfig:
|
||||
ResourcesFolder: string
|
||||
SuiteInstallerFolder: string
|
||||
SuiteStageInstallerFolder: string
|
||||
# EncryptionMasterKey was added by AZ-183 and removed in the post-cycle-1 revert.
|
||||
```
|
||||
|
||||
## 3. External API Specification
|
||||
@@ -96,25 +137,34 @@ N/A — internal component.
|
||||
|
||||
| Query | Frequency | Hot Path | Index Needed |
|
||||
|-------|-----------|----------|--------------|
|
||||
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes — UNIQUE INDEX `users_email_uidx` on `email` (security audit F-3, `env/db/06_users_email_unique.sql`) |
|
||||
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes — UNIQUE INDEX `users_email_uidx` on `email` |
|
||||
| `SELECT * FROM users` with optional filters | Medium | No | No |
|
||||
| `UPDATE users SET ... WHERE email = ?` | Medium | No | No |
|
||||
| `INSERT INTO users` | Low | No | No (UNIQUE INDEX above also enforces single-row-per-email atomically) |
|
||||
| `INSERT INTO users` | Low | No | UNIQUE INDEX above |
|
||||
| `DELETE FROM users WHERE email = ?` | Low | No | No |
|
||||
| `SELECT * FROM sessions WHERE refresh_token_hash = ?` (AZ-531) | High | Yes | Yes — UNIQUE INDEX on `refresh_token_hash` (`08_sessions.sql`) |
|
||||
| `UPDATE sessions SET revoked_at..., revoked_reason... WHERE id = ?` (AZ-535) | Medium | No | PK |
|
||||
| `UPDATE sessions SET revoked_... WHERE user_id = ? AND revoked_at IS NULL` (AZ-535 logout/all) | Low | No | INDEX on `(user_id, revoked_at)` |
|
||||
| `UPDATE sessions SET revoked_... WHERE aircraft_id = ? AND class='mission' AND revoked_at IS NULL` (AZ-533) | Low | No | INDEX on `(aircraft_id, class, revoked_at)` |
|
||||
| `SELECT ... FROM sessions WHERE revoked_at >= ? AND expires_at > now()` (AZ-535 verifier poll) | High | Yes | INDEX on `revoked_at` |
|
||||
| `SELECT count(*) FROM audit_events WHERE event_type='login_failed' AND email=? AND occurred_at >= ?` (AZ-537) | High | Yes | INDEX on `(email, event_type, occurred_at)` |
|
||||
| `INSERT INTO audit_events (...)` (AZ-537 / AZ-534) | High | Yes | n/a |
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
| Data | Cache Type | TTL | Invalidation |
|
||||
|------|-----------|-----|-------------|
|
||||
| User by email | In-memory (LazyCache) | 4 hours | On `UpdateQueueOffsets` (post-AZ-197 — hardware paths gone) |
|
||||
| User by email | In-memory (LazyCache) | 4 hours | On `UpdateQueueOffsets`, on lazy-rehash (AZ-536), on MFA enroll/confirm/disable (AZ-534), on user enable/disable, on lockout state changes (AZ-537) |
|
||||
|
||||
> The `Resources.Latest.{arch}.{stage}` cache key (added by AZ-183) was removed in the post-cycle-1 revert.
|
||||
> Refresh tokens, sessions, and audit events are NOT cached — they are read directly from Postgres on every request. The verifier-poll snapshot (`/sessions/revoked`) is the only "edge" cache and lives in the verifier process, not in this component.
|
||||
|
||||
### Storage Estimates
|
||||
|
||||
| Table | Est. Row Count (1yr) | Row Size | Total Size | Growth Rate |
|
||||
|-------|---------------------|----------|------------|-------------|
|
||||
| `users` | 100–1000 web users + 2000–10000 CompanionPC device users (AZ-196 grows this) | ~500 bytes | ~5 MB | Medium (device fleet) |
|
||||
| `users` | 100–1000 web users + 2000–10000 CompanionPC device users | ~700 bytes (post-MFA columns) | ~7 MB | Medium |
|
||||
| `sessions` (AZ-531) | 30 d retention (`RefreshAbsoluteHours`) × N active sessions per user × pruning job | ~400 bytes | ~50 MB ceiling | High during active fleet ops; bounded by retention |
|
||||
| `audit_events` (AZ-537) | ~50 events/user/day × ~5000 users × 365 d | ~150 bytes | ~14 GB/yr | High — partition or archive after 90 d (operational follow-up) |
|
||||
| `detection_classes` (AZ-513) | 10–200 | ~250 bytes | ~50 KB | Low |
|
||||
|
||||
### Data Management
|
||||
@@ -181,12 +231,15 @@ N/A — internal component.
|
||||
|
||||
## Modules Covered
|
||||
- `Common/Configs/ConnectionStrings`
|
||||
- `Common/Configs/JwtConfig`
|
||||
- `Common/Configs/JwtConfig` *(AZ-532 — ES256 + session config)*
|
||||
- `Common/Configs/AuthConfig` *(new in cycle 2 — AZ-536 + AZ-537)*
|
||||
- `Common/Configs/ResourcesConfig`
|
||||
- `Common/Entities/User`
|
||||
- `Common/Entities/RoleEnum`
|
||||
- `Common/Entities/User` *(extended in cycle 2 — AZ-537 + AZ-534)*
|
||||
- `Common/Entities/RoleEnum` *(extended in cycle 2 — AZ-535 added `Service`)*
|
||||
- `Common/Entities/Session` *(new in cycle 2 — AZ-531 + AZ-535)*
|
||||
- `Common/Entities/AuditEvent` *(new in cycle 2 — AZ-537)*
|
||||
- `Common/Entities/DetectionClass` *(added cycle 1, AZ-513)*
|
||||
- `Common/Database/AzaionDb` (now also holds the `DetectionClasses` table; the `Resources` ITable added by AZ-183 was removed in the post-cycle-1 revert)
|
||||
- `Common/Database/AzaionDbSchemaHolder`
|
||||
- `Common/Database/AzaionDb` (`Sessions` and `AuditEvents` ITables added in cycle 2)
|
||||
- `Common/Database/AzaionDbSchemaHolder` (Session + AuditEvent mappings, jsonb for `MfaRecoveryCodes`)
|
||||
- `Common/Database/DbFactory`
|
||||
- `Services/Cache`
|
||||
|
||||
@@ -1,92 +1,181 @@
|
||||
# Authentication & Security
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — AZ-197 simplified `GetApiEncryptionKey` to `(email, password)` and removed `GetHWHash` outright. The hardware-binding threat model that motivated those primitives is no longer in scope (fTPM-anchored Jetsons + browser SaaS).
|
||||
> **Cycle 1 (2026-05-13) note** — AZ-197 simplified `GetApiEncryptionKey` to `(email, password)` and removed `GetHWHash` outright. The hardware-binding threat model is no longer in scope.
|
||||
>
|
||||
> **Cycle 2 — early (2026-05-14, batches 01-04)** — `GetApiEncryptionKey`, `EncryptTo`, and `DecryptTo` were removed along with the encrypted-download endpoint. `Security` was briefly a one-method utility (`ToHash`) wrapping SHA-384.
|
||||
>
|
||||
> **Cycle 2 — Auth Modernization (2026-05-14, AZ-531..AZ-538)** — this component was rebuilt from a single-token issuer + SHA-384 hasher into the full session/refresh/MFA/audit/mission stack described below. Old single-token, symmetric-HS256, SHA-384 paths are gone.
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: JWT token creation/validation and cryptographic utilities (password hashing, AES file encryption/decryption).
|
||||
**Purpose**: end-to-end authentication, authorization, session management, second factor (TOTP), token signing/verification, mission credentials, audit, and request-time abuse protection (rate limiting / lockout).
|
||||
|
||||
**Architectural Pattern**: Service + static utility — `AuthService` is a DI-managed service for JWT operations; `Security` is a static class for cryptographic primitives.
|
||||
**Architectural Pattern**: a cluster of focused DI-registered services backed by Postgres tables, fronted by Admin API endpoints. Token signing is asymmetric (ES256) with file-system key storage and JWKS publication. Refresh tokens use server-side rotation with reuse detection. MFA secrets are encrypted at rest via ASP.NET `IDataProtector`.
|
||||
|
||||
**Upstream dependencies**: Data Layer (JwtConfig, IUserService for GetByEmail), ASP.NET Core (IHttpContextAccessor).
|
||||
**Upstream dependencies**:
|
||||
- Data Layer (`AzaionDb`, `JwtConfig`, `SessionConfig`, `AuthConfig`, `IUserService.GetByEmail`)
|
||||
- ASP.NET Core (`IHttpContextAccessor`, `IDataProtectionProvider`, `RateLimiter` middleware)
|
||||
- File system (`JwtConfig.KeysFolder` for ES256 keys; one PEM per kid)
|
||||
|
||||
**Downstream consumers**: Admin API (token creation on login, current user resolution), User Management (password hashing for both web users and provisioned devices), Resource Management (encryption key derivation, stream encryption).
|
||||
**Downstream consumers**:
|
||||
- Admin API endpoints (`/login`, `/login/mfa`, `/refresh`, `/logout`, `/logout/all`, `/users/me/mfa/*`, `/sessions/{sid}`, `/aircraft/{id}/sessions`, `/sessions/revoked`, `/missions/sessions`, `/.well-known/jwks.json`)
|
||||
- All authorized requests (JWT bearer middleware verifies via `IJwtSigningKeyProvider` and Verifier services consult the revoked-sessions snapshot)
|
||||
- User Management (Argon2id hashing for register/update; lazy migration on login)
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### Interface: IAuthService
|
||||
### Service: `IAuthService`
|
||||
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `GetCurrentUser` | (none — reads from HttpContext) | `User?` | Yes | None |
|
||||
| `CreateToken` | `User` | `string` (JWT) | No | None |
|
||||
| Method | Input | Output | Async | Notes |
|
||||
|--------|-------|--------|-------|-------|
|
||||
| `GetCurrentUser` | (HttpContext) | `User?` | Yes | Reads `ClaimTypes.Name` (email) and looks up via `IUserService.GetByEmail` |
|
||||
| `CreateToken` | `User`, `Guid sessionId`, `Guid jti`, `IEnumerable<string>? amr` | `AccessToken` (record: `Jwt`, `ExpiresAt`) | No | ES256 signed; lifetime from `JwtConfig.AccessTokenLifetimeMinutes`. Stamps `sub` (`NameIdentifier`), `email` (`Name`), `role`, `sid`, `jti`, and one `amr` claim per value (defaults to `["pwd"]`). |
|
||||
|
||||
### Static: Security
|
||||
### Service: `IRefreshTokenService` *(AZ-531)*
|
||||
|
||||
| Method | Input | Output | Notes |
|
||||
|--------|-------|--------|-------|
|
||||
| `IssueForNewLogin` | `Guid userId`, `bool mfaAuthenticated`, `CancellationToken` | `(string OpaqueToken, Session Session)` | Creates a new session family (the returned `Session.Id` is the `sid` claim) + initial refresh token. `MfaAuthenticated` is pinned on the session so refresh rotations inherit AMR strength. |
|
||||
| `Rotate` | `string opaqueToken`, `CancellationToken` | `(string OpaqueToken, Session Session)` | Validates → marks old as rotated → inserts new row in same family. Presenting an already-rotated token revokes the entire family. |
|
||||
|
||||
### Service: `ISessionService` *(AZ-535)*
|
||||
|
||||
| Method | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| `RevokeBySid` | `Guid sessionId`, `Guid? byUserId`, `string reason`, `CancellationToken` | `Task<bool>` (true = was already revoked = no-op) |
|
||||
| `RevokeAllForUser` | `Guid userId`, `Guid? byUserId`, `string reason`, `CancellationToken` | `Task<int>` (rows revoked) |
|
||||
| `RevokeMissionsForAircraft` | `Guid aircraftId`, `CancellationToken` | `Task<int>` (called from `MissionTokenService.Issue` and from any successful aircraft re-login) |
|
||||
| `GetRevokedSince` | `DateTime since`, `CancellationToken` | `Task<IReadOnlyList<RevokedSession>>` (sid, exp, revokedAt, reason) |
|
||||
|
||||
### Service: `IMfaService` *(AZ-534)*
|
||||
|
||||
| Method | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| `Enroll` | `Guid userId`, `string password`, `CancellationToken` | `Task<MfaEnrollResponse>` (otpauth URL, base32 secret, QR PNG bytes — DataProtection-encrypted secret persisted) |
|
||||
| `Confirm` | `Guid userId`, `string code`, `CancellationToken` | `Task` (sets `MfaEnabled=true`, generates and stores hashed recovery codes) |
|
||||
| `Disable` | `Guid userId`, `string password`, `string code`, `CancellationToken` | `Task` |
|
||||
| `IssueMfaStepToken` | `Guid userId` | `string` (short-lived JWT with `mfa_pending`, audience `mfa-step`, signed by active ES256 key) |
|
||||
| `ValidateMfaStepToken` | `string token` | `Guid userId` |
|
||||
| `VerifyForLogin` | `Guid userId`, `string code`, `CancellationToken` | `Task<string[]>` — returns the AMR array (`["pwd","mfa"]` or with `"recovery"` appended); throws `InvalidMfaCode` on failure |
|
||||
|
||||
### Service: `IMissionTokenService` *(AZ-533)*
|
||||
|
||||
| Method | Input | Output | Notes |
|
||||
|--------|-------|--------|-------|
|
||||
| `Issue` | `Guid pilotUserId`, `MissionSessionRequest`, `CancellationToken` | `Task<MissionSessionResponse>` | Validates aircraft is `CompanionPC`; auto-revokes prior mission sessions for the aircraft; inserts session row with `Class = "mission"` BEFORE signing so `sid` is bound; planned duration = absolute lifetime (no refresh). |
|
||||
|
||||
### Service: `IJwtSigningKeyProvider` *(AZ-532)*
|
||||
|
||||
| Member | Output | Notes |
|
||||
|--------|--------|-------|
|
||||
| `Active` | `JwtSigningKey` (`Kid`, `EcdsaSecurityKey SecurityKey`, `ECDsa Ecdsa`) | The signing key. Eager — constructed once at app start so missing/malformed keys fail-fast. |
|
||||
| `All` | `IReadOnlyList<JwtSigningKey>` | Drives `/.well-known/jwks.json` and `IssuerSigningKeyResolver`. All discovered keys are exposed; only `Active` signs. |
|
||||
|
||||
### Service: `IAuditLog` *(AZ-537 + AZ-534)*
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `RecordLoginSuccess(email)` / `RecordLoginFailed(email)` / `RecordLoginLockout(email)` | Persists `audit_events` rows with normalised email + caller IP. |
|
||||
| `RecordMfaEnroll/Confirm/Disable/LoginSuccess/LoginFailed/RecoveryUsed(email)` | One per MFA lifecycle event. |
|
||||
| `CountRecentFailedLogins(email, windowSeconds)` | Backs the per-account sliding-window check in `UserService.ValidateUser`. |
|
||||
|
||||
### Static: `Security` *(AZ-536 — replaces SHA-384)*
|
||||
|
||||
| Method | Input | Output | Description |
|
||||
|--------|-------|--------|-------------|
|
||||
| `ToHash` | `string` | `string` (Base64) | SHA-384 hash |
|
||||
| `GetApiEncryptionKey` | `string email, string password` | `string` (Base64) | Derives the per-user AES encryption key string. **Signature simplified by AZ-197** (`hardwareHash` parameter removed). |
|
||||
| `EncryptTo` | `Stream input, Stream output, string key, CancellationToken` | void | AES-256-CBC encrypt stream |
|
||||
| `DecryptTo` | `Stream encrypted, Stream output, string key, CancellationToken` | void | AES-256-CBC decrypt stream |
|
||||
| `HashPassword` | `string` | `string` (PHC) | Argon2id, parameters from `AuthConfig.PasswordHashing` |
|
||||
| `VerifyPassword` | `string presented`, `string stored` | `VerifyResult` (`Ok`, `NeedsRehash`) | Constant-time; recognizes legacy SHA-384 base64 strings and returns `Ok=true, NeedsRehash=true` so `UserService` can lazy-upgrade |
|
||||
|
||||
**Removed by AZ-197**: `GetHWHash(string hardware)` — no remaining callers in the post-cycle-1 codebase.
|
||||
**Removed**:
|
||||
- `ToHash(string)` — removed by AZ-536. All callers now use `HashPassword` / `VerifyPassword`.
|
||||
- `GetHWHash`, `GetApiEncryptionKey`, `EncryptTo`, `DecryptTo` — removed earlier in cycle 2.
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
N/A — exposed through Admin API.
|
||||
Exposed via Admin API (component 05). Cycle 2 added:
|
||||
|
||||
- `POST /login` — now returns either `LoginResponse` (access + refresh + sid) or `MfaRequiredResponse` (mfa_token only when MFA is enabled). Per-IP sliding-window rate limit applied.
|
||||
- `POST /login/mfa` — completes MFA login (anonymous + per-IP rate limit; the step-1 token is the proof of mid-flow) → `LoginResponse`
|
||||
- `POST /token/refresh` — rotates refresh token + new access token (anonymous; the refresh token IS the proof)
|
||||
- `POST /logout` — revokes the caller's current `sid` (read from the access-token claim). Idempotent.
|
||||
- `POST /logout/all` — revokes every session for the caller's user
|
||||
- `POST /users/me/mfa/enroll` / `confirm` / `disable`
|
||||
- `POST /sessions/{sid:guid}/revoke` *(ApiAdmin)*
|
||||
- `GET /sessions/revoked?since=...` *(verifier role / ApiAdmin via `revocationReaderPolicy`)*
|
||||
- `POST /sessions/mission` *(authenticated; pilot's interactive token)* → mission `LoginResponse`-shaped reply
|
||||
- `GET /.well-known/jwks.json` — anonymous; serves all loaded ES256 public keys (active + retiring); cached 1h.
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
No direct database access. `AuthService.GetCurrentUser` delegates to `IUserService.GetByEmail`.
|
||||
| Service | Tables touched | Pattern |
|
||||
|---------|----------------|---------|
|
||||
| `RefreshTokenService` | `public.sessions` | Insert on issue / rotate; update `RevokedAt`+`RevokedReason` on rotate / reuse-detected; index lookup by `RefreshTokenHash` |
|
||||
| `SessionService` | `public.sessions` | Update by `Sid`; bulk update by `UserId`; range read for revoked-since snapshot |
|
||||
| `MfaService` | `public.users` | Update MFA columns (`MfaEnabled`, `MfaSecret`, `MfaRecoveryCodes`, `MfaEnrolledAt`, `MfaLastUsedWindow`) |
|
||||
| `MissionTokenService` | `public.sessions`, `public.users` | Insert mission session row; lookup aircraft user |
|
||||
| `AuditLog` | `public.audit_events`, `public.users` | Insert events; update `FailedLoginCount` / `LockoutUntil` on the user |
|
||||
| `AuthService` / `UserService` | `public.users` | Reads for current-user resolution and password verify; updates on lazy rehash |
|
||||
|
||||
All tables are LinqToDB-mapped via `AzaionDbShemaHolder`; recovery codes use `jsonb`.
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**Algorithmic Complexity**: Encryption/decryption is O(n) where n is file size, streaming in 512 KB buffers.
|
||||
**Argon2id parameters** (cycle 2 default): time=3, memory=64 MiB, parallelism=2 — overridable via `AuthConfig.PasswordHashing`. Output is a PHC-format string self-describing all parameters; verification re-derives them from the stored value.
|
||||
|
||||
**State Management**: `AuthService` is stateless (reads claims from HTTP context per request). `Security` is purely static.
|
||||
**ES256 keys**: one PEM file per kid in `JwtConfig.KeysFolder`. `ActiveKid` selects the signer; all PEMs with valid `P-256` curves are exposed via JWKS. Rotation procedure: drop a new PEM, set `ActiveKid` to it, restart. Old keys remain in JWKS until physically removed (by ops) so already-issued tokens stay verifiable.
|
||||
|
||||
**Key Dependencies**:
|
||||
**Refresh token format**: opaque random `Base64Url(32 bytes)`. Server stores SHA-256 hash + family id (`Sid`) + `RotatedFromTokenId` to support reuse detection. Sliding window per `SessionConfig.RefreshSlidingHours`; absolute cap per `SessionConfig.RefreshAbsoluteHours`.
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| System.IdentityModel.Tokens.Jwt | 7.1.2 | JWT token generation |
|
||||
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.3 | JWT middleware integration |
|
||||
**Reuse detection**: presenting an already-rotated refresh token revokes the entire family (`Sid`) with reason `RefreshReuseDetected`. The next-snapshot poll picks this up.
|
||||
|
||||
**Error Handling Strategy**:
|
||||
- `EncryptTo` throws `ArgumentNullException` for unreadable streams or empty keys.
|
||||
- JWT token creation does not throw (malformed config would cause runtime errors at middleware level).
|
||||
- `GetCurrentUser` returns null if claims are missing or user not found.
|
||||
**MFA**:
|
||||
- Secret: 20 random bytes → base32; URL `otpauth://totp/Azaion:{email}?secret=...&issuer=Azaion`.
|
||||
- QR: PNG generated with `QRCoder` and returned as bytes (only on enroll).
|
||||
- Recovery codes: 10 codes, each `Argon2id`-hashed before storage. Single-use; checked on `VerifyForLoginAsync` after TOTP fails.
|
||||
- Step-1 token: short-lived JWT (`mfa_pending = true`, audience `mfa-step`) signed by the active ES256 key. Lifetime `JwtConfig.MfaStepTokenLifetimeMinutes`.
|
||||
- Replay defense: persisted `MfaLastUsedWindow` blocks reuse of the same TOTP window within the 30s step.
|
||||
|
||||
**Rate limiting / lockout** (AZ-537):
|
||||
- Per-IP token-bucket via ASP.NET Core `RateLimiter` on `/login`, `/login/mfa`, `/refresh`.
|
||||
- Per-account sliding window via `IAuditLog.CountRecentFailedLoginsAsync`; threshold + window from `AuthConfig.RateLimit`.
|
||||
- Lockout via `LockoutOptions`: N consecutive failures within window → `LockoutUntil` set; subsequent logins throw `AccountLocked` with `RetryAfterSeconds`.
|
||||
|
||||
**HSTS / HTTPS / CORS** (AZ-538):
|
||||
- HSTS enabled in non-Development with the standard 1y `includeSubDomains` policy.
|
||||
- HTTPS redirection in non-Development.
|
||||
- CORS narrowed to the configured admin origins; credentials allowed only for those origins.
|
||||
|
||||
## 6. Extensions and Helpers
|
||||
|
||||
None — `Security` itself is a utility consumed by other components.
|
||||
- `Program.cs` helpers: `ParseSidClaim`, `ParseUserIdClaim` (both throw `InvalidRefreshToken` on malformed/missing claims so handlers don't need to repeat the check).
|
||||
- `BusinessExceptionHandler` adds the `Retry-After` header for `AccountLocked` / `LoginRateLimited`.
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations**:
|
||||
- Password hashing uses SHA-384 without per-user salt or key stretching. Not resistant to rainbow table attacks. (Unchanged by cycle 1.)
|
||||
- The encryption-key salt is a hardcoded constant. (`Security.GetApiEncryptionKey` body — see `services_security.md`.)
|
||||
- `GetCurrentUserEmail` assumes `ClaimTypes.Name` is always present; accessing a missing key would throw `KeyNotFoundException`.
|
||||
- AES encryption prepends IV as first 16 bytes — consumers must know this format.
|
||||
|
||||
**Removed in cycle 1**: hardware fingerprint hashing was a known weakness (static salt, no rotation); deleting it via AZ-197 also removed that attack surface.
|
||||
|
||||
**Performance bottlenecks**:
|
||||
- Large file encryption loads encrypted output into `MemoryStream` before sending — high memory usage for large files.
|
||||
- **Asymmetric key roll-forward only**: revoking a kid means deleting its PEM. There is no per-kid revocation list separate from the file system. Operators must coordinate kid retirement with refresh-token expiry.
|
||||
- **Verifier polling cadence**: `GET /sessions/revoked?since=` returns the snapshot since a timestamp. Verifiers must clock-skew-tolerate by stepping `since` back ~30s. Snapshot rows are pruned only after both `expiry + grace` window has passed.
|
||||
- **MFA recovery codes are single-use**: there is no `regenerate` endpoint in cycle 2. A user who burns all 10 codes and loses their authenticator must contact an admin to disable MFA via `/users/me/mfa/disable` (re-uses password + TOTP, so admin is currently NOT able to disable on behalf of the user — flagged as a follow-up).
|
||||
- **Mission tokens have no refresh**: `planned_duration_h` is the hard cap; expiry is absolute. Aircraft must re-request via the admin path on re-connect.
|
||||
- **Lazy password rehash leak window**: a successful login with a SHA-384 stored hash returns `Ok=true, NeedsRehash=true` and `UserService` re-hashes via Argon2id within the same request. If that update fails (DB error), the legacy hash stays — surfaced via logs but not blocking.
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: Data Layer (for JwtConfig, IUserService).
|
||||
**Must be implemented after**: Data Layer (configs + DB tables `users`, `sessions`, `audit_events`).
|
||||
|
||||
**Can be implemented in parallel with**: User Management (shared dependency on Data Layer).
|
||||
|
||||
**Blocks**: Admin API, Resource Management (uses encryption).
|
||||
**Blocks**: Admin API (every authenticated endpoint), Verifier components (consume `GET /sessions/revoked` and JWKS).
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
No explicit logging in AuthService or Security.
|
||||
- All MFA failures, lockouts, refresh-reuse events, and admin revocations log at `Warning`+ via `IAuditLog` and structured logger.
|
||||
- Successful logins log at `Information`.
|
||||
- Argon2id verification failures log only the audit row (no plaintext, no hash).
|
||||
|
||||
## Modules Covered
|
||||
- `Services/AuthService`
|
||||
- `Services/Security`
|
||||
- `Services/RefreshTokenService`
|
||||
- `Services/SessionService`
|
||||
- `Services/MfaService`
|
||||
- `Services/MissionTokenService`
|
||||
- `Services/JwtSigningKeyProvider`
|
||||
- `Services/AuditLog`
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Resource Management
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — AZ-197 removed the `Hardware` field from `GetResourceRequest` and removed `CheckResourceRequest` and `POST /resources/check` entirely. AZ-183 introduced an OTA update path (`POST /get-update`, `POST /resources/publish`, `IResourceUpdateService`, `Resource` entity, `resources` table, `ResourcesConfig.EncryptionMasterKey`) but it was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete. The component is now back to filesystem-backed storage only.
|
||||
> **Cycle 1 (2026-05-13) note** — AZ-197 removed the `Hardware` field from `GetResourceRequest` and removed `CheckResourceRequest` and `POST /resources/check` entirely. AZ-183 introduced an OTA update path (`POST /get-update`, `POST /resources/publish`, `IResourceUpdateService`, `Resource` entity, `resources` table, `ResourcesConfig.EncryptionMasterKey`) but it was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete.
|
||||
>
|
||||
> **Cycle 2 (2026-05-14) note** — the encrypted-download endpoint (`POST /resources/get/{dataFolder?}`) and both installer endpoints (`GET /resources/get-installer[/stage]`) were removed as obsolete. With them went `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest` (and `WrongResourceName = 50`), and the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` properties + their env-var rows. The component is now upload + list + clear only and no longer depends on Authentication & Security for encryption primitives.
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: filesystem-backed storage — upload, list, download (per-user AES-encrypted), folder clearing, installer distribution. Owned by `IResourcesService`.
|
||||
**Purpose**: filesystem-backed storage — upload, list, clear. Owned by `IResourcesService`.
|
||||
|
||||
**Architectural Pattern**: a single service over the local filesystem. No DB access, no cache.
|
||||
|
||||
**Upstream dependencies**: Data Layer (`ResourcesConfig`), Authentication & Security (encryption via `Security.EncryptTo`).
|
||||
**Upstream dependencies**: Data Layer (`ResourcesConfig`).
|
||||
|
||||
**Downstream consumers**: Admin API (resource endpoints).
|
||||
|
||||
@@ -18,22 +20,18 @@
|
||||
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `GetInstaller` | `bool isStage` | `(string?, Stream?)` | No | None (returns nulls if not found) |
|
||||
| `GetEncryptedResource` | `string? dataFolder, string fileName, string key, CancellationToken` | `Stream` | Yes | `FileNotFoundException` |
|
||||
| `SaveResource` | `string? dataFolder, IFormFile data, CancellationToken` | void | Yes | `BusinessException(NoFileProvided)` |
|
||||
| `ListResources` | `string? dataFolder, string? search, CancellationToken` | `IEnumerable<string>` | Yes | `DirectoryNotFoundException` |
|
||||
| `ClearFolder` | `string? dataFolder` | void | No | None |
|
||||
|
||||
**Input DTO**:
|
||||
```
|
||||
GetResourceRequest (post-AZ-197):
|
||||
Password: string (required, min 8 chars)
|
||||
FileName: string (required, not empty)
|
||||
// Hardware field removed by AZ-197.
|
||||
**Removed**:
|
||||
- `GetEncryptedResource` — removed in cycle 2 with the encrypted-download endpoint.
|
||||
- `GetInstaller` — removed in cycle 2 with the installer endpoints.
|
||||
|
||||
// CheckResourceRequest — REMOVED by AZ-197.
|
||||
// GetUpdateRequest, PublishResourceRequest — added by AZ-183, removed in the post-cycle-1 revert.
|
||||
```
|
||||
**Removed DTOs**:
|
||||
- `GetResourceRequest` — removed in cycle 2 (file deleted).
|
||||
- `CheckResourceRequest` — removed by AZ-197 (cycle 1).
|
||||
- `GetUpdateRequest`, `PublishResourceRequest` — removed in the post-cycle-1 AZ-183 revert.
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
@@ -49,7 +47,7 @@ N/A — exposed through Admin API.
|
||||
|
||||
### Storage Estimates
|
||||
|
||||
- **Filesystem**: AI models, DLLs, installers — potentially hundreds of MB per file.
|
||||
- **Filesystem**: AI models, DLLs, etc. — potentially hundreds of MB per file.
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
@@ -61,32 +59,26 @@ N/A — exposed through Admin API.
|
||||
- `SaveResource` throws `BusinessException(NoFileProvided)` for null uploads.
|
||||
- Missing files/directories throw standard .NET I/O exceptions.
|
||||
- `ClearFolder` silently returns if directory doesn't exist.
|
||||
- `GetInstaller` returns `(null, null)` tuple if installer file is not found.
|
||||
|
||||
## 6. Extensions and Helpers
|
||||
|
||||
| Helper | Purpose | Used By |
|
||||
|--------|---------|---------|
|
||||
| `Security.EncryptTo` | AES stream encryption | `GetEncryptedResource` |
|
||||
| `Security.GetApiEncryptionKey(email, password)` | Per-user key derivation (post-AZ-197 — no hardware component) | Admin API (before calling `GetEncryptedResource`) |
|
||||
None remaining after the cycle-2 removal of `Security.EncryptTo` and `Security.GetApiEncryptionKey`.
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations** (security-audit findings):
|
||||
- **F-2 (High)** — no path traversal protection: `dataFolder` parameter is concatenated directly with `ResourcesFolder`. A malicious `dataFolder` like `../../etc` could access arbitrary filesystem paths. Filed as separate ticket.
|
||||
- `SaveResource` deletes existing file before writing — no versioning or backup.
|
||||
- `GetEncryptedResource` loads the entire encrypted file into a `MemoryStream` — memory-intensive for large files.
|
||||
- `ListResources` wraps a synchronous `DirectoryInfo.GetFiles` in `Task.FromResult` — not truly async.
|
||||
|
||||
**Performance bottlenecks**:
|
||||
- Full file encryption to memory before streaming response: memory usage scales with file size.
|
||||
- `ClearFolder` iterates and deletes files synchronously.
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: Data Layer (ResourcesConfig), Authentication & Security (encryption).
|
||||
**Must be implemented after**: Data Layer (ResourcesConfig).
|
||||
|
||||
**Can be implemented in parallel with**: User Management.
|
||||
**Can be implemented in parallel with**: User Management, Authentication & Security.
|
||||
|
||||
**Blocks**: Admin API.
|
||||
|
||||
@@ -101,6 +93,5 @@ N/A — exposed through Admin API.
|
||||
**Log storage**: console + rolling file (via Serilog configured in Program.cs).
|
||||
|
||||
## Modules Covered
|
||||
- `Services/ResourcesService`
|
||||
- `Common/Requests/GetResourceRequest` (post-AZ-197 — no `CheckResourceRequest`, no `Hardware` field)
|
||||
- `Common/Configs/ResourcesConfig` (the `EncryptionMasterKey` field added by AZ-183 was removed in the post-cycle-1 revert)
|
||||
- `Services/ResourcesService` (post-cycle-2 — only `SaveResource` / `ListResources` / `ClearFolder` remain)
|
||||
- `Common/Configs/ResourcesConfig` (post-cycle-2 — only `ResourcesFolder` remains)
|
||||
|
||||
@@ -2,135 +2,201 @@
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: HTTP API entry point — configures DI, middleware pipeline, authentication, authorization, CORS, Swagger, and defines all REST endpoints using ASP.NET Core Minimal API.
|
||||
**Purpose**: HTTP API entry point — configures DI, middleware pipeline, authentication, authorization, CORS, HSTS, HTTPS redirection, rate limiting, Swagger, DataProtection, and defines all REST endpoints using ASP.NET Core Minimal API.
|
||||
|
||||
**Architectural Pattern**: Composition root + Minimal API endpoints — top-level statements configure the application and map HTTP routes to service methods.
|
||||
**Architectural Pattern**: Composition root + Minimal API endpoints — top-level statements configure the application and map HTTP routes to service methods. A static `IssueDualTokens` helper centralises the access+refresh issuance pattern shared by `/login` (no MFA) and `/login/mfa` (with MFA), and a tiny `ParseSidClaim` / `ParseUserIdClaim` pair extracts session/user identity from the request principal.
|
||||
|
||||
**Upstream dependencies**: User Management (IUserService), Authentication & Security (IAuthService, Security), Resource Management (IResourcesService), Data Layer (IDbFactory, ICache, configs).
|
||||
**Upstream dependencies**: Authentication & Security (AuthService, RefreshTokenService, SessionService, MissionTokenService, MfaService, JwtSigningKeyProvider, AuditLog, Security), User Management (IUserService), Resource Management (IResourcesService), Detection Classes (IDetectionClassService), Data Layer (IDbFactory, ICache, all configs).
|
||||
|
||||
**Downstream consumers**: None (top-level entry point, consumed by HTTP clients).
|
||||
**Downstream consumers**: HTTP clients (admin web UI, verifier services, CompanionPC).
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### BusinessExceptionHandler
|
||||
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `TryHandleAsync` | `HttpContext, Exception, CancellationToken` | `bool` | Yes | None |
|
||||
| Method | Input | Output | Async |
|
||||
|--------|-------|--------|-------|
|
||||
| `TryHandleAsync` | `HttpContext`, `Exception`, `CancellationToken` | `bool` | Yes |
|
||||
|
||||
Converts `BusinessException` to HTTP 409 JSON response: `{ ErrorCode: int, Message: string }`.
|
||||
Cycle 2 (AZ-537 / AZ-531 / AZ-533 / AZ-534 / AZ-535) — the handler now maps `BusinessException` → an exception-specific HTTP status code via a `MapStatusCode` switch, preserves the legacy `409 Conflict` default, and stamps a `Retry-After` response header when `RetryAfterSeconds` is set. It also handles `BadHttpRequestException` → `400 Bad Request` with `{ ErrorCode: 0, Message }` so malformed payloads have a consistent shape with business errors.
|
||||
|
||||
| `ExceptionEnum` | HTTP status |
|
||||
|-----------------|-------------|
|
||||
| `AccountLocked` | `423 Locked` |
|
||||
| `LoginRateLimited` | `429 Too Many Requests` |
|
||||
| `InvalidRefreshToken` / `InvalidMfaCode` / `InvalidMfaToken` | `401 Unauthorized` |
|
||||
| `SessionNotFound` | `404 Not Found` |
|
||||
| `InvalidMissionRequest` / `AircraftNotFound` | `400 Bad Request` |
|
||||
| `MfaAlreadyEnabled` / `MfaNotEnrolling` / `MfaNotEnabled` | `409 Conflict` |
|
||||
| any other | `409 Conflict` (legacy default) |
|
||||
|
||||
### Static helpers in `Program.cs`
|
||||
|
||||
- `IssueDualTokens(user, authService, refreshTokens, sessionService, amr, ct)` — issues a refresh token + an access token, also auto-revokes any open mission sessions if the just-authenticated user is a `CompanionPC` (AZ-533 AC-4).
|
||||
- `ParseSidClaim(ClaimsPrincipal)` / `ParseUserIdClaim(ClaimsPrincipal)` — read `sid` / `nameid` claims; throw `BusinessException(InvalidRefreshToken)` (→ 401) on missing/malformed.
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — endpoints below reflect the post-cycle-1 surface (AZ-513 Detection Classes CRUD, AZ-196 device auto-provisioning, AZ-197 hardware-binding removal). AZ-183 (OTA) shipped in cycle 1 but was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete. For per-endpoint cycle origins see `modules/admin_api_program.md`.
|
||||
> **Cycle 2 (2026-05-14) — auth modernization**: `/login` is now multi-shape (MFA branch); `/login/mfa`, `/token/refresh`, `/logout`, `/logout/all`, `/sessions/*`, `/users/me/mfa/*`, `/.well-known/jwks.json` are all new. The legacy "single JWT" response is preserved as a `Token` getter on `LoginResponse` for compatibility with old clients (= same value as `AccessToken`).
|
||||
|
||||
### Authentication & Sessions
|
||||
|
||||
| Endpoint | Method | Auth | Cycle | Description |
|
||||
|----------|--------|------|-------|-------------|
|
||||
| `/login` | POST | Anonymous | AZ-531/534/537 | Validates credentials. Returns `LoginResponse` (access + refresh + sid) OR `MfaRequiredResponse` (`mfa_required: true`, short-lived `mfa_token`). Per-IP rate limited. |
|
||||
| `/login/mfa` | POST | Anonymous | AZ-534 | Validates the step-1 `mfa_token` + the user's TOTP / recovery code. Returns `LoginResponse`. Per-IP rate limited. |
|
||||
| `/token/refresh` | POST | Anonymous | AZ-531 | Rotates a refresh token. Reuse of a rotated token revokes the entire session family. |
|
||||
| `/logout` | POST | Authenticated | AZ-535 | Revokes the caller's current `sid` (idempotent). |
|
||||
| `/logout/all` | POST | Authenticated | AZ-535 | Revokes every active session for the caller's user. |
|
||||
| `/sessions/{sid:guid}/revoke` | POST | ApiAdmin | AZ-535 | Admin-revoke by session id. |
|
||||
| `/sessions/revoked` | GET | revocationReader (Service or ApiAdmin) | AZ-535 | Verifier-poll snapshot of revoked sessions still within their TTL. `since` is clamped to a 12 h floor to prevent table scans. |
|
||||
| `/sessions/mission` | POST | Authenticated | AZ-533 | Pilot issues a long-lived no-refresh mission token bound to one aircraft + one mission. |
|
||||
| `/.well-known/jwks.json` | GET | Anonymous | AZ-532 | All loaded ES256 public keys (active + retiring). `Cache-Control: public, max-age=3600`. |
|
||||
|
||||
### MFA
|
||||
|
||||
### Authentication
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/login` | POST | Anonymous | Validates credentials, returns JWT |
|
||||
| `/users/me/mfa/enroll` | POST | Authenticated | Starts TOTP enrollment, returns secret + otpauth URL + PNG QR. |
|
||||
| `/users/me/mfa/confirm` | POST | Authenticated | Confirms with a TOTP code. Returns `{ mfaEnabled: true }`. |
|
||||
| `/users/me/mfa/disable` | POST | Authenticated | Requires password + TOTP. Returns `{ mfaEnabled: false }`. |
|
||||
|
||||
### User Management
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/users` | POST | ApiAdmin | Creates a new user |
|
||||
| `/devices` | POST | ApiAdmin | **AZ-196**: provisions a CompanionPC device user (returns serial + email + plaintext password once) |
|
||||
| `/users/current` | GET | Authenticated | Returns current user |
|
||||
| `/users` | GET | ApiAdmin | Lists users (optional email/role filters) |
|
||||
| `/users/queue-offsets/set` | PUT | Authenticated | Updates queue offsets |
|
||||
| `/users/{email}/set-role/{role}` | PUT | ApiAdmin | Changes user role |
|
||||
| `/users/{email}/enable` | PUT | ApiAdmin | Enables user |
|
||||
| `/users/{email}/disable` | PUT | ApiAdmin | Disables user |
|
||||
| `/users/{email}` | DELETE | ApiAdmin | Removes user |
|
||||
|
||||
**Removed by AZ-197**: `PUT /users/hardware/set` (Hardware-binding feature deleted)
|
||||
| `/users` | POST | ApiAdmin | Creates a new user (Argon2id-hashed password, AZ-536). |
|
||||
| `/devices` | POST | ApiAdmin | Provisions a CompanionPC device user (returns serial + email + plaintext password once). |
|
||||
| `/users/current` | GET | Authenticated | Returns current user. |
|
||||
| `/users` | GET | ApiAdmin | Lists users (optional email/role filters). |
|
||||
| `/users/queue-offsets/set` | PUT | Authenticated | Updates queue offsets. |
|
||||
| `/users/{email}/set-role/{role}` | PUT | ApiAdmin | Changes user role. |
|
||||
| `/users/{email}/enable` | PUT | ApiAdmin | Enables user. |
|
||||
| `/users/{email}/disable` | PUT | ApiAdmin | Disables user (revokes all active sessions for that user via `SessionService`). |
|
||||
| `/users/{email}` | DELETE | ApiAdmin | Removes user. |
|
||||
|
||||
### Resource Management
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/resources/{dataFolder?}` | POST | Authenticated | Uploads a file (up to 200 MB) |
|
||||
| `/resources/list/{dataFolder?}` | GET | Authenticated | Lists files |
|
||||
| `/resources/clear/{dataFolder?}` | POST | ApiAdmin | Clears folder |
|
||||
| `/resources/get/{dataFolder?}` | POST | Authenticated | Downloads encrypted resource (key derived from `email + password` only — no Hardware) |
|
||||
| `/resources/get-installer` | GET | Authenticated | Downloads production installer |
|
||||
| `/resources/get-installer/stage` | GET | Authenticated | Downloads staging installer |
|
||||
|
||||
**Removed by AZ-197**: `POST /resources/check` (was the hardware-binding side-effect probe).
|
||||
**Removed in post-cycle-1 revert**: `POST /get-update` and `POST /resources/publish` (AZ-183 reverted — security audit F-1; OTA delivery model itself obsolete).
|
||||
| `/resources/{dataFolder?}` | POST | Authenticated | Uploads a file (up to 200 MB). |
|
||||
| `/resources/list/{dataFolder?}` | GET | Authenticated | Lists files. |
|
||||
| `/resources/clear/{dataFolder?}` | POST | ApiAdmin | Clears folder. |
|
||||
|
||||
### Detection Classes
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/classes` | POST | ApiAdmin | **AZ-513**: creates a detection class |
|
||||
| `/classes/{id:int}` | PATCH | ApiAdmin | **AZ-513**: partial-merge update of a detection class |
|
||||
| `/classes/{id:int}` | DELETE | ApiAdmin | **AZ-513**: deletes a detection class |
|
||||
| `/classes` | POST | ApiAdmin | Creates a detection class. |
|
||||
| `/classes/{id:int}` | PATCH | ApiAdmin | Partial-merge update. |
|
||||
| `/classes/{id:int}` | DELETE | ApiAdmin | Deletes a detection class. |
|
||||
|
||||
### Health
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/health/live` | GET | Anonymous (excluded from Swagger) | Process liveness; never touches DB. |
|
||||
| `/health/ready` | GET | Anonymous (excluded from Swagger) | Pings both DB connections with a 2 s timeout; 503 on failure. |
|
||||
|
||||
### Authorization Policies
|
||||
- **apiAdminPolicy**: requires `ApiAdmin` role (used on most admin endpoints)
|
||||
|
||||
> The `apiUploaderPolicy` was added by AZ-183 and removed in the post-cycle-1 revert along with the OTA endpoints it guarded. `RoleEnum.ResourceUploader` remains as data only.
|
||||
| Policy | Roles | Notes |
|
||||
|--------|-------|-------|
|
||||
| `apiAdminPolicy` | `ApiAdmin` | The "admin endpoints" policy. |
|
||||
| `revocationReaderPolicy` | `Service`, `ApiAdmin` | AZ-535 — verifier services authenticate as `Service`-role identities and are the only callers (besides admin) allowed to read `/sessions/revoked`. |
|
||||
|
||||
### CORS
|
||||
- Allowed origins: `https://admin.azaion.com`, `http://admin.azaion.com`
|
||||
- All methods/headers, credentials allowed
|
||||
> The `apiUploaderPolicy` from AZ-183 was removed in the post-cycle-1 revert. `RoleEnum.ResourceUploader` remains as data only.
|
||||
|
||||
### CORS, HSTS, HTTPS (AZ-538)
|
||||
|
||||
- **CORS** — single origin `https://admin.azaion.com`, `AllowAnyMethod` + `AllowAnyHeader` + `AllowCredentials`. The legacy `http://` origin combined with credentials would have permitted credentialed cleartext traffic; cycle 2 removed it.
|
||||
- **HSTS** — non-Development only: 1 y `MaxAge`, `IncludeSubDomains`, `Preload`.
|
||||
- **HTTPS redirection** — non-Development only. Development skips both so `dotnet watch` on plain HTTP keeps working.
|
||||
|
||||
### Rate limiting (AZ-537)
|
||||
|
||||
- **Per-IP** — ASP.NET Core `RateLimiter` middleware with a `SlidingWindowRateLimiter`. Policy `login-per-ip` is attached to `/login` and `/login/mfa`. Permit limit + window seconds come from `AuthConfig.RateLimit`. Rejection sets `429` and stamps `Retry-After`.
|
||||
- **Per-account** — DB-backed sliding-window check in `UserService.ValidateUser` via `IAuditLog.CountRecentFailedLogins`. Survives process restarts.
|
||||
- **Per-account lockout** — `LockoutOptions` in `AuthConfig`. N consecutive failures → `LockoutUntil`; subsequent logins throw `AccountLocked` with `RetryAfterSeconds`.
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
No direct data access — delegates to service components.
|
||||
No direct data access — delegates to service components. The composition root also fail-fast checks on missing connection strings (`AzaionDb`, `AzaionDbAdmin`) and missing `JwtConfig` (`Issuer` + `Audience` required).
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**State Management**: Stateless — ASP.NET Core request pipeline.
|
||||
|
||||
**DI registrations added in cycle 2**:
|
||||
- `IJwtSigningKeyProvider` (singleton, eager-built before DI so it's the same instance JwtBearer's `IssuerSigningKeyResolver` uses)
|
||||
- `IRefreshTokenService`, `ISessionService`, `IMissionTokenService`, `IMfaService` (scoped)
|
||||
- `IAuditLog` (scoped)
|
||||
- `IDataProtectionProvider` via `AddDataProtection().SetApplicationName("Azaion.AdminApi")` — production deployments MUST set `DataProtection:KeysFolder` to a persistent volume so encrypted MFA secrets survive restarts.
|
||||
|
||||
**Middleware pipeline (cycle 2 order)**:
|
||||
1. `UseSwagger`/`UseSwaggerUI` (Development only)
|
||||
2. `UseHsts` + `UseHttpsRedirection` (non-Development only)
|
||||
3. `UseCors("AdminCorsPolicy")`
|
||||
4. `UseAuthentication`
|
||||
5. `UseAuthorization`
|
||||
6. `UseRateLimiter`
|
||||
7. `UseRewriter` (root → `/swagger`)
|
||||
8. Endpoint mappings
|
||||
9. `UseExceptionHandler` (registered last so the `BusinessExceptionHandler` exception-handler component runs)
|
||||
|
||||
**JWT Bearer config**:
|
||||
- `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` — pinned to ES256 so a token forged with `alg=HS256` using the public key as the HMAC secret cannot pass validation (AZ-532 AC-5).
|
||||
- `IssuerSigningKeyResolver` consults the same `IJwtSigningKeyProvider` instance the rest of the app uses; if the token has a `kid` it's matched, otherwise all loaded keys are returned.
|
||||
- `ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey` all true.
|
||||
|
||||
**Key Dependencies**:
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Swashbuckle.AspNetCore | 10.1.4 | Swagger/OpenAPI documentation |
|
||||
| FluentValidation.AspNetCore | 11.3.0 | Request validation pipeline |
|
||||
| Serilog | 4.1.0 | Structured logging |
|
||||
| Serilog.Sinks.Console | 6.0.0 | Console log output |
|
||||
| Serilog.Sinks.File | 6.0.0 | Rolling file log output |
|
||||
| Library | Purpose |
|
||||
|---------|---------|
|
||||
| Microsoft.AspNetCore.Authentication.JwtBearer | JWT bearer middleware |
|
||||
| Microsoft.AspNetCore.RateLimiting | Per-IP sliding window |
|
||||
| Microsoft.AspNetCore.DataProtection | Encrypt MFA secrets at rest |
|
||||
| Microsoft.AspNetCore.Rewrite | `/` → `/swagger` redirect |
|
||||
| Swashbuckle.AspNetCore | Swagger/OpenAPI |
|
||||
| FluentValidation.AspNetCore | Request validation pipeline |
|
||||
| Serilog | Structured logging (Console + rolling file) |
|
||||
|
||||
**Error Handling Strategy**:
|
||||
- `BusinessException` → `BusinessExceptionHandler` → HTTP 409 with JSON body.
|
||||
- `UnauthorizedAccessException` → thrown in resource endpoints when current user is null.
|
||||
- `FileNotFoundException` → thrown when installer not found.
|
||||
- FluentValidation errors → automatic 400 Bad Request via middleware.
|
||||
- Unhandled exceptions → default ASP.NET Core exception handling.
|
||||
- `BusinessException` → `BusinessExceptionHandler` → per-enum status code (see table above) + optional `Retry-After`.
|
||||
- `BadHttpRequestException` → `400 Bad Request` with `{ ErrorCode: 0, Message }`.
|
||||
- FluentValidation errors → 400 via `Results.ValidationProblem`.
|
||||
- Unhandled → default ASP.NET Core handling.
|
||||
|
||||
## 6. Extensions and Helpers
|
||||
|
||||
None.
|
||||
- `IssueDualTokens` static helper (Program.cs)
|
||||
- `ParseSidClaim` / `ParseUserIdClaim` static helpers (Program.cs)
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations**:
|
||||
- All endpoints are defined in a single `Program.cs` file — no route grouping or controller separation.
|
||||
- Swagger UI only available in Development environment.
|
||||
- CORS origins are hardcoded (not configurable).
|
||||
- Antiforgery disabled for resource upload endpoint.
|
||||
- Root URL (`/`) redirects to `/swagger`.
|
||||
|
||||
**Performance bottlenecks**:
|
||||
- Kestrel max request body: 200 MB — allows large file uploads but could be a memory concern.
|
||||
- All endpoints are still defined in a single `Program.cs` file — cycle 2 added significantly more endpoints; consider splitting into endpoint groups in a future cycle.
|
||||
- Swagger UI only available in Development.
|
||||
- CORS origins are hardcoded — moving to config is a follow-up.
|
||||
- `BusinessExceptionHandler` lives under namespace `Azaion.Common` despite the file path `Azaion.AdminApi/`. Documented as historical accident; do not "fix" without coordinated rename.
|
||||
- Antiforgery disabled on resource upload.
|
||||
- Kestrel max request body 200 MB.
|
||||
- The eager `JwtSigningKeyProvider` construction means a missing or malformed PEM crashes the app at startup. This is intentional — it's safer than serving requests with no signing key.
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: All other components (composition root).
|
||||
|
||||
**Can be implemented in parallel with**: Nothing — depends on all services.
|
||||
|
||||
**Blocks**: Nothing.
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
| Log Level | When | Example |
|
||||
|-----------|------|---------|
|
||||
| WARN | Business exception caught | `BusinessExceptionHandler` logs the exception |
|
||||
| INFO | Serilog minimum level | General application events |
|
||||
| Log Level | When | Notes |
|
||||
|-----------|------|-------|
|
||||
| `Warning` | Business exception caught by `BusinessExceptionHandler` | Includes the full exception |
|
||||
| `Warning` | `BadHttpRequestException` caught | |
|
||||
| `Information` | Default for everything else | Serilog minimum level |
|
||||
|
||||
**Log format**: Serilog structured logging with context enrichment.
|
||||
|
||||
**Log storage**: Console + rolling file (`logs/log.txt`, daily rotation).
|
||||
|
||||
## Modules Covered
|
||||
|
||||
+187
-49
@@ -1,24 +1,72 @@
|
||||
# Azaion Admin API — Data Model
|
||||
|
||||
> **Cycle 2 (2026-05-14) — Auth Modernization**: this doc is rewritten to reflect Postgres state after migrations `07`, `08`, `09`, `10`. Three new tables/columns clusters were added: account-lockout + audit (AZ-537), refresh-token sessions + revocation + mission tokens (AZ-531/535/533), TOTP MFA (AZ-534).
|
||||
|
||||
## Entity-Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USERS {
|
||||
uuid id PK
|
||||
varchar email "unique, not null"
|
||||
varchar password_hash "not null"
|
||||
text hardware "nullable"
|
||||
varchar hardware_hash "nullable"
|
||||
varchar role "not null (text enum)"
|
||||
varchar user_config "nullable (JSON)"
|
||||
timestamp created_at "not null, default now()"
|
||||
timestamp last_login "nullable"
|
||||
bool is_enabled "not null, default true"
|
||||
varchar email "unique"
|
||||
varchar password_hash "Argon2id PHC; legacy SHA-384 base64 lazily upgraded"
|
||||
text hardware "tombstoned (AZ-197)"
|
||||
varchar role
|
||||
varchar user_config "JSON"
|
||||
timestamp created_at
|
||||
timestamp last_login
|
||||
bool is_enabled
|
||||
int failed_login_count "AZ-537"
|
||||
timestamp lockout_until "AZ-537"
|
||||
bool mfa_enabled "AZ-534"
|
||||
text mfa_secret "AZ-534, IDataProtector-encrypted"
|
||||
jsonb mfa_recovery_codes "AZ-534"
|
||||
timestamp mfa_enrolled_at "AZ-534"
|
||||
bigint mfa_last_used_window "AZ-534"
|
||||
}
|
||||
```
|
||||
|
||||
The system has a single table (`users`). There are no foreign key relationships.
|
||||
SESSIONS {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
text refresh_hash "nullable for missions"
|
||||
uuid family_id "AZ-531 reuse-detection key"
|
||||
timestamp issued_at
|
||||
timestamp last_used_at
|
||||
timestamp expires_at
|
||||
timestamp revoked_at
|
||||
varchar revoked_reason
|
||||
uuid parent_session_id FK
|
||||
timestamp family_started_at
|
||||
uuid revoked_by_user_id FK "AZ-535"
|
||||
varchar class "AZ-533: interactive | mission"
|
||||
uuid aircraft_id FK "AZ-533"
|
||||
bool mfa_authenticated "AZ-534"
|
||||
}
|
||||
|
||||
AUDIT_EVENTS {
|
||||
bigserial id PK
|
||||
varchar event_type
|
||||
timestamp occurred_at
|
||||
varchar email
|
||||
varchar ip
|
||||
text metadata
|
||||
}
|
||||
|
||||
DETECTION_CLASSES {
|
||||
int id PK
|
||||
varchar name
|
||||
varchar short_name
|
||||
varchar color
|
||||
double max_size_m
|
||||
varchar photo_mode
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
USERS ||--o{ SESSIONS : owns
|
||||
USERS ||--o{ SESSIONS : "revoked_by (AZ-535)"
|
||||
USERS ||--o{ SESSIONS : "aircraft (AZ-533)"
|
||||
SESSIONS ||--o{ SESSIONS : "rotated_from (AZ-531)"
|
||||
```
|
||||
|
||||
## Table: `users`
|
||||
|
||||
@@ -26,56 +74,147 @@ The system has a single table (`users`). There are no foreign key relationships.
|
||||
|
||||
| Column | Type | Nullable | Default | Description |
|
||||
|--------|------|----------|---------|-------------|
|
||||
| `id` | `uuid` | No | (application-generated) | Primary key, `Guid.NewGuid()` |
|
||||
| `email` | `varchar(160)` | No | — | Unique user identifier |
|
||||
| `password_hash` | `varchar(255)` | No | — | SHA-384 hash, Base64-encoded |
|
||||
| `hardware` | `text` | Yes | null | Raw hardware fingerprint string |
|
||||
| `hardware_hash` | `varchar(120)` | Yes | null | Defined in DDL but not used by application code |
|
||||
| `role` | `varchar(20)` | No | — | Text representation of `RoleEnum` |
|
||||
| `user_config` | `varchar(512)` | Yes | null | JSON-serialized `UserConfig` object |
|
||||
| `created_at` | `timestamp` | No | `now()` | Account creation time |
|
||||
| `last_login` | `timestamp` | Yes | null | Last hardware check / resource access time |
|
||||
| `is_enabled` | `bool` | No | `true` | Account active flag |
|
||||
| `id` | `uuid` | No | (application-generated) | Primary key |
|
||||
| `email` | `varchar(160)` | No | — | Unique (UNIQUE INDEX `users_email_uidx`, security audit F-3) |
|
||||
| `password_hash` | `varchar(255)` | No | — | **AZ-536**: Argon2id PHC string. Legacy SHA-384 base64 strings are accepted on verify and lazily re-hashed to Argon2id on next successful login. |
|
||||
| `hardware` | `text` | Yes | null | TOMBSTONED (AZ-197) |
|
||||
| `role` | `varchar(20)` | No | — | Text representation of `RoleEnum` (now includes `Service` — AZ-535) |
|
||||
| `user_config` | `varchar(512)` | Yes | null | JSON-serialized `UserConfig` |
|
||||
| `created_at` | `timestamp` | No | `now()` | |
|
||||
| `last_login` | `timestamp` | Yes | null | Updated on successful login |
|
||||
| `is_enabled` | `bool` | No | `true` | Setting to `false` triggers `SessionService.RevokeAllForUser` |
|
||||
| `failed_login_count` | `int` | No | `0` | **AZ-537**: incremented on failed login; reset on success or lockout release |
|
||||
| `lockout_until` | `timestamp` | Yes | null | **AZ-537**: UTC; `now() < lockout_until` → `BusinessException(AccountLocked)` with `Retry-After` |
|
||||
| `mfa_enabled` | `boolean` | No | `false` | **AZ-534** |
|
||||
| `mfa_secret` | `text` | Yes | null | **AZ-534**: base32 TOTP secret, IDataProtector-encrypted (purpose `Azaion.Mfa.Secret`), then base64 |
|
||||
| `mfa_recovery_codes` | `jsonb` | Yes | null | **AZ-534**: array of `{ hash: <argon2id>, used_at: <ts|null> }`; single-use enforced by setting `used_at` |
|
||||
| `mfa_enrolled_at` | `timestamp` | Yes | null | **AZ-534** |
|
||||
| `mfa_last_used_window` | `bigint` | Yes | null | **AZ-534**: last accepted RFC 6238 step counter; anti-replay |
|
||||
|
||||
### ORM Mapping (linq2db)
|
||||
### Indexes
|
||||
|
||||
Column names are auto-converted from PascalCase to snake_case via `AzaionDbSchemaHolder`:
|
||||
- `User.PasswordHash` → `password_hash`
|
||||
- `User.CreatedAt` → `created_at`
|
||||
| Index | Type | Columns |
|
||||
|-------|------|---------|
|
||||
| `users_pkey` | PK | `id` |
|
||||
| `users_email_uidx` | UNIQUE | `email` |
|
||||
|
||||
Special mappings:
|
||||
- `Role`: stored as text, converted to/from `RoleEnum` via `Enum.Parse`
|
||||
- `UserConfig`: stored as nullable JSON string, serialized/deserialized via `Newtonsoft.Json`
|
||||
## Table: `sessions` *(AZ-531 + AZ-535 + AZ-533 + AZ-534)*
|
||||
|
||||
One row per issued refresh token. Mission tokens are also rows here (`class='mission'`, `refresh_hash` null).
|
||||
|
||||
### Columns
|
||||
|
||||
| Column | Type | Nullable | Default | Description |
|
||||
|--------|------|----------|---------|-------------|
|
||||
| `id` | `uuid` | No | (application) | PK; used as the JWT `sid` claim |
|
||||
| `user_id` | `uuid` | No | — | FK → `users.id` ON DELETE CASCADE |
|
||||
| `refresh_hash` | `text` | Yes | — | SHA-256 of opaque refresh token. Required for `class='interactive'`; null for `class='mission'` (AZ-533) |
|
||||
| `family_id` | `uuid` | No | — | **AZ-531**: shared by every rotation in the same login session; reuse detection revokes by `family_id` |
|
||||
| `issued_at` | `timestamp` | No | `now()` | |
|
||||
| `last_used_at` | `timestamp` | No | `now()` | Updated on rotate |
|
||||
| `expires_at` | `timestamp` | No | — | Sliding for interactive (`SessionConfig.RefreshSlidingHours`), absolute for mission (`planned_duration_h`) |
|
||||
| `revoked_at` | `timestamp` | Yes | null | Set on rotate (`rotated`), reuse detection (`reuse_detected`), logout (`logged_out`), logout/all (`logged_out_all`), admin revoke (`admin_revoked`), aircraft reconnect (`aircraft_reconnected`), user disable, refresh expiry sweep |
|
||||
| `revoked_reason` | `varchar(64)` | Yes | null | One of `SessionRevokedReasons` constants |
|
||||
| `parent_session_id` | `uuid` | Yes | null | FK → `sessions.id`; rotation chain pointer |
|
||||
| `family_started_at` | `timestamp` | No | `now()` | Hard cap is `family_started_at + RefreshAbsoluteHours` |
|
||||
| `revoked_by_user_id` | `uuid` | Yes | null | **AZ-535**: who revoked (admin id, system, or self for logout) |
|
||||
| `class` | `varchar(32)` | No | `'interactive'` | **AZ-533**: `interactive` or `mission` |
|
||||
| `aircraft_id` | `uuid` | Yes | null | **AZ-533**: FK → `users.id`; only set for `class='mission'` |
|
||||
| `mfa_authenticated` | `boolean` | No | `false` | **AZ-534**: pinned at issue; refresh rotations inherit it |
|
||||
|
||||
### Indexes
|
||||
|
||||
| Index | Type | Columns | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `sessions_pkey` | PK | `id` | |
|
||||
| `sessions_refresh_hash_idx` | UNIQUE | `refresh_hash` | O(1) lookup on rotate; nulls allowed (mission rows) |
|
||||
| `sessions_family_active_idx` | partial | `family_id` WHERE `revoked_at IS NULL` | Reuse-detection family revoke; logout-all |
|
||||
| `sessions_aircraft_active_idx` | partial | `(aircraft_id, class)` WHERE `revoked_at IS NULL AND aircraft_id IS NOT NULL` | **AZ-533** auto-revoke-on-reconnect |
|
||||
| `sessions_revoked_at_idx` | partial | `revoked_at` WHERE `revoked_at IS NOT NULL` | **AZ-535** verifier-poll snapshot |
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- **Issue (interactive)**: `RefreshTokenService.IssueForNewLogin` inserts a row with new `id` and `family_id`; `mfa_authenticated` reflects the login path.
|
||||
- **Rotate**: `RefreshTokenService.Rotate` updates the existing row's `revoked_at`+`revoked_reason='rotated'` and inserts a new row in the same `family_id` with `parent_session_id` pointing to the old row.
|
||||
- **Reuse detected**: presenting a refresh token whose row already has `revoked_reason='rotated'` → the entire `family_id` is revoked with `reason='reuse_detected'`.
|
||||
- **Logout**: `SessionService.RevokeBySid(sid, caller, 'logged_out')`. Idempotent.
|
||||
- **Logout all**: `SessionService.RevokeAllForUser(userId, caller, 'logged_out_all')`.
|
||||
- **Admin revoke**: `SessionService.RevokeBySid(sid, admin, 'admin_revoked')`.
|
||||
- **Mission issue**: `MissionTokenService.Issue` inserts row with `class='mission'`, `aircraft_id` set, `refresh_hash=null`, `expires_at = now + planned_duration_h`. **Before** signing the access token, prior mission rows for that `aircraft_id` are revoked with `reason='aircraft_reconnected'` (also called from successful login of a `CompanionPC` user).
|
||||
|
||||
## Table: `audit_events` *(AZ-537 + AZ-534)*
|
||||
|
||||
Append-only log used by the per-account sliding-window rate limit (AZ-537 AC-2) and as evidence for security audits.
|
||||
|
||||
### Columns
|
||||
|
||||
| Column | Type | Nullable | Default | Description |
|
||||
|--------|------|----------|---------|-------------|
|
||||
| `id` | `bigserial` | No | identity | PK |
|
||||
| `event_type` | `varchar(64)` | No | — | One of: `login_failed`, `login_success`, `login_lockout`, `mfa_enroll`, `mfa_confirm`, `mfa_disable`, `mfa_login_success`, `mfa_login_failed`, `mfa_recovery_used` |
|
||||
| `occurred_at` | `timestamp` | No | `now()` | |
|
||||
| `email` | `varchar(160)` | Yes | null | Lowercase normalised on insert |
|
||||
| `ip` | `varchar(64)` | Yes | null | `HttpContext.Connection.RemoteIpAddress` |
|
||||
| `metadata` | `text` | Yes | null | Reserved (no current writer) |
|
||||
|
||||
### Indexes
|
||||
|
||||
| Index | Columns |
|
||||
|-------|---------|
|
||||
| `audit_events_pkey` | `id` |
|
||||
| `audit_events_event_type_email_idx` | `(event_type, email, occurred_at DESC)` |
|
||||
|
||||
### Permissions
|
||||
|
||||
| Role | Privileges |
|
||||
|------|-----------|
|
||||
| `azaion_reader` | SELECT on `users` |
|
||||
| `azaion_admin` | SELECT, INSERT, UPDATE, DELETE on `users` |
|
||||
| `azaion_superadmin` | Superuser (DB owner) |
|
||||
| `azaion_admin` | INSERT, SELECT, USAGE+SELECT on the sequence |
|
||||
| `azaion_reader` | SELECT |
|
||||
|
||||
### Seed Data
|
||||
> **Retention**: not yet partitioned. With ~50 events/user/day × ~5000 users × 365 d this is ~14 GB/yr; consider time-partition + 90-day archive in a future cycle.
|
||||
|
||||
Two default users (from `env/db/02_structure.sql`):
|
||||
## Table: `detection_classes`
|
||||
|
||||
| Email | Role |
|
||||
|-------|------|
|
||||
| `admin@azaion.com` | `ApiAdmin` |
|
||||
| `uploader@azaion.com` | `ResourceUploader` |
|
||||
Unchanged in cycle 2. See `_docs/03_implementation/batch_06_report.md` for the original AZ-513 spec.
|
||||
|
||||
## ORM Mapping (linq2db)
|
||||
|
||||
Column names auto-converted from PascalCase → snake_case via `AzaionDbSchemaHolder`. Special mappings introduced in cycle 2:
|
||||
|
||||
- `Session.RevokedReason` → enum-like text constants in `SessionRevokedReasons` (string-keyed; not a Postgres enum)
|
||||
- `Session.Class` → string constants in `SessionClasses` (`"interactive"`, `"mission"`)
|
||||
- `User.MfaRecoveryCodes` → `jsonb` via `Newtonsoft.Json` serialization (List<string> on the read path; the persisted shape is `[{ hash, used_at }]`)
|
||||
- `AuditEvent.EventType` → string constants in `AuditEventTypes`
|
||||
- `User.Role` → text via `Enum.Parse` (now also recognises `Service`)
|
||||
|
||||
## Permissions (post-cycle-2)
|
||||
|
||||
| Role | Tables | Notes |
|
||||
|------|--------|-------|
|
||||
| `azaion_reader` | SELECT on `users`, `sessions`, `audit_events`, `detection_classes` | Used by the read-only `IDbFactory.Run` path |
|
||||
| `azaion_admin` | SELECT/INSERT/UPDATE/DELETE on `users`; SELECT/INSERT/UPDATE on `sessions`; SELECT/INSERT on `audit_events`; full DML on `detection_classes` | Used by `IDbFactory.RunAdmin`. Note: no `DELETE` on `sessions` — revocation is logical via `revoked_at` |
|
||||
| `azaion_superadmin` | DB owner | Migrations only |
|
||||
|
||||
## Schema Migration History
|
||||
|
||||
Schema is managed via SQL scripts in `env/db/`:
|
||||
|
||||
1. `00_install.sh` — PostgreSQL installation and configuration
|
||||
2. `01_permissions.sql` — Role creation (superadmin, admin, reader)
|
||||
3. `02_structure.sql` — Table creation + seed data
|
||||
4. `03_add_timestamp_columns.sql` — Adds `created_at`, `last_login`, `is_enabled` columns
|
||||
| File | Cycle | Description |
|
||||
|------|-------|-------------|
|
||||
| `00_install.sh` | baseline | Postgres install + roles |
|
||||
| `01_permissions.sql` | baseline | Role grants |
|
||||
| `02_structure.sql` | baseline | `users` table + seed data (`admin@azaion.com`, `uploader@azaion.com`) |
|
||||
| `03_add_timestamp_columns.sql` | baseline | `created_at`, `last_login`, `is_enabled` |
|
||||
| `04_detection_classes.sql` | cycle 1 (AZ-513) | `detection_classes` |
|
||||
| `06_users_email_unique.sql` | post-cycle-1 | Security audit F-3: UNIQUE on `users.email` |
|
||||
| `07_auth_lockout_and_audit.sql` | cycle 2 (AZ-537) | `users.failed_login_count`, `users.lockout_until`, `audit_events` |
|
||||
| `08_sessions.sql` | cycle 2 (AZ-531) | `sessions` table + indexes |
|
||||
| `09_sessions_logout_and_mission.sql` | cycle 2 (AZ-535+533) | `sessions.revoked_by_user_id`, `class`, `aircraft_id`; relax `refresh_hash NOT NULL`; aircraft + revoked_at indexes |
|
||||
| `10_users_mfa.sql` | cycle 2 (AZ-534) | `users.mfa_*`, `sessions.mfa_authenticated` |
|
||||
|
||||
No ORM migration framework is used. Schema changes are applied manually via SQL scripts.
|
||||
No ORM migration framework is used — scripts are applied in numeric order by `env/db/00_install.sh`. Numbers are not contiguous (`05` is missing) by design — kept as gaps so cherry-picks land in their original slot.
|
||||
|
||||
## UserConfig JSON Schema
|
||||
## UserConfig JSON Schema (unchanged)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -87,10 +226,9 @@ No ORM migration framework is used. Schema changes are applied manually via SQL
|
||||
}
|
||||
```
|
||||
|
||||
Stored in the `user_config` column. Deserialized to `UserConfig` → `UserQueueOffsets` on read. Default empty `UserConfig` is created when the field is null or empty.
|
||||
## Observations / Caveats
|
||||
|
||||
## Observations
|
||||
|
||||
- The `hardware_hash` column exists in the DDL but is not referenced in application code. The application stores the raw hardware string in `hardware` and computes hashes at runtime.
|
||||
- No unique constraint on `email` column in the DDL — uniqueness is enforced at the application level (`UserService.RegisterUser` checks for duplicates before insert).
|
||||
- `user_config` is limited to `varchar(512)`, which could be insufficient if queue offsets grow or additional config fields are added.
|
||||
- `users.user_config` is still `varchar(512)`. With cycle 2 not adding to UserConfig, this is unchanged but remains a future-growth concern.
|
||||
- `sessions.refresh_hash` UNIQUE INDEX accepts multiple NULLs (Postgres semantics) — that's intentional for mission rows.
|
||||
- `audit_events` has no FK to `users` because it must survive user deletion (post-incident forensics).
|
||||
- The `Service` role is data-only on the user table; no provisioning UI exists yet — verifier accounts are seeded out-of-band.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
## Configuration
|
||||
|
||||
### appsettings.json Defaults
|
||||
- `ResourcesConfig`: ResourcesFolder=`"Content"`, SuiteInstallerFolder=`"suite"`, SuiteStageInstallerFolder=`"suite-stage"`
|
||||
- `ResourcesConfig`: ResourcesFolder=`"Content"` (the `SuiteInstallerFolder` / `SuiteStageInstallerFolder` keys were removed in cycle 2 along with the installer endpoints)
|
||||
- `JwtConfig`: Issuer=`"AzaionApi"`, Audience=`"Annotators/OrangePi/Admins"`, TokenLifetimeHours=`4`
|
||||
- `ConnectionStrings` and `JwtConfig.Secret` are NOT in appsettings — must be provided via environment variables
|
||||
|
||||
@@ -25,8 +25,6 @@ Configuration is loaded via ASP.NET Core's `IConfiguration` with the following s
|
||||
| `JwtConfig.Audience` | Token audience | — |
|
||||
| `JwtConfig.TokenLifetimeHours` | Token TTL | — |
|
||||
| `ResourcesConfig.ResourcesFolder` | File storage root | — |
|
||||
| `ResourcesConfig.SuiteInstallerFolder` | Prod installer dir | — |
|
||||
| `ResourcesConfig.SuiteStageInstallerFolder` | Stage installer dir | — |
|
||||
|
||||
## Infrastructure Scripts (`env/`)
|
||||
|
||||
|
||||
@@ -65,11 +65,10 @@ graph TD
|
||||
| # | Component | Modules | Purpose |
|
||||
|---|-----------|---------|---------|
|
||||
| 01 | Data Layer | 9 | DB access, entities, configs, caching |
|
||||
| 02 | User Management | 5 | User CRUD, hardware binding, role management |
|
||||
| 03 | Auth & Security | 2 | JWT tokens, cryptographic utilities |
|
||||
| 04 | Resource Management | 3 | File upload/download/encryption |
|
||||
| 02 | User Management | 5 | User CRUD, role management, device provisioning (hardware binding removed by AZ-197) |
|
||||
| 03 | Auth & Security | 2 | JWT tokens + SHA-384 password hashing (per-user file encryption removed in cycle 2) |
|
||||
| 04 | Resource Management | 2 | File upload / list / clear (encrypted-download + installer endpoints removed in cycle 2) |
|
||||
| 05 | Admin API | 2 | HTTP endpoints, middleware, DI composition |
|
||||
| — | Common Helpers | 6 | Extensions, BusinessException |
|
||||
| — | Tests | 2 | SecurityTest, UserServiceTest |
|
||||
|
||||
**Total**: 27 modules across 5 components + common helpers + tests.
|
||||
**Total**: 26 modules across 5 components + common helpers. The previously listed in-process unit tests (`SecurityTest`, `UserServiceTest`) and the `Azaion.Test` project itself were removed in cycle 2; remaining test coverage lives in `e2e/Azaion.E2E/`.
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
# Flow: Encrypted Resource Download
|
||||
# Flow: Encrypted Resource Download — OBSOLETE
|
||||
|
||||
> **Removed in cycle 2 (2026-05-14).**
|
||||
>
|
||||
> The `POST /resources/get/{dataFolder?}` endpoint, the `ResourcesService.GetEncryptedResource` method, the `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` helpers, the `GetResourceRequest` DTO + validator, and the `ExceptionEnum.WrongResourceName` (50) error code no longer exist. Per-user file encryption is no longer part of the system; resource files are stored as plain bytes and only ever leave the server through upload (`POST /resources/{dataFolder?}`) and admin clear (`POST /resources/clear/{dataFolder?}`).
|
||||
>
|
||||
> See `_docs/02_document/architecture.md` ADR-003 (retired) and `_docs/02_document/system-flows.md` flow F3 (removed) for context.
|
||||
>
|
||||
> This file is retained as a tombstone so historical references resolve. Do not link to it from new docs.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant API as Admin API
|
||||
participant Auth as AuthService
|
||||
participant US as UserService
|
||||
participant Sec as Security
|
||||
participant RS as ResourcesService
|
||||
participant FS as Filesystem
|
||||
|
||||
Client->>API: POST /resources/get {password, hardware, fileName}
|
||||
API->>Auth: GetCurrentUser()
|
||||
Auth-->>API: User
|
||||
API->>US: CheckHardwareHash(user, hardware)
|
||||
US->>Sec: GetHWHash(hardware)
|
||||
Sec-->>US: hash
|
||||
US-->>API: hwHash
|
||||
API->>Sec: GetApiEncryptionKey(email, password, hwHash)
|
||||
Sec-->>API: AES key
|
||||
API->>RS: GetEncryptedResource(folder, fileName, key)
|
||||
RS->>FS: Read file
|
||||
FS-->>RS: FileStream
|
||||
RS->>Sec: EncryptTo(stream, key) [AES-256-CBC]
|
||||
Sec-->>RS: Encrypted MemoryStream
|
||||
RS-->>API: Stream
|
||||
API-->>Client: 200 OK (application/octet-stream)
|
||||
flowchart TD
|
||||
Start([POST /resources/get — REMOVED]) --> Removed[Endpoint deleted in cycle 2]
|
||||
```
|
||||
|
||||
@@ -1,20 +1,92 @@
|
||||
# Flow: User Login
|
||||
# Flow: User Login (dual token + MFA)
|
||||
|
||||
> **Cycle 2 (2026-05-14)**: rebuilt around the AZ-531 + AZ-532 + AZ-534 + AZ-536 + AZ-537 stack. Single-token, SHA-384, HS256 path is gone. See `_docs/02_document/system-flows.md` F1 for the full narrative; this file is the canonical sequence diagram.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Mid as RateLimiter (per-IP, AZ-537)
|
||||
participant API as Admin API
|
||||
participant US as UserService
|
||||
participant Sec as Security (Argon2id, AZ-536)
|
||||
participant AL as AuditLog
|
||||
participant Mfa as MfaService
|
||||
participant RT as RefreshTokenService
|
||||
participant Auth as AuthService (ES256, AZ-532)
|
||||
participant SS as SessionService
|
||||
participant DB as PostgreSQL
|
||||
participant Auth as AuthService
|
||||
|
||||
Client->>API: POST /login {email, password}
|
||||
Client->>Mid: POST /login {email, password}
|
||||
Mid->>Mid: per-IP sliding-window check
|
||||
alt no permits
|
||||
Mid-->>Client: 429 + Retry-After
|
||||
end
|
||||
Mid->>API: forward
|
||||
API->>US: ValidateUser(request)
|
||||
US->>DB: SELECT user WHERE email = ?
|
||||
DB-->>US: User record
|
||||
US->>US: Compare password hash (SHA-384)
|
||||
US-->>API: User entity
|
||||
API->>Auth: CreateToken(user)
|
||||
Auth-->>API: JWT string (HMAC-SHA256)
|
||||
API-->>Client: 200 OK {token}
|
||||
US->>DB: SELECT users WHERE email = ?
|
||||
US->>AL: CountRecentFailedLogins(email, window)
|
||||
alt account locked OR per-account threshold exceeded
|
||||
US-->>API: AccountLocked / LoginRateLimited (RetryAfterSeconds)
|
||||
API-->>Client: 423 / 429 + Retry-After
|
||||
end
|
||||
US->>Sec: VerifyPassword(presented, stored)
|
||||
alt VerifyResult.Ok=false
|
||||
US->>AL: RecordLoginFailed
|
||||
US->>DB: UPDATE failed_login_count++; lockout_until = now + LockoutSeconds (if newly over)
|
||||
US-->>API: WrongPassword (or NoEmailFound)
|
||||
API-->>Client: 409
|
||||
end
|
||||
alt VerifyResult.NeedsRehash=true (legacy SHA-384)
|
||||
US->>Sec: HashPassword (Argon2id)
|
||||
US->>DB: UPDATE password_hash (lazy migrate)
|
||||
end
|
||||
US->>AL: RecordLoginSuccess
|
||||
US->>DB: UPDATE failed_login_count = 0, lockout_until = NULL, last_login = now
|
||||
US-->>API: User
|
||||
|
||||
alt user.MfaEnabled
|
||||
API->>Mfa: IssueMfaStepToken(userId)
|
||||
Mfa-->>API: ES256 JWT (mfa_pending=true, audience=mfa-step, ~5 min)
|
||||
API-->>Client: 200 OK MfaRequiredResponse {mfa_required, mfa_token, expires_in: 300}
|
||||
|
||||
Note over Client,API: --- second factor ---
|
||||
Client->>Mid: POST /login/mfa {mfa_token, code}
|
||||
Mid->>Mid: per-IP sliding-window check
|
||||
Mid->>API: forward
|
||||
API->>Mfa: ValidateMfaStepToken(mfa_token) -> userId
|
||||
API->>US: GetById(userId) -> User
|
||||
API->>Mfa: VerifyForLogin(userId, code)
|
||||
Mfa->>DB: TOTP verify decrypted mfa_secret OR consume recovery code
|
||||
Mfa->>AL: RecordMfaLoginSuccess (or MfaRecoveryUsed)
|
||||
Mfa-->>API: amr = ["pwd","mfa"] (+ "recovery" if used)
|
||||
API->>RT: IssueForNewLogin(userId, mfaAuthenticated=true)
|
||||
RT->>DB: INSERT INTO sessions (id, family_id=id, refresh_hash=SHA256(opaque), expires_at, mfa_authenticated=true)
|
||||
RT-->>API: (opaqueRefreshToken, Session)
|
||||
API->>Auth: CreateToken(user, sid=Session.Id, jti, amr=["pwd","mfa"])
|
||||
Auth-->>API: AccessToken (ES256)
|
||||
opt user.Role == CompanionPC
|
||||
API->>SS: RevokeMissionsForAircraft(user.Id)
|
||||
end
|
||||
API-->>Client: 200 OK LoginResponse {AccessToken, AccessExp, RefreshToken, RefreshExp}
|
||||
else
|
||||
API->>RT: IssueForNewLogin(userId, mfaAuthenticated=false)
|
||||
RT->>DB: INSERT INTO sessions (..., mfa_authenticated=false)
|
||||
RT-->>API: (opaqueRefreshToken, Session)
|
||||
API->>Auth: CreateToken(user, sid=Session.Id, jti, amr=["pwd"])
|
||||
Auth-->>API: AccessToken (ES256)
|
||||
opt user.Role == CompanionPC
|
||||
API->>SS: RevokeMissionsForAircraft(user.Id)
|
||||
end
|
||||
API-->>Client: 200 OK LoginResponse {AccessToken, AccessExp, RefreshToken, RefreshExp}
|
||||
end
|
||||
```
|
||||
|
||||
## Related diagrams (cycle 2)
|
||||
|
||||
- `flow_refresh_token.md` *(see system-flows.md F11)*
|
||||
- `flow_logout_revocation.md` *(see system-flows.md F12)*
|
||||
- `flow_mission_token.md` *(see system-flows.md F13)*
|
||||
- `flow_mfa_lifecycle.md` *(see system-flows.md F14)*
|
||||
- `flow_revocation_snapshot.md` *(see system-flows.md F15)*
|
||||
|
||||
These are documented inline in `system-flows.md` rather than as standalone files; this `flow_login.md` is kept as a separate file because it is referenced from multiple ADRs and the security report.
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
**Language**: csharp
|
||||
**Layout Convention**: solution-flat (legacy — pre-`src/` convention)
|
||||
**Root**: `./` (csproj folders sit at workspace root)
|
||||
**Last Updated**: 2026-05-13
|
||||
**Last Updated**: 2026-05-14 *(cycle 2 Auth Modernization AZ-531..AZ-538; cycle-2 hotfix AZ-552..AZ-557 added `scripts/`, `secrets/`, `env/`, `.env.example` to Admin API Owns)*
|
||||
|
||||
## Layout Rules
|
||||
|
||||
1. This admin/ workspace is one **deployable** (the `Azaion.AdminApi` HTTP service) split across four production csproj projects + one e2e test csproj: `Azaion.AdminApi`, `Azaion.Services`, `Azaion.Common`, `Azaion.Test`, `e2e/Azaion.E2E`.
|
||||
1. This admin/ workspace is one **deployable** (the `Azaion.AdminApi` HTTP service) split across three production csproj projects + one e2e test csproj: `Azaion.AdminApi`, `Azaion.Services`, `Azaion.Common`, `e2e/Azaion.E2E`. (The `Azaion.Test` unit-test project was removed in cycle 2 once its only test class — `SecurityTest.cs` — was deleted along with the encrypted-download stack; no in-process unit tests remain.)
|
||||
2. Existing task specs (`_docs/02_tasks/*/AZ-*.md`) all use `Component: Admin API` as a single coarse identifier covering this entire workspace. The Per-Component Mapping below honors that convention rather than rewriting every task spec.
|
||||
3. The conceptual sub-components documented in `_docs/02_document/components/01_data_layer..05_admin_api/` are **read-time** documentation aids, not write-time ownership boundaries. They are listed under "Conceptual Sub-Components" below for reference only.
|
||||
4. Public API surface = the namespaces / interfaces exposed across csproj boundaries (`I*Service` interfaces in `Azaion.Services`, request DTOs in `Azaion.Common/Requests/`, entities in `Azaion.Common/Entities/`).
|
||||
5. Tests live in `Azaion.Test/` (in-process unit/integration) and `e2e/Azaion.E2E/` (HTTP black-box). Production code never imports from either.
|
||||
5. Tests live in `e2e/Azaion.E2E/` (HTTP black-box). Production code never imports from there.
|
||||
|
||||
## Per-Component Mapping
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
- `Azaion.AdminApi/**`
|
||||
- `Azaion.Services/**`
|
||||
- `Azaion.Common/**`
|
||||
- `Azaion.Test/**`
|
||||
- `e2e/Azaion.E2E/**` (xUnit/HttpClient-based black-box tests)
|
||||
- `e2e/db-init/**` (test-DB seed/init scripts consumed by the e2e harness)
|
||||
- `docker.test/**` (test fixture / schema-init helpers used by `Azaion.Test`)
|
||||
- `docker-compose.test.yml`
|
||||
- `scripts/**` (deploy / lifecycle bash helpers — workspace-root infra)
|
||||
- `secrets/**` (sops + age handover, public env overlays, jwt-keys host dir)
|
||||
- `env/**` (DB schema/install scripts, dev convenience env setters)
|
||||
- `.env.example` (operator-facing runtime env template)
|
||||
- **Public API** (visible to other csprojs within the workspace):
|
||||
- `Azaion.Services/I*Service.cs` interfaces (UserService, AuthService, ResourcesService, …)
|
||||
- `Azaion.Services/Security.cs`, `Azaion.Services/Cache.cs` (used by `Azaion.AdminApi/Program.cs`)
|
||||
@@ -52,12 +54,12 @@ These come from `_docs/02_document/components/` and exist for reading the codeba
|
||||
|
||||
| # | Sub-component | Primary file locations |
|
||||
|---|----------------------|------------------------|
|
||||
| 1 | Data Layer | `Azaion.Common/Database/`, `Azaion.Common/Configs/`, `Azaion.Common/Entities/` (incl. `DetectionClass.cs` added cycle 1; `Resource.cs` added then removed in same cycle — see post-cycle-1 revert) |
|
||||
| 2 | User Management | `Azaion.Services/UserService.cs` (incl. `RegisterDevice` added cycle 1 / AZ-196 — calls `RegisterUser` end-to-end after security-audit consolidation, finding F-3), `Azaion.Common/Requests/Register{User,DeviceResponse}.cs`, `LoginRequest.cs`, `SetUserQueueOffsetsRequest.cs` |
|
||||
| 3 | Auth & Security | `Azaion.Services/AuthService.cs`, `Azaion.Services/Security.cs` (post-AZ-197 — `GetHWHash` removed; signature simplified), `Azaion.Services/Cache.cs` |
|
||||
| 4 | Resource Management | `Azaion.Services/ResourcesService.cs`, `Azaion.Common/Requests/GetResourceRequest.cs` (`SetHWRequest.cs` removed by AZ-197; `ResourceUpdateService.cs` + `GetUpdateRequest.cs` + `PublishResourceRequest.cs` removed when AZ-183 was reverted) |
|
||||
| 1 | Data Layer | `Azaion.Common/Database/`, `Azaion.Common/Configs/` (incl. cycle-2 `AuthConfig.cs` + `JwtConfig.cs` rebuilt for ES256 + new `SessionConfig`), `Azaion.Common/Entities/` (incl. cycle-1 `DetectionClass.cs`; cycle-2 `Session.cs` + `AuditEvent.cs`; `User.cs` extended with lockout + MFA columns; `RoleEnum.cs` + `Service = 60`) |
|
||||
| 2 | User Management | `Azaion.Services/UserService.cs` (cycle-2 — Argon2id verify/hash + lazy migration + lockout + per-account rate-limit checks; new dependencies on `IAuditLog`, `IOptions<AuthConfig>`), `Azaion.Common/Requests/Register{User,DeviceResponse}.cs`, `LoginRequest.cs`, `LoginResponse.cs` *(new — AZ-531)*, `MfaRequests.cs` *(new — AZ-534)*, `MissionSessionRequest.cs` *(new — AZ-533)*, `SetUserQueueOffsetsRequest.cs` |
|
||||
| 3 | Auth & Security | `Azaion.Services/AuthService.cs` (cycle-2 — ES256 + `AccessToken` record + sid/jti/amr claims), `Azaion.Services/Security.cs` (cycle-2 — Argon2id `HashPassword`/`VerifyPassword`; `ToHash` deleted), `Azaion.Services/RefreshTokenService.cs` *(new — AZ-531)*, `Azaion.Services/SessionService.cs` *(new — AZ-535)*, `Azaion.Services/MfaService.cs` *(new — AZ-534)*, `Azaion.Services/MissionTokenService.cs` *(new — AZ-533)*, `Azaion.Services/JwtSigningKeyProvider.cs` *(new — AZ-532)*, `Azaion.Services/AuditLog.cs` *(new — AZ-537)*, `Azaion.Services/Cache.cs` |
|
||||
| 4 | Resource Management | `Azaion.Services/ResourcesService.cs` (`GetResourceRequest.cs` removed in cycle 2 with `POST /resources/get`; `SetHWRequest.cs` removed by AZ-197; `ResourceUpdateService.cs` + `GetUpdateRequest.cs` + `PublishResourceRequest.cs` removed when AZ-183 was reverted) |
|
||||
| 4b | Detection Classes | `Azaion.Services/DetectionClassService.cs` + `Azaion.Common/Requests/{Create,Update}DetectionClassRequest.cs` (added cycle 1 / AZ-513) |
|
||||
| 5 | Admin API (HTTP) | `Azaion.AdminApi/Program.cs`, `Azaion.AdminApi/BusinessExceptionHandler.cs`, `Azaion.AdminApi/appsettings*.json` |
|
||||
| 5 | Admin API (HTTP) | `Azaion.AdminApi/Program.cs` (cycle-2 — significantly expanded: HSTS / HTTPS redirect, RateLimiter, DataProtection, eight new endpoints, `IssueDualTokens` + `ParseSidClaim`/`ParseUserIdClaim` helpers), `Azaion.AdminApi/BusinessExceptionHandler.cs` (cycle-2 — per-enum status mapping + `Retry-After` header), `Azaion.AdminApi/appsettings*.json` |
|
||||
|
||||
## Allowed Dependencies (csproj layering)
|
||||
|
||||
@@ -66,7 +68,6 @@ These come from `_docs/02_document/components/` and exist for reading the codeba
|
||||
| 4. Entry / Host | `Azaion.AdminApi` | `Azaion.Services`, `Azaion.Common` |
|
||||
| 3. Application | `Azaion.Services` | `Azaion.Common` |
|
||||
| 2. Foundation | `Azaion.Common` | (none) |
|
||||
| —. Tests (in-process) | `Azaion.Test` | `Azaion.Services`, `Azaion.Common`, `Azaion.AdminApi` (integration only) |
|
||||
| —. Tests (out-of-process e2e) | `e2e/Azaion.E2E` | (none from production csprojs — HTTP only) |
|
||||
|
||||
A reference from a lower production layer to a higher production layer is an **Architecture** finding (High severity) in `/code-review` Phase 7. Test projects may reference any production csproj; production csprojs may NOT reference test projects.
|
||||
@@ -75,7 +76,7 @@ A reference from a lower production layer to a higher production layer is an **A
|
||||
|
||||
| Language | Root | Per-component path | Public API file | Test path |
|
||||
|----------|------|-------------------|-----------------|-----------|
|
||||
| C# (.NET) | `./` (this workspace, legacy flat layout) | `./<Csproj>/` | namespace-root types in each csproj | `Azaion.Test/`, `e2e/Azaion.E2E/` |
|
||||
| C# (.NET) | `./` (this workspace, legacy flat layout) | `./<Csproj>/` | namespace-root types in each csproj | `e2e/Azaion.E2E/` |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -5,106 +5,143 @@ 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 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 | `/resources/get/{dataFolder?}` | Any authenticated | Downloads an encrypted resource (key derived from `email + password` only) | AZ-197 wire change (no `Hardware` field) |
|
||||
| GET | `/resources/get-installer` | Any authenticated | Downloads latest production installer | — |
|
||||
| GET | `/resources/get-installer/stage` | Any authenticated | Downloads latest staging installer | — |
|
||||
| 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 in cycle 1
|
||||
### Removed endpoints
|
||||
|
||||
The following endpoints were removed during cycle 1 and now return `404`:
|
||||
The following endpoints have been removed and now return `404`:
|
||||
|
||||
| Method | Path | Reason removed |
|
||||
|--------|------|----------------|
|
||||
| PUT | `/users/hardware/set` | AZ-197 — hardware-binding feature deleted (no fielded clients in target architecture) |
|
||||
| POST | `/resources/check` | AZ-197 — was the hardware-binding side-effect probe; no remaining purpose |
|
||||
| POST | `/get-update` | OTA delivery model retired post-cycle-1 (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` | Same revert as `/get-update` — the publish counterpart of the OTA flow |
|
||||
| Method | Path | Removed in | Reason |
|
||||
|--------|------|------------|--------|
|
||||
| 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 paths
|
||||
- `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,30 +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 |
|
||||
| `WrongResourceName` | 50 | Wrong resource file name |
|
||||
| `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). Code 40 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 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
|
||||
@@ -49,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).
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Module: Azaion.Common.Configs.ResourcesConfig
|
||||
|
||||
## Purpose
|
||||
Configuration POCO for file resource storage paths, bound from `appsettings.json` section `ResourcesConfig`.
|
||||
Configuration POCO for the file resource storage root, bound from `appsettings.json` section `ResourcesConfig`.
|
||||
|
||||
> **Cycle 2 (2026-05-14) note** — `SuiteInstallerFolder` and `SuiteStageInstallerFolder` were removed along with the installer endpoints (`GET /resources/get-installer[/stage]`) and `ResourcesService.GetInstaller`. Their `ASPNETCORE_ResourcesConfig__SuiteInstallerFolder` / `__SuiteStageInstallerFolder` env-var rows were removed from `appsettings.json`, `.env.example`, `secrets/staging.public.env`, `secrets/production.public.env`, and `docker-compose.test.yml`.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `ResourcesFolder` | `string` | Root directory for uploaded resource files |
|
||||
| `SuiteInstallerFolder` | `string` | Subdirectory for production installer files |
|
||||
| `SuiteStageInstallerFolder` | `string` | Subdirectory for staging installer files |
|
||||
|
||||
## Internal Logic
|
||||
None — pure data class.
|
||||
@@ -18,7 +18,7 @@ None — pure data class.
|
||||
None.
|
||||
|
||||
## Consumers
|
||||
- `ResourcesService` — uses all three properties to resolve file paths
|
||||
- `ResourcesService` — uses `ResourcesFolder` to resolve upload / list / clear paths
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
@@ -30,7 +30,7 @@ Bound via `builder.Configuration.GetSection(nameof(ResourcesConfig))` in `Progra
|
||||
None.
|
||||
|
||||
## Security
|
||||
Paths control where files are read from and written to on the server's filesystem.
|
||||
Path controls where files are read from and written to on the server's filesystem.
|
||||
|
||||
## Tests
|
||||
None.
|
||||
|
||||
@@ -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 via `UserServiceTest` and `SecurityTest`.
|
||||
Indirectly tested end-to-end via `e2e/Azaion.E2E/Tests/LoginTests.cs`, `UserManagementTests.cs`, `DeviceTests.cs`, `RateLimitLockoutTests.cs`, `MfaEnrollmentTests.cs`, `MfaLoginTests.cs`.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Module: Azaion.Common.Extensions.StreamExtensions
|
||||
|
||||
## Purpose
|
||||
Stream-to-string conversion utility.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `ConvertToString` | `static string ConvertToString(this Stream stream)` | Reads entire stream as UTF-8 string, resets position to 0 afterward |
|
||||
|
||||
## Internal Logic
|
||||
Resets stream position to 0, reads via `StreamReader`, then resets again so the stream remains usable.
|
||||
|
||||
## Dependencies
|
||||
- `System.Text.Encoding`, `System.IO.StreamReader` (BCL only)
|
||||
|
||||
## Consumers
|
||||
- `SecurityTest.EncryptDecryptTest` — converts decrypted stream to string for assertion
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
Indirectly tested via `SecurityTest.EncryptDecryptTest`.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Module: Azaion.Common.Requests.GetResourceRequest
|
||||
|
||||
## Purpose
|
||||
Request DTO and validator for the `POST /resources/get/{dataFolder?}` endpoint. The user's password is supplied per-request so the server can derive the per-user AES encryption key for the response stream.
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — the `Hardware` property and its `BadHardware` validator rule were removed by AZ-197 (admin-side hardware-binding cleanup). The wire-compat policy was "drop entirely" — any client still sending `Hardware` will not see it deserialized. The companion `CheckResourceRequest` was removed along with the `POST /resources/check` endpoint. See `_docs/03_implementation/batch_06_report.md`.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### GetResourceRequest
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Password` | `string` | User's password (used to derive the encryption key) |
|
||||
| `FileName` | `string` | Resource file to retrieve |
|
||||
|
||||
### GetResourceRequestValidator
|
||||
| Rule | Constraint | Error Code |
|
||||
|------|-----------|------------|
|
||||
| `Password` min length | >= 8 chars | `PasswordLengthIncorrect` |
|
||||
| `FileName` not empty | Required | `WrongResourceName` |
|
||||
|
||||
## Internal Logic
|
||||
Validator uses `BusinessException.GetMessage()` to derive user-facing error messages from `ExceptionEnum`.
|
||||
|
||||
## Dependencies
|
||||
- `BusinessException`, `ExceptionEnum`
|
||||
- FluentValidation
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` `POST /resources/get/{dataFolder?}` endpoint
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
- Password is sent in the POST body (not URL) to avoid logging in access logs.
|
||||
- Per-user encryption key derivation now uses `email + password` only (see `services_security.md`).
|
||||
|
||||
## Tests
|
||||
- `e2e/Azaion.E2E/Tests/ResourceTests.cs` (encrypted download / round-trip) — updated by AZ-197 to stop sending `Hardware`
|
||||
@@ -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`, `/resources/get`, `/resources/get-installer`, `/resources/check` — call `GetCurrentUser`
|
||||
|
||||
- `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).
|
||||
@@ -1,23 +1,21 @@
|
||||
# Module: Azaion.Services.ResourcesService
|
||||
|
||||
## Purpose
|
||||
File-based resource management: upload, list, download (encrypted), clear, and installer retrieval from the server's filesystem.
|
||||
File-based resource management: upload, list, and clear files in the server's filesystem.
|
||||
|
||||
> **Cycle 2 (2026-05-14) note** — `GetInstaller` and `GetEncryptedResource` were removed along with the `POST /resources/get/{dataFolder?}` and `GET /resources/get-installer[/stage]` endpoints; the corresponding interface methods, the `Security.EncryptTo` dependency, and the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` properties went with them. The service is now upload + list + clear only.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### IResourcesService
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `GetInstaller` | `(string?, Stream?) GetInstaller(bool isStage)` | Returns the latest installer file (prod or stage) |
|
||||
| `GetEncryptedResource` | `Task<Stream> GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken ct)` | Reads a file and returns it AES-encrypted |
|
||||
| `SaveResource` | `Task SaveResource(string? dataFolder, IFormFile data, CancellationToken ct)` | Saves an uploaded file to the resource folder |
|
||||
| `ListResources` | `Task<IEnumerable<string>> ListResources(string? dataFolder, string? search, CancellationToken ct)` | Lists file names in a resource folder, optionally filtered |
|
||||
| `ClearFolder` | `void ClearFolder(string? dataFolder)` | Deletes all files and subdirectories in the specified folder |
|
||||
|
||||
## Internal Logic
|
||||
- **GetResourceFolder**: resolves the target directory. If `dataFolder` is null/empty, uses `ResourcesConfig.ResourcesFolder` directly; otherwise, appends it as a subdirectory.
|
||||
- **GetInstaller**: scans the installer folder for files matching `"AzaionSuite.Iterative*"`, returns the first match as a `FileStream`.
|
||||
- **GetEncryptedResource**: opens the file, encrypts via `Security.EncryptTo` extension into a `MemoryStream`, returns the encrypted stream.
|
||||
- **SaveResource**: creates the folder if needed, deletes any existing file with the same name, then copies the uploaded file.
|
||||
- **ListResources**: uses `DirectoryInfo.GetFiles` with optional search pattern.
|
||||
- **ClearFolder**: iterates and deletes all files and subdirectories.
|
||||
@@ -26,24 +24,22 @@ File-based resource management: upload, list, download (encrypted), clear, and i
|
||||
- `IOptions<ResourcesConfig>` — folder paths
|
||||
- `ILogger<ResourcesService>` — logs successful saves
|
||||
- `BusinessException` — thrown for null file uploads
|
||||
- `Security.EncryptTo` — stream encryption extension
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` — all `/resources/*` endpoints
|
||||
- `Program.cs` — `POST /resources/{dataFolder?}` (upload), `GET /resources/list/{dataFolder?}`, `POST /resources/clear/{dataFolder?}`
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
Uses `ResourcesConfig` (ResourcesFolder, SuiteInstallerFolder, SuiteStageInstallerFolder).
|
||||
Uses `ResourcesConfig.ResourcesFolder`.
|
||||
|
||||
## External Integrations
|
||||
Local filesystem for resource storage.
|
||||
|
||||
## Security
|
||||
- Resources are encrypted per-user using a key derived from `email + password` (the hardware-hash component was removed by AZ-197 — see `services_security.md`).
|
||||
- File deletion overwrites existing files before writing new ones.
|
||||
- No path traversal protection on `dataFolder` parameter.
|
||||
- No path traversal protection on `dataFolder` parameter (security audit F-2 — open).
|
||||
|
||||
## Tests
|
||||
None at the module level. End-to-end coverage lives in `e2e/Azaion.E2E/Tests/ResourceTests.cs` (encrypted download / round-trip / 200 MB upload limit) — updated by AZ-197 to stop sending the `Hardware` field.
|
||||
End-to-end coverage in `e2e/Azaion.E2E/Tests/ResourceTests.cs` — `File_upload_succeeds` and `Upload_without_file_is_rejected_with_400_or_409_and_60_on_conflict`.
|
||||
|
||||
@@ -1,50 +1,79 @@
|
||||
# Module: Azaion.Services.Security
|
||||
|
||||
## Purpose
|
||||
Static utility class providing cryptographic operations: password hashing, encryption key derivation, and AES-CBC stream encryption/decryption.
|
||||
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 (admin-side hardware-binding cleanup). The hardware-hash component of the derived key is gone; existing ciphertexts produced under the old derivation are no longer re-derivable from the new signature. See `_docs/03_implementation/batch_06_report.md`.
|
||||
> **Cycle 1 (2026-05-13) note** — `GetHWHash` deleted; `GetApiEncryptionKey` simplified by AZ-197.
|
||||
>
|
||||
> **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 |
|
||||
| `GetApiEncryptionKey` | `static string GetApiEncryptionKey(string email, string password)` | Derives the per-user AES encryption key string from email + password (+ static salt) |
|
||||
| `EncryptTo` | `static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken ct)` | AES-256-CBC encrypts a stream; prepends IV to output |
|
||||
| `DecryptTo` | `static async Task DecryptTo(this Stream encryptedStream, Stream toStream, string key, CancellationToken ct)` | Reads IV prefix, then AES-256-CBC decrypts stream |
|
||||
| `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
|
||||
- **Password hashing**: `ToHash` uses SHA-384 with UTF-8 encoding, outputting Base64.
|
||||
- **Encryption key derivation**: `GetApiEncryptionKey` concatenates email and password with the static salt `"-#%@AzaionKey@%#---"`, then hashes via `ToHash` (SHA-384, Base64).
|
||||
- **Encryption**: AES-256-CBC with PKCS7 padding. Key is SHA-256 of the derived key string. IV is randomly generated and prepended to the output stream. Uses 512 KB buffer for streaming.
|
||||
- **Decryption**: Reads the first 16 bytes as IV, then AES-256-CBC decrypts with PKCS7 padding.
|
||||
|
||||
**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` (Aes, SHA256, 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
|
||||
- `Program.cs` `/resources/get/{dataFolder}` endpoint — calls `GetApiEncryptionKey(user.Email, request.Password)`
|
||||
- `ResourcesService.GetEncryptedResource` — uses `EncryptTo` extension
|
||||
- `Azaion.Test/SecurityTest` — directly tests `EncryptTo` / `DecryptTo` round-trips (no longer tests hardware-hash derivation)
|
||||
|
||||
- `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
|
||||
- `BUFFER_SIZE = 524288` (512 KB) — hardcoded streaming buffer size
|
||||
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
|
||||
Core cryptographic module. Key observations:
|
||||
- Passwords are hashed with SHA-384 (no per-user salt, no key stretching — not bcrypt/scrypt/argon2). This is unchanged by AZ-197.
|
||||
- AES encryption uses SHA-256 of the derived key, with random IV per encryption.
|
||||
- All salts/prefixes are hardcoded constants.
|
||||
- Per AZ-197: device hardware fingerprints no longer participate in key derivation. The threat that hardware binding mitigated (credential reuse via desktop installers) was eliminated by the architectural shift to fTPM-secured Jetsons + browser-only SaaS access.
|
||||
|
||||
- Argon2id memory cost (64 MiB) makes GPU bruteforce attacks orders of magnitude slower than the previous SHA-384 path. Each verify costs ~50–200 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
|
||||
- `Azaion.Test/SecurityTest.EncryptDecryptTest` — round-trip encrypt/decrypt of a string
|
||||
- `Azaion.Test/SecurityTest.EncryptDecryptLargeFileTest` — round-trip encrypt/decrypt of a ~400 MB generated file
|
||||
|
||||
- `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,67 +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
|
||||
- `Azaion.Test/UserServiceTest.cs` — unit/integration tests against the live test database (hardware-binding tests removed by AZ-197)
|
||||
- `e2e/Azaion.E2E/Tests/DeviceTests.cs` — e2e for AZ-196 device-provisioning ACs
|
||||
- `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.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Module: Azaion.Test.SecurityTest
|
||||
|
||||
## Purpose
|
||||
xUnit tests for the `Security` encryption/decryption functionality.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `EncryptDecryptTest` | Round-trip encrypt/decrypt of a ~1 KB string; asserts decrypted output matches original |
|
||||
| `EncryptDecryptLargeFileTest` | Round-trip encrypt/decrypt of a ~400 MB generated file; compares SHA-256 hashes of original and decrypted files |
|
||||
|
||||
## Internal Logic
|
||||
- **EncryptDecryptTest**: creates a key via `Security.GetApiEncryptionKey`, encrypts a test string to a `MemoryStream`, decrypts back, compares with `FluentAssertions`.
|
||||
- **EncryptDecryptLargeFileTest**: generates a large JSON file (4M numbers chunked), encrypts, decrypts to a new file, compares file hashes via `SHA256.HashDataAsync`.
|
||||
|
||||
Private helpers:
|
||||
- `CompareFiles` — SHA-256 hash comparison of two files
|
||||
- `CreateLargeFile` — generates a large file by serializing number dictionaries in 100K chunks
|
||||
- `StringToStream` — converts a UTF-8 string to a `MemoryStream`
|
||||
|
||||
## Dependencies
|
||||
- `Security` (encrypt/decrypt)
|
||||
- `StreamExtensions.ConvertToString`
|
||||
- `FluentAssertions`
|
||||
- `Newtonsoft.Json`
|
||||
- xUnit
|
||||
|
||||
## Consumers
|
||||
None — test module.
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
Local filesystem (creates/deletes `large.txt` and `large_decrypted.txt` during large file test).
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
This IS the test module.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Module: Azaion.Test.UserServiceTest
|
||||
|
||||
## Purpose
|
||||
xUnit integration test for `UserService.CheckHardwareHash` against a live PostgreSQL database.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `CheckHardwareHashTest` | Looks up a known user by email, then calls `CheckHardwareHash` with a hardware fingerprint string |
|
||||
|
||||
## Internal Logic
|
||||
- Creates a `DbFactory` with hardcoded connection strings pointing to a remote PostgreSQL instance.
|
||||
- Creates a `UserService` with that factory and a fresh `MemoryCache`.
|
||||
- Fetches user `spielberg@azaion.com`, then calls `CheckHardwareHash` with a specific hardware string.
|
||||
- No assertion — the test only verifies no exception is thrown.
|
||||
|
||||
## Dependencies
|
||||
- `UserService`, `DbFactory`, `MemoryCache`
|
||||
- `ConnectionStrings`, `OptionsWrapper`
|
||||
- xUnit
|
||||
|
||||
## Consumers
|
||||
None — test module.
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
Hardcoded connection strings to `188.245.120.247:4312` (remote database).
|
||||
|
||||
## External Integrations
|
||||
Live PostgreSQL database (remote server).
|
||||
|
||||
## Security
|
||||
Contains hardcoded database credentials in source code. This is a security concern — credentials should be in test configuration or environment variables.
|
||||
|
||||
## Tests
|
||||
This IS the test module.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Documentation Ripple Log — Cycle 2 (Auth Modernization, AZ-531..AZ-538)
|
||||
|
||||
> Generated by `document` skill, Task Step 0.5 (Import-Graph Ripple), 2026-05-14.
|
||||
> Source: cycle-2 implementation report (`_docs/03_implementation/implementation_report_auth_modernization_cycle2.md`).
|
||||
|
||||
## Method
|
||||
|
||||
For each source file changed by the cycle, identified C# namespace consumers via `rg "using Azaion\.<namespace>"`. Resolved consumer csproj membership via `module-layout.md`. Folded transitively-affected component / module docs into the refresh set.
|
||||
|
||||
## Direct + Ripple-affected docs (already refreshed in this cycle)
|
||||
|
||||
| Trigger (changed in cycle 2) | Importing namespaces / files | Doc(s) refreshed | Reason |
|
||||
|------------------------------|------------------------------|------------------|--------|
|
||||
| `Azaion.Services.Security` (Argon2id rebuild — AZ-536) | `UserService`, `MfaService` | `modules/services_security.md`, `modules/services_user_service.md`, `modules/services_mfa_service.md` | API surface changed (`HashPassword`/`VerifyPassword` replace `ToHash`); both consumers had to be re-read |
|
||||
| `Azaion.Services.AuthService` (ES256 — AZ-532) | `Azaion.AdminApi/Program.cs` | `modules/services_auth_service.md`, `modules/admin_api_program.md` | `CreateToken` signature (`sid`, `jti`, `amr`); JWKS publication wired in Program.cs |
|
||||
| `Azaion.Services.RefreshTokenService` (new — AZ-531) | `Program.cs` | `modules/services_refresh_token_service.md` (new), `modules/admin_api_program.md` | New endpoints `/login`, `/login/mfa`, `/token/refresh` consume it |
|
||||
| `Azaion.Services.SessionService` (new — AZ-535) | `Program.cs`, `MissionTokenService`, `UserService.SetEnableStatus` | `modules/services_session_service.md` (new), `modules/admin_api_program.md`, `modules/services_user_service.md`, `modules/services_mission_token_service.md` | `RevokeMissionsForAircraft` called from login/refresh; `RevokeAllForUser` called when user disabled |
|
||||
| `Azaion.Services.MfaService` (new — AZ-534) | `Program.cs` | `modules/services_mfa_service.md` (new), `modules/admin_api_program.md` | New endpoints `/users/me/mfa/{enroll,confirm,disable}` + step-1 token in login |
|
||||
| `Azaion.Services.MissionTokenService` (new — AZ-533) | `Program.cs` | `modules/services_mission_token_service.md` (new), `modules/admin_api_program.md` | `/sessions/mission` |
|
||||
| `Azaion.Services.JwtSigningKeyProvider` (new — AZ-532) | `Program.cs`, `AuthService`, `MfaService` | `modules/services_jwt_signing_key_provider.md` (new), `modules/admin_api_program.md`, `modules/services_auth_service.md`, `modules/services_mfa_service.md` | Eager-built singleton; both JwtBearer `IssuerSigningKeyResolver` and AuthService consume it |
|
||||
| `Azaion.Services.AuditLog` (new — AZ-537+534) | `UserService`, `MfaService`, `Program.cs` (DI only) | `modules/services_audit_log.md` (new), `modules/services_user_service.md`, `modules/services_mfa_service.md` | Per-account rate-limit + lifecycle audit |
|
||||
| `Azaion.Common.Entities.User` (extended — AZ-537+534) | `UserService`, `MfaService`, `RefreshTokenService` (UserId), `SessionService`, `AuthService` | `modules/common_entities_user.md`, all services above | New columns drive new application logic |
|
||||
| `Azaion.Common.Entities.Session` (new — AZ-531+535+533+534) | `RefreshTokenService`, `SessionService`, `MissionTokenService` | `modules/common_entities_session.md` (new); already-listed services | Direct ORM consumer |
|
||||
| `Azaion.Common.Entities.AuditEvent` (new — AZ-537+534) | `AuditLog`, `UserService` | `modules/common_entities_audit_event.md` (new) | Direct ORM consumer |
|
||||
| `Azaion.Common.Entities.RoleEnum` (extended — `Service` — AZ-535) | `Program.cs` (`revocationReaderPolicy`), `UserService` | `modules/common_entities_role_enum.md`, `modules/admin_api_program.md` | Authorization policy gate |
|
||||
| `Azaion.Common.Configs.JwtConfig` (rebuilt — AZ-532) | `Program.cs`, `AuthService`, `MfaService`, `JwtSigningKeyProvider` | `modules/common_configs_jwt_config.md`, downstream services already covered | All ES256-related config |
|
||||
| `Azaion.Common.Configs.AuthConfig` (new — AZ-536+537) | `Program.cs`, `UserService`, `Security` | `modules/common_configs_auth_config.md` (new), downstream covered | Argon2id parameters + rate limit + lockout |
|
||||
| `Azaion.Common.Configs.SessionConfig` (new — AZ-531) | `Program.cs`, `RefreshTokenService` | folded into `modules/common_configs_jwt_config.md` (renamed JwtConfig + SessionConfig), downstream covered | Refresh sliding + absolute lifetimes |
|
||||
| `Azaion.Common.Requests.LoginResponse` / `RefreshTokenRequest` (new — AZ-531) | `Program.cs` | `modules/common_requests_login_response.md` (new), `modules/admin_api_program.md`, `modules/common_requests_login_request.md` (cross-ref note) | New response shape; backward-compat `Token` getter |
|
||||
| `Azaion.Common.Requests.MissionSessionRequest` / `MissionSessionResponse` (new — AZ-533) | `Program.cs`, `MissionTokenService` | `modules/common_requests_mission_session_request.md` (new) | New endpoint payload |
|
||||
| `Azaion.Common.Requests.MfaRequests` (new — AZ-534) | `Program.cs`, `MfaService` | `modules/common_requests_mfa_requests.md` (new) | Five DTOs grouped in one file |
|
||||
| `Azaion.Common.BusinessException` / `ExceptionEnum` (extended — AZ-531+533+534+535+537) | All services + `BusinessExceptionHandler` | `modules/common_business_exception.md`, `modules/admin_api_program.md` (handler section) | New error codes + `Retry-After` header support |
|
||||
| `Azaion.Common.Database.AzaionDb` / `AzaionDbShemaHolder` (extended — Sessions + AuditEvents + jsonb mappings) | all services using them | covered transitively via component 01 Data Layer | New ITables; new mappings |
|
||||
|
||||
## Component-level rollup
|
||||
|
||||
| Component | Refreshed? | Why |
|
||||
|-----------|------------|-----|
|
||||
| 01 Data Layer | yes | `Session`, `AuditEvent`, extended `User`/`RoleEnum`, new `AuthConfig`/`SessionConfig`, rebuilt `JwtConfig`, new ITables, new indexes |
|
||||
| 02 User Management | yes (within `services_user_service.md`) | Argon2id + lockout + rate-limit + audit |
|
||||
| 03 Auth & Security | yes | Major rebuild — full rewrite of `components/03_auth_and_security/description.md` |
|
||||
| 04 Resource Management | no | Cycle 2 auth-modernization did not touch resource code |
|
||||
| 04b Detection Classes | no | Same |
|
||||
| 05 Admin API | yes | Major endpoint surface expansion + middleware pipeline rewrite |
|
||||
|
||||
## System-level docs refreshed
|
||||
|
||||
- `system-flows.md` — F1 rewritten; F11–F17 added; F2/F7/F9 minor edits (Argon2id, session-revoke-on-disable)
|
||||
- `data_model.md` — full rewrite to cover sessions / audit_events / new user columns / migrations / permissions
|
||||
- `architecture.md` — section 1 rewritten, sections 2–7 updated, ADRs 6–9 added
|
||||
- `module-layout.md` — sub-component table refreshed for cycle 2 services
|
||||
- `diagrams/flows/flow_login.md` — full rewrite for the dual-token + MFA model
|
||||
|
||||
## Tests (out-of-process)
|
||||
|
||||
15 new e2e test files under `e2e/Azaion.E2E/Tests/` consume `Azaion.*` namespaces but are out-of-process HTTP tests; they do not have their own module docs by design (per `module-layout.md` §1). They are referenced from each module's "Tests" section.
|
||||
|
||||
## Heuristic / parse-failure notes
|
||||
|
||||
None. The C# `using` graph was directly resolvable for every changed namespace.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `_docs/00_problem/*` — no AC / input-parameter changes from cycle 2 that aren't already captured in the per-task specs
|
||||
- `_docs/04_deploy/*` — deployment ripple (ES256 PEM volume, DataProtection volume, HSTS/HTTPS rollout) is owned by the *deploy* skill (Step 14 of the autodev existing-code flow), not the *document* skill
|
||||
- `_docs/05_security/*` — security report ripple is owned by the *security* skill
|
||||
+498
-103
@@ -1,45 +1,65 @@
|
||||
# Azaion Admin API — System Flows
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — F4 (Hardware Check) was deleted by AZ-197; F3 no longer depends on hardware. Two new flows were added: F8 Detection Classes CRUD (AZ-513), F9 Device Auto-Provisioning (AZ-196). F10 OTA Update Check & Publish (AZ-183) 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. F3's narrative was updated to drop the hardware-check step.
|
||||
> **Cycle 1 (2026-05-13) note** — F4 (Hardware Check) was deleted by AZ-197; F8 Detection Classes (AZ-513), F9 Device Auto-Provisioning (AZ-196) added; F10 OTA reverted after security audit F-1.
|
||||
>
|
||||
> **Cycle 2 — early (2026-05-14)** — F3 (Encrypted Resource Download) and F6 (Installer Download) removed entirely as obsolete. ADR-003 retired.
|
||||
>
|
||||
> **Cycle 2 — Auth Modernization (2026-05-14)** — F1 was rebuilt around the new dual-token + MFA model (AZ-531/532/534/536/537). Six new flows were added: F11 Refresh Token Rotation (AZ-531), F12 Logout / Revocation (AZ-535), F13 Mission Token Issuance (AZ-533), F14 MFA Enrollment & Confirmation (AZ-534), F15 Verifier Revocation Snapshot (AZ-535), F16 Account Lockout & Per-IP Rate Limit (AZ-537). The legacy single-token narrative is no longer accurate.
|
||||
|
||||
## Flow Inventory
|
||||
|
||||
| # | Flow Name | Trigger | Primary Components | Criticality |
|
||||
|---|-----------|---------|-------------------|-------------|
|
||||
| F1 | User Login | POST /login | Admin API, User Mgmt, Auth & Security | High |
|
||||
| F2 | User Registration | POST /users | Admin API, User Mgmt | High |
|
||||
| F3 | Encrypted Resource Download | POST /resources/get | Admin API, Auth, User Mgmt, Resource Mgmt | High |
|
||||
| ~~F4~~ | ~~Hardware Check~~ | ~~POST /resources/check~~ | — | **REMOVED — AZ-197** |
|
||||
| F5 | Resource Upload | POST /resources | Admin API, Resource Mgmt | Medium |
|
||||
| F6 | Installer Download | GET /resources/get-installer | Admin API, Auth, Resource Mgmt | Medium |
|
||||
| F7 | User Management (CRUD) | Various /users/* | Admin API, User Mgmt | Medium |
|
||||
| F8 | Detection Classes CRUD *(AZ-513)* | POST/PATCH/DELETE /classes | Admin API, DetectionClassService | High |
|
||||
| F9 | Device Auto-Provisioning *(AZ-196)* | POST /devices | Admin API, User Mgmt | High |
|
||||
| ~~F10~~ | ~~OTA Update Check & Publish~~ | ~~POST /get-update + POST /resources/publish~~ | — | **REMOVED — post-cycle-1 (AZ-183 reverted, see security audit F-1)** |
|
||||
| F1 | User Login (dual token + MFA) | `POST /login` (+ `/login/mfa`) | Admin API, User Mgmt, Auth & Security | **Critical** |
|
||||
| F2 | User Registration | `POST /users` | Admin API, User Mgmt | High |
|
||||
| ~~F3~~ | ~~Encrypted Resource Download~~ | — | — | **REMOVED — cycle 2 early** |
|
||||
| ~~F4~~ | ~~Hardware Check~~ | — | — | **REMOVED — AZ-197** |
|
||||
| F5 | Resource Upload | `POST /resources` | Admin API, Resource Mgmt | Medium |
|
||||
| ~~F6~~ | ~~Installer Download~~ | — | — | **REMOVED — cycle 2 early** |
|
||||
| F7 | User Management (CRUD) | Various `/users/*` | Admin API, User Mgmt | Medium |
|
||||
| F8 | Detection Classes CRUD | `POST/PATCH/DELETE /classes` | Admin API, DetectionClassService | High |
|
||||
| F9 | Device Auto-Provisioning | `POST /devices` | Admin API, User Mgmt | High |
|
||||
| ~~F10~~ | ~~OTA Update Check & Publish~~ | — | — | **REMOVED — post-cycle-1** |
|
||||
| **F11** | **Refresh Token Rotation** *(AZ-531)* | `POST /token/refresh` | Admin API, RefreshTokenService, AuthService, SessionService | **Critical** |
|
||||
| **F12** | **Logout / Revocation** *(AZ-535)* | `POST /logout`, `/logout/all`, `/sessions/{sid}/revoke` | Admin API, SessionService | High |
|
||||
| **F13** | **Mission Token Issuance** *(AZ-533)* | `POST /sessions/mission` | Admin API, MissionTokenService, SessionService, AuthService | High |
|
||||
| **F14** | **MFA Enrollment & Confirmation** *(AZ-534)* | `POST /users/me/mfa/{enroll,confirm,disable}` | Admin API, MfaService, AuditLog | High |
|
||||
| **F15** | **Verifier Revocation Snapshot** *(AZ-535)* | `GET /sessions/revoked?since=` | Admin API, SessionService | **Critical** for verifier fleet |
|
||||
| **F16** | **Account Lockout & Rate Limit** *(AZ-537)* | (cross-cuts F1) | Admin API rate-limiter middleware, UserService, AuditLog | High |
|
||||
| **F17** | **JWKS Publication** *(AZ-532)* | `GET /.well-known/jwks.json` | Admin API, JwtSigningKeyProvider | **Critical** for verifier fleet |
|
||||
|
||||
## Flow Dependencies
|
||||
|
||||
| Flow | Depends On | Shares Data With |
|
||||
|------|-----------|-----------------|
|
||||
| F1 | — | All other flows (produces JWT token) |
|
||||
| F2 | — | F1, F9 (creates user records — including device users via F9) |
|
||||
| F3 | F1 (requires JWT) | — (post-AZ-197: no hardware-binding dependency) |
|
||||
| F5 | F1 (requires JWT) | F3 (uploaded resources are later downloaded) |
|
||||
| F6 | F1 (requires JWT) | — |
|
||||
| F7 | F1 (requires JWT, ApiAdmin role) | F3 (user data) |
|
||||
| F8 | F1 (requires JWT, ApiAdmin role) | UI Detection Classes table |
|
||||
| F9 | F1 (requires JWT, ApiAdmin role) | F2 (writes a user row, but reuses `RegisterUser` end-to-end), F1 (provisioned devices later log in) |
|
||||
| F1 | F17 (signing keys must exist), F16 (rate limit gate) | F11 (refresh chain), F12 (sid is the revocation key), F14 (MFA branch) |
|
||||
| F2 | — | F1 (created users can log in) |
|
||||
| F5 | F1 / F11 (access token) | — |
|
||||
| F7 | F1 / F11 + ApiAdmin | F12 (disabling a user revokes their sessions) |
|
||||
| F8 | F1 / F11 + ApiAdmin | UI |
|
||||
| F9 | F1 / F11 + ApiAdmin | F1 (provisioned devices later log in) |
|
||||
| F11 | F1 (created the family) | F12 (rotation is the same row store) |
|
||||
| F12 | F1 / F11 (sid claim) | F15 (revoked rows surface here) |
|
||||
| F13 | F1 / F11 (pilot's interactive token) | F12 (auto-revoke prior aircraft mission rows) |
|
||||
| F14 | F1 (caller is authenticated) | F1 (the MFA branch consumes enrolled state) |
|
||||
| F15 | — (verifier role only) | F12 (consumes revocation rows) |
|
||||
| F16 | — | F1, F11 (gates them) |
|
||||
| F17 | — | F1, F11, F13, F14 (every signed token), F15 (verifiers cache JWKS) |
|
||||
|
||||
---
|
||||
|
||||
## Flow F1: User Login
|
||||
## Flow F1: User Login (dual token + MFA) *(rebuilt cycle 2)*
|
||||
|
||||
### Description
|
||||
A user submits email/password credentials. The system validates them against the database and returns a signed JWT token for subsequent authenticated requests.
|
||||
A user submits email/password credentials. The system enforces per-IP and per-account rate limits + lockout (F16), verifies the password with constant-time Argon2id (lazily migrating from SHA-384 if needed — AZ-536), and either:
|
||||
- (no MFA) issues a short-lived ES256 access token + opaque refresh token bound to a new session row, OR
|
||||
- (MFA enabled) issues a short-lived `mfa_token` (JWT, audience `mfa-step`, signed by the active ES256 key) and waits for `POST /login/mfa` to complete the second factor.
|
||||
|
||||
### Preconditions
|
||||
- User account exists in the database
|
||||
- User knows correct password
|
||||
- User account exists, is enabled, and is not within an active lockout window
|
||||
- Per-IP rate-limit bucket has remaining permits
|
||||
- Per-account sliding-window failed-login count is below threshold
|
||||
- For the MFA branch: user has previously enrolled and confirmed MFA (F14)
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
@@ -47,27 +67,84 @@ A user submits email/password credentials. The system validates them against the
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant API as Admin API
|
||||
participant RL as RateLimiter (per-IP, AZ-537)
|
||||
participant US as UserService
|
||||
participant AL as AuditLog
|
||||
participant Sec as Security (Argon2id, AZ-536)
|
||||
participant DB as PostgreSQL
|
||||
participant Mfa as MfaService
|
||||
participant RT as RefreshTokenService
|
||||
participant Auth as AuthService
|
||||
participant SS as SessionService
|
||||
|
||||
Client->>API: POST /login {email, password}
|
||||
API->>RL: per-IP sliding window check
|
||||
alt rate-limited
|
||||
RL-->>Client: 429 + Retry-After
|
||||
end
|
||||
API->>US: ValidateUser(request)
|
||||
US->>DB: SELECT user WHERE email = ?
|
||||
DB-->>US: User record
|
||||
US->>US: Compare password hash
|
||||
US->>DB: SELECT users WHERE email=? (read conn)
|
||||
US->>AL: CountRecentFailedLogins(email, window)
|
||||
alt account locked OR per-account threshold exceeded
|
||||
US-->>API: BusinessException(AccountLocked / LoginRateLimited, RetryAfterSeconds)
|
||||
API-->>Client: 423 / 429 + Retry-After
|
||||
end
|
||||
US->>Sec: VerifyPassword(presented, stored)
|
||||
alt VerifyResult.Ok=false
|
||||
US->>AL: RecordLoginFailed
|
||||
US->>DB: UPDATE failed_login_count, lockout_until
|
||||
US-->>API: WrongPassword (or NoEmailFound)
|
||||
API-->>Client: 409
|
||||
end
|
||||
alt VerifyResult.NeedsRehash=true
|
||||
US->>Sec: HashPassword (Argon2id)
|
||||
US->>DB: UPDATE password_hash (lazy migrate)
|
||||
end
|
||||
US->>AL: RecordLoginSuccess
|
||||
US->>DB: UPDATE failed_login_count=0, last_login=now()
|
||||
US-->>API: User entity
|
||||
API->>Auth: CreateToken(user)
|
||||
Auth-->>API: JWT string
|
||||
API-->>Client: 200 OK {token}
|
||||
|
||||
alt user.MfaEnabled
|
||||
API->>Mfa: IssueMfaStepToken(userId)
|
||||
Mfa-->>API: short-lived JWT (mfa_pending=true)
|
||||
API-->>Client: 200 OK {mfa_required: true, mfa_token, expires_in: 300}
|
||||
else
|
||||
API->>RT: IssueForNewLogin(userId, mfaAuthenticated=false)
|
||||
RT->>DB: INSERT INTO sessions (new id, family_id=id, refresh_hash, expires_at, mfa_authenticated=false)
|
||||
RT-->>API: (opaqueRefreshToken, Session)
|
||||
API->>Auth: CreateToken(user, sessionId=Session.Id, jti=new, amr=["pwd"])
|
||||
Auth-->>API: AccessToken (ES256)
|
||||
opt user.Role == CompanionPC
|
||||
API->>SS: RevokeMissionsForAircraft(user.Id) // F13 / AZ-533 AC-4
|
||||
end
|
||||
API-->>Client: 200 OK LoginResponse {AccessToken, AccessExp, RefreshToken, RefreshExp}
|
||||
end
|
||||
|
||||
Note over Client,API: MFA branch only:
|
||||
Client->>API: POST /login/mfa {mfa_token, code}
|
||||
API->>RL: per-IP sliding window check
|
||||
API->>Mfa: ValidateMfaStepToken(mfa_token) -> userId
|
||||
API->>US: GetById(userId)
|
||||
API->>Mfa: VerifyForLogin(userId, code) -> amr
|
||||
Mfa->>DB: TOTP verify against decrypted mfa_secret OR recovery code consume
|
||||
Mfa->>AL: RecordMfaLoginSuccess (or MfaRecoveryUsed)
|
||||
API->>RT: IssueForNewLogin(userId, mfaAuthenticated=true)
|
||||
API->>Auth: CreateToken(user, sessionId, jti, amr=["pwd","mfa"])
|
||||
API-->>Client: 200 OK LoginResponse
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Email not found | UserService.ValidateUser | No DB record | 409: NoEmailFound (code 10) |
|
||||
| Wrong password | UserService.ValidateUser | Hash mismatch | 409: WrongPassword (code 30) |
|
||||
| Per-IP limit exceeded | Rate-limiter middleware | sliding window | 429 + `Retry-After` |
|
||||
| Account locked | UserService.ValidateUser | `now() < lockout_until` | 423 `AccountLocked` (code 50) + `Retry-After` |
|
||||
| Per-account threshold | UserService.ValidateUser | failed-login count over window | 429 `LoginRateLimited` (code 51) + `Retry-After` |
|
||||
| Email not found | UserService.ValidateUser | No DB record | 409 `NoEmailFound` (code 10) |
|
||||
| Wrong password | UserService.ValidateUser | `VerifyPassword.Ok=false` | 409 `WrongPassword` (code 30) — also increments `failed_login_count` |
|
||||
| User disabled | UserService.ValidateUser | `is_enabled=false` | 409 `UserDisabled` (code 38) |
|
||||
| MFA token invalid | MfaService.ValidateMfaStepToken | bad signature / wrong audience / expired | 401 `InvalidMfaToken` (code 61) |
|
||||
| MFA code wrong | MfaService.VerifyForLogin | TOTP and recovery both miss | 401 `InvalidMfaCode` (code 59) — `mfa_login_failed` audit row |
|
||||
|
||||
---
|
||||
|
||||
@@ -96,7 +173,7 @@ sequenceDiagram
|
||||
API->>US: RegisterUser(request)
|
||||
US->>DB: SELECT user WHERE email = ?
|
||||
DB-->>US: null (no duplicate)
|
||||
US->>US: Hash password (SHA-384)
|
||||
US->>US: Hash password (Argon2id, AZ-536)
|
||||
US->>DB: INSERT user (admin connection)
|
||||
DB-->>US: OK
|
||||
US-->>API: void
|
||||
@@ -112,48 +189,9 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
## Flow F3: Encrypted Resource Download
|
||||
## Flow F3: Encrypted Resource Download — REMOVED (cycle 2, 2026-05-14)
|
||||
|
||||
> **Updated by AZ-197 (2026-05-13)** — the hardware-binding precondition and the `CheckHardwareHash` / `GetHWHash` steps were removed; the encryption key is now derived from `email + password` only. The diagram below reflects the post-cycle-1 path.
|
||||
|
||||
### Description
|
||||
An authenticated user requests a resource file. The system derives a per-user encryption key from email + password, encrypts the file with AES-256-CBC, and streams the encrypted content.
|
||||
|
||||
### Preconditions
|
||||
- User is authenticated (JWT)
|
||||
- Resource file exists on server
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant API as Admin API
|
||||
participant Auth as AuthService
|
||||
participant Sec as Security
|
||||
participant RS as ResourcesService
|
||||
participant FS as Filesystem
|
||||
|
||||
Client->>API: POST /resources/get {password, fileName}
|
||||
API->>Auth: GetCurrentUser()
|
||||
Auth-->>API: User
|
||||
API->>Sec: GetApiEncryptionKey(email, password)
|
||||
Sec-->>API: AES key string
|
||||
API->>RS: GetEncryptedResource(folder, fileName, key)
|
||||
RS->>FS: Read file
|
||||
FS-->>RS: FileStream
|
||||
RS->>Sec: EncryptTo(stream, key)
|
||||
Sec-->>RS: Encrypted MemoryStream
|
||||
RS-->>API: Stream
|
||||
API-->>Client: 200 OK (application/octet-stream)
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Not authenticated | API | No/invalid JWT | 401 Unauthorized |
|
||||
| File not found | ResourcesService | FileStream throws | 500 Internal Server Error |
|
||||
The `POST /resources/get/{dataFolder?}` endpoint and its supporting stack (`Security.GetApiEncryptionKey`, `Security.EncryptTo`, `Security.DecryptTo`, `ResourcesService.GetEncryptedResource`, `GetResourceRequest` DTO + validator, `ExceptionEnum.WrongResourceName` (50)) were removed as obsolete. Per-user file encryption is no longer part of the system; resource files are now stored as plain bytes and only ever leave the server through the upload (F5) and admin clear paths. ADR-003 in `architecture.md` was retired in the same change.
|
||||
|
||||
---
|
||||
|
||||
@@ -195,34 +233,9 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
## Flow F6: Installer Download
|
||||
## Flow F6: Installer Download — REMOVED (cycle 2, 2026-05-14)
|
||||
|
||||
### Description
|
||||
An authenticated user downloads the latest Azaion Suite installer (production or staging).
|
||||
|
||||
### Preconditions
|
||||
- User is authenticated (JWT)
|
||||
- Installer file exists on server
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant API as Admin API
|
||||
participant Auth as AuthService
|
||||
participant RS as ResourcesService
|
||||
participant FS as Filesystem
|
||||
|
||||
Client->>API: GET /resources/get-installer
|
||||
API->>Auth: GetCurrentUser()
|
||||
Auth-->>API: User (not null)
|
||||
API->>RS: GetInstaller(isStage: false)
|
||||
RS->>FS: Scan for AzaionSuite.Iterative*
|
||||
FS-->>RS: FileInfo
|
||||
RS-->>API: (name, FileStream)
|
||||
API-->>Client: 200 OK (application/octet-stream)
|
||||
```
|
||||
The `GET /resources/get-installer` and `GET /resources/get-installer/stage` endpoints, the `ResourcesService.GetInstaller` method, the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` configuration properties, and their environment-variable rows in every config artifact (`appsettings.json`, `.env.example`, `secrets/*.public.env`, `docker-compose.test.yml`) were removed. The installer-shipping era is over in the target architecture (browser SaaS + fTPM Jetsons); installer artefacts are no longer served from the Admin API.
|
||||
|
||||
---
|
||||
|
||||
@@ -234,7 +247,9 @@ Admin operations: list users, change role, enable/disable, update queue offsets,
|
||||
### Preconditions
|
||||
- Caller has ApiAdmin role (for most operations)
|
||||
|
||||
All operations follow the same pattern: API endpoint → UserService method → DbFactory.RunAdmin → PostgreSQL UPDATE/DELETE. Cache is invalidated for affected user keys after writes (the `UpdateQueueOffsets` path is the only remaining cache-invalidation site post-AZ-197).
|
||||
All operations follow the same pattern: API endpoint → UserService method → DbFactory.RunAdmin → PostgreSQL UPDATE/DELETE. Cache is invalidated for affected user keys after writes.
|
||||
|
||||
> **Cycle 2 cross-cut**: `PUT /users/{email}/disable` now also calls `SessionService.RevokeAllForUser` so disabling a user instantly cuts every active session. Verifiers pick this up via F15 within their poll cadence.
|
||||
|
||||
---
|
||||
|
||||
@@ -305,7 +320,7 @@ sequenceDiagram
|
||||
## Flow F9: Device Auto-Provisioning *(AZ-196, 2026-05-13)*
|
||||
|
||||
### Description
|
||||
ApiAdmin requests a fresh CompanionPC device user. The server allocates the next sequential serial (`azj-NNNN`), generates a 32-char hex password, persists the user with the SHA-384 hash, and returns the plaintext credentials exactly once. The provisioning script (out-of-tree) embeds the values into the device's `device.conf`.
|
||||
ApiAdmin requests a fresh CompanionPC device user. The server allocates the next sequential serial (`azj-NNNN`), generates a 32-char hex password, persists the user with an Argon2id hash (cycle 2 — AZ-536), and returns the plaintext credentials exactly once. The provisioning script (out-of-tree) embeds the values into the device's `device.conf`.
|
||||
|
||||
### Preconditions
|
||||
- Caller has ApiAdmin role (`apiAdminPolicy`)
|
||||
@@ -326,7 +341,7 @@ sequenceDiagram
|
||||
US->>US: nextNumber = parse(lastEmail.suffix) + 1 (or 0)
|
||||
US->>US: serial = "azj-" + nextNumber.PadLeft(4)
|
||||
US->>US: password = ToHex(RandomBytes(16)) // 32 hex chars
|
||||
US->>DB: INSERT user {Email=serial@domain, PasswordHash=SHA384(password), Role=CompanionPC, IsEnabled=true} (admin conn)
|
||||
US->>DB: INSERT user {Email=serial@domain, PasswordHash=Argon2id(password), Role=CompanionPC, IsEnabled=true} (admin conn)
|
||||
DB-->>US: OK
|
||||
US-->>API: RegisterDeviceResponse {Serial, Email, Password}
|
||||
API-->>Admin: 200 OK {Serial, Email, Password}
|
||||
@@ -352,3 +367,383 @@ Reasons:
|
||||
2. The OTA delivery model is itself a leftover from the installer-shipping era; the target architecture (browser-only SaaS + fTPM-secured Jetsons) does not need it.
|
||||
|
||||
The `apiUploaderPolicy` definition was removed from `Program.cs`; the `RoleEnum.ResourceUploader` enum value remains as data (the seed `uploader@azaion.com` user still uses it for negative-auth tests) but is no longer wired to any endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Flow F11: Refresh Token Rotation *(AZ-531, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
The client presents an opaque refresh token; the server validates it, rotates it (marks the old row as `revoked_reason='rotated'`), inserts a new row in the same `family_id`, and mints a new ES256 access token. Reuse of an already-rotated token revokes the entire family with `reason='reuse_detected'` (and triggers F15 surfacing for verifiers).
|
||||
|
||||
### Preconditions
|
||||
- Refresh token is well-formed and corresponds to a non-revoked, non-expired session row
|
||||
- The session is within both the sliding window (`SessionConfig.RefreshSlidingHours`) and the absolute cap (`SessionConfig.RefreshAbsoluteHours` measured from `family_started_at`)
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant API as Admin API
|
||||
participant RT as RefreshTokenService
|
||||
participant US as UserService
|
||||
participant Auth as AuthService
|
||||
participant SS as SessionService
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Client->>API: POST /token/refresh {refreshToken}
|
||||
API->>RT: Rotate(opaqueToken)
|
||||
RT->>DB: SELECT * FROM sessions WHERE refresh_hash = SHA256(token)
|
||||
alt row missing
|
||||
RT-->>API: 401 InvalidRefreshToken
|
||||
end
|
||||
alt row.revoked_reason = 'rotated' (reuse!)
|
||||
RT->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='reuse_detected' WHERE family_id = row.family_id AND revoked_at IS NULL
|
||||
RT-->>API: 401 InvalidRefreshToken
|
||||
end
|
||||
alt row.revoked_at IS NOT NULL OR row.expires_at <= now
|
||||
RT-->>API: 401 InvalidRefreshToken
|
||||
end
|
||||
RT->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='rotated', last_used_at=now WHERE id = row.id
|
||||
RT->>DB: INSERT INTO sessions (new id, family_id=row.family_id, refresh_hash=SHA256(newToken), parent_session_id=row.id, expires_at=now+sliding, mfa_authenticated=row.mfa_authenticated)
|
||||
RT-->>API: (newOpaqueToken, newSession)
|
||||
API->>US: GetById(newSession.UserId)
|
||||
US-->>API: User
|
||||
API->>Auth: CreateToken(user, sessionId=newSession.Id, jti=new, amr= ['pwd','mfa'] if mfaAuthenticated else ['pwd'])
|
||||
Auth-->>API: AccessToken
|
||||
opt user.Role == CompanionPC
|
||||
API->>SS: RevokeMissionsForAircraft(user.Id)
|
||||
end
|
||||
API-->>Client: 200 OK LoginResponse {AccessToken, AccessExp, RefreshToken=newOpaqueToken, RefreshExp=newSession.ExpiresAt}
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Token missing / not in DB | RefreshTokenService.Rotate | `SHA256(token)` not found | 401 `InvalidRefreshToken` |
|
||||
| Reuse detected | RefreshTokenService.Rotate | row already `revoked_reason='rotated'` | 401 `InvalidRefreshToken` + entire family revoked (visible via F15) |
|
||||
| Sliding window expired | RefreshTokenService.Rotate | `expires_at <= now()` | 401 `InvalidRefreshToken` |
|
||||
| Absolute cap exceeded | RefreshTokenService.Rotate | `now() - family_started_at > RefreshAbsoluteHours` | 401 `InvalidRefreshToken` |
|
||||
| User missing (race with deletion) | API | `UserService.GetById` returns null | 401 `InvalidRefreshToken` |
|
||||
|
||||
---
|
||||
|
||||
## Flow F12: Logout / Revocation *(AZ-535, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
Three endpoints share `SessionService.RevokeBySid` / `RevokeAllForUser`:
|
||||
- `POST /logout` — revoke caller's current `sid` (idempotent; returns `{ alreadyRevoked }`)
|
||||
- `POST /logout/all` — revoke every active session for the caller's user
|
||||
- `POST /sessions/{sid}/revoke` *(ApiAdmin)* — admin revoke-by-sid
|
||||
|
||||
All revocations write `revoked_at`, `revoked_reason`, and `revoked_by_user_id`; the rows surface to verifiers via F15 within the next poll window.
|
||||
|
||||
### Preconditions
|
||||
- `/logout` / `/logout/all` — caller is authenticated; the access token's `sid` claim is well-formed
|
||||
- `/sessions/{sid}/revoke` — caller is `ApiAdmin`
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant API as Admin API
|
||||
participant SS as SessionService
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over Client,API: Self logout
|
||||
Client->>API: POST /logout (Bearer access)
|
||||
API->>API: ParseSidClaim(user) -> sid
|
||||
API->>API: ParseUserIdClaim(user) -> caller
|
||||
API->>SS: RevokeBySid(sid, caller, 'logged_out')
|
||||
SS->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='logged_out', revoked_by_user_id=caller WHERE id=sid AND revoked_at IS NULL
|
||||
SS-->>API: alreadyRevoked: bool
|
||||
API-->>Client: 200 OK { alreadyRevoked }
|
||||
|
||||
Note over Client,API: Logout-all
|
||||
Client->>API: POST /logout/all
|
||||
API->>SS: RevokeAllForUser(caller, caller, 'logged_out_all')
|
||||
SS->>DB: UPDATE ... WHERE user_id=caller AND revoked_at IS NULL
|
||||
SS-->>API: int (rows revoked)
|
||||
API-->>Client: 200 OK { revoked }
|
||||
|
||||
Note over Client,API: Admin revoke-by-sid
|
||||
Client->>API: POST /sessions/{sid}/revoke (ApiAdmin)
|
||||
API->>SS: RevokeBySid(sid, admin, 'admin_revoked')
|
||||
SS->>DB: UPDATE ... WHERE id=sid AND revoked_at IS NULL
|
||||
SS-->>API: alreadyRevoked: bool
|
||||
API-->>Client: 200 OK { alreadyRevoked }
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Missing/malformed `sid` claim | ParseSidClaim | not a Guid | 401 `InvalidRefreshToken` |
|
||||
| Sid not in DB (admin path) | SessionService.RevokeBySid | row not found | 404 `SessionNotFound` |
|
||||
| Already revoked | SessionService.RevokeBySid | UPDATE affected 0 rows | 200 OK with `alreadyRevoked: true` (idempotent) |
|
||||
|
||||
---
|
||||
|
||||
## Flow F13: Mission Token Issuance *(AZ-533, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
A pilot (an authenticated interactive user) requests a long-lived no-refresh access token bound to one aircraft and one mission. Before signing the token, the server inserts a `class='mission'` session row (so `sid` is bound), and revokes any previously-active mission sessions for that aircraft (`reason='aircraft_reconnected'`).
|
||||
|
||||
### Preconditions
|
||||
- Caller is authenticated (interactive token; AMR can be `["pwd"]` or `["pwd","mfa"]` — F1 follow-up tightens this to require `mfa` once policy is set)
|
||||
- `request.aircraftId` resolves to an existing user with `Role = CompanionPC`
|
||||
- `request.missionId` matches the validation pattern; `request.plannedDurationH` is within bounds
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Pilot
|
||||
participant API as Admin API
|
||||
participant MTS as MissionTokenService
|
||||
participant SS as SessionService
|
||||
participant US as UserService
|
||||
participant Auth as AuthService
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Pilot->>API: POST /sessions/mission {aircraftId, missionId, plannedDurationH, region}
|
||||
API->>MTS: Issue(pilotId, request)
|
||||
MTS->>US: GetById(aircraftId) (read conn)
|
||||
alt aircraft missing or wrong role
|
||||
MTS-->>API: 400 AircraftNotFound
|
||||
end
|
||||
MTS->>SS: RevokeMissionsForAircraft(aircraftId) // AC-4
|
||||
SS->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='aircraft_reconnected' WHERE aircraft_id=? AND class='mission' AND revoked_at IS NULL
|
||||
MTS->>DB: INSERT INTO sessions (id, user_id=aircraftId, class='mission', aircraft_id=aircraftId, refresh_hash=NULL, expires_at=now + plannedDurationH)
|
||||
MTS->>Auth: CreateToken(aircraftUser, sessionId=newSid, jti, amr=['pwd','mission'])
|
||||
Auth-->>MTS: AccessToken
|
||||
MTS-->>API: MissionSessionResponse {access_token, expires_at, mission_id, aircraft_id}
|
||||
API-->>Pilot: 200 OK
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Validation failure | FluentValidation / MissionTokenService | bad `mission_id` pattern, `plannedDurationH` out of bounds | 400 `InvalidMissionRequest` (code 54) |
|
||||
| Aircraft not a CompanionPC | MissionTokenService.Issue | role mismatch | 400 `AircraftNotFound` (code 55) |
|
||||
|
||||
---
|
||||
|
||||
## Flow F14: MFA Enrollment & Confirmation *(AZ-534, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
Three-step user-initiated lifecycle:
|
||||
1. **Enroll** — server generates a new TOTP secret, encrypts it via `IDataProtector` (purpose `Azaion.Mfa.Secret`), persists with `mfa_enabled=false`, returns base32 secret + otpauth URL + QR PNG bytes.
|
||||
2. **Confirm** — client submits a TOTP code; on success server flips `mfa_enabled=true`, generates 10 single-use Argon2id-hashed recovery codes, and returns them once.
|
||||
3. **Disable** — requires both password + a current TOTP; server clears all MFA columns.
|
||||
|
||||
### Preconditions
|
||||
- Caller is authenticated
|
||||
- For Confirm: a prior Enroll call left the encrypted secret on the user
|
||||
- For Disable: `mfa_enabled = true`
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant API as Admin API
|
||||
participant Mfa as MfaService
|
||||
participant DP as IDataProtector
|
||||
participant Sec as Security (Argon2id)
|
||||
participant AL as AuditLog
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over User,API: ENROLL
|
||||
User->>API: POST /users/me/mfa/enroll {password}
|
||||
API->>Mfa: Enroll(userId, password)
|
||||
Mfa->>DB: SELECT user
|
||||
Mfa->>Sec: VerifyPassword(presented, stored)
|
||||
Mfa->>Mfa: Generate 20-byte secret, base32 encode
|
||||
Mfa->>DP: Protect(base32) -> encrypted base64
|
||||
Mfa->>DB: UPDATE users SET mfa_secret = encrypted, mfa_enrolled_at = now, mfa_enabled=false
|
||||
Mfa->>AL: RecordMfaEnroll
|
||||
Mfa-->>API: MfaEnrollResponse { secret_base32, otpauth_url, qr_png }
|
||||
API-->>User: 200 OK
|
||||
|
||||
Note over User,API: CONFIRM
|
||||
User->>API: POST /users/me/mfa/confirm {code}
|
||||
API->>Mfa: Confirm(userId, code)
|
||||
Mfa->>DP: Unprotect(stored) -> base32 secret
|
||||
Mfa->>Mfa: TOTP verify
|
||||
alt code wrong
|
||||
Mfa-->>API: 401 InvalidMfaCode
|
||||
end
|
||||
Mfa->>Mfa: Generate 10 recovery codes
|
||||
Mfa->>Sec: HashPassword each (Argon2id)
|
||||
Mfa->>DB: UPDATE users SET mfa_enabled=true, mfa_recovery_codes = jsonb([{ hash, used_at=null } x10]), mfa_last_used_window=current_step
|
||||
Mfa->>AL: RecordMfaConfirm
|
||||
Mfa-->>API: { recovery_codes: [...] }
|
||||
API-->>User: 200 OK { mfaEnabled: true, recovery_codes }
|
||||
|
||||
Note over User,API: DISABLE
|
||||
User->>API: POST /users/me/mfa/disable {password, code}
|
||||
API->>Mfa: Disable(userId, password, code)
|
||||
Mfa->>Sec: VerifyPassword
|
||||
Mfa->>Mfa: TOTP verify
|
||||
Mfa->>DB: UPDATE users SET mfa_enabled=false, mfa_secret=NULL, mfa_recovery_codes=NULL, mfa_enrolled_at=NULL, mfa_last_used_window=NULL
|
||||
Mfa->>AL: RecordMfaDisable
|
||||
Mfa-->>API: ok
|
||||
API-->>User: 200 OK { mfaEnabled: false }
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Already enrolled (Enroll) | MfaService.Enroll | `mfa_enabled=true` | 409 `MfaAlreadyEnabled` (code 56) |
|
||||
| Not enrolling (Confirm) | MfaService.Confirm | `mfa_secret IS NULL` | 409 `MfaNotEnrolling` (code 57) |
|
||||
| Not enabled (Disable) | MfaService.Disable | `mfa_enabled=false` | 409 `MfaNotEnabled` (code 58) |
|
||||
| Wrong password | Sec.VerifyPassword | hash mismatch | 409 `WrongPassword` (code 30) |
|
||||
| Wrong TOTP code | MfaService TOTP path | code/window miss | 401 `InvalidMfaCode` (code 59) |
|
||||
|
||||
---
|
||||
|
||||
## Flow F15: Verifier Revocation Snapshot *(AZ-535, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
A `Service`-role identity (verifier fleet) polls `GET /sessions/revoked?since={iso8601}` periodically. The server returns every session whose `revoked_at >= since` and `expires_at > now()` so verifiers can deny tokens whose `sid` appears in the snapshot.
|
||||
|
||||
The `since` parameter is **clamped to a 12-hour floor** server-side so a buggy verifier asking for "everything since 1970" doesn't trigger a multi-million-row table scan. Verifiers should clock-skew-tolerate by stepping `since` back ~30s on each poll.
|
||||
|
||||
### Preconditions
|
||||
- Caller has role `Service` or `ApiAdmin` (`revocationReaderPolicy`)
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Verifier
|
||||
participant API as Admin API
|
||||
participant SS as SessionService
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Verifier->>API: GET /sessions/revoked?since=2026-05-14T05:30:00Z
|
||||
API->>API: clamp since to max(now-12h, since)
|
||||
API->>SS: GetRevokedSince(effectiveSince)
|
||||
SS->>DB: SELECT id, expires_at, revoked_at, revoked_reason FROM sessions WHERE revoked_at >= ? AND expires_at > now() ORDER BY revoked_at
|
||||
DB-->>SS: rows (uses sessions_revoked_at_idx)
|
||||
SS-->>API: IReadOnlyList<RevokedSession>
|
||||
API-->>Verifier: 200 OK [{ sid, exp, revokedAt, reason }, ...] + Cache-Control: no-cache
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Wrong role | API authorization | not Service/ApiAdmin | 403 Forbidden |
|
||||
| `since` missing | API | bind null `DateTime?` | clamp falls back to `now-12h` |
|
||||
|
||||
---
|
||||
|
||||
## Flow F16: Account Lockout & Per-IP Rate Limit *(AZ-537, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
Cross-cuts F1 and F11. Two layers:
|
||||
1. **Per-IP** — ASP.NET Core `RateLimiter` middleware (`SlidingWindowRateLimiter`) attached to `/login` and `/login/mfa` via the `login-per-ip` policy. Rejection sets `429` and stamps `Retry-After` from the lease metadata.
|
||||
2. **Per-account + lockout** — DB-backed in `UserService.ValidateUser`:
|
||||
- Read `failed_login_count` and `lockout_until` from `users`.
|
||||
- If `now() < lockout_until` → throw `BusinessException(AccountLocked, RetryAfterSeconds = LockoutUntil - now)`.
|
||||
- Else: count `audit_events` rows where `event_type='login_failed' AND email=? AND occurred_at >= now - PerAccountWindowSeconds`. If over threshold → throw `BusinessException(LoginRateLimited, RetryAfterSeconds = PerAccountWindowSeconds)`.
|
||||
- On wrong password: `RecordLoginFailed` + UPDATE `failed_login_count = failed_login_count + 1`. If new count >= `ConsecutiveFailureThreshold` → set `lockout_until = now + LockoutSeconds`, `RecordLoginLockout`, throw `AccountLocked`.
|
||||
- On success: `RecordLoginSuccess` + UPDATE `failed_login_count = 0`, `lockout_until = NULL`.
|
||||
|
||||
### Preconditions
|
||||
- `AuthConfig.RateLimit.*` and `AuthConfig.Lockout.*` are non-zero
|
||||
- `audit_events` table exists
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Mid as RateLimiter middleware
|
||||
participant API as Admin API
|
||||
participant US as UserService
|
||||
participant AL as AuditLog
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Client->>Mid: POST /login {email, password}
|
||||
Mid->>Mid: SlidingWindow per-IP check
|
||||
alt no permits
|
||||
Mid-->>Client: 429 + Retry-After
|
||||
end
|
||||
Mid->>API: forward
|
||||
API->>US: ValidateUser
|
||||
US->>DB: SELECT users (read)
|
||||
US->>AL: CountRecentFailedLogins(email, window)
|
||||
alt account locked OR threshold exceeded
|
||||
US->>AL: RecordLoginFailed (or RecordLoginLockout if newly locked)
|
||||
US-->>API: BusinessException(AccountLocked / LoginRateLimited, RetryAfterSeconds)
|
||||
API-->>Client: 423 / 429 + Retry-After
|
||||
end
|
||||
US->>US: VerifyPassword
|
||||
alt wrong password
|
||||
US->>AL: RecordLoginFailed
|
||||
US->>DB: UPDATE failed_login_count++; lockout_until = now + LockoutSeconds (if newly over)
|
||||
US-->>API: BusinessException(WrongPassword)
|
||||
API-->>Client: 409
|
||||
end
|
||||
US->>AL: RecordLoginSuccess
|
||||
US->>DB: UPDATE failed_login_count = 0, lockout_until = NULL, last_login = now
|
||||
US-->>API: User
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Per-IP limit | RateLimiter middleware | sliding window | 429 + `Retry-After` |
|
||||
| Account locked | UserService.ValidateUser | `now < lockout_until` | 423 `AccountLocked` + `Retry-After` |
|
||||
| Per-account threshold | UserService.ValidateUser | `audit_events` count over window | 429 `LoginRateLimited` + `Retry-After` |
|
||||
|
||||
---
|
||||
|
||||
## Flow F17: JWKS Publication *(AZ-532, 2026-05-14)*
|
||||
|
||||
### Description
|
||||
`GET /.well-known/jwks.json` (anonymous) returns the JSON Web Key Set containing one entry per loaded ES256 key. Verifiers cache for 1 hour (`Cache-Control: public, max-age=3600`).
|
||||
|
||||
### Preconditions
|
||||
- `JwtConfig.KeysFolder` exists with at least one well-formed P-256 PEM
|
||||
- `JwtConfig.ActiveKid` matches one of the loaded files (the others are still served, allowing verifiers to validate already-issued tokens during a key rotation)
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Verifier
|
||||
participant API as Admin API
|
||||
participant JKP as JwtSigningKeyProvider
|
||||
participant FS as Filesystem
|
||||
|
||||
Note over JKP,FS: At app startup
|
||||
API->>JKP: ctor (eager)
|
||||
JKP->>FS: scan KeysFolder/*.pem
|
||||
JKP->>JKP: validate P-256 curve, build EcdsaSecurityKey list
|
||||
JKP-->>API: ready (or fail-fast if 0 keys)
|
||||
|
||||
Note over Verifier,API: Per-poll
|
||||
Verifier->>API: GET /.well-known/jwks.json
|
||||
API->>JKP: All
|
||||
JKP-->>API: list of JwtSigningKey
|
||||
API->>API: project to JWK { kty:EC, crv:P-256, kid, use:sig, alg:ES256, x, y }
|
||||
API-->>Verifier: 200 OK { keys: [...] } + Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| No keys / malformed PEM | JwtSigningKeyProvider ctor | startup crash (intentional) | Operator fix + restart |
|
||||
| Wrong curve in PEM | JwtSigningKeyProvider ctor | startup crash | Operator fix + restart |
|
||||
|
||||
> **Rotation procedure**: drop a new PEM into `KeysFolder`, set `JwtConfig:ActiveKid` to the new kid, restart. Already-issued tokens remain verifiable until their `exp`. Old PEMs are physically removed only after the longest possible token TTL has elapsed.
|
||||
|
||||
@@ -184,51 +184,17 @@
|
||||
|
||||
---
|
||||
|
||||
### FT-P-09: Download Encrypted Resource
|
||||
### FT-P-09: Download Encrypted Resource — OBSOLETE (cycle 2, 2026-05-14)
|
||||
|
||||
**Summary**: Authenticated user downloads an encrypted resource file.
|
||||
**Traces to**: AC-14, AC-18
|
||||
**Category**: Resource Distribution
|
||||
The `POST /resources/get/{dataFolder?}` endpoint, the `Security.GetApiEncryptionKey` / `EncryptTo` helpers, the `ResourcesService.GetEncryptedResource` method, the `GetResourceRequest` DTO, and the e2e tests `Encrypted_download_returns_octet_stream_and_non_empty_body` (in `ResourceTests.cs`) and `Per_user_encryption_produces_distinct_ciphertext_for_same_file` (in `SecurityTests.cs`) were all removed. The endpoint now returns 404 — verified by FT-N-16 below.
|
||||
|
||||
**Preconditions**:
|
||||
- User authenticated, hardware bound, resource file uploaded
|
||||
|
||||
**Input data**: `{"password":"validpwd1","hardware":"test-hw-001","fileName":"test.txt"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /resources/get with credentials | HTTP 200, Content-Type: application/octet-stream, non-empty body |
|
||||
|
||||
**Expected outcome**: HTTP 200 with encrypted binary content
|
||||
**Max execution time**: 10s
|
||||
ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun.
|
||||
|
||||
---
|
||||
|
||||
### FT-P-10: Encryption Round-Trip Verification
|
||||
### FT-P-10: Encryption Round-Trip Verification — OBSOLETE (cycle 2, 2026-05-14)
|
||||
|
||||
**Summary**: Downloaded encrypted resource decrypts to original file content.
|
||||
**Traces to**: AC-15, AC-19
|
||||
**Category**: Resource Distribution
|
||||
|
||||
**Preconditions**:
|
||||
- Known file uploaded, user credentials known
|
||||
|
||||
**Input data**: Original file content, user email, password, hardware hash
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | Upload known file | HTTP 200 |
|
||||
| 2 | Download encrypted file via API | HTTP 200, encrypted bytes |
|
||||
| 3 | Derive AES key from email + password + hwHash | Key bytes |
|
||||
| 4 | Decrypt downloaded content with derived key | Decrypted bytes |
|
||||
| 5 | Compare decrypted bytes with original | Byte-level equality |
|
||||
|
||||
**Expected outcome**: Decrypted content matches original file exactly
|
||||
**Max execution time**: 10s
|
||||
Same removal as FT-P-09. Additionally `Security.DecryptTo` and the e2e test `Encryption_round_trip_decrypt_matches_original_bytes` (in `ResourceTests.cs`) are gone. ID retained for traceability stability.
|
||||
|
||||
---
|
||||
|
||||
@@ -487,12 +453,38 @@ The following legacy entries describe behaviour removed by AZ-197 (admin-side ha
|
||||
- FT-P-04 (First Hardware Check Stores Fingerprint) — superseded; the `POST /resources/check` endpoint and the hardware-store side-effect were removed.
|
||||
- FT-P-05 (Subsequent Hardware Check Matches) — superseded; same endpoint removed.
|
||||
- FT-N-06 (Hardware Mismatch) — superseded; the `HardwareIdMismatch` / error code 40 path no longer exists in `ExceptionEnum`.
|
||||
- FT-P-09 / FT-P-10 wire shape — the `hardware` field on `POST /resources/get/{dataFolder}` is no longer required; the encryption key is now derived from `email + password` only. The tests still pass without the field; do not regenerate spec bodies until a full `/test-spec` rerun.
|
||||
- FT-P-09 / FT-P-10 — fully obsolete after the cycle-2 cleanup; the endpoint, support code, and corresponding e2e tests are gone (see the FT-P-09 / FT-P-10 stubs above and FT-N-16 below).
|
||||
|
||||
See `_docs/03_implementation/batch_06_report.md` for the full AZ-197 implementation rationale and the wire-compat policy decision (drop entirely).
|
||||
|
||||
---
|
||||
|
||||
### Cycle-2 Cleanup (2026-05-14) — Obsolete Resource Endpoints Removed
|
||||
|
||||
#### FT-N-16: Removed Resource Endpoints Return 404
|
||||
|
||||
**Summary**: After the cycle-2 cleanup, the three obsolete resource endpoints are no longer routed and return 404.
|
||||
**Traces to**: Cycle-2 AC-1, Cycle-2 AC-2, Cycle-2 AC-3
|
||||
**Category**: Negative — Removed Endpoints
|
||||
|
||||
**Preconditions**:
|
||||
- Caller authenticated as any user (404 must precede any auth check, since the route is gone)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | `POST /resources/get` (with or without body) | HTTP 404 |
|
||||
| 2 | `POST /resources/get/somefolder` | HTTP 404 |
|
||||
| 3 | `GET /resources/get-installer` | HTTP 404 |
|
||||
| 4 | `GET /resources/get-installer/stage` | HTTP 404 |
|
||||
|
||||
**Expected outcome**: each request returns HTTP 404 (not 401, not 405); no `Security.GetApiEncryptionKey` / `EncryptTo` invocation observable in logs.
|
||||
|
||||
**Notes**: this is a parallel to FT-N-15 (which covers the AZ-197 endpoint removals). Together they enumerate every route that has been retired in cycles 1 and 2.
|
||||
|
||||
---
|
||||
|
||||
### Detection Classes CRUD (AZ-513)
|
||||
|
||||
#### FT-P-14: POST /classes Creates Detection Class
|
||||
@@ -819,3 +811,665 @@ The scenarios `FT-P-21`, `FT-P-22`, `FT-P-23` are retained here as ID placeholde
|
||||
**Max execution time**: 5s
|
||||
|
||||
Note: AZ-197 AC-1 (resource download works without `Hardware`) is implicitly covered by the existing FT-P-09 / FT-P-10 scenarios once their request bodies are aligned with the new wire shape. AZ-197 AC-3..AC-8 are internal-signature / build-system invariants and are verified at build/CI time, not via a blackbox HTTP scenario.
|
||||
|
||||
---
|
||||
|
||||
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
|
||||
|
||||
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the eight tasks under AZ-529 (Auth Mechanism Modernization) and AZ-530 (CMMC Compliance Hardening): AZ-531 (refresh-token flow), AZ-532 (asymmetric signing + JWKS), AZ-533 (mission-token UAV), AZ-534 (TOTP 2FA), AZ-535 (logout + revocation), AZ-536 (Argon2id), AZ-537 (rate-limit + lockout), AZ-538 (CORS HTTPS-only + HSTS). Numbering continues from FT-P-23 / FT-N-16. Security-only ACs live in `security-tests.md`.
|
||||
|
||||
### Argon2id Password Hashing (AZ-536)
|
||||
|
||||
#### FT-P-24: Legacy SHA-384 Password Still Validates
|
||||
|
||||
**Summary**: A user whose `password_hash` is in the pre-AZ-536 unsalted SHA-384 format can still log in with the correct password.
|
||||
**Traces to**: AZ-536 AC-2
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- Seed user `legacy@azaion.com` with `password_hash` set to `Convert.ToBase64String(SHA384.HashData("LegacyPwd1!"))` (the historical format)
|
||||
|
||||
**Input data**: `{"email":"legacy@azaion.com","password":"LegacyPwd1!"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login with the legacy user's credentials | HTTP 200, dual-token body (per AZ-531) |
|
||||
|
||||
**Expected outcome**: HTTP 200, login succeeds against legacy hash format
|
||||
**Max execution time**: 5s (note: Argon2id verify cost is incurred only on the post-login re-hash)
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-25: Successful Legacy Login Re-Hashes to Argon2id
|
||||
|
||||
**Summary**: After FT-P-24 succeeds, the user's `password_hash` is silently upgraded to Argon2id PHC format and the same plaintext continues to validate.
|
||||
**Traces to**: AZ-536 AC-3
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- FT-P-24 has just executed successfully for `legacy@azaion.com`
|
||||
|
||||
**Input data**: `{"email":"legacy@azaion.com","password":"LegacyPwd1!"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | Read `users.password_hash` for `legacy@azaion.com` directly from DB | Value starts with `$argon2id$v=19$m=` and parses to m ≥ 65536, t ≥ 3, p ≥ 1 |
|
||||
| 2 | POST /login with the same plaintext password again | HTTP 200, dual-token body |
|
||||
|
||||
**Expected outcome**: Hash format upgraded to Argon2id PHC; subsequent login still works
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-N-17: Wrong Password Fails for Both Hash Formats
|
||||
|
||||
**Summary**: Wrong password is rejected with the same error (`WrongPassword`) regardless of whether the stored hash is legacy SHA-384 or Argon2id.
|
||||
**Traces to**: AZ-536 AC-4
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- One user with legacy SHA-384 hash, one user with Argon2id hash already in DB
|
||||
|
||||
**Input data**: Wrong password against each user
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login (legacy user, wrong pwd) | HTTP 409, ExceptionEnum=WrongPassword (code 30) |
|
||||
| 2 | POST /login (Argon2id user, wrong pwd) | HTTP 409, ExceptionEnum=WrongPassword (code 30) |
|
||||
|
||||
**Expected outcome**: Same error code on both code paths; no information leak about hash format
|
||||
**Max execution time**: 5s per attempt (Argon2id cost incurred regardless of success/failure)
|
||||
|
||||
---
|
||||
|
||||
### /login Rate Limit + Account Lockout (AZ-537)
|
||||
|
||||
#### FT-P-26: Successful Login Resets the Failed-Attempt Counter
|
||||
|
||||
**Summary**: After some wrong-password attempts (within budget), a successful login zeros `failed_login_count` and clears `lockout_until`.
|
||||
**Traces to**: AZ-537 AC-4
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- User `alice@azaion.com` exists with Argon2id-hashed password
|
||||
|
||||
**Input data**: 5 wrong-password attempts followed by 1 correct attempt
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login with wrong pwd × 5 (within rate-limit budget) | HTTP 409 each (WrongPassword) |
|
||||
| 2 | Read `users.failed_login_count` for alice | Value = 5 |
|
||||
| 3 | POST /login with correct pwd | HTTP 200, dual-token body |
|
||||
| 4 | Read `users.failed_login_count` and `lockout_until` for alice | `failed_login_count = 0`, `lockout_until IS NULL` |
|
||||
|
||||
**Expected outcome**: Counter reset on success
|
||||
**Max execution time**: 30s (5× Argon2id verifies)
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-27: Lockout Auto-Expires After Configured Duration
|
||||
|
||||
**Summary**: A locked account becomes loginable again automatically once `lockout_until < now()`.
|
||||
**Traces to**: AZ-537 AC-5
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- `Auth:Lockout:DurationMinutes` set to a small value (e.g. 1 minute) in the test env so the test does not have to wait 15 min
|
||||
- User `bob@azaion.com` exists with Argon2id hash
|
||||
|
||||
**Input data**: 10 wrong attempts to trigger lockout, then a correct attempt after the duration window
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login with wrong pwd × 10 | first 9 → 409 WrongPassword; the 10th → 423 Locked OR 409 followed by lockout flag |
|
||||
| 2 | POST /login with correct pwd immediately | HTTP 423 Locked (account is locked) |
|
||||
| 3 | Wait `Auth:Lockout:DurationMinutes + 1s` | — |
|
||||
| 4 | POST /login with correct pwd | HTTP 200, dual-token body |
|
||||
|
||||
**Expected outcome**: 423 → 200 transition once the lockout window expires
|
||||
**Max execution time**: 90s (depends on configured lockout duration in test env)
|
||||
|
||||
---
|
||||
|
||||
### CORS HTTPS-Only + HSTS (AZ-538)
|
||||
|
||||
#### FT-P-28: HTTPS Origin Preflight Succeeds
|
||||
|
||||
**Summary**: The CORS allow-list still admits the canonical `https://admin.azaion.com` origin and echoes the credentials flag.
|
||||
**Traces to**: AZ-538 AC-2
|
||||
**Category**: Cross-Origin
|
||||
|
||||
**Preconditions**:
|
||||
- Admin API running with `AdminCorsPolicy` configured (post-AZ-538)
|
||||
|
||||
**Input data**:
|
||||
- Method: OPTIONS
|
||||
- Path: /login
|
||||
- Header: `Origin: https://admin.azaion.com`
|
||||
- Header: `Access-Control-Request-Method: POST`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | OPTIONS /login with the headers above | HTTP 204; `Access-Control-Allow-Origin: https://admin.azaion.com`; `Access-Control-Allow-Credentials: true` |
|
||||
|
||||
**Expected outcome**: HTTPS origin preflight succeeds with credentials flag
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-29: Development Env — No HTTPS Redirect, No HSTS
|
||||
|
||||
**Summary**: When `ASPNETCORE_ENVIRONMENT=Development`, plain HTTP requests to localhost still serve 200 responses with no `Strict-Transport-Security` header.
|
||||
**Traces to**: AZ-538 AC-5
|
||||
**Category**: Cross-Origin
|
||||
|
||||
**Preconditions**:
|
||||
- Admin API running with `ASPNETCORE_ENVIRONMENT=Development` (the default test container env)
|
||||
|
||||
**Input data**: GET http://localhost:8080/health/live
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | GET http://localhost:8080/health/live | HTTP 200; no `Strict-Transport-Security` header; no 307 redirect |
|
||||
|
||||
**Expected outcome**: Dev workflow preserved — no redirect, no HSTS
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
### Refresh-Token Flow (AZ-531)
|
||||
|
||||
#### FT-P-30: /login Returns Dual Tokens
|
||||
|
||||
**Summary**: Successful login returns both a short-lived access token (≈15 min) and an opaque refresh token; a `sessions` row is created.
|
||||
**Traces to**: AZ-531 AC-1
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- Seed user without MFA enabled
|
||||
|
||||
**Input data**: Valid email + password
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login | HTTP 200; body has `access_token` (JWT), `access_exp` ≈ now+15m ±60s, `refresh_token` (opaque ≥43 chars), `refresh_exp` |
|
||||
| 2 | Decode `access_token` payload | Contains `sub`, `iss`, `aud`, `exp`, `jti`, `sid` claims |
|
||||
| 3 | Query `sessions` table by `user_id` | Exactly one row with non-null `refresh_hash`, non-null `family_id`, `revoked_at IS NULL` |
|
||||
|
||||
**Expected outcome**: Dual tokens issued, session row persisted, access token has short TTL
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-31: /token/refresh Rotates the Refresh Token
|
||||
|
||||
**Summary**: A valid refresh token is exchanged for a new access + new refresh; the previous refresh is invalidated; the session chain extends via `parent_session_id`.
|
||||
**Traces to**: AZ-531 AC-2
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- FT-P-30 just produced refresh token R1
|
||||
|
||||
**Input data**: `{"refresh_token":"<R1>"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /token/refresh with R1 | HTTP 200; body has new `access_token`, new `refresh_token` (R2 ≠ R1), new `access_exp`, new `refresh_exp` |
|
||||
| 2 | POST /token/refresh with R1 again (same call) | HTTP 401 (R1 has been rotated; see AC-3 reuse-detection in NFT-SEC-08) |
|
||||
| 3 | Inspect `sessions` table | Original row's `refresh_hash` rotated; new row has `parent_session_id` chained to the previous row |
|
||||
|
||||
**Expected outcome**: Rotation succeeds; old refresh dies; chain is preserved
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-32: Refresh Sliding + Absolute Expiry
|
||||
|
||||
**Summary**: Refresh tokens slide on use up to the per-family absolute cap (12 h since the family's first issue); after the absolute cap, refresh fails.
|
||||
**Traces to**: AZ-531 AC-4
|
||||
**Category**: Authentication
|
||||
|
||||
**Preconditions**:
|
||||
- A `sessions` family with `family_first_issued_at` set to `now() - 11h59m` (verified via DB seed) and a current valid refresh token R-current
|
||||
|
||||
**Input data**: `{"refresh_token":"<R-current>"}`, called near and past the absolute cap
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /token/refresh at family-age 11h59m | HTTP 200, rotation succeeds; sliding window extended |
|
||||
| 2 | Seed another family with `family_first_issued_at = now() - 12h01s` | — |
|
||||
| 3 | POST /token/refresh on that family | HTTP 401, body indicates absolute-expiry violation |
|
||||
|
||||
**Expected outcome**: Sliding works inside 12 h; absolute cap rejects beyond
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
### Asymmetric Signing + JWKS (AZ-532)
|
||||
|
||||
#### FT-P-33: GET /.well-known/jwks.json Serves the Active Public Key
|
||||
|
||||
**Summary**: The JWKS endpoint is anonymous, cacheable, and returns a well-formed JWKS containing the active EC P-256 public key with `kid`.
|
||||
**Traces to**: AZ-532 AC-2
|
||||
**Category**: Cryptography / Discovery
|
||||
|
||||
**Preconditions**:
|
||||
- Admin running with an ES256 keypair loaded from `secrets/jwt_signing_key.pem`
|
||||
|
||||
**Input data**: None (anonymous GET)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | GET /.well-known/jwks.json (no JWT) | HTTP 200; `Content-Type: application/json`; `Cache-Control: public, max-age=3600` |
|
||||
| 2 | Parse body | `{"keys":[{"kty":"EC","crv":"P-256","kid":<non-empty>,"x":<base64url>,"y":<base64url>,"alg":"ES256","use":"sig"}, …]}` |
|
||||
|
||||
**Expected outcome**: JWKS shape matches RFC 7517; cache headers present
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-34: Two-Key Overlap During Rotation
|
||||
|
||||
**Summary**: When two signing keys are configured (`kid-A` active + `kid-B` standby), JWKS exposes both; tokens signed with the active key continue to verify; switching the active flag to `kid-B` produces `kid-B`-stamped tokens that also verify.
|
||||
**Traces to**: AZ-532 AC-3
|
||||
**Category**: Cryptography / Rotation
|
||||
|
||||
**Preconditions**:
|
||||
- Two keys configured in `secrets/`: `jwt_signing_key_a.pem` (active), `jwt_signing_key_b.pem` (standby)
|
||||
|
||||
**Input data**: Sequenced login + rotation toggle
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | GET /.well-known/jwks.json | Both `kid-A` and `kid-B` appear in `keys` array |
|
||||
| 2 | POST /login | Returned access token has `kid: kid-A` in header |
|
||||
| 3 | Toggle active key → `kid-B` (test-only admin endpoint or env reload) | — |
|
||||
| 4 | POST /login again | Returned access token has `kid: kid-B` in header |
|
||||
| 5 | Use either token against any protected endpoint | HTTP 200 (both verify against their respective public keys in JWKS) |
|
||||
|
||||
**Expected outcome**: Overlap window allows both keys; verifiers can keep working through rotation
|
||||
**Max execution time**: 10s
|
||||
|
||||
---
|
||||
|
||||
### Mission-Token Issuance for UAV (AZ-533)
|
||||
|
||||
#### FT-P-35: POST /sessions/mission Issues a Long-Lived Mission Token
|
||||
|
||||
**Summary**: An authenticated pilot session can mint a mission-class access token with a duration ≈ `planned_duration_h + 1h` and no refresh token.
|
||||
**Traces to**: AZ-533 AC-1
|
||||
**Category**: Mission Sessions
|
||||
|
||||
**Preconditions**:
|
||||
- Pilot user with valid (post-AZ-531) access token; MFA already proven within the session (post-AZ-534)
|
||||
- Aircraft user `UAV-117` with `Role=CompanionPC` exists
|
||||
|
||||
**Input data**: `{"mission_id":"M-2026-05-14-042","aircraft_id":"UAV-117","planned_duration_h":9,"requested_scope":["GPS"]}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /sessions/mission with the body above + pilot access token | HTTP 200; body has `access_token`, no `refresh_token`, `exp` ≈ now + 10h ±60s |
|
||||
| 2 | Decode token payload | `token_class = "mission"` |
|
||||
| 3 | Query `sessions` table | Row with `class='mission'`, `aircraft_id='UAV-117'`, `revoked_at IS NULL` |
|
||||
|
||||
**Expected outcome**: Long-lived mission token issued; session persisted with class marker
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-36: Mission Token Carries Scope Claims
|
||||
|
||||
**Summary**: The mission token's payload exposes `mission_id`, `aircraft_id`, `aud`, `permissions`, `sid`, `jti`.
|
||||
**Traces to**: AZ-533 AC-3
|
||||
**Category**: Mission Sessions
|
||||
|
||||
**Preconditions**:
|
||||
- FT-P-35 just produced a mission token
|
||||
|
||||
**Input data**: The mission token from FT-P-35
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | Decode mission token payload | `mission_id == "M-2026-05-14-042"`, `aircraft_id == "UAV-117"`, `aud == "satellite-provider"`, `permissions` contains `"GPS"`, `sid` non-empty, `jti` non-empty |
|
||||
|
||||
**Expected outcome**: All scope claims present and correctly populated
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-37: Mission Token Auto-Revoked on Aircraft Reconnect
|
||||
|
||||
**Summary**: When the aircraft user behind a mission session calls `/login` or `/token/refresh` again, every open mission session for that aircraft is marked `revoked_reason='post_flight_reconnect'` and the mission token stops working.
|
||||
**Traces to**: AZ-533 AC-4
|
||||
**Category**: Mission Sessions
|
||||
|
||||
**Preconditions**:
|
||||
- Open mission session for `UAV-117` from FT-P-35 (token MT)
|
||||
|
||||
**Input data**: A `/login` from the `UAV-117` companion PC user
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login as `UAV-117` (CompanionPC creds) | HTTP 200, dual tokens (per AZ-531) |
|
||||
| 2 | Query `sessions` row for the original mission MT | `revoked_at` set; `revoked_reason = 'post_flight_reconnect'` |
|
||||
| 3 | Use MT against any protected endpoint | HTTP 401 |
|
||||
|
||||
**Expected outcome**: Reconnect implicitly revokes outstanding mission sessions for the same aircraft
|
||||
**Max execution time**: 10s
|
||||
|
||||
---
|
||||
|
||||
#### FT-N-18: POST /sessions/mission Requires Authentication
|
||||
|
||||
**Summary**: Without an Authorization header, mission-token issuance is rejected at the gateway.
|
||||
**Traces to**: AZ-533 AC-5
|
||||
**Category**: Mission Sessions
|
||||
|
||||
**Preconditions**: None
|
||||
|
||||
**Input data**: Same body as FT-P-35, no Authorization header
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /sessions/mission with no JWT | HTTP 401 |
|
||||
|
||||
**Expected outcome**: Unauthenticated mission requests are rejected
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-N-19: POST /sessions/mission Rejects Over-Cap Duration
|
||||
|
||||
**Summary**: A request for `planned_duration_h > 12` is rejected with HTTP 400 and a descriptive error message.
|
||||
**Traces to**: AZ-533 AC-2
|
||||
**Category**: Mission Sessions
|
||||
|
||||
**Preconditions**:
|
||||
- Authenticated pilot session (with MFA `amr=mfa`)
|
||||
|
||||
**Input data**: `{"mission_id":"M-2026-05-14-099","aircraft_id":"UAV-117","planned_duration_h":15,"requested_scope":["GPS"]}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /sessions/mission with the over-cap body | HTTP 400; response body contains `"planned_duration_h must be ≤ 12"` |
|
||||
|
||||
**Expected outcome**: 400 with cap-violation message; no session row created
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
### TOTP-Based 2FA at Login (AZ-534)
|
||||
|
||||
#### FT-P-38: POST /users/me/mfa/enroll Returns Usable Secret + Recovery Codes
|
||||
|
||||
**Summary**: A user without MFA can begin enrollment and receives a 32-char base32 TOTP secret, an `otpauth://` URL, a base64 PNG QR, and 10 recovery codes (≥12 chars each).
|
||||
**Traces to**: AZ-534 AC-1
|
||||
**Category**: MFA Enrollment
|
||||
|
||||
**Preconditions**:
|
||||
- Authenticated user `mfauser@azaion.com`, `mfa_enabled = false`
|
||||
|
||||
**Input data**: `{"password":"<plaintext>"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /users/me/mfa/enroll with the body above | HTTP 200; body has `secret` (32-char base32), `otpauth_url` (matches `^otpauth://totp/`), `qr_png_base64` (non-empty), `recovery_codes` (length = 10, each ≥ 12 chars, base32) |
|
||||
| 2 | Read `users.mfa_enabled` for the user | Value still `false` (only flips after `confirm`) |
|
||||
|
||||
**Expected outcome**: Enrollment package returned; `mfa_enabled` not yet flipped
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-39: POST /users/me/mfa/confirm Activates MFA
|
||||
|
||||
**Summary**: Submitting a valid TOTP code from the just-issued secret completes enrollment and flips `mfa_enabled = true`.
|
||||
**Traces to**: AZ-534 AC-2
|
||||
**Category**: MFA Enrollment
|
||||
|
||||
**Preconditions**:
|
||||
- FT-P-38 just executed for the same user; the test holds the returned `secret`
|
||||
|
||||
**Input data**: `{"code":"<TOTP code computed from secret at current time>"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | Compute current 6-digit TOTP from `secret` (RFC 6238, 30 s window) | 6 digits |
|
||||
| 2 | POST /users/me/mfa/confirm with the code | HTTP 200 |
|
||||
| 3 | Read `users.mfa_enabled` and `users.mfa_enrolled_at` | `mfa_enabled = true`, `mfa_enrolled_at` non-null |
|
||||
|
||||
**Expected outcome**: MFA activated; subsequent /login goes through the two-step flow
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-40: Two-Step Login With TOTP
|
||||
|
||||
**Summary**: When a user has MFA enabled, `/login` returns an MFA-required envelope with a short-lived `mfa_token`; calling `/login/mfa` with the `mfa_token` + a valid TOTP code yields the real access + refresh; the access token's `amr` claim contains both `pwd` and `mfa`.
|
||||
**Traces to**: AZ-534 AC-3
|
||||
**Category**: Authentication / MFA
|
||||
|
||||
**Preconditions**:
|
||||
- User from FT-P-39 (MFA enabled)
|
||||
|
||||
**Input data**: Valid email + password, then `mfa_token` + TOTP code
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login with email + password | HTTP 200; body = `{ "mfa_required": true, "mfa_token": "<short-lived JWT>", "expires_in": 300 }`; no access/refresh present |
|
||||
| 2 | POST /login/mfa with `{ "mfa_token": "<from step 1>", "code": "<TOTP>" }` | HTTP 200; body has access + refresh tokens |
|
||||
| 3 | Decode access token | `amr` claim = `["pwd","mfa"]` |
|
||||
|
||||
**Expected outcome**: Two-step flow completes; access token's `amr` reflects both factors
|
||||
**Max execution time**: 10s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-41: Recovery Code Substitutes for TOTP and Burns On Use
|
||||
|
||||
**Summary**: A recovery code may be used in place of a TOTP code at `/login/mfa`. The same code on a subsequent attempt fails (single-use). The successful access token's `amr` claim records `recovery`.
|
||||
**Traces to**: AZ-534 AC-4
|
||||
**Category**: Authentication / MFA
|
||||
|
||||
**Preconditions**:
|
||||
- User from FT-P-39; the test holds the `recovery_codes` array from FT-P-38
|
||||
|
||||
**Input data**: First recovery code, then re-use of the same code
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /login → get `mfa_token` | HTTP 200, MFA-required envelope |
|
||||
| 2 | POST /login/mfa with `{ "mfa_token", "code": "<recovery_codes[0]>" }` | HTTP 200, access + refresh issued; `amr` = `["pwd","mfa","recovery"]` |
|
||||
| 3 | POST /login → get a new `mfa_token` | HTTP 200, MFA-required envelope |
|
||||
| 4 | POST /login/mfa with the SAME recovery code | HTTP 401 (recovery code burned) |
|
||||
|
||||
**Expected outcome**: Recovery code works once, then is rejected
|
||||
**Max execution time**: 10s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-42: POST /users/me/mfa/disable Removes MFA
|
||||
|
||||
**Summary**: Submitting password + a valid TOTP code disables MFA; subsequent `/login` returns access + refresh directly without the two-step flow.
|
||||
**Traces to**: AZ-534 AC-5
|
||||
**Category**: MFA Enrollment
|
||||
|
||||
**Preconditions**:
|
||||
- User from FT-P-39
|
||||
|
||||
**Input data**: `{"password":"<plaintext>","code":"<TOTP>"}`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /users/me/mfa/disable | HTTP 200 |
|
||||
| 2 | Read `users.mfa_enabled` | `false` |
|
||||
| 3 | POST /login with email + password | HTTP 200; body has access + refresh directly (no `mfa_required`) |
|
||||
|
||||
**Expected outcome**: MFA disabled, single-step login restored
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
### Logout + Revocation Surface (AZ-535)
|
||||
|
||||
#### FT-P-43: POST /logout Revokes the Current Session
|
||||
|
||||
**Summary**: A POST /logout with a valid access token marks the session row revoked and disables the paired refresh token.
|
||||
**Traces to**: AZ-535 AC-1
|
||||
**Category**: Session Lifecycle
|
||||
|
||||
**Preconditions**:
|
||||
- Active session from a prior /login (access token A, refresh token R)
|
||||
|
||||
**Input data**: Authorization header `Bearer <A>`, empty body
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /logout with bearer A | HTTP 200 |
|
||||
| 2 | Query the session row | `revoked_at` set; `revoked_reason = 'user_logout'` |
|
||||
| 3 | POST /token/refresh with R | HTTP 401 |
|
||||
|
||||
**Expected outcome**: Session revoked, refresh dies immediately
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-44: POST /logout/all Revokes Every Session for the User
|
||||
|
||||
**Summary**: A user with multiple active sessions can sign out of all of them in one call.
|
||||
**Traces to**: AZ-535 AC-2
|
||||
**Category**: Session Lifecycle
|
||||
|
||||
**Preconditions**:
|
||||
- User with three active sessions S1/S2/S3 (each from a separate /login)
|
||||
|
||||
**Input data**: Authorization header `Bearer <A from S1>`, empty body
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /logout/all from S1 | HTTP 200 |
|
||||
| 2 | Query `sessions` for the user | All three rows have `revoked_at` set |
|
||||
| 3 | POST /token/refresh with the refresh tokens of S1/S2/S3 | All three return HTTP 401 |
|
||||
|
||||
**Expected outcome**: Every session for the user is revoked
|
||||
**Max execution time**: 10s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-45: POST /sessions/{sid}/revoke Lets Admin Kill Any Session
|
||||
|
||||
**Summary**: An Admin-role JWT can revoke any other user's session by id; the revoked row records the admin's user id.
|
||||
**Traces to**: AZ-535 AC-3
|
||||
**Category**: Admin Session Management
|
||||
|
||||
**Preconditions**:
|
||||
- Admin user with valid (post-AZ-531) access token
|
||||
- Target user with active session SID-X
|
||||
|
||||
**Input data**: Authorization header `Bearer <admin access>`, path `/sessions/<SID-X>/revoke`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /sessions/SID-X/revoke as admin | HTTP 200 |
|
||||
| 2 | Query the SID-X row | `revoked_at` set; `revoked_by_user_id` = admin's user id |
|
||||
| 3 | POST /token/refresh with SID-X's refresh | HTTP 401 |
|
||||
|
||||
**Expected outcome**: Admin-driven revocation works and records actor
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-46: GET /sessions/revoked?since=… Returns Recent, Non-Expired Revocations
|
||||
|
||||
**Summary**: A verifier identity (`Role=Service`) polls the snapshot endpoint and gets the recently-revoked, still-valid sessions; expired entries are auto-pruned.
|
||||
**Traces to**: AZ-535 AC-4
|
||||
**Category**: Verifier Snapshot
|
||||
|
||||
**Preconditions**:
|
||||
- 5 sessions revoked in the last hour, 2 of which already have `exp < now()`
|
||||
- Verifier identity (Service role) with valid bearer
|
||||
|
||||
**Input data**: Authorization header `Bearer <verifier access>`, query `?since=<unix-ts 1h ago>`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | GET /sessions/revoked?since=<ts> with verifier bearer | HTTP 200; `Cache-Control: no-cache`; body is JSON array of length 3 |
|
||||
| 2 | Inspect each entry | `{ jti, sid, exp }` shape; no expired entries present |
|
||||
|
||||
**Expected outcome**: 3 non-expired revocations returned; expired ones pruned
|
||||
**Max execution time**: 5s
|
||||
|
||||
---
|
||||
|
||||
#### FT-P-47: POST /logout Is Idempotent
|
||||
|
||||
**Summary**: Logging out a session that is already revoked returns 200 with `already_revoked: true` and does not write to the DB.
|
||||
**Traces to**: AZ-535 AC-5
|
||||
**Category**: Session Lifecycle
|
||||
|
||||
**Preconditions**:
|
||||
- Already-revoked session from FT-P-43
|
||||
|
||||
**Input data**: Authorization header `Bearer <still-valid-but-stale access>`, empty body
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | POST /logout again | HTTP 200; body `{ "already_revoked": true }` |
|
||||
| 2 | Query the session row's `updated_at` (or equivalent audit column) | Unchanged from before step 1 |
|
||||
|
||||
**Expected outcome**: Idempotent — no second DB mutation
|
||||
**Max execution time**: 5s
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | GET /users (no JWT) | HTTP 401 |
|
||||
| 2 | POST /resources/get (no JWT) | HTTP 401 |
|
||||
| 3 | POST /resources/check (no JWT) | HTTP 401 |
|
||||
| 4 | GET /resources/get-installer (no JWT) | HTTP 401 |
|
||||
| 5 | PUT /users/role (no JWT) | HTTP 401 |
|
||||
| 6 | DELETE /users (no JWT) | HTTP 401 |
|
||||
| 2 | POST /resources/{folder} upload (no JWT) | HTTP 401 |
|
||||
| 3 | GET /resources/list/{folder} (no JWT) | HTTP 401 |
|
||||
| 4 | PUT /users/{email}/set-role/{role} (no JWT) | HTTP 401 |
|
||||
| 5 | DELETE /users/{email} (no JWT) | HTTP 401 |
|
||||
| 6 | POST /classes (no JWT) | HTTP 401 |
|
||||
|
||||
**Pass criteria**: All endpoints return HTTP 401 for unauthenticated requests
|
||||
**Pass criteria**: All remaining protected endpoints return HTTP 401 for unauthenticated requests.
|
||||
|
||||
> Earlier revisions of this scenario also covered `POST /resources/get`, `POST /resources/check`, and `GET /resources/get-installer`. Those endpoints were removed (AZ-197 / cycle 2) and now return 404 — see FT-N-15 (AZ-197 routes) and FT-N-16 (cycle-2 routes) in `blackbox-tests.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,21 +73,9 @@
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-05: Encryption Key Uniqueness
|
||||
### NFT-SEC-05: Encryption Key Uniqueness — OBSOLETE (cycle 2, 2026-05-14)
|
||||
|
||||
**Summary**: Different users produce different encryption keys for the same resource.
|
||||
**Traces to**: AC-19
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Upload test file | HTTP 200 |
|
||||
| 2 | Download encrypted file as User A | Encrypted bytes A |
|
||||
| 3 | Download same file as User B (different credentials + hardware) | Encrypted bytes B |
|
||||
| 4 | Compare encrypted bytes A and B | Different |
|
||||
|
||||
**Pass criteria**: Encrypted outputs differ between users
|
||||
The `POST /resources/get/{dataFolder?}` endpoint that this test exercised was removed along with `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` and `ResourcesService.GetEncryptedResource`. Per-user resource encryption is no longer part of the system. ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,3 +92,310 @@
|
||||
| 2 | Attempt POST /login with disabled user credentials | HTTP 409 or HTTP 403 |
|
||||
|
||||
**Pass criteria**: Disabled user cannot obtain a JWT token
|
||||
|
||||
---
|
||||
|
||||
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
|
||||
|
||||
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the security-only / cryptography-invariant ACs in cycle 2. Functional flows live in `blackbox-tests.md` under the matching task. Numbering continues from NFT-SEC-06.
|
||||
|
||||
### NFT-SEC-07: New User Hashes Use Argon2id (AZ-536)
|
||||
|
||||
**Summary**: A freshly-registered user's `password_hash` is in Argon2id PHC format with parameters at or above the configured floor.
|
||||
**Traces to**: AZ-536 AC-1
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /users (ApiAdmin JWT) registering `freshuser@azaion.com` with a known password | HTTP 200 |
|
||||
| 2 | Read `users.password_hash` for `freshuser@azaion.com` directly from Postgres | Value starts with `$argon2id$v=19$m=` |
|
||||
| 3 | Parse the PHC string parameters | `m ≥ 65536`, `t ≥ 3`, `p ≥ 1` |
|
||||
|
||||
**Pass criteria**: All new users land in Argon2id PHC format with at least the configured cost parameters; no SHA-384 base64 strings written for new accounts.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-08: Argon2id Verify Has No Remotely Observable Timing Leak (AZ-536)
|
||||
|
||||
**Summary**: `VerifyPassword` is constant-time across wrong passwords of various lengths; timing variance does not leak information about the candidate password.
|
||||
**Traces to**: AZ-536 AC-5
|
||||
|
||||
**Preconditions**:
|
||||
- User with Argon2id-hashed password
|
||||
- Test environment with low concurrency (this test is sensitive to host noise — if it intermittently trips, widen the bound or warm Argon2 with a non-test login first; see cycle-2 carry-forward F6)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /login with a wrong 8-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
|
||||
| 2 | POST /login with a wrong 64-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
|
||||
| 3 | Compute median of each sample; compare | `|median_8 − median_64| / median_8 < 0.20` (within 20% of each other — Argon2id cost dominates string-comparison cost) |
|
||||
|
||||
**Pass criteria**: Wrong-password verify time is dominated by Argon2id cost, not by string-length-dependent comparison; no exploitable timing channel.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-09: Per-IP Rate Limit Returns 429 (AZ-537)
|
||||
|
||||
**Summary**: 11 `/login` requests from the same client IP within 60 s force the 11th into HTTP 429 with a `Retry-After` header.
|
||||
**Traces to**: AZ-537 AC-1
|
||||
|
||||
**Preconditions**:
|
||||
- Rate-limit `Auth:RateLimit:PerIp` set to 10 / 60 s sliding (the test env value)
|
||||
- Test client preserves source IP across requests (E2E container-shared-IP caveat applies — see test_run_report cycle 2 skip note for the legitimate environment-mismatch skip)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /login × 10 from the same IP within 5 s (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
|
||||
| 2 | POST /login as the 11th request inside the 60 s window | HTTP 429; response includes `Retry-After` header (integer seconds) |
|
||||
|
||||
**Pass criteria**: 11th request inside the window is rejected with 429 + Retry-After. (Legitimate environment-mismatch skip in shared-IP container envs — verified by ASP.NET Core RateLimiter unit tests + manual probe documented in AZ-537 spec.)
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-10: Per-Account Rate Limit Returns 429 (AZ-537)
|
||||
|
||||
**Summary**: 6 `/login` requests for the same email from 6 different IPs within 5 min force the 6th into HTTP 429.
|
||||
**Traces to**: AZ-537 AC-2
|
||||
|
||||
**Preconditions**:
|
||||
- Rate-limit `Auth:RateLimit:PerAccount` set to 5 / 5 min sliding
|
||||
- Test ability to spoof / vary the source IP per request (e.g. via `X-Forwarded-For` if the app trusts a known forwarder, or a multi-host test fixture)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /login for `alice@azaion.com` from IPs 1..5 within 1 min (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
|
||||
| 2 | POST /login for `alice@azaion.com` from IP 6 inside the 5 min window | HTTP 429; `Retry-After` present |
|
||||
|
||||
**Pass criteria**: Per-account partition triggers independently of per-IP partition.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-11: Account Lockout Returns 423 Even For Correct Password (AZ-537)
|
||||
|
||||
**Summary**: Once `failed_login_count` hits the lockout threshold, the account returns HTTP 423 Locked even for subsequent correct-password attempts until `lockout_until` passes.
|
||||
**Traces to**: AZ-537 AC-3
|
||||
|
||||
**Preconditions**:
|
||||
- `Auth:Lockout:MaxAttempts = 10` (default)
|
||||
- User `bob@azaion.com` with Argon2id-hashed password
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /login for `bob@azaion.com` with wrong password × 10 (across IPs / within rate budget) | First 9 → HTTP 409 WrongPassword; 10th → HTTP 423 Locked OR final 409 followed by lockout flag |
|
||||
| 2 | Read `users.lockout_until` and `users.failed_login_count` for `bob` | `lockout_until > now()`; counter at threshold |
|
||||
| 3 | POST /login for `bob` with correct password immediately after | HTTP 423 Locked (lockout precedes credential check) |
|
||||
|
||||
**Pass criteria**: Lockout state takes precedence over correct credentials within the lockout window; counter persists across IPs (per-account, not per-IP).
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-12: Lockout Is Audit-Logged (AZ-537)
|
||||
|
||||
**Summary**: When NFT-SEC-11 fires the lockout transition, an audit-log row is written with the email, source IP, and timestamp.
|
||||
**Traces to**: AZ-537 AC-6
|
||||
|
||||
**Preconditions**:
|
||||
- Audit log infrastructure online (verified by existing logging tests)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Trigger NFT-SEC-11 against `bob@azaion.com` from IP `203.0.113.7` | Lockout fires |
|
||||
| 2 | Query the audit log for entries with `event = 'login_lockout'` since the test start | At least one row with `email = 'bob@azaion.com'`, `ip = '203.0.113.7'`, `timestamp` within ± 5 s of the lockout trigger |
|
||||
|
||||
**Pass criteria**: Each lockout produces a `login_lockout` audit entry with the security-relevant fields.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-13: HTTP CORS Origin Is Rejected (AZ-538)
|
||||
|
||||
**Summary**: A browser preflight from the cleartext `http://admin.azaion.com` origin must NOT receive an `Access-Control-Allow-Origin` header (CORS denies the request).
|
||||
**Traces to**: AZ-538 AC-1
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | OPTIONS /login with `Origin: http://admin.azaion.com`, `Access-Control-Request-Method: POST` | HTTP 204 OR 200; response has NO `Access-Control-Allow-Origin` header |
|
||||
|
||||
**Pass criteria**: HTTP origin gets no ACAO header — browser-side fetch with credentials will fail in any compliant browser.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-14: HSTS Header Present in Production (AZ-538)
|
||||
|
||||
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, every HTTPS response includes a strict `Strict-Transport-Security` header.
|
||||
**Traces to**: AZ-538 AC-3
|
||||
|
||||
**Preconditions**:
|
||||
- Admin container running with `ASPNETCORE_ENVIRONMENT=Production`
|
||||
- Note: the default test harness runs `Development`; this test must be run with the production env override OR is the legitimate environment-mismatch skip documented in cycle-2 test_run_report
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | GET https://admin.azaion.com/health/live (or any HTTPS endpoint) | HTTP 200; response header `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` |
|
||||
|
||||
**Pass criteria**: Production responses always carry HSTS with the documented directives.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-15: HTTP Request Redirects to HTTPS in Production (AZ-538)
|
||||
|
||||
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, a cleartext HTTP request returns HTTP 307 to the same path on HTTPS.
|
||||
**Traces to**: AZ-538 AC-4
|
||||
|
||||
**Preconditions**: Same as NFT-SEC-14
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | GET http://admin.azaion.com/health/live | HTTP 307; `Location: https://admin.azaion.com/health/live` |
|
||||
|
||||
**Pass criteria**: HTTP traffic is redirected at the protocol layer, not silently served.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-16: Refresh-Token Reuse Kills the Session Family (AZ-531)
|
||||
|
||||
**Summary**: If a previously-rotated refresh token is presented again, the entire `sessions` family chain (parent + all descendants) is marked `revoked_reason='reuse_detected'` and every refresh in that family stops working.
|
||||
**Traces to**: AZ-531 AC-3
|
||||
|
||||
**Preconditions**:
|
||||
- A session family with refresh R1 rotated to R2 (per FT-P-31)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /token/refresh with R1 (already rotated) | HTTP 401 |
|
||||
| 2 | Query `sessions` for the family | Every row in the family has `revoked_at` set; `revoked_reason = 'reuse_detected'` |
|
||||
| 3 | POST /token/refresh with R2 | HTTP 401 (R2 also dead — family-wide kill) |
|
||||
|
||||
**Pass criteria**: Reuse detection kills the entire family, not just the reused refresh.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-17: Refresh Tokens Are Opaque, Not JWT (AZ-531)
|
||||
|
||||
**Summary**: Refresh tokens issued by /login or /token/refresh are not JWTs; the persisted form is the SHA-256 hash; the raw value never appears in logs.
|
||||
**Traces to**: AZ-531 AC-5
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /login → capture `refresh_token` R | R is a non-empty string ≥ 43 chars (base64url of 32 bytes) |
|
||||
| 2 | Attempt to parse R as a JWT (split on `.` and base64url-decode the segments) | Parse fails — R does not split into a JWT header/payload/signature shape |
|
||||
| 3 | Read the matching `sessions.refresh_hash` column directly from Postgres | Length 32 bytes (SHA-256 raw or base64-encoded), value ≠ R |
|
||||
| 4 | Grep API logs (Serilog output) for the literal R | No match (raw refresh value never logged) |
|
||||
|
||||
**Pass criteria**: Refresh tokens are opaque, hashed at rest, and never logged in raw form.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-18: Admin Tokens Are Signed With ES256 + kid (AZ-532)
|
||||
|
||||
**Summary**: An access token returned by /login has `alg=ES256` and a `kid` matching one of the active JWKS keys.
|
||||
**Traces to**: AZ-532 AC-1
|
||||
|
||||
**Preconditions**:
|
||||
- Admin running with at least one ES256 keypair loaded from `secrets/jwt_signing_key.pem`
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /login with valid credentials | HTTP 200, dual tokens |
|
||||
| 2 | Decode the access token's JOSE header | `alg == "ES256"`, `kid` non-empty |
|
||||
| 3 | GET /.well-known/jwks.json | The same `kid` appears in the returned `keys` array |
|
||||
|
||||
**Pass criteria**: Tokens are signed asymmetrically and carry the `kid` discriminator needed for rotation.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-19: JWKS Endpoint Never Exposes Private Material (AZ-532)
|
||||
|
||||
**Summary**: The JWKS payload contains only public components; no `d`, `p`, `q`, `dp`, `dq`, or `qi` field appears.
|
||||
**Traces to**: AZ-532 AC-4
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | GET /.well-known/jwks.json | HTTP 200, JSON body |
|
||||
| 2 | Inspect every entry in `keys` for forbidden private-material fields | None of `d`, `p`, `q`, `dp`, `dq`, `qi` is present |
|
||||
|
||||
**Pass criteria**: Public-key set strictly excludes any private scalar (EC) or RSA private primes.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-20: alg-Confusion Attack Is Rejected (AZ-532)
|
||||
|
||||
**Summary**: A forged token with `alg=HS256` (where the signature is computed using the public key as the HMAC secret) is rejected by every protected endpoint, because `TokenValidationParameters.ValidAlgorithms` pins ES256 only.
|
||||
**Traces to**: AZ-532 AC-5
|
||||
|
||||
**Preconditions**:
|
||||
- Test fixture able to construct a forged JWT given the public key
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Build a JWT with header `{ "alg":"HS256","typ":"JWT","kid":"<active-kid>" }`; payload claims valid; signature = HMAC-SHA256(publicKeyBytes, signingInput) | Forged token string |
|
||||
| 2 | GET /users with the forged token | HTTP 401 |
|
||||
|
||||
**Pass criteria**: Algorithm-confusion forgery is rejected; verifier does not silently downgrade to HS256.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-21: Mission Token Requires MFA Step-Up (AZ-533 + AZ-534)
|
||||
|
||||
**Summary**: After AZ-534 ships, `POST /sessions/mission` MUST reject access tokens whose `amr` does not include `mfa`. Caller gets 403 with a step-up message.
|
||||
**Traces to**: AZ-533 AC-6
|
||||
|
||||
**Preconditions**:
|
||||
- AZ-534 already landed (it has — cycle 2 batch 4)
|
||||
- Caller holds an access token with `amr=["pwd"]` (e.g. legacy session, or a service account that doesn't enroll MFA)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | POST /sessions/mission with the `amr=["pwd"]` access token + a valid mission body | HTTP 403; response body contains `"mission tokens require step-up MFA"` |
|
||||
|
||||
**Pass criteria**: Mission-class tokens cannot be minted without MFA in the access-token `amr` chain.
|
||||
|
||||
> Note: cycle-2 follow-up F1 in `_docs/03_implementation/implementation_report_auth_modernization_cycle2.md` calls out that `/sessions/mission` enforcement of `amr=mfa` is the small wire-up still pending after AZ-534 shipped (the AC was deferred during AZ-533, then re-opened under F1). Until F1 lands, this scenario is the spec contract; the matching test may be marked Pending in the SUT.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-22: TOTP Secret Is Encrypted at Rest (AZ-534)
|
||||
|
||||
**Summary**: The `users.mfa_secret` column never holds plaintext base32; only ciphertext.
|
||||
**Traces to**: AZ-534 AC-6
|
||||
|
||||
**Preconditions**:
|
||||
- An enrolled user from FT-P-39
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Read `users.mfa_secret` for the enrolled user directly from Postgres | Value is non-empty |
|
||||
| 2 | Try to base32-decode the value as if it were a 32-char TOTP secret | Decode either fails OR yields material that does NOT round-trip to a working TOTP code |
|
||||
| 3 | Confirm the value is the output of `IDataProtector.Protect(<plaintext base32>)` (length ≫ 32 chars; format-prefixed) | Matches `IDataProtector` ciphertext shape |
|
||||
|
||||
**Pass criteria**: `mfa_secret` is stored encrypted; reading the DB row alone does not yield a usable TOTP secret. (Operational note: production must set `DataProtection:KeysFolder` for the `IDataProtector` to outlive container restarts — see cycle-2 carry-forward F3.)
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
|----------|-----------|---------|-------------|-----------|
|
||||
| Acceptance Criteria (baseline) | 19 | 19 | 0 | 100% |
|
||||
| Acceptance Criteria (cycle 1) | 24 | 24 | 0 | 100% |
|
||||
| Acceptance Criteria (cycle 2) | 6 | 6 | 0 | 100% |
|
||||
| Restrictions | 8 | 5 | 3 | 63% |
|
||||
| **Total** | **51** | **48** | **3** | **94%** |
|
||||
| **Total** | **57** | **54** | **3** | **95%** |
|
||||
|
||||
## Uncovered Items Analysis
|
||||
|
||||
@@ -118,3 +119,117 @@ The matrix rows below are kept for ID stability but no longer reflect production
|
||||
| AC-11 (Subsequent hardware check validates) | Obsoleted by AZ-197 — endpoint removed |
|
||||
| AC-12 (Hardware mismatch returns code 40) | Obsoleted by AZ-197 — `ExceptionEnum` value removed |
|
||||
| AC-19 (Encryption key derived from email+password+hw) | Partially obsoleted — derivation is now `email + password` only |
|
||||
|
||||
## Cycle 2 Cleanup (2026-05-14) — Obsolete Resource Endpoints Removed
|
||||
|
||||
The encrypted-download and installer-download endpoints were removed as obsolete. Affected matrix rows below are kept for ID stability but the underlying behaviour is gone; they are superseded by FT-N-16 in `blackbox-tests.md`.
|
||||
|
||||
| Removed surface | Endpoint(s) | Affected legacy entries | Status |
|
||||
|-----------------|-------------|-------------------------|--------|
|
||||
| Per-user encrypted resource download | `POST /resources/get/{dataFolder?}` | AC-14 (AES-256-CBC encryption), AC-15 (round-trip), AC-19 (key derivation), FT-P-09, FT-P-10 | **Reverted** — endpoint deleted; `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` and `ResourcesService.GetEncryptedResource` deleted; `GetResourceRequest` DTO deleted; e2e tests `Encrypted_download_returns_octet_stream_and_non_empty_body` and `Encryption_round_trip_decrypt_matches_original_bytes` deleted from `ResourceTests.cs`; e2e test `Per_user_encryption_produces_distinct_ciphertext_for_same_file` deleted from `SecurityTests.cs`; `Azaion.Test/SecurityTest.cs` deleted (and the now-empty `Azaion.Test` project removed from the solution). |
|
||||
| Installer download (production + staging) | `GET /resources/get-installer`, `GET /resources/get-installer/stage` | AC-23 (latest installer), `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` references | **Reverted** — endpoints deleted; `ResourcesService.GetInstaller` deleted; both config properties removed from `appsettings.json`, `.env.example`, `secrets/staging.public.env`, `secrets/production.public.env`, and `docker-compose.test.yml`. No e2e tests had been written for these endpoints, so no tests required removal. |
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| Cycle-2 AC-1 | `POST /resources/get/{dataFolder?}` returns 404 | FT-N-16 | Covered |
|
||||
| Cycle-2 AC-2 | `GET /resources/get-installer` returns 404 | FT-N-16 | Covered |
|
||||
| Cycle-2 AC-3 | `GET /resources/get-installer/stage` returns 404 | FT-N-16 | Covered |
|
||||
| Cycle-2 AC-4 | `ExceptionEnum` no longer carries `WrongResourceName` (50); the gap is preserved | — | Build/CI invariant — verified by enum read |
|
||||
| Cycle-2 AC-5 | `Azaion.Test` project no longer in solution; build is clean | — | Build invariant — `dotnet build Azaion.AdminApi.sln` clean post-cleanup |
|
||||
| Cycle-2 AC-6 | E2E suite passes after the test deletions above | All e2e tests | Covered by Step 11 Run Tests post-cleanup (2026-05-14) |
|
||||
|
||||
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
|
||||
|
||||
Appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the eight tasks delivered by the auth-modernization + CMMC-hardening epics. Rows below are namespaced by tracker ID; functional scenarios live in `blackbox-tests.md`, security-only invariants in `security-tests.md`. Existing AC/test IDs from earlier cycles are preserved unchanged.
|
||||
|
||||
### AZ-536 — Argon2id Password Hashing (epic AZ-530, 5 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-536 AC-1 | New users get Argon2id hashes (PHC, m ≥ 64 MiB, t ≥ 3, p ≥ 1) | NFT-SEC-07 | Covered |
|
||||
| AZ-536 AC-2 | Legacy SHA-384 hashes still validate | FT-P-24 | Covered |
|
||||
| AZ-536 AC-3 | Successful legacy login transparently re-hashes to Argon2id | FT-P-25 | Covered |
|
||||
| AZ-536 AC-4 | Wrong password fails for both formats with the same error code | FT-N-17 | Covered |
|
||||
| AZ-536 AC-5 | Verify is constant-time (no remotely observable timing leak) | NFT-SEC-08 | Covered (with known suite-concurrency flake — see cycle-2 carry-forward F6) |
|
||||
|
||||
### AZ-537 — /login Rate Limit + Account Lockout (epic AZ-530, 6 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-537 AC-1 | Per-IP rate limit triggers HTTP 429 with `Retry-After` | NFT-SEC-09 | Covered (legitimate environment-mismatch skip in shared-IP container env) |
|
||||
| AZ-537 AC-2 | Per-account rate limit triggers HTTP 429 across IPs | NFT-SEC-10 | Covered |
|
||||
| AZ-537 AC-3 | Account lockout after 10 failures returns 423 even on correct password | NFT-SEC-11 | Covered |
|
||||
| AZ-537 AC-4 | Successful login resets `failed_login_count` and clears `lockout_until` | FT-P-26 | Covered |
|
||||
| AZ-537 AC-5 | Lockout auto-expires after configured duration | FT-P-27 | Covered |
|
||||
| AZ-537 AC-6 | Audit-log entry written on each lockout event | NFT-SEC-12 | Covered |
|
||||
|
||||
### AZ-538 — CORS HTTPS-Only + HSTS (epic AZ-530, 5 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-538 AC-1 | HTTP origin gets no `Access-Control-Allow-Origin` header | NFT-SEC-13 | Covered |
|
||||
| AZ-538 AC-2 | HTTPS origin preflight echoes credentials flag | FT-P-28 | Covered |
|
||||
| AZ-538 AC-3 | HSTS header present in production responses | NFT-SEC-14 | Covered (legitimate Production-only environment-mismatch skip in dev test harness — verified by code inspection of `Program.cs UseHsts`) |
|
||||
| AZ-538 AC-4 | HTTP request returns 307 to HTTPS in production | NFT-SEC-15 | Covered (legitimate Production-only environment-mismatch skip in dev test harness — verified by code inspection of `Program.cs UseHttpsRedirection`) |
|
||||
| AZ-538 AC-5 | Development env unchanged (no redirect, no HSTS) | FT-P-29 | Covered |
|
||||
|
||||
### AZ-531 — Refresh-Token Flow (epic AZ-529, 5 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-531 AC-1 | `/login` returns dual tokens, session row persisted | FT-P-30 | Covered |
|
||||
| AZ-531 AC-2 | `/token/refresh` rotates refresh + chains via `parent_session_id` | FT-P-31 | Covered |
|
||||
| AZ-531 AC-3 | Reuse-detection kills the entire session family | NFT-SEC-16 | Covered |
|
||||
| AZ-531 AC-4 | Sliding window + 12 h absolute family expiry | FT-P-32 | Covered |
|
||||
| AZ-531 AC-5 | Refresh tokens are opaque, hashed at rest, never logged in raw form | NFT-SEC-17 | Covered |
|
||||
|
||||
### AZ-532 — Asymmetric Signing + JWKS (epic AZ-529, 5 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-532 AC-1 | Access tokens carry `alg=ES256` + `kid` | NFT-SEC-18 | Covered |
|
||||
| AZ-532 AC-2 | `GET /.well-known/jwks.json` serves the active public key with cache headers | FT-P-33 | Covered |
|
||||
| AZ-532 AC-3 | Two-key overlap during rotation (both JWKS entries valid) | FT-P-34 | Covered |
|
||||
| AZ-532 AC-4 | JWKS never exposes private material | NFT-SEC-19 | Covered |
|
||||
| AZ-532 AC-5 | alg-confusion forgery (HS256 with public key as secret) is rejected | NFT-SEC-20 | Covered |
|
||||
|
||||
### AZ-533 — Mission-Token Issuance for UAV (epic AZ-529, 6 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-533 AC-1 | Mission token issued with correct lifetime (`planned_duration_h + 1h`) | FT-P-35 | Covered |
|
||||
| AZ-533 AC-2 | Hard cap of 12 h enforced (HTTP 400 with cap message) | FT-N-19 | Covered |
|
||||
| AZ-533 AC-3 | Mission token carries `mission_id`, `aircraft_id`, `aud`, `permissions`, `sid`, `jti` | FT-P-36 | Covered |
|
||||
| AZ-533 AC-4 | Mission session auto-revoked when aircraft user reconnects | FT-P-37 | Covered |
|
||||
| AZ-533 AC-5 | Endpoint requires authenticated session | FT-N-18 | Covered |
|
||||
| AZ-533 AC-6 | MFA step-up required (`amr` must include `mfa`) | NFT-SEC-21 | **Spec only** — pending wire-up post-AZ-534 (cycle-2 carry-forward F1) |
|
||||
|
||||
### AZ-534 — TOTP-Based 2FA at Login (epic AZ-529, 6 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-534 AC-1 | Enrollment returns secret + QR + 10 recovery codes | FT-P-38 | Covered |
|
||||
| AZ-534 AC-2 | Confirm with valid TOTP completes enrollment | FT-P-39 | Covered |
|
||||
| AZ-534 AC-3 | Two-step `/login` → `/login/mfa` flow; access-token `amr=["pwd","mfa"]` | FT-P-40 | Covered |
|
||||
| AZ-534 AC-4 | Recovery code substitutes for TOTP and is single-use | FT-P-41 | Covered |
|
||||
| AZ-534 AC-5 | Disable requires password + valid TOTP | FT-P-42 | Covered |
|
||||
| AZ-534 AC-6 | TOTP secret encrypted at rest in `users.mfa_secret` | NFT-SEC-22 | Covered |
|
||||
|
||||
### AZ-535 — Logout + Revocation Surface (epic AZ-529, 5 ACs)
|
||||
|
||||
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|
||||
|-------|---------------------|----------|----------|
|
||||
| AZ-535 AC-1 | `POST /logout` revokes the current session and kills refresh | FT-P-43 | Covered |
|
||||
| AZ-535 AC-2 | `POST /logout/all` revokes every session for the user | FT-P-44 | Covered |
|
||||
| AZ-535 AC-3 | Admin can revoke any session by id; row records actor | FT-P-45 | Covered |
|
||||
| AZ-535 AC-4 | `GET /sessions/revoked?since=…` returns recent, non-expired entries | FT-P-46 | Covered |
|
||||
| AZ-535 AC-5 | `POST /logout` is idempotent (no second DB write) | FT-P-47 | Covered |
|
||||
|
||||
## Cycle 2 Coverage Update
|
||||
|
||||
| Category | Total Items | Covered | Not Yet Wired | Coverage % |
|
||||
|----------|-----------|---------|---------------|-----------|
|
||||
| Acceptance Criteria (cycle 2 — auth modernization) | 43 | 42 | 1 (AZ-533 AC-6 — pending wire-up F1) | 98% |
|
||||
| Acceptance Criteria — combined total (baseline + cycle 1 + cycle 2 cleanup + cycle 2 auth) | 100 | 96 | 1 (F1) + 3 baseline restrictions still uncovered | 96% |
|
||||
|
||||
The single uncovered cycle-2 AC (AZ-533 AC-6) is documented in the cycle-2 implementation report as carry-forward item F1 — the `/sessions/mission` `amr=mfa` enforcement was deferred during AZ-533, became implementable once AZ-534 shipped, and is filed as a follow-up ticket to be picked up in a later cycle.
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
# 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 (post cycle-2 hotfix batch 6; previous 2026-05-14)
|
||||
**Total Tasks**: 25 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 5 done auth-modernization + 6 done cycle-2 hotfix)
|
||||
**Total Complexity Points**: 82 (all done)
|
||||
|
||||
| Task | Name | Complexity | Dependencies | Epic | Status |
|
||||
|--------|-------------------------------|-----------:|-------------------------|--------|--------|
|
||||
| AZ-189 | test_infrastructure | 5 | None | AZ-188 | done |
|
||||
| AZ-190 | auth_tests | 3 | AZ-189 | AZ-188 | done |
|
||||
| AZ-191 | user_mgmt_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-192 | hardware_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-193 | resource_tests | 5 | AZ-189, AZ-190, AZ-192 | AZ-188 | done |
|
||||
| AZ-194 | security_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-195 | resilience_perf_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-183 | resources_table_update_api | 3 | None | AZ-181 | todo |
|
||||
| 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 |
|
||||
| Task | Name | Complexity | Dependencies | Epic | Status |
|
||||
|--------|-------------------------------------|-----------:|-------------------------|--------|--------|
|
||||
| AZ-189 | test_infrastructure | 5 | None | AZ-188 | done |
|
||||
| AZ-190 | auth_tests | 3 | AZ-189 | AZ-188 | done |
|
||||
| AZ-191 | user_mgmt_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-192 | hardware_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-193 | resource_tests | 5 | AZ-189, AZ-190, AZ-192 | AZ-188 | done |
|
||||
| AZ-194 | security_tests | 3 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-195 | resilience_perf_tests | 5 | AZ-189, AZ-190 | AZ-188 | done |
|
||||
| AZ-183 | resources_table_update_api | 3 | None | AZ-181 | done |
|
||||
| AZ-196 | register_device_endpoint | 2 | None | AZ-181 | done |
|
||||
| AZ-197 | remove_hardware_id | 3 | None | AZ-181 | done |
|
||||
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done |
|
||||
| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done |
|
||||
| AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | done |
|
||||
| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | done |
|
||||
| AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | done |
|
||||
| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | done |
|
||||
| AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | done |
|
||||
| AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | done |
|
||||
| AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | done |
|
||||
| AZ-552 | drop_jwt_secret_deploy_preflight | 1 | None | AZ-530 | done |
|
||||
| AZ-553 | bind_mount_es256_keys | 2 | AZ-552 | AZ-530 | done |
|
||||
| AZ-554 | persist_dataprotection_keys | 2 | AZ-553 | AZ-530 | done |
|
||||
| AZ-555 | secrets_readme_es256_rewrite | 1 | AZ-552, AZ-553, AZ-554 | AZ-530 | done |
|
||||
| AZ-556 | unify_login_error_codes | 2 | None | AZ-530 | done |
|
||||
| AZ-557 | mfa_brute_force_lockout | 3 | AZ-534, AZ-537 | AZ-530 | done |
|
||||
|
||||
## 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.
|
||||
- **AZ-552..AZ-557 added 2026-05-14** as the cycle-2 hotfix sprint blocking the next deploy. All six roll up to **AZ-530** per the `cycle-2-hotfix` / `AZ-530-followup` Jira labels. Source of truth: `_docs/05_security/security_report_cycle2.md` "Tracker Follow-Ups" section. 11 story points total. Recommended landing order: AZ-552 → AZ-553 → AZ-554 → AZ-555 (docs) in one PR train; AZ-556 + AZ-557 (auth-surface) can land in parallel with the deploy chain. None of the six depend on the deferred Medium / Low items (AZ-NEW-7..AZ-NEW-15 — see security_report_cycle2.md "Open" table).
|
||||
|
||||
@@ -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/`.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Drop Obsolete `JwtConfig__Secret` From Deploy Preflight
|
||||
|
||||
**Task**: AZ-552_drop_jwt_secret_deploy_preflight
|
||||
**Name**: Drop obsolete `JwtConfig__Secret` from deploy preflight
|
||||
**Description**: `scripts/start-services.sh` still hard-requires `ASPNETCORE_JwtConfig__Secret`, the HS256-era env var that AZ-532 removed. A correctly-configured cycle-2 deploy fails at preflight before the container starts. Replace the check with the new ES256 inputs (`KeysFolder` + `ActiveKid`).
|
||||
**Complexity**: 1 point
|
||||
**Dependencies**: None
|
||||
**Component**: Deploy / scripts
|
||||
**Tracker**: AZ-552
|
||||
**Epic**: AZ-530
|
||||
**CMMC ref**: SC.L2-3.13.11 (FIPS-validated cryptography — cycle-2 ES256 supersedes HS256)
|
||||
**Source**: `_docs/05_security/security_report_cycle2.md` F-INFRA-1 (Critical, deploy-blocking); `_docs/05_security/infrastructure_review_cycle2.md` §F-2026Q2-INFRA-1
|
||||
|
||||
## Problem
|
||||
|
||||
`scripts/start-services.sh` calls `require_env ... ASPNETCORE_JwtConfig__Secret` against the obsolete HS256 symmetric secret. AZ-532 removed `JwtConfig.Secret` from `Azaion.Common/Configs/JwtConfig.cs` — `Program.cs` now configures JwtBearer via `IssuerSigningKeyResolver` backed by `JwtSigningKeyProvider`, which reads ES256 PEMs from `JwtConfig.KeysFolder` and selects the active key by `JwtConfig.ActiveKid`. A cycle-2 deploy that follows the new `.env.example` (which does NOT set `JwtConfig__Secret`) fails the preflight gate and never starts the container. Operators who work around this by setting a dummy `JwtConfig__Secret=dummy` immediately hit F-INFRA-2 (no key folder mounted), so the workaround doesn't help.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Cycle-2 deploys that supply `ASPNETCORE_JwtConfig__KeysFolder` + `ASPNETCORE_JwtConfig__ActiveKid` pass preflight without `JwtConfig__Secret` being set.
|
||||
- Cycle-2 deploys that omit `KeysFolder` or `ActiveKid` fail preflight with a clear, actionable error naming the missing variable.
|
||||
- The deploy script no longer references `JwtConfig__Secret` anywhere.
|
||||
- `.env.example` no longer documents `JwtConfig__Secret`.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- Edit `scripts/start-services.sh`: replace the `require_env ... ASPNETCORE_JwtConfig__Secret` line with the cycle-2 required pair.
|
||||
- Audit `scripts/_lib.sh`, `scripts/deploy.sh`, `scripts/pull-images.sh`, `scripts/health-check.sh` and `.env.example` for any other reference to `JwtConfig__Secret` / `JWT_SECRET`; remove them.
|
||||
- Update `_docs/04_deploy/` if any deploy doc still names `JwtConfig__Secret` as required.
|
||||
|
||||
### Excluded
|
||||
|
||||
- The bind-mount of the keys folder itself — that is AZ-553. This ticket only stops the deploy from failing on the obsolete env var; AZ-553 makes the keys actually reach the container.
|
||||
- `secrets/README.md` rewrite — that is AZ-555.
|
||||
- The suite-level `_infra/deploy/webserver/` flow that still uses `JWT_SECRET`. That is owned by the suite repo, not admin. Logged separately as a process leftover.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Deploy preflight passes without `JwtConfig__Secret`**
|
||||
Given `ASPNETCORE_JwtConfig__KeysFolder=/etc/azaion/jwt-keys` and `ASPNETCORE_JwtConfig__ActiveKid=<kid>` are set
|
||||
And `ASPNETCORE_JwtConfig__Secret` is unset
|
||||
When `scripts/start-services.sh` runs preflight
|
||||
Then preflight completes successfully and the container is started.
|
||||
|
||||
**AC-2: Preflight fails clearly when `KeysFolder` is missing**
|
||||
Given `ASPNETCORE_JwtConfig__ActiveKid` is set but `ASPNETCORE_JwtConfig__KeysFolder` is unset
|
||||
When `scripts/start-services.sh` runs preflight
|
||||
Then the script exits non-zero with an error message that names `ASPNETCORE_JwtConfig__KeysFolder`.
|
||||
|
||||
**AC-3: Preflight fails clearly when `ActiveKid` is missing**
|
||||
Given `ASPNETCORE_JwtConfig__KeysFolder` is set but `ASPNETCORE_JwtConfig__ActiveKid` is unset
|
||||
When `scripts/start-services.sh` runs preflight
|
||||
Then the script exits non-zero with an error message that names `ASPNETCORE_JwtConfig__ActiveKid`.
|
||||
|
||||
**AC-4: No references to `JwtConfig__Secret` remain in `scripts/` or `.env.example`**
|
||||
Given the admin repo at HEAD
|
||||
When `rg "JwtConfig__Secret"` is run against `scripts/` and `.env.example`
|
||||
Then no matches are returned.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Compatibility**
|
||||
- Existing operators with both old and new env vars set must not be broken by the change — the old var is simply ignored.
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Env: `KeysFolder`+`ActiveKid` set, `Secret` unset | Run `start-services.sh` preflight | Preflight passes, container starts | — |
|
||||
| AC-2 | Env: `ActiveKid` set, `KeysFolder` unset | Run `start-services.sh` preflight | Exit non-zero, error names `KeysFolder` | — |
|
||||
| AC-3 | Env: `KeysFolder` set, `ActiveKid` unset | Run `start-services.sh` preflight | Exit non-zero, error names `ActiveKid` | — |
|
||||
| AC-4 | Repo at HEAD | `rg "JwtConfig__Secret" scripts/ .env.example` | Empty result | — |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Must not change any runtime behaviour of the application — this is a script-only change.
|
||||
- Error messages must come from the existing `require_env` helper in `_lib.sh` (do not add a new ad-hoc error path).
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Operators with stale `.env` files**
|
||||
- *Risk*: An operator with an old `.env` that sets `JwtConfig__Secret` but not the new pair will see the deploy fail at preflight.
|
||||
- *Mitigation*: This is the desired behaviour. Document the migration in `secrets/README.md` (AZ-555) so the failure is self-diagnosable.
|
||||
|
||||
**Risk 2: Suite-level `_infra/deploy/webserver/` deploy still works the old way**
|
||||
- *Risk*: The suite-level webserver deploy pipeline at `suite/_infra/deploy/webserver/` injects `JWT_SECRET` and would still appear functional even though it shouldn't. Out-of-scope here; logged as suite-level leftover.
|
||||
- *Mitigation*: Cross-reference the suite-level follow-up ticket in this task's commit message so the linkage is discoverable.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user