mirror of
https://github.com/azaion/admin.git
synced 2026-06-22 12:31:10 +00:00
Compare commits
26 Commits
15631b37cb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b0fe6582 | |||
| 6e1e147562 | |||
| 837b1f2374 | |||
| 5224a12589 | |||
| 8b7d8a4275 | |||
| 4bf2e689cb | |||
| ebde2b2d25 | |||
| f369153149 | |||
| d2b5308b45 | |||
| 1bdbe8c96d | |||
| a77b3f8a59 | |||
| c2c659ef62 | |||
| 1e1ded73f5 | |||
| 8e7c602f51 | |||
| 51a293dbcc | |||
| 491993f9c1 | |||
| 9679b5636f | |||
| 3a925b9b0f | |||
| c7b297de83 | |||
| 43fe38e67d | |||
| 0c9340a1af | |||
| 4914f08aff | |||
| 5e90512987 | |||
| 5ca9ccab2c | |||
| f13c57b314 | |||
| e40ea3eeaa |
@@ -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).
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# =============================================================================
|
||||
# Azaion Admin API — environment variable template
|
||||
# Copy to `.env` (git-ignored) and fill in real values for your environment.
|
||||
# Production secrets MUST come from the secret manager, not from a checked-in
|
||||
# file. See _docs/04_deploy/reports/deploy_status_report.md for the full table.
|
||||
# =============================================================================
|
||||
|
||||
# ---------- ASP.NET Core runtime --------------------------------------------
|
||||
ASPNETCORE_ENVIRONMENT=Development # Development | Staging | Production
|
||||
ASPNETCORE_URLS=http://+:8080 # Kestrel bind address inside the container
|
||||
|
||||
# ---------- Database (PostgreSQL on port 4312 in prod, 5432 in test) --------
|
||||
# Two roles: reader (read-only) and admin (read/write). See env/db/01_permissions.sql.
|
||||
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 (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__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
|
||||
|
||||
# ---------- Container build / image label ------------------------------------
|
||||
# Injected at build time as --build-arg CI_COMMIT_SHA=… by Woodpecker.
|
||||
# Local builds may leave it unset (Dockerfile defaults to "unknown").
|
||||
# CI_COMMIT_SHA=
|
||||
|
||||
# ---------- Deploy targets (consumed by scripts/, not by the API process) ---
|
||||
DEPLOY_HOST=admin.azaion.com # SSH target for scripts/deploy.sh
|
||||
DEPLOY_SSH_USER=root # SSH user on DEPLOY_HOST
|
||||
DEPLOY_CONTAINER_NAME=azaion.api # Docker container name on the host
|
||||
DEPLOY_HOST_PORT=4000 # Port published on DEPLOY_HOST (mapped to 8080 in container)
|
||||
DEPLOY_HOST_CONTENT_DIR=/root/api/content # Bind-mount for resource files
|
||||
DEPLOY_HOST_LOGS_DIR=/root/api/logs # Bind-mount for Serilog rolling files
|
||||
|
||||
# ---------- Container registry ----------------------------------------------
|
||||
REGISTRY_HOST=docker.azaion.com # Private registry; CI may use localhost:5000
|
||||
REGISTRY_IMAGE=azaion/admin # Image path inside REGISTRY_HOST
|
||||
REGISTRY_TAG=dev-arm # main→arm, stage→stage-arm, dev→dev-arm
|
||||
REGISTRY_USER= # CI / scripts only — leave empty in dev .env
|
||||
REGISTRY_TOKEN= # CI / scripts only — leave empty in dev .env
|
||||
@@ -11,3 +11,7 @@ Content/
|
||||
.DS_Store
|
||||
e2e/test-results/*
|
||||
!e2e/test-results/.gitkeep
|
||||
|
||||
# AZ-532 — never commit production JWT signing keys.
|
||||
secrets/jwt-keys/*
|
||||
!secrets/jwt-keys/.gitkeep
|
||||
+39
-4
@@ -2,18 +2,53 @@ when:
|
||||
event: [push, pull_request, manual]
|
||||
branch: [dev, stage, main]
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- PLATFORM: arm64
|
||||
TAG_SUFFIX: arm
|
||||
# - PLATFORM: amd64
|
||||
# TAG_SUFFIX: amd
|
||||
|
||||
labels:
|
||||
platform: arm64
|
||||
platform: ${PLATFORM}
|
||||
|
||||
steps:
|
||||
- name: lint-format
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
commands:
|
||||
- dotnet format Azaion.AdminApi.sln --verify-no-changes --verbosity diagnostic
|
||||
|
||||
- name: unit-tests
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
commands:
|
||||
- dotnet restore Azaion.AdminApi.sln
|
||||
- dotnet test Azaion.AdminApi.sln --no-restore --configuration Release --logger "console;verbosity=normal" --logger "trx;LogFileName=test-results.trx" --results-directory /app/test-results
|
||||
|
||||
- name: e2e-tests
|
||||
- name: deps-audit
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
commands:
|
||||
- dotnet restore e2e/Azaion.E2E/Azaion.E2E.csproj
|
||||
- dotnet test e2e/Azaion.E2E/Azaion.E2E.csproj --no-restore --configuration Release --logger "console;verbosity=normal" --logger "trx;LogFileName=e2e-results.trx" --results-directory /app/test-results
|
||||
# Security audit recommendation 13: fail the build on any High or Critical
|
||||
# vulnerable dependency. The grep returns non-zero when no match is found,
|
||||
# which we want to treat as success — hence the explicit inversion.
|
||||
- dotnet restore Azaion.AdminApi.sln
|
||||
- dotnet list Azaion.AdminApi.sln package --vulnerable --include-transitive 2>&1 | tee deps-audit.log
|
||||
- if grep -E "^\s+>\s+\S+\s+\S+\s+\S+\s+(High|Critical)\s*$" deps-audit.log; then echo "Vulnerable High/Critical dependency found"; exit 1; fi
|
||||
|
||||
- name: e2e-tests
|
||||
image: docker
|
||||
commands:
|
||||
# Mirrors scripts/run-tests.sh: drop volumes from any prior run so the DB
|
||||
# init scripts re-run on a clean data dir, then run compose to completion.
|
||||
- docker compose -f docker-compose.test.yml down -v --remove-orphans
|
||||
- docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from e2e-consumer
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
- name: e2e-cleanup
|
||||
image: docker
|
||||
when:
|
||||
status: [success, failure]
|
||||
commands:
|
||||
- docker compose -f docker-compose.test.yml down -v --remove-orphans
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -29,15 +29,25 @@ steps:
|
||||
from_secret: registry_token
|
||||
commands:
|
||||
- echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
|
||||
- export TAG=${CI_COMMIT_BRANCH}-${TAG_SUFFIX}
|
||||
- export BRANCH_TAG=${CI_COMMIT_BRANCH}-${TAG_SUFFIX}
|
||||
# 12-char SHA prefix is human-readable while still globally-unique inside
|
||||
# the repo. Pair with TAG_SUFFIX so multi-arch entries don't collide.
|
||||
- export SHA_TAG=$(echo "$CI_COMMIT_SHA" | cut -c1-12)-${TAG_SUFFIX}
|
||||
- export BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
- export IMAGE=$REGISTRY_HOST/azaion/admin
|
||||
- |
|
||||
docker build -f Dockerfile \
|
||||
--build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \
|
||||
--build-arg BUILD_DATE=$BUILD_DATE \
|
||||
--label org.opencontainers.image.revision=$CI_COMMIT_SHA \
|
||||
--label org.opencontainers.image.created=$BUILD_DATE \
|
||||
--label org.opencontainers.image.source=$CI_REPO_URL \
|
||||
-t $REGISTRY_HOST/azaion/admin:$TAG .
|
||||
- docker push $REGISTRY_HOST/azaion/admin:$TAG
|
||||
-t $IMAGE:$BRANCH_TAG \
|
||||
-t $IMAGE:$SHA_TAG .
|
||||
# Mutable branch tag for "give me whatever's latest on dev" pulls.
|
||||
- docker push $IMAGE:$BRANCH_TAG
|
||||
# Immutable SHA tag — the deploy scripts pin to this and rollback uses it.
|
||||
- docker push $IMAGE:$SHA_TAG
|
||||
- echo "Pushed $IMAGE:$BRANCH_TAG and $IMAGE:$SHA_TAG"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
+4
-8
@@ -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,13 +6,10 @@ 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
|
||||
.dockerignore = .dockerignore
|
||||
deploy.cmd = deploy.cmd
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
@@ -33,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
|
||||
};
|
||||
}
|
||||
+484
-67
@@ -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;
|
||||
@@ -6,10 +8,15 @@ using Azaion.Common.Entities;
|
||||
using Azaion.Common.Requests;
|
||||
using Azaion.Services;
|
||||
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;
|
||||
@@ -29,9 +36,26 @@ 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.
|
||||
var connectionStrings = builder.Configuration.GetSection(nameof(ConnectionStrings)).Get<ConnectionStrings>();
|
||||
if (connectionStrings == null
|
||||
|| string.IsNullOrEmpty(connectionStrings.AzaionDb)
|
||||
|| string.IsNullOrEmpty(connectionStrings.AzaionDbAdmin))
|
||||
throw new Exception("Missing configuration section: ConnectionStrings (AzaionDb and AzaionDbAdmin are required)");
|
||||
|
||||
// Graceful shutdown: 30 s for in-flight requests; pair with `docker stop -t 40`.
|
||||
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(30));
|
||||
|
||||
builder.Services.AddSerilog();
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
@@ -45,7 +69,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtConfig.Issuer,
|
||||
ValidAudience = jwtConfig.Audience,
|
||||
IssuerSigningKey = signingKey
|
||||
// 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] : [];
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,13 +87,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
var apiAdminPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
|
||||
|
||||
var apiUploaderPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole(RoleEnum.ResourceUploader.ToString(), 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(apiUploaderPolicy), apiUploaderPolicy);
|
||||
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
|
||||
});
|
||||
|
||||
#endregion Policies
|
||||
@@ -93,10 +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();
|
||||
@@ -105,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())
|
||||
@@ -124,21 +272,309 @@ 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"));
|
||||
|
||||
#region Health endpoints
|
||||
// Anonymous; expected to be exposed only on the management interface (not via the
|
||||
// public Nginx vhost). Surface contract documented in
|
||||
// _docs/04_deploy/deployment_procedures.md §2 and observability.md §7.
|
||||
|
||||
app.MapGet("/health/live", (HttpContext http) =>
|
||||
{
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return Results.Ok(new { status = "live" });
|
||||
}).AllowAnonymous().ExcludeFromDescription();
|
||||
|
||||
app.MapGet("/health/ready", async (IDbFactory dbFactory, HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
await dbFactory.Run(db => db.ExecuteAsync<int>("SELECT 1"));
|
||||
await dbFactory.RunAdmin(db => db.ExecuteAsync<int>("SELECT 1"));
|
||||
return Results.Ok(new { status = "ready" });
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
return Results.Json(new { status = "not-ready", reason = "db-timeout" }, statusCode: 503);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Json(new { status = "not-ready", reason = ex.GetType().Name }, statusCode: 503);
|
||||
}
|
||||
}).AllowAnonymous().ExcludeFromDescription();
|
||||
#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,
|
||||
@@ -153,6 +589,12 @@ app.MapPost("/users",
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Creates a new user");
|
||||
|
||||
app.MapPost("/devices",
|
||||
async (IUserService userService, CancellationToken cancellationToken)
|
||||
=> await userService.RegisterDevice(cancellationToken))
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Creates a new device (server-assigned serial, email and password)");
|
||||
|
||||
app.MapGet("/users/current",
|
||||
async (IAuthService authService) => await authService.GetCurrentUser())
|
||||
.RequireAuthorization()
|
||||
@@ -164,12 +606,6 @@ app.MapGet("/users",
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("List users by criteria");
|
||||
|
||||
app.MapPut("/users/hardware/set",
|
||||
async ([FromBody]SetHWRequest request, IUserService userService, ICache cache, CancellationToken ct) =>
|
||||
await userService.UpdateHardware(request.Email, request.Hardware, ct: ct))
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Sets user's hardware");
|
||||
|
||||
app.MapPut("/users/queue-offsets/set",
|
||||
async ([FromBody]SetUserQueueOffsetsRequest request, IUserService userService, CancellationToken ct)
|
||||
=> await userService.UpdateQueueOffsets(request.Email, request.Offsets, ct))
|
||||
@@ -219,59 +655,40 @@ 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,
|
||||
IUserService userService, IResourcesService resourcesService, CancellationToken ct) =>
|
||||
app.MapPost("/classes",
|
||||
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
||||
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
||||
{
|
||||
var user = await authService.GetCurrentUser();
|
||||
if (user == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
var validation = await validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
return Results.ValidationProblem(validation.ToDictionary());
|
||||
var created = await detectionClassService.Create(request, ct);
|
||||
return Results.Ok(created);
|
||||
})
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Creates a new detection class");
|
||||
|
||||
var hwHash = await userService.CheckHardwareHash(user, request.Hardware);
|
||||
|
||||
var key = Security.GetApiEncryptionKey(user.Email, request.Password, hwHash);
|
||||
var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct);
|
||||
|
||||
return Results.File(stream, "application/octet-stream", request.FileName);
|
||||
}).RequireAuthorization()
|
||||
.WithSummary("Gets encrypted by users Password and HardwareHash resources. POST method for secure password");
|
||||
|
||||
app.MapGet("/resources/get-installer",
|
||||
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
||||
app.MapPatch("/classes/{id:int}",
|
||||
async (int id, UpdateDetectionClassRequest request, IValidator<UpdateDetectionClassRequest> validator,
|
||||
IDetectionClassService detectionClassService, 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");
|
||||
var validation = await validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
return Results.ValidationProblem(validation.ToDictionary());
|
||||
var updated = await detectionClassService.Update(id, request, ct);
|
||||
return updated == null ? Results.NotFound() : Results.Ok(updated);
|
||||
})
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Updates an existing detection class (partial-merge accepted)");
|
||||
|
||||
app.MapGet("/resources/get-installer/stage",
|
||||
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
||||
app.MapDelete("/classes/{id:int}",
|
||||
async (int id, IDetectionClassService detectionClassService, 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("/resources/check",
|
||||
async (CheckResourceRequest request, IAuthService authService, IUserService userService) =>
|
||||
{
|
||||
var user = await authService.GetCurrentUser();
|
||||
if (user == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
await userService.CheckHardwareHash(user, request.Hardware);
|
||||
return true;
|
||||
});
|
||||
var ok = await detectionClassService.Delete(id, ct);
|
||||
return ok ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Deletes a detection class");
|
||||
|
||||
app.UseExceptionHandler(_ => {});
|
||||
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="11.10.0" />
|
||||
<PackageReference Include="linq2db" Version="5.4.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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,18 +53,55 @@ public enum ExceptionEnum
|
||||
|
||||
WrongEmail = 37,
|
||||
|
||||
// AZ-556 — DEPRECATED: see the `NoEmailFound` deprecation note above.
|
||||
[Description("User account is disabled.")]
|
||||
UserDisabled = 38,
|
||||
|
||||
[Description("Hardware mismatch! You are not authorized to access this resource from this hardware.")]
|
||||
HardwareIdMismatch = 40,
|
||||
// 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,
|
||||
|
||||
[Description("Hardware should be not empty.")]
|
||||
BadHardware = 45,
|
||||
// AZ-556 — DEPRECATED: see the `AccountLocked` deprecation note above.
|
||||
[Description("Too many login attempts. Try again later.")]
|
||||
LoginRateLimited = 51,
|
||||
|
||||
[Description("Wrong resource file name.")]
|
||||
WrongResourceName = 50,
|
||||
[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
|
||||
}
|
||||
@@ -4,6 +4,39 @@ public class JwtConfig
|
||||
{
|
||||
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!;
|
||||
}
|
||||
@@ -7,4 +7,7 @@ namespace Azaion.Common.Database;
|
||||
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,8 +34,30 @@ 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")
|
||||
.Property(x => x.Id)
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public interface IDbFactory
|
||||
Task<T> Run<T>(Func<AzaionDb, Task<T>> func);
|
||||
Task Run(Func<AzaionDb, Task> func);
|
||||
Task RunAdmin(Func<AzaionDb, Task> func);
|
||||
Task<T> RunAdmin<T>(Func<AzaionDb, Task<T>> func);
|
||||
}
|
||||
|
||||
public class DbFactory : IDbFactory
|
||||
@@ -54,4 +55,10 @@ public class DbFactory : IDbFactory
|
||||
await using var db = new AzaionDb(_dataOptionsAdmin);
|
||||
await func(db);
|
||||
}
|
||||
|
||||
public async Task<T> RunAdmin<T>(Func<AzaionDb, Task<T>> func)
|
||||
{
|
||||
await using var db = new AzaionDb(_dataOptionsAdmin);
|
||||
return await func(db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Azaion.Common.Entities;
|
||||
|
||||
public class DetectionClass
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string ShortName { get; set; } = null!;
|
||||
public string Color { get; set; } = null!;
|
||||
public double MaxSizeM { get; set; }
|
||||
public string? PhotoMode { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
public class CreateDetectionClassRequest
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
public string ShortName { get; set; } = null!;
|
||||
public string Color { get; set; } = null!;
|
||||
public double MaxSizeM { get; set; }
|
||||
public string? PhotoMode { get; set; }
|
||||
}
|
||||
|
||||
public class CreateDetectionClassValidator : AbstractValidator<CreateDetectionClassRequest>
|
||||
{
|
||||
public CreateDetectionClassValidator()
|
||||
{
|
||||
RuleFor(r => r.Name).NotEmpty().MaximumLength(120);
|
||||
RuleFor(r => r.ShortName).NotEmpty().MaximumLength(20);
|
||||
RuleFor(r => r.Color).NotEmpty().MaximumLength(20);
|
||||
RuleFor(r => r.MaxSizeM).GreaterThan(0);
|
||||
RuleFor(r => r.PhotoMode!).MaximumLength(20).When(r => r.PhotoMode != null);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
public class CheckResourceRequest
|
||||
{
|
||||
public string Hardware { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class GetResourceRequest
|
||||
{
|
||||
public string Password { get; set; } = null!;
|
||||
public string Hardware { 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.Hardware)
|
||||
.NotEmpty()
|
||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.BadHardware));
|
||||
|
||||
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,8 @@
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
public class RegisterDeviceResponse
|
||||
{
|
||||
public string Serial { get; set; } = null!;
|
||||
public string Email { get; set; } = null!;
|
||||
public string Password { get; set; } = null!;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
public class SetHWRequest
|
||||
{
|
||||
public string Email { get; set; } = null!;
|
||||
public string? Hardware { get; set; }
|
||||
}
|
||||
|
||||
public class SetHWRequestValidator : AbstractValidator<SetHWRequest>
|
||||
{
|
||||
public SetHWRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Email).NotEmpty()
|
||||
.WithErrorCode(ExceptionEnum.EmailLengthIncorrect.ToString())
|
||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.EmailLengthIncorrect));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Azaion.Common.Requests;
|
||||
|
||||
public class UpdateDetectionClassRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? ShortName { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public double? MaxSizeM { get; set; }
|
||||
public string? PhotoMode { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateDetectionClassValidator : AbstractValidator<UpdateDetectionClassRequest>
|
||||
{
|
||||
public UpdateDetectionClassValidator()
|
||||
{
|
||||
RuleFor(r => r.Name!).NotEmpty().MaximumLength(120).When(r => r.Name != null);
|
||||
RuleFor(r => r.ShortName!).NotEmpty().MaximumLength(20).When(r => r.ShortName != null);
|
||||
RuleFor(r => r.Color!).NotEmpty().MaximumLength(20).When(r => r.Color != null);
|
||||
RuleFor(r => r.MaxSizeM!.Value).GreaterThan(0).When(r => r.MaxSizeM != null);
|
||||
RuleFor(r => r.PhotoMode!).MaximumLength(20).When(r => r.PhotoMode != null);
|
||||
}
|
||||
}
|
||||
@@ -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.1" />
|
||||
<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,57 @@
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Common.Entities;
|
||||
using Azaion.Common.Requests;
|
||||
using LinqToDB;
|
||||
|
||||
namespace Azaion.Services;
|
||||
|
||||
public interface IDetectionClassService
|
||||
{
|
||||
Task<DetectionClass> Create(CreateDetectionClassRequest request, CancellationToken ct = default);
|
||||
Task<DetectionClass?> Update(int id, UpdateDetectionClassRequest request, CancellationToken ct = default);
|
||||
Task<bool> Delete(int id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DetectionClassService(IDbFactory dbFactory) : IDetectionClassService
|
||||
{
|
||||
public async Task<DetectionClass> Create(CreateDetectionClassRequest request, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var entity = new DetectionClass
|
||||
{
|
||||
Name = request.Name,
|
||||
ShortName = request.ShortName,
|
||||
Color = request.Color,
|
||||
MaxSizeM = request.MaxSizeM,
|
||||
PhotoMode = request.PhotoMode,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var newId = await db.InsertWithInt32IdentityAsync(entity, token: ct);
|
||||
entity.Id = newId;
|
||||
return entity;
|
||||
});
|
||||
|
||||
public async Task<DetectionClass?> Update(int id, UpdateDetectionClassRequest request, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var existing = await db.DetectionClasses.FirstOrDefaultAsync(x => x.Id == id, token: ct);
|
||||
if (existing == null)
|
||||
return null;
|
||||
|
||||
if (request.Name != null) existing.Name = request.Name;
|
||||
if (request.ShortName != null) existing.ShortName = request.ShortName;
|
||||
if (request.Color != null) existing.Color = request.Color;
|
||||
if (request.MaxSizeM.HasValue) existing.MaxSizeM = request.MaxSizeM.Value;
|
||||
if (request.PhotoMode != null) existing.PhotoMode = request.PhotoMode;
|
||||
|
||||
await db.UpdateAsync(existing, token: ct);
|
||||
return existing;
|
||||
});
|
||||
|
||||
public async Task<bool> Delete(int id, CancellationToken ct = default) =>
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var deleted = await db.DetectionClasses.DeleteAsync(x => x.Id == id, token: ct);
|
||||
return deleted > 0;
|
||||
});
|
||||
}
|
||||
@@ -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
-47
@@ -1,64 +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 GetHWHash(string hardware) =>
|
||||
$"Azaion_{hardware}_%$$$)0_".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 string GetApiEncryptionKey(string email, string password, string? hardwareHash) =>
|
||||
$"{email}-{password}-{hardwareHash}-#%@AzaionKey@%#---".ToHash();
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
+234
-57
@@ -1,47 +1,120 @@
|
||||
using Azaion.Common;
|
||||
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;
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default);
|
||||
Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default);
|
||||
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
|
||||
Task<User?> GetByEmail(string? email, CancellationToken ct = default);
|
||||
Task UpdateHardware(string email, string? hardware = null, 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<string> CheckHardwareHash(User user, string hardware, 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)
|
||||
private const int SerialNumberLength = 4;
|
||||
private const int DevicePasswordBytes = 16; // hex-encoded → 32 chars
|
||||
|
||||
public async Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
{
|
||||
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, token: ct);
|
||||
if (existingUser != null)
|
||||
throw new BusinessException(ExceptionEnum.EmailExists);
|
||||
|
||||
await db.InsertAsync(new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = request.Email,
|
||||
PasswordHash = request.Password.ToHash(),
|
||||
PasswordHash = Security.HashPassword(request.Password),
|
||||
Role = request.Role,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsEnabled = true
|
||||
}, token: ct);
|
||||
});
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
throw new BusinessException(ExceptionEnum.EmailExists);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default)
|
||||
{
|
||||
var (serial, email) = await NextDeviceIdentity(ct);
|
||||
var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(DevicePasswordBytes)).ToLowerInvariant();
|
||||
|
||||
await RegisterUser(new RegisterUserRequest
|
||||
{
|
||||
Email = email,
|
||||
Password = password,
|
||||
Role = RoleEnum.CompanionPC
|
||||
}, ct);
|
||||
|
||||
return new RegisterDeviceResponse
|
||||
{
|
||||
Serial = serial,
|
||||
Email = email,
|
||||
Password = password
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(string Serial, string Email)> NextDeviceIdentity(CancellationToken ct) =>
|
||||
await dbFactory.Run(async db =>
|
||||
{
|
||||
var lastEmail = await db.Users
|
||||
.Where(u => u.Role == RoleEnum.CompanionPC)
|
||||
.OrderByDescending(u => u.CreatedAt)
|
||||
.Select(u => u.Email)
|
||||
.FirstOrDefaultAsync(token: ct);
|
||||
|
||||
var nextNumber = 0;
|
||||
if (!string.IsNullOrEmpty(lastEmail) && lastEmail.Length >= SerialNumberStart + SerialNumberLength)
|
||||
{
|
||||
var serialPart = lastEmail.Substring(SerialNumberStart, SerialNumberLength);
|
||||
if (int.TryParse(serialPart, out var current))
|
||||
nextNumber = current + 1;
|
||||
}
|
||||
|
||||
var serial = $"{DeviceEmailPrefix}{nextNumber.ToString($"D{SerialNumberLength}")}";
|
||||
var email = $"{serial}{DeviceEmailDomain}";
|
||||
return (serial, email);
|
||||
});
|
||||
|
||||
public async Task<User?> GetByEmail(string? email, CancellationToken ct = default)
|
||||
{
|
||||
@@ -52,32 +125,165 @@ 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 db.Users.FirstOrDefaultAsync(x => x.Email == request.Email, token: ct);
|
||||
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)
|
||||
throw new BusinessException(ExceptionEnum.NoEmailFound);
|
||||
|
||||
if (request.Password.ToHash() != user.PasswordHash)
|
||||
throw new BusinessException(ExceptionEnum.WrongPassword);
|
||||
|
||||
if (!user.IsEnabled)
|
||||
throw new BusinessException(ExceptionEnum.UserDisabled);
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
|
||||
public async Task UpdateHardware(string email, string? hardware = null, CancellationToken ct = default)
|
||||
{
|
||||
Security.VerifyDummy(request.Password);
|
||||
await auditLog.RecordLoginFailedUnknownEmail(request.Email, ct);
|
||||
throw new BusinessException(ExceptionEnum.InvalidCredentials);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 =>
|
||||
{
|
||||
await db.Users.UpdateAsync(x => x.Email == email,
|
||||
u => new User { Hardware = hardware }, token: ct);
|
||||
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);
|
||||
}
|
||||
});
|
||||
cache.Invalidate(User.GetCacheKey(email));
|
||||
|
||||
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)
|
||||
@@ -106,35 +312,6 @@ public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
||||
u => u.Role == searchRole)
|
||||
.ToListAsync(token: ct));
|
||||
|
||||
public async Task<string> CheckHardwareHash(User user, string hardware, CancellationToken ct = default)
|
||||
{
|
||||
var requestHWHash = Security.GetHWHash(hardware);
|
||||
|
||||
//For the new users Hardware would be empty, fill it with actual hardware on the very first request
|
||||
if (string.IsNullOrEmpty(user.Hardware))
|
||||
{
|
||||
await UpdateHardware(user.Email, hardware, ct);
|
||||
cache.Invalidate(User.GetCacheKey(user.Email));
|
||||
await UpdateLastLoginDate(user, ct);
|
||||
return requestHWHash;
|
||||
}
|
||||
|
||||
var userHWHash = Security.GetHWHash(user.Hardware);
|
||||
if (userHWHash != requestHWHash)
|
||||
throw new BusinessException(ExceptionEnum.HardwareIdMismatch);
|
||||
await UpdateLastLoginDate(user, ct);
|
||||
return userHWHash;
|
||||
}
|
||||
|
||||
private async Task UpdateLastLoginDate(User user, CancellationToken ct = default)
|
||||
{
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
await db.Users.UpdateAsync(x => x.Email == user.Email, u => new User
|
||||
{
|
||||
LastLogin = DateTime.UtcNow
|
||||
}, ct));
|
||||
}
|
||||
|
||||
public async Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default)
|
||||
{
|
||||
await dbFactory.RunAdmin(async db =>
|
||||
|
||||
@@ -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,112 +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 hardwareId = "test_hardware_id";
|
||||
|
||||
var key = Security.GetApiEncryptionKey(email, password, hardwareId);
|
||||
|
||||
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 hardwareId = "test_hardware_id";
|
||||
|
||||
var key = Security.GetApiEncryptionKey(username, password, hardwareId);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Azaion.Common.Configs;
|
||||
using Azaion.Common.Database;
|
||||
using Azaion.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Test;
|
||||
|
||||
public class UserServiceTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHardwareHashTest()
|
||||
{
|
||||
var dbFactory = new DbFactory(new OptionsWrapper<ConnectionStrings>(new ConnectionStrings
|
||||
{
|
||||
AzaionDb = "Host=188.245.120.247;Port=4312;Database=azaion;Username=azaion_reader;Password=A@1n_zxre@d!only@$Az",
|
||||
AzaionDbAdmin = "Host=188.245.120.247;Port=4312;Database=azaion;Username=azaion_admin;Password=Az@1on_Oddmin$$@r"
|
||||
}));
|
||||
var userService = new UserService(dbFactory, new MemoryCache());
|
||||
var user = await userService.GetByEmail("spielberg@azaion.com");
|
||||
if (user == null)
|
||||
throw new Exception("User not found");
|
||||
|
||||
var res = await userService.CheckHardwareHash(user,
|
||||
"CPU: AMD Ryzen 9 3900XT 12-Core Processor. GPU: Microsoft Remote Display Adapter. Memory: 67037080. DriveSerial: PHMB746301G6480DGN _00000001.");
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -1,4 +1,14 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
# curl is needed by the HEALTHCHECK below. CA certs and ICU are already in the
|
||||
# aspnet:10.0 image. Trim the apt cache to keep the layer small.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Non-root user (security audit F-6 / AZ-518). The aspnet:10.0 image ships an
|
||||
# `app` user; we only need to create + chown the dirs that get bind-mounted
|
||||
# from the host so the runtime can write to them.
|
||||
RUN mkdir -p /app/Content /app/logs \
|
||||
&& chown -R app:app /app
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -19,7 +29,15 @@ RUN arch=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
|
||||
# Build runtime
|
||||
FROM base AS final
|
||||
ARG CI_COMMIT_SHA=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
ENV AZAION_REVISION=$CI_COMMIT_SHA
|
||||
LABEL org.opencontainers.image.title="azaion.admin-api" \
|
||||
org.opencontainers.image.revision="$CI_COMMIT_SHA" \
|
||||
org.opencontainers.image.created="$BUILD_DATE" \
|
||||
org.opencontainers.image.source="https://git.azaion.com/azaion/admin"
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
COPY --from=publish --chown=app:app /app/publish .
|
||||
USER app
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl --fail --silent --show-error http://localhost:8080/health/live || exit 1
|
||||
ENTRYPOINT ["dotnet", "Azaion.AdminApi.dll"]
|
||||
|
||||
@@ -2,20 +2,36 @@
|
||||
|
||||
## 1. System Context
|
||||
|
||||
**Problem being solved**: Azaion Suite requires a centralized admin API to manage users, assign roles, bind hardware to user accounts, and securely distribute encrypted software resources (DLLs, AI models, installers) to authorized devices.
|
||||
**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, hardware fingerprint validation.
|
||||
- **Outside**: Client applications (Azaion Suite desktop app, admin web panel at admin.azaion.com), 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, 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, hardware check, 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
|
||||
|
||||
@@ -26,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 |
|
||||
@@ -52,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
|
||||
|
||||
@@ -60,19 +84,25 @@
|
||||
|
||||
| Entity | Description | Owned By Component |
|
||||
|--------|-------------|--------------------|
|
||||
| User | System user with email, password hash, hardware binding, role, config | 01 Data Layer |
|
||||
| UserConfig | JSON-serialized per-user configuration (queue offsets) | 01 Data Layer |
|
||||
| RoleEnum | Authorization role hierarchy (None → ApiAdmin) | 01 Data Layer |
|
||||
| ExceptionEnum | Business error code catalog | 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 |
|
||||
|
||||
**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 credentials + hardware)
|
||||
- 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
|
||||
|
||||
@@ -80,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
|
||||
|
||||
@@ -98,27 +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
|
||||
- `apiUploaderPolicy` — requires `ResourceUploader` or `ApiAdmin` (defined but never applied to any endpoint)
|
||||
- `apiAdminPolicy` — requires `ApiAdmin`
|
||||
- `revocationReaderPolicy` — requires `Service` OR `ApiAdmin` (verifier fleet)
|
||||
- General `[Authorize]` — any authenticated user
|
||||
|
||||
**Data protection**:
|
||||
- At rest: Resources encrypted with AES-256-CBC using per-user derived key (email + password + hardware hash)
|
||||
- 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
|
||||
|
||||
@@ -138,21 +183,26 @@ 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 hardware.
|
||||
**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, password, and hardware hash. The client must know all three 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.
|
||||
**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
|
||||
### ADR-004: Hardware Fingerprint Binding — RETIRED (AZ-197)
|
||||
|
||||
**Context**: Resources should only be usable on a specific physical machine.
|
||||
**Original context**: Resources should only be usable on a specific physical machine.
|
||||
|
||||
**Decision**: On first resource access, the user's hardware fingerprint string is stored. Subsequent accesses compare the hash of the provided hardware against the stored value.
|
||||
**Original decision**: On first resource access, the user's hardware fingerprint string was stored. Subsequent accesses compared the hash of the provided hardware against the stored value.
|
||||
|
||||
**Consequences**: Ties resources to a single device. Hardware changes require admin intervention to reset. The raw hardware string is stored in the DB; only the hash is compared.
|
||||
**Retirement decision (2026-05-13, AZ-197)**: The threat model that motivated this binding (credential reuse across machines via desktop installers) no longer applies:
|
||||
|
||||
- **Edge devices** ship as **fTPM-secured Jetsons** (secure boot, fTPM-protected key storage, no user filesystem access, no installer redistribution). Hardware identity is anchored in the fTPM, not in a SHA-384 of CPU/GPU/Memory/DriveSerial strings.
|
||||
- **Server / desktop access** is **SaaS-only** (browser → admin API). There is no installer to copy and no hardware fingerprint to take.
|
||||
|
||||
The binding's only remaining effect was a real production failure mode (`HardwareIdMismatch`, error code 40) on legitimate hardware events. AZ-197 removed `CheckHardwareHash`, `UpdateHardware`, `Security.GetHWHash`, the `PUT /users/hardware/set` and `POST /resources/check` endpoints, and the `Hardware` field from `GetResourceRequest`. The `User.Hardware` DB column is a nullable tombstone (no migration in AZ-197; separate ticket if/when the column is dropped).
|
||||
|
||||
### ADR-005: linq2db over Entity Framework
|
||||
|
||||
@@ -161,3 +211,68 @@ No explicit availability, latency, throughput, or recovery targets found in the
|
||||
**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,27 +29,66 @@
|
||||
|
||||
### Entities
|
||||
|
||||
> **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)
|
||||
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)
|
||||
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)
|
||||
|
||||
UserConfig:
|
||||
QueueOffsets: UserQueueOffsets? (optional)
|
||||
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)
|
||||
|
||||
UserQueueOffsets:
|
||||
AnnotationsOffset: ulong
|
||||
AnnotationsConfirmOffset: ulong
|
||||
AnnotationsCommandsOffset: ulong
|
||||
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)
|
||||
|
||||
RoleEnum: None=0, Operator=10, Validator=20, CompanionPC=30, Admin=40, ResourceUploader=50, ApiAdmin=1000
|
||||
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
|
||||
@@ -59,16 +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
|
||||
```
|
||||
|
||||
## 3. External API Specification
|
||||
@@ -81,23 +137,35 @@ N/A — internal component.
|
||||
|
||||
| Query | Frequency | Hot Path | Index Needed |
|
||||
|-------|-----------|----------|--------------|
|
||||
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes (email) |
|
||||
| `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 |
|
||||
| `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 hardware update, queue offset update, hardware check |
|
||||
| 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) |
|
||||
|
||||
> 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 | ~500 bytes | ~500 KB | Low |
|
||||
| `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
|
||||
|
||||
@@ -116,7 +184,7 @@ N/A — internal component.
|
||||
| linq2db | 5.4.1 | ORM for PostgreSQL access |
|
||||
| Npgsql | 10.0.1 | PostgreSQL ADO.NET provider |
|
||||
| LazyCache | 2.4.0 | In-memory cache with async support |
|
||||
| Newtonsoft.Json | 13.0.1 | JSON serialization for UserConfig |
|
||||
| Newtonsoft.Json | 13.0.4 | JSON serialization for UserConfig (bumped from 13.0.1 by security audit D-1, GHSA-5crp-9r3c-p9vr) |
|
||||
|
||||
**Error Handling Strategy**:
|
||||
- `DbFactory.LoadOptions` throws `ArgumentException` on empty connection strings (fail-fast at startup).
|
||||
@@ -163,11 +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/Database/AzaionDb`
|
||||
- `Common/Database/AzaionDbSchemaHolder`
|
||||
- `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` (`Sessions` and `AuditEvents` ITables added in cycle 2)
|
||||
- `Common/Database/AzaionDbSchemaHolder` (Session + AuditEvent mappings, jsonb for `MfaRecoveryCodes`)
|
||||
- `Common/Database/DbFactory`
|
||||
- `Services/Cache`
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# User Management
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — hardware-binding methods (`UpdateHardware`, `CheckHardwareHash`) and `SetHWRequest` were removed by AZ-197; the `ValidateUser` error set now includes `UserDisabled`; `RegisterDevice` was added by AZ-196 to back the new `POST /devices` endpoint. Post-cycle-1 (security audit F-3): `RegisterDevice` now reuses `RegisterUser` for the row insert; the duplicate-row race was closed by adding a UNIQUE INDEX on `users.email` (`env/db/06_users_email_unique.sql`) and translating `Npgsql.PostgresException(SqlState=23505)` to `BusinessException(EmailExists)` inside `RegisterUser`.
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: Full user lifecycle management — registration, credential validation, hardware binding, role changes, account enable/disable, and deletion.
|
||||
**Purpose**: Full user lifecycle management — web-user registration, credential validation, role changes, account enable/disable, deletion, plus auto-provisioning of CompanionPC device users.
|
||||
|
||||
**Architectural Pattern**: Service layer — stateless business logic operating on the Data Layer through `IDbFactory`.
|
||||
|
||||
**Upstream dependencies**: Data Layer (IDbFactory, ICache, User entity), Security & Cryptography (hashing).
|
||||
**Upstream dependencies**: Data Layer (IDbFactory, ICache, User entity), Security & Cryptography (hashing), `System.Security.Cryptography.RandomNumberGenerator` (device password entropy).
|
||||
|
||||
**Downstream consumers**: Admin API (endpoint handlers), Authentication (GetByEmail).
|
||||
|
||||
@@ -16,18 +18,19 @@
|
||||
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `RegisterUser` | `RegisterUserRequest, CancellationToken` | void | Yes | `BusinessException(EmailExists)` |
|
||||
| `ValidateUser` | `LoginRequest, CancellationToken` | `User` | Yes | `BusinessException(NoEmailFound, WrongPassword)` |
|
||||
| `RegisterUser` | `RegisterUserRequest, CancellationToken` | void | Yes | `BusinessException(EmailExists)` — translated from `PostgresException(23505)` after the F-3 hardening |
|
||||
| `RegisterDevice` | `CancellationToken` | `RegisterDeviceResponse` | Yes | `BusinessException(EmailExists)` (propagated from `RegisterUser`) — added by AZ-196, refactored post-audit to call `RegisterUser` end-to-end |
|
||||
| `ValidateUser` | `LoginRequest, CancellationToken` | `User` | Yes | `BusinessException(NoEmailFound, WrongPassword, UserDisabled)` |
|
||||
| `GetByEmail` | `string? email, CancellationToken` | `User?` | Yes | `ArgumentNullException` |
|
||||
| `UpdateHardware` | `string email, string? hardware, CancellationToken` | void | Yes | None |
|
||||
| `UpdateQueueOffsets` | `string email, UserQueueOffsets, CancellationToken` | void | Yes | None |
|
||||
| `GetUsers` | `string? searchEmail, RoleEnum? searchRole, CancellationToken` | `IEnumerable<User>` | Yes | None |
|
||||
| `CheckHardwareHash` | `User, string hardware, CancellationToken` | `string` (hash) | Yes | `BusinessException(HardwareIdMismatch)` |
|
||||
| `ChangeRole` | `string email, RoleEnum, CancellationToken` | void | Yes | None |
|
||||
| `SetEnableStatus` | `string email, bool, CancellationToken` | void | Yes | None |
|
||||
| `RemoveUser` | `string email, CancellationToken` | void | Yes | None |
|
||||
|
||||
**Input DTOs**:
|
||||
**Removed by AZ-197**: `UpdateHardware`, `CheckHardwareHash`, and the private `UpdateLastLoginDate` helper.
|
||||
|
||||
**Input / Output DTOs**:
|
||||
```
|
||||
RegisterUserRequest:
|
||||
Email: string (required) — validated: min 8 chars, valid email format
|
||||
@@ -38,9 +41,10 @@ LoginRequest:
|
||||
Email: string (required)
|
||||
Password: string (required)
|
||||
|
||||
SetHWRequest:
|
||||
Email: string (required, validated: not empty)
|
||||
Hardware: string? (optional — null clears hardware)
|
||||
RegisterDeviceResponse (AZ-196):
|
||||
Serial: string ("azj-NNNN", zero-padded)
|
||||
Email: string ("azj-NNNN@azaion.com")
|
||||
Password: string (32-char hex, plaintext, exposed exactly once)
|
||||
|
||||
SetUserQueueOffsetsRequest:
|
||||
Email: string (required)
|
||||
@@ -67,7 +71,7 @@ N/A — exposed through Admin API component.
|
||||
|
||||
| Data | Cache Type | TTL | Invalidation |
|
||||
|------|-----------|-----|-------------|
|
||||
| User by email | In-memory (via ICache) | 4 hours | After UpdateHardware, UpdateQueueOffsets, CheckHardwareHash (first login) |
|
||||
| User by email | In-memory (via ICache) | 4 hours | After `UpdateQueueOffsets` (only — `UpdateHardware` / `CheckHardwareHash` invalidations are gone with AZ-197) |
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
@@ -89,20 +93,21 @@ N/A — exposed through Admin API component.
|
||||
|
||||
| Helper | Purpose | Used By |
|
||||
|--------|---------|---------|
|
||||
| `Security.ToHash` | Password hashing (SHA-384) | RegisterUser, ValidateUser |
|
||||
| `Security.GetHWHash` | Hardware fingerprint hashing | CheckHardwareHash |
|
||||
| `Security.ToHash` | Password hashing (SHA-384) | RegisterUser, RegisterDevice, ValidateUser |
|
||||
| `RandomNumberGenerator.GetBytes(16)` + `Convert.ToHexString` | 32-char hex device password | RegisterDevice |
|
||||
| `QueryableExtensions.WhereIf` | Conditional LINQ filters | GetUsers |
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations**:
|
||||
- No pagination on `GetUsers` — returns all matching users.
|
||||
- `CheckHardwareHash` auto-stores hardware on first access (no explicit admin approval step).
|
||||
- `RemoveUser` is a hard delete, not soft delete.
|
||||
- `RegisterDevice` returns the plaintext password to the caller exactly once; if the provisioning script loses it, the device must be re-registered.
|
||||
- The `User.Hardware` column is left in place but unused (AZ-197 chose to leave the column nullable rather than ship a migration).
|
||||
|
||||
**Potential race conditions**:
|
||||
- Concurrent `RegisterUser` calls with the same email: both could pass the existence check before insert. Mitigated by database unique constraint on email (if one exists).
|
||||
- `CheckHardwareHash` first-login path: concurrent requests could trigger multiple hardware updates.
|
||||
- Concurrent `RegisterDevice` calls: both could read the same "most recent CompanionPC" row and try to claim the same `azj-NNNN` serial. Mitigated by the `users.email` unique constraint — the loser will fail the insert. (Out of cycle-1 scope: a sequence-based serial allocator would eliminate the retry.)
|
||||
|
||||
**Performance bottlenecks**:
|
||||
- `GetUsers` loads full user objects including `UserConfig` JSON; for large user bases, projection would be more efficient.
|
||||
@@ -123,5 +128,7 @@ No explicit logging in UserService.
|
||||
- `Services/UserService`
|
||||
- `Common/Requests/LoginRequest`
|
||||
- `Common/Requests/RegisterUserRequest`
|
||||
- `Common/Requests/SetHWRequest`
|
||||
- `Common/Requests/RegisterDeviceResponse` *(added cycle 1, AZ-196)*
|
||||
- `Common/Requests/SetUserQueueOffsetsRequest`
|
||||
|
||||
**Removed cycle 1 (AZ-197)**: `Common/Requests/SetHWRequest`
|
||||
|
||||
@@ -1,87 +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 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, hardware fingerprint 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, hardware hashing), 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 |
|
||||
| `GetHWHash` | `string hardware` | `string` (Base64) | Salted hardware hash |
|
||||
| `GetApiEncryptionKey` | `string email, string password, string? hwHash` | `string` (Base64) | Derives AES encryption key |
|
||||
| `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**:
|
||||
- `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.
|
||||
- Hardware and encryption key salts are hardcoded constants.
|
||||
- `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.
|
||||
|
||||
**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,12 +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.
|
||||
>
|
||||
> **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**: Server-side file storage management — upload, list, download (with per-user AES encryption), folder clearing, and installer distribution.
|
||||
**Purpose**: filesystem-backed storage — upload, list, clear. Owned by `IResourcesService`.
|
||||
|
||||
**Architectural Pattern**: Service layer — filesystem operations with encryption applied at the service boundary.
|
||||
**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).
|
||||
|
||||
@@ -16,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 DTOs**:
|
||||
```
|
||||
GetResourceRequest:
|
||||
Password: string (required, min 8 chars)
|
||||
Hardware: string (required, not empty)
|
||||
FileName: string (required, not empty)
|
||||
**Removed**:
|
||||
- `GetEncryptedResource` — removed in cycle 2 with the encrypted-download endpoint.
|
||||
- `GetInstaller` — removed in cycle 2 with the installer endpoints.
|
||||
|
||||
CheckResourceRequest:
|
||||
Hardware: string (required)
|
||||
```
|
||||
**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
|
||||
|
||||
@@ -39,48 +39,46 @@ N/A — exposed through Admin API.
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
No database access. All operations are filesystem-based.
|
||||
`ResourcesService` is filesystem-only — no DB access, no cache.
|
||||
|
||||
| Source | Service | Pattern |
|
||||
|--------|---------|---------|
|
||||
| Filesystem (`ResourcesConfig.ResourcesFolder`) | `ResourcesService` | Direct read/write/delete |
|
||||
|
||||
### Storage Estimates
|
||||
|
||||
Resources are stored as flat files in configured directories. Size depends on uploaded content (AI models, DLLs, installers — potentially hundreds of MB per file).
|
||||
- **Filesystem**: AI models, DLLs, etc. — potentially hundreds of MB per file.
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**State Management**: Stateless — reads/writes directly to filesystem.
|
||||
**State Management**: stateless — reads/writes directly to filesystem.
|
||||
|
||||
**Key Dependencies**: None beyond BCL (System.IO).
|
||||
**Key Dependencies**: none beyond BCL (System.IO).
|
||||
|
||||
**Error Handling Strategy**:
|
||||
- `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` | Key derivation | Admin API (before calling GetEncryptedResource) |
|
||||
None remaining after the cycle-2 removal of `Security.EncryptTo` and `Security.GetApiEncryptionKey`.
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations**:
|
||||
- No path traversal protection: `dataFolder` parameter is concatenated directly with `ResourcesFolder`. A malicious `dataFolder` like `../../etc` could access arbitrary filesystem paths.
|
||||
**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.
|
||||
|
||||
@@ -90,11 +88,10 @@ Resources are stored as flat files in configured directories. Size depends on up
|
||||
|-----------|------|---------|
|
||||
| INFO | Successful file save | `Resource {data.FileName} Saved Successfully` |
|
||||
|
||||
**Log format**: String interpolation via Serilog.
|
||||
**Log format**: string interpolation via Serilog (security audit F-12 hygiene item: convert to structured form).
|
||||
|
||||
**Log storage**: Console + rolling file (via Serilog configured in Program.cs).
|
||||
**Log storage**: console + rolling file (via Serilog configured in Program.cs).
|
||||
|
||||
## Modules Covered
|
||||
- `Services/ResourcesService`
|
||||
- `Common/Requests/GetResourceRequest` (includes CheckResourceRequest)
|
||||
- `Common/Configs/ResourcesConfig`
|
||||
- `Services/ResourcesService` (post-cycle-2 — only `SaveResource` / `ListResources` / `ClearFolder` remain)
|
||||
- `Common/Configs/ResourcesConfig` (post-cycle-2 — only `ResourcesFolder` remains)
|
||||
|
||||
@@ -2,121 +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
|
||||
|
||||
### Authentication
|
||||
> **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
|
||||
|
||||
| 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 |
|
||||
| `/users/current` | GET | Authenticated | Returns current user |
|
||||
| `/users` | GET | ApiAdmin | Lists users (optional email/role filters) |
|
||||
| `/users/hardware/set` | PUT | ApiAdmin | Sets user hardware |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/resources/get-installer` | GET | Authenticated | Downloads production installer |
|
||||
| `/resources/get-installer/stage` | GET | Authenticated | Downloads staging installer |
|
||||
| `/resources/check` | POST | Authenticated | Validates hardware |
|
||||
| `/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 | 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)
|
||||
- **apiUploaderPolicy**: requires `ResourceUploader` or `ApiAdmin` role (**defined but never applied to any endpoint — dead code**)
|
||||
|
||||
### CORS
|
||||
- Allowed origins: `https://admin.azaion.com`, `http://admin.azaion.com`
|
||||
- All methods/headers, credentials allowed
|
||||
| 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`. |
|
||||
|
||||
> 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,17 +1,14 @@
|
||||
# Flow: Hardware Check
|
||||
# Flow: Hardware Check — OBSOLETE
|
||||
|
||||
> **Removed in AZ-197 (2026-05-13).**
|
||||
>
|
||||
> The `POST /resources/check` endpoint, the `UserService.CheckHardwareHash` method, the `HardwareIdMismatch` (40) and `BadHardware` (45) error codes, and the hardware-hash component of `Security.GetApiEncryptionKey` no longer exist. Resource downloads no longer require a hardware fingerprint.
|
||||
>
|
||||
> See `_docs/03_implementation/batch_06_report.md` and the AZ-197 task spec for context. Devices ship as fTPM-secured Jetsons or via SaaS; per-machine credential binding is no longer the relevant threat model.
|
||||
>
|
||||
> This file is retained as a tombstone so historical references resolve. Do not link to it from new docs.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([POST /resources/check]) --> GetUser[AuthService.GetCurrentUser]
|
||||
GetUser --> CheckNull{User null?}
|
||||
CheckNull -->|Yes| Unauth[401 Unauthorized]
|
||||
CheckNull -->|No| CheckHW[UserService.CheckHardwareHash]
|
||||
CheckHW --> HasHW{User has stored hardware?}
|
||||
HasHW -->|No - first time| StoreHW[Store hardware string in DB]
|
||||
StoreHW --> UpdateLogin[Update last_login]
|
||||
UpdateLogin --> ReturnHash([Return hwHash])
|
||||
HasHW -->|Yes| CompareHash{Hashes match?}
|
||||
CompareHash -->|Yes| UpdateLogin2[Update last_login]
|
||||
UpdateLogin2 --> ReturnHash2([Return hwHash])
|
||||
CompareHash -->|No| Mismatch([409: HardwareIdMismatch])
|
||||
Start([POST /resources/check — REMOVED]) --> Removed[Endpoint deleted in AZ-197]
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Module Layout
|
||||
|
||||
**Language**: csharp
|
||||
**Layout Convention**: solution-flat (legacy — pre-`src/` convention)
|
||||
**Root**: `./` (csproj folders sit at workspace root)
|
||||
**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 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 `e2e/Azaion.E2E/` (HTTP black-box). Production code never imports from there.
|
||||
|
||||
## Per-Component Mapping
|
||||
|
||||
### Component: Admin API
|
||||
|
||||
- **Epic**: AZ-181 (and any other admin-API epic, e.g. AZ-509 for the Detection Classes feature)
|
||||
- **Directory**: workspace root (multi-csproj, see below)
|
||||
- **Owns (exclusive write during implementation)**:
|
||||
- `Azaion.AdminApi/**`
|
||||
- `Azaion.Services/**`
|
||||
- `Azaion.Common/**`
|
||||
- `e2e/Azaion.E2E/**` (xUnit/HttpClient-based black-box tests)
|
||||
- `e2e/db-init/**` (test-DB seed/init scripts consumed by the e2e harness)
|
||||
- `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`)
|
||||
- `Azaion.Common/Requests/*` request DTOs
|
||||
- `Azaion.Common/Entities/*` linq2db entities
|
||||
- `Azaion.Common/Database/*` `IDbFactory` + connection helpers
|
||||
- `Azaion.Common/Configs/*` strongly-typed config records
|
||||
- `Azaion.Common/Extensions/*` extension methods
|
||||
- `Azaion.Common/BusinessException.cs`
|
||||
- `Azaion.AdminApi/Program.cs` (composition root + minimal-API endpoints)
|
||||
- `Azaion.AdminApi/BusinessExceptionHandler.cs`
|
||||
- **Internal (do NOT import across csproj boundaries)**:
|
||||
- private/internal members within each csproj (default C# visibility rules apply)
|
||||
- `Azaion.AdminApi/appsettings*.json` (loaded by the host, not imported)
|
||||
- `e2e/Azaion.E2E/Helpers/*` (test-only helpers, never imported by production)
|
||||
- **Imports from**: (none — this is the only deployable in the workspace; the Loader is architecturally retired per `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`)
|
||||
- **Consumed by**: HTTP clients (UI workspace, edge services on secured Jetson, SaaS browser sessions) — out of process
|
||||
|
||||
## Conceptual Sub-Components (documentation only — NOT ownership boundaries)
|
||||
|
||||
These come from `_docs/02_document/components/` and exist for reading the codebase, not for assigning task ownership. A single task may legitimately touch multiple sub-components within the `Admin API` umbrella.
|
||||
|
||||
| # | Sub-component | Primary file locations |
|
||||
|---|----------------------|------------------------|
|
||||
| 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` (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)
|
||||
|
||||
| Layer | csproj | May reference |
|
||||
|-------|--------|---------------|
|
||||
| 4. Entry / Host | `Azaion.AdminApi` | `Azaion.Services`, `Azaion.Common` |
|
||||
| 3. Application | `Azaion.Services` | `Azaion.Common` |
|
||||
| 2. Foundation | `Azaion.Common` | (none) |
|
||||
| —. 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.
|
||||
|
||||
## Layout Conventions (reference)
|
||||
|
||||
| Language | Root | Per-component path | Public API file | Test path |
|
||||
|----------|------|-------------------|-----------------|-----------|
|
||||
| C# (.NET) | `./` (this workspace, legacy flat layout) | `./<Csproj>/` | namespace-root types in each csproj | `e2e/Azaion.E2E/` |
|
||||
|
||||
## Notes
|
||||
|
||||
- This file was authored 2026-05-13 by `/autodev` Step 10 to satisfy `/implement` Step 4. The `_docs/` artifact set predates the Step 1.5 module-layout addition, so this is a **backfill** rather than a fresh decompose Step 1.5 run.
|
||||
- If the project later splits into multiple deployables (e.g. carving out `Azaion.AnnotationsApi`), re-run `/decompose` Step 1.5 to produce a finer-grained mapping.
|
||||
@@ -5,89 +5,143 @@ Application entry point: configures DI, middleware, authentication, authorizatio
|
||||
|
||||
## Public Interface (HTTP Endpoints)
|
||||
|
||||
| Method | Path | Auth | Summary |
|
||||
|--------|------|------|---------|
|
||||
| POST | `/login` | Anonymous | Validates credentials, returns JWT token |
|
||||
| POST | `/users` | ApiAdmin | Creates a new user |
|
||||
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims |
|
||||
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters |
|
||||
| PUT | `/users/hardware/set` | ApiAdmin | Sets a user's hardware fingerprint |
|
||||
| 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 |
|
||||
| GET | `/resources/get-installer` | Any authenticated | Downloads latest production installer |
|
||||
| GET | `/resources/get-installer/stage` | Any authenticated | Downloads latest staging installer |
|
||||
| POST | `/resources/check` | Any authenticated | Validates hardware fingerprint |
|
||||
> **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 origin |
|
||||
|--------|------|------|---------|--------------|
|
||||
| GET | `/health/live` | Anonymous | Liveness check (`Cache-Control: no-store`); excluded from Swagger | AZ-510 |
|
||||
| GET | `/health/ready` | Anonymous | Readiness check — pings both DB connections with a 2-s timeout; 503 with reason on failure | AZ-510 |
|
||||
| POST | `/login` | Anonymous + per-IP rate limit | Validates credentials. Returns `LoginResponse` (access + refresh) when MFA is off, `MfaRequiredResponse` when MFA is enabled. | AZ-531 / AZ-534 / AZ-537 |
|
||||
| POST | `/login/mfa` | Anonymous + per-IP rate limit | Second-factor verification (TOTP or recovery code). Returns `LoginResponse`. | AZ-534 |
|
||||
| POST | `/token/refresh` | Anonymous (token in body) | Rotates a refresh token; returns a fresh `LoginResponse`. Reuse-detection kills the family. | AZ-531 |
|
||||
| POST | `/logout` | Authenticated | Revokes the caller's current session (idempotent — returns `{ alreadyRevoked }`). | AZ-535 |
|
||||
| POST | `/logout/all` | Authenticated | Revokes every active session for the caller's user (returns `{ revoked: N }`). | AZ-535 |
|
||||
| POST | `/sessions/{sid:guid}/revoke` | ApiAdmin | Admin revoke-by-session-id. | AZ-535 |
|
||||
| GET | `/sessions/revoked` | revocationReader (Service or ApiAdmin) | Verifier-poll snapshot of revoked-but-not-yet-expired sessions. `Cache-Control: no-cache`; `since` clamped to `now - 12h`. | AZ-535 |
|
||||
| POST | `/sessions/mission` | Authenticated | Mints a long-lived no-refresh mission token bound to one aircraft. AZ-533 AC-6 step-up MFA gate is a TODO comment until org-wide MFA adoption. | AZ-533 |
|
||||
| POST | `/users/me/mfa/enroll` | Authenticated | Returns TOTP secret + otpauth URL + QR PNG + 10 recovery codes (ONCE). | AZ-534 |
|
||||
| POST | `/users/me/mfa/confirm` | Authenticated | Validates one TOTP code and flips `mfa_enabled=true`. | AZ-534 |
|
||||
| POST | `/users/me/mfa/disable` | Authenticated | Removes MFA (requires password + valid code). | AZ-534 |
|
||||
| GET | `/.well-known/jwks.json` | Anonymous (excluded from Swagger) | Public JWKS feed for verifiers; `Cache-Control: public, max-age=3600`. | AZ-532 |
|
||||
| POST | `/users` | ApiAdmin | Creates a new user. | — |
|
||||
| POST | `/devices` | ApiAdmin | Creates a CompanionPC device user (auto serial / email / 32-hex password). | AZ-196 |
|
||||
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims. | — |
|
||||
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters. | — |
|
||||
| PUT | `/users/queue-offsets/set` | Any authenticated | Updates user's queue offsets. | — |
|
||||
| PUT | `/users/{email}/set-role/{role}` | ApiAdmin | Changes a user's role. | — |
|
||||
| PUT | `/users/{email}/enable` | ApiAdmin | Enables a user account. | — |
|
||||
| PUT | `/users/{email}/disable` | ApiAdmin | Disables a user account. | — |
|
||||
| DELETE | `/users/{email}` | ApiAdmin | Removes a user. | — |
|
||||
| POST | `/resources/{dataFolder?}` | Any authenticated | Uploads a resource file. | — |
|
||||
| GET | `/resources/list/{dataFolder?}` | Any authenticated | Lists files in a resource folder. | — |
|
||||
| POST | `/resources/clear/{dataFolder?}` | ApiAdmin | Clears a resource folder. | — |
|
||||
| POST | `/classes` | ApiAdmin | Creates a detection class. | AZ-513 |
|
||||
| PATCH | `/classes/{id:int}` | ApiAdmin | Updates a detection class (partial-merge). | AZ-513 |
|
||||
| DELETE | `/classes/{id:int}` | ApiAdmin | Deletes a detection class. | AZ-513 |
|
||||
|
||||
### Removed endpoints
|
||||
|
||||
The following endpoints have been removed and now return `404`:
|
||||
|
||||
| Method | Path | Removed in | Reason |
|
||||
|--------|------|------------|--------|
|
||||
| PUT | `/users/hardware/set` | cycle 1 (AZ-197) | hardware-binding feature deleted |
|
||||
| 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)
|
||||
- `IAuditLog` → `AuditLog` (Scoped) — **AZ-537 / AZ-534**
|
||||
- `IDbFactory` → `DbFactory` (Singleton)
|
||||
- `ICache` → `MemoryCache` (Scoped)
|
||||
- `LazyCache` via `AddLazyCache()`
|
||||
- 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
|
||||
- `apiUploaderPolicy`: requires `RoleEnum.ResourceUploader` OR `RoleEnum.ApiAdmin` role
|
||||
- `revocationReaderPolicy`: requires `RoleEnum.Service` OR `RoleEnum.ApiAdmin` (gates `/sessions/revoked`) — **AZ-535**
|
||||
|
||||
### 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,29 +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 8 characters |
|
||||
| `EmailLengthIncorrect` | 35 | Email is empty or invalid |
|
||||
| `WrongEmail` | 37 | (no description attribute) |
|
||||
| `HardwareIdMismatch` | 40 | Hardware mismatch — unauthorized hardware |
|
||||
| `BadHardware` | 45 | Hardware should be not empty |
|
||||
| `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** |
|
||||
|
||||
### 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/hardware validation failures
|
||||
- `ResourcesService` — throws for missing file uploads
|
||||
- `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
|
||||
@@ -48,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).
|
||||
@@ -0,0 +1,44 @@
|
||||
# Module: Azaion.Common.Entities.DetectionClass
|
||||
|
||||
## Purpose
|
||||
Domain entity for a single detection class shown to operators in the Detection Classes admin table. Persisted to the `detection_classes` table; managed via the `/classes` admin endpoints introduced by AZ-513.
|
||||
|
||||
> **Cycle 1 (2026-05-13) origin** — added by AZ-513 to back the new admin `/classes` CRUD endpoints; previously the read path was served by another service (likely `annotations/`) and admin/ had no own model for it.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Id` | `int` | Auto-assigned identity (DB-generated via `InsertWithInt32IdentityAsync`) |
|
||||
| `Name` | `string` | Full display name (max 120 chars per validator) |
|
||||
| `ShortName` | `string` | Short label used in tight UI (max 20 chars) |
|
||||
| `Color` | `string` | UI color (e.g. `"#FF0000"`, max 20 chars — accepts hex strings or named-color tokens) |
|
||||
| `MaxSizeM` | `double` | Maximum real-world object size in meters (must be > 0) |
|
||||
| `PhotoMode` | `string?` | Optional capture-mode hint (max 20 chars when present) |
|
||||
| `CreatedAt` | `DateTime` | UTC creation timestamp set by the service on insert |
|
||||
|
||||
## Internal Logic
|
||||
Plain POCO; no behaviour. Identity is assigned by the database on insert (`InsertWithInt32IdentityAsync`).
|
||||
|
||||
## Dependencies
|
||||
None (no `using` directives on `Azaion.Services` / external libs).
|
||||
|
||||
## Consumers
|
||||
- `Azaion.Services.DetectionClassService` — CRUD operations
|
||||
- `AzaionDb.DetectionClasses` — linq2db table mapping (see `common_database_azaion_db.md`)
|
||||
- `Azaion.AdminApi.Program` — `POST/PATCH/DELETE /classes` endpoints
|
||||
|
||||
## Data Models
|
||||
Maps 1:1 to the `detection_classes` PostgreSQL table.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None directly; persisted via `IDbFactory` → PostgreSQL.
|
||||
|
||||
## Security
|
||||
Data is operator-controlled metadata; no PII or secrets.
|
||||
|
||||
## Tests
|
||||
- `e2e/Azaion.E2E/Tests/DetectionClassesTests.cs` — covers AZ-513 ACs 1–9
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Module: Azaion.Common.Requests.CreateDetectionClassRequest
|
||||
|
||||
## Purpose
|
||||
Request DTO + FluentValidation validator for `POST /classes` (AZ-513).
|
||||
|
||||
> **Cycle 1 (2026-05-13) origin** — added by AZ-513.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### CreateDetectionClassRequest
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Name` | `string` | Full display name |
|
||||
| `ShortName` | `string` | Short label |
|
||||
| `Color` | `string` | UI color string (hex or named) |
|
||||
| `MaxSizeM` | `double` | Max real-world size in meters |
|
||||
| `PhotoMode` | `string?` | Optional capture-mode hint |
|
||||
|
||||
### CreateDetectionClassValidator
|
||||
| Rule | Constraint |
|
||||
|------|-----------|
|
||||
| `Name` | NotEmpty, ≤ 120 chars |
|
||||
| `ShortName` | NotEmpty, ≤ 20 chars |
|
||||
| `Color` | NotEmpty, ≤ 20 chars |
|
||||
| `MaxSizeM` | > 0 |
|
||||
| `PhotoMode` | ≤ 20 chars when present |
|
||||
|
||||
## Internal Logic
|
||||
Plain DTO; validator runs in the `/classes` POST handler before the service call. Validation failures are surfaced via `Results.ValidationProblem(...)` (HTTP 400).
|
||||
|
||||
## Dependencies
|
||||
- FluentValidation
|
||||
|
||||
## Consumers
|
||||
- `Azaion.AdminApi.Program` `POST /classes`
|
||||
- `Azaion.Services.DetectionClassService.Create`
|
||||
|
||||
## Data Models
|
||||
Maps to the writable subset of `DetectionClass` (see `common_entities_detection_class.md`).
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
ApiAdmin-only endpoint; FluentValidation enforces field bounds. No HTML/JS sanitisation — the UI is responsible for safe rendering of `Name`, `ShortName`, `Color`.
|
||||
|
||||
## Tests
|
||||
- e2e: `AC1_Post_classes_creates_class_with_assigned_id`, `AC2_Post_classes_*`
|
||||
@@ -1,50 +0,0 @@
|
||||
# Module: Azaion.Common.Requests.GetResourceRequest
|
||||
|
||||
## Purpose
|
||||
Request DTOs and validator for resource access endpoints. Contains both `GetResourceRequest` and `CheckResourceRequest`.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### CheckResourceRequest
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Hardware` | `string` | Hardware fingerprint to validate |
|
||||
|
||||
### GetResourceRequest
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Password` | `string` | User's password (used to derive encryption key) |
|
||||
| `Hardware` | `string` | Hardware fingerprint for authorization |
|
||||
| `FileName` | `string` | Resource file to retrieve |
|
||||
|
||||
### GetResourceRequestValidator
|
||||
| Rule | Constraint | Error Code |
|
||||
|------|-----------|------------|
|
||||
| `Password` min length | >= 8 chars | `PasswordLengthIncorrect` |
|
||||
| `Hardware` not empty | Required | `BadHardware` |
|
||||
| `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` `/resources/get/{dataFolder?}` and `/resources/check` endpoints
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
Password is sent in the POST body (not URL) to avoid logging in access logs. Hardware fingerprint validates device authorization.
|
||||
|
||||
## Tests
|
||||
None.
|
||||
@@ -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,41 @@
|
||||
# Module: Azaion.Common.Requests.RegisterDeviceResponse
|
||||
|
||||
## Purpose
|
||||
Response DTO returned by `POST /devices` (AZ-196) — provides the provisioning script with the freshly-generated `Serial`, `Email`, and one-shot plaintext `Password` for a new CompanionPC device user.
|
||||
|
||||
> **Cycle 1 (2026-05-13) origin** — added by AZ-196.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Serial` | `string` | Server-assigned device serial in the form `azj-NNNN` (zero-padded to 4 digits) |
|
||||
| `Email` | `string` | `{Serial}@azaion.com` — the persisted user's login email |
|
||||
| `Password` | `string` | Plaintext 32-char hex password — exposed exactly once at provisioning; never re-derivable from the SHA-384 hash that is persisted |
|
||||
|
||||
## Internal Logic
|
||||
Plain POCO. All field values are produced inside `UserService.RegisterDevice` (see `services_user_service.md`).
|
||||
|
||||
## Dependencies
|
||||
None.
|
||||
|
||||
## Consumers
|
||||
- `Azaion.AdminApi.Program` `POST /devices` (returned via `Results.Ok(...)` implicit)
|
||||
- `Azaion.Services.UserService.RegisterDevice` (constructs and returns the response)
|
||||
- Provisioning script (out-of-tree) — embeds the values into `device.conf` on the Jetson
|
||||
|
||||
## Data Models
|
||||
Mirrors a subset of fields written into the `users` row (`Email`, `PasswordHash`).
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
- The `Password` is the only chance to capture the plaintext — once the response is consumed by the provisioning pipeline, the value cannot be recovered from the database (only the SHA-384 hash is persisted).
|
||||
- The endpoint is gated by `apiAdminPolicy`. Treat the response as a credential — log carefully.
|
||||
|
||||
## Tests
|
||||
- e2e: `AC1_Post_devices_returns_serial_email_and_password`, `AC3_Returned_credentials_can_login`
|
||||
@@ -1,39 +0,0 @@
|
||||
# Module: Azaion.Common.Requests.SetHWRequest
|
||||
|
||||
## Purpose
|
||||
Request DTO and validator for setting a user's hardware fingerprint (`PUT /users/hardware/set`).
|
||||
|
||||
## Public Interface
|
||||
|
||||
### SetHWRequest
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Email` | `string` | Target user's email |
|
||||
| `Hardware` | `string?` | Hardware fingerprint (null clears it) |
|
||||
|
||||
### SetHWRequestValidator
|
||||
| Rule | Constraint | Error Code |
|
||||
|------|-----------|------------|
|
||||
| `Email` not empty | Required | `EmailLengthIncorrect` |
|
||||
|
||||
## Dependencies
|
||||
- `BusinessException`, `ExceptionEnum`
|
||||
- FluentValidation
|
||||
|
||||
## Consumers
|
||||
- `Program.cs` `/users/hardware/set` endpoint
|
||||
|
||||
## Data Models
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
None.
|
||||
|
||||
## Tests
|
||||
None.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Module: Azaion.Common.Requests.UpdateDetectionClassRequest
|
||||
|
||||
## Purpose
|
||||
Request DTO + FluentValidation validator for `PATCH /classes/{id}` (AZ-513). All fields are nullable so callers may send the complete body OR only the changed fields — the service applies partial-merge semantics.
|
||||
|
||||
> **Cycle 1 (2026-05-13) origin** — added by AZ-513.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### UpdateDetectionClassRequest
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Name` | `string?` | If non-null, replace existing |
|
||||
| `ShortName` | `string?` | If non-null, replace existing |
|
||||
| `Color` | `string?` | If non-null, replace existing |
|
||||
| `MaxSizeM` | `double?` | If non-null, replace existing |
|
||||
| `PhotoMode` | `string?` | If non-null, replace existing |
|
||||
|
||||
### UpdateDetectionClassValidator
|
||||
| Rule | Constraint (only checked when field is non-null) |
|
||||
|------|--------------------------------------------------|
|
||||
| `Name` | NotEmpty, ≤ 120 chars |
|
||||
| `ShortName` | NotEmpty, ≤ 20 chars |
|
||||
| `Color` | NotEmpty, ≤ 20 chars |
|
||||
| `MaxSizeM` | > 0 |
|
||||
| `PhotoMode` | ≤ 20 chars |
|
||||
|
||||
## Internal Logic
|
||||
Each rule is gated by `.When(r => r.Field != null)` — fields the caller did not send pass validation untouched. The service then applies the same null-check pattern when writing back.
|
||||
|
||||
## Dependencies
|
||||
- FluentValidation
|
||||
|
||||
## Consumers
|
||||
- `Azaion.AdminApi.Program` `PATCH /classes/{id:int}`
|
||||
- `Azaion.Services.DetectionClassService.Update`
|
||||
|
||||
## Data Models
|
||||
Optional / partial view over `DetectionClass`.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
None.
|
||||
|
||||
## Security
|
||||
ApiAdmin-only endpoint. Per the AZ-513 spec, the UI sends the complete body on edit even though partial-merge is supported on the server — that keeps the implementer free to choose either policy without breaking the client.
|
||||
|
||||
## Tests
|
||||
- e2e: `AC3_Patch_classes_full_body_updates_class`, `AC4_Patch_classes_partial_body_only_updates_specified_field`, `AC5_Patch_classes_unknown_id_returns_404`, `AC6_Patch_classes_without_jwt_returns_401`
|
||||
@@ -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,47 @@
|
||||
# Module: Azaion.Services.DetectionClassService
|
||||
|
||||
## Purpose
|
||||
CRUD service for `DetectionClass` rows backing the admin Detection Classes table. Wraps `IDbFactory.RunAdmin` calls and translates request DTOs into entity writes.
|
||||
|
||||
> **Cycle 1 (2026-05-13) origin** — added by AZ-513.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### IDetectionClassService
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `Create` | `Task<DetectionClass> Create(CreateDetectionClassRequest request, CancellationToken ct)` | Inserts a new class; returns the entity with the DB-assigned `Id` |
|
||||
| `Update` | `Task<DetectionClass?> Update(int id, UpdateDetectionClassRequest request, CancellationToken ct)` | Partial-merge update; returns `null` when the id doesn't exist |
|
||||
| `Delete` | `Task<bool> Delete(int id, CancellationToken ct)` | Returns `true` when at least one row was deleted; `false` when the id wasn't present |
|
||||
|
||||
## Internal Logic
|
||||
- **Create**: instantiates `DetectionClass`, sets `CreatedAt = DateTime.UtcNow`, calls `db.InsertWithInt32IdentityAsync`, assigns the returned id back to the entity, returns it.
|
||||
- **Update**: loads the row by id under the admin connection, returns `null` if missing. Otherwise applies a null-aware merge: each non-null property on the request overwrites the entity, then `db.UpdateAsync(existing)` persists the row. The route returns 404 when the service returns null.
|
||||
- **Delete**: `db.DetectionClasses.DeleteAsync(x => x.Id == id, ct)`; returns `deleted > 0`. The route returns 404 when the service returns false.
|
||||
|
||||
All writes go through `IDbFactory.RunAdmin` (admin DB connection / role).
|
||||
|
||||
## Dependencies
|
||||
- `IDbFactory` (`Azaion.Common.Database.IDbFactory`)
|
||||
- `DetectionClass` entity
|
||||
- `CreateDetectionClassRequest`, `UpdateDetectionClassRequest`
|
||||
- `LinqToDB` extension methods (`FirstOrDefaultAsync`, `InsertWithInt32IdentityAsync`, `UpdateAsync`, `DeleteAsync`)
|
||||
|
||||
## Consumers
|
||||
- `Azaion.AdminApi.Program` — `POST /classes`, `PATCH /classes/{id:int}`, `DELETE /classes/{id:int}` handlers
|
||||
|
||||
## Data Models
|
||||
Operates on `DetectionClass` via `AzaionDb.DetectionClasses`.
|
||||
|
||||
## Configuration
|
||||
None.
|
||||
|
||||
## External Integrations
|
||||
PostgreSQL via `IDbFactory.RunAdmin`.
|
||||
|
||||
## Security
|
||||
- All endpoints that delegate to this service require `apiAdminPolicy` at the route level.
|
||||
- Validators run before the service (no extra defensive validation inside the service).
|
||||
|
||||
## Tests
|
||||
- `e2e/Azaion.E2E/Tests/DetectionClassesTests.cs` — covers AZ-513 ACs 1–9
|
||||
@@ -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 + hardware hash
|
||||
- File deletion overwrites existing files before writing new ones
|
||||
- No path traversal protection on `dataFolder` parameter
|
||||
- File deletion overwrites existing files before writing new ones.
|
||||
- No path traversal protection on `dataFolder` parameter (security audit F-2 — open).
|
||||
|
||||
## Tests
|
||||
None.
|
||||
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`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user