---
phase: 2
slug: users-authentication
status: verified
threats_open: 0
asvs_level: L2
created: 2026-06-01
---

# Phase 2 — Security

> Per-phase security contract: threat register, accepted risks, and audit trail.

---

## Trust Boundaries

| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| client→API (auth service) | Untrusted email, password, handle, totp_code, backup_code in JSON body | Credentials / PII |
| API→Redis (rate limiter + replay) | IP-keyed/email-keyed counters + TOTP replay keys written/read | Opaque rate counters, used-code markers |
| API→HIBP external | SHA-1 prefix (5 chars) of password sent to third-party | Anonymised password hash fragment |
| FastAPI→browser (cookies) | httpOnly refresh token cookie | Short-lived session credential |
| admin JWT→API (admin endpoints) | Admin Bearer token verified on every request | Role-restricted metadata |
| admin→user data | Admin reads user metadata; must never see document content or credentials | User PII (whitelisted only) |
| router guard | Unauthenticated or non-admin client navigates to /admin | Route meta, role claim |
| layout selection | Auth pages must not render app shell leaking user identity | Sidebar / session info |

---

## Threat Register

| Threat ID | STRIDE | Component | Disposition | Mitigation | Status | Evidence |
|-----------|--------|-----------|-------------|------------|--------|----------|
| T-02-01 | Spoofing | JWT decode typ claim | mitigate | `payload.get("typ") != "access"` raises ValueError — prevents reset tokens used as access tokens | CLOSED | `services/auth.py:93` |
| T-02-02 | Spoofing | Refresh token reuse | mitigate | Family revocation: all tokens for user_id revoked + security alert email on reuse | CLOSED | `services/auth.py:181-185` |
| T-02-03 | Tampering | Backup code storage | mitigate | Argon2 hash stored; constant-time `verify_password()` compare | CLOSED | `services/auth.py:310,338` |
| T-02-04 | Repudiation | bootstrap_admin idempotency | mitigate | `select(User).limit(1)` guard before insert; WARNING log when env vars absent | CLOSED | `services/auth.py:397-408` |
| T-02-05 | Info Disclosure | HIBP k-anonymity | mitigate | SHA-1[:5] prefix only sent; suffix compared locally via `hmac.compare_digest` | CLOSED | `services/auth.py:360` |
| T-02-06 | DoS | HIBP network call | accept | Fail-open (return False), httpx timeout=5s, warning logged — see Accepted Risks | CLOSED | `services/auth.py:369-371` |
| T-02-07 | EoP | get_current_admin | mitigate | `if user.role != "admin": raise HTTPException(403)` | CLOSED | `deps/auth.py:87` |
| T-02-08 | EoP | Admin impersonation exclusion | mitigate | Architectural exclusion — zero impersonation endpoints; AST confirmed | CLOSED | `api/admin.py` (0 grep hits) |
| T-02-SC | Tampering | Supply chain (PyJWT/pwdlib/pyotp/slowapi) | mitigate | All packages pinned in requirements.txt; legitimacy verified at plan time | CLOSED | `backend/requirements.txt:23-26` |
| T-02-09 | Spoofing | Login email enumeration | mitigate | Identical `"Incorrect email or password"` for non-existent email and wrong password | CLOSED | `api/auth.py:248` |
| T-02-10 | Spoofing | Password reset email enumeration | mitigate | 202 returned unconditionally — outside `if user is not None` block | CLOSED | `api/auth.py:648,673` |
| T-02-11 | Tampering | CSRF | mitigate | `samesite="strict"` on refresh cookie + `OriginValidationMiddleware` rejects foreign origins | CLOSED | `api/auth.py:100`, `main.py:47-61` |
| T-02-12 | Info Disclosure | Access token in JavaScript | accept | Pinia `ref(null)` only; zero localStorage/sessionStorage writes — see Accepted Risks | CLOSED | `stores/auth.js` |
| T-02-13 | DoS | Login/register rate limiting | mitigate | `@limiter.limit("10/minute")` on /login, /register, /refresh + per-account Redis counter 10/15min | CLOSED | `api/auth.py:121,195,326,215-224` |
| T-02-14 | Info Disclosure | Security headers missing | mitigate | `SecurityHeadersMiddleware` sets CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff | CLOSED | `main.py:32-40` |
| T-02-15 | Tampering | CORS wildcard | mitigate | `allow_origins=settings.cors_origins` — wildcard removed | CLOSED | `main.py:124` |
| T-02-16 | EoP | password_must_change bypass | mitigate | /login returns 200 `{requires_password_change: true}` with no tokens when flag set | CLOSED | `api/auth.py:259-260` |
| T-02-17 | Spoofing | TOTP replay | mitigate | Redis key `totp_used:{user_id}:{code}` pre-checked; written with `ex=90` (s) | CLOSED | `services/auth.py:262-270` |
| T-02-18 | Spoofing | Backup code reuse | mitigate | `BackupCode.used_at.is_(None)` filter; `used_at = now()` on first use | CLOSED | `services/auth.py:330,345` |
| T-02-19 | Info Disclosure | Backup codes one-time exposure | mitigate | Plaintext returned once from `/totp/enable` only; DB stores Argon2 hashes | CLOSED | `api/auth.py:594-609` |
| T-02-20 | EoP | Password reset token type confusion | mitigate | `decode_password_reset_token` validates `typ="password-reset"` | CLOSED | `services/auth.py:125-126` |
| T-02-21 | EoP | Password reset auto-login | mitigate | Confirm endpoint returns `{"message": "..."}` only — no `access_token` key | CLOSED | `api/auth.py:730` |
| T-02-22 | Info Disclosure | Email enumeration via password reset | mitigate | HTTP 202 returned unconditionally, outside `if user is not None` block | CLOSED | `api/auth.py:673` |
| T-02-23 | Tampering | TOTP constant-time compare | accept | pyotp compare negligible for 6-digit codes; 10/min rate limit is primary defence — see Accepted Risks | CLOSED | `api/auth.py:565` |
| T-02-24 | Spoofing | Sign-out-all confirmation | mitigate | `ConfirmBlock.vue` explicit `confirmed` emit; `AccountView` wires `@confirmed` → `logoutAll()` | CLOSED | `ConfirmBlock.vue` |
| T-02-25 | DoS | TOTP brute force | mitigate | `@limiter.limit("10/minute")` on `POST /totp/enable` | CLOSED | `api/auth.py:565` |
| T-02-26A | EoP | Admin endpoints without role check | mitigate | `get_current_admin` Depends() on all 12 handlers in admin.py | CLOSED | `api/admin.py` (grep count = 12) |
| T-02-26B | Spoofing | Backup code reuse at login | mitigate | `verify_backup_code()` sets `used_at`; subsequent calls always return False | CLOSED | `services/auth.py:330` |
| T-02-27A | Info Disclosure | Admin user list sensitive fields | mitigate | `_user_to_dict()` whitelist — `password_hash`, `credentials_enc`, `totp_secret` absent | CLOSED | `api/admin.py:75-90` |
| T-02-27B | Spoofing | Backup code brute force at login | mitigate | Per-account Redis counter incremented before TOTP/backup_code branch — covers all login paths | CLOSED | `api/auth.py:215-224` |
| T-02-28 | EoP | Admin impersonation (no endpoint) | mitigate | Zero grep matches for impersonation strings; `test_admin_impersonation_not_found` asserts 404 | CLOSED | `api/admin.py` |
| T-02-29 | DoS | Admin deactivating all admins | mitigate | `active_admin_count <= 1` guard; raises HTTP 400 before deactivation | CLOSED | `api/admin.py:305-316` |
| T-02-30A | Tampering | Admin password reset grants admin access | mitigate | HTTP 202 + message only; reset token emailed to user's inbox; never in response body | CLOSED | `api/admin.py:348,377` |
| T-02-30B | EoP | Admin link visible to non-admin | mitigate | `v-if="authStore.user?.role === 'admin'"` on sidebar link | CLOSED | `AppSidebar.vue:189` |
| T-02-31A | Info Disclosure | Quota endpoint exposes storage | accept | Admin operational data — no PII, no document content — see Accepted Risks | CLOSED | `api/admin.py` |
| T-02-31B | EoP | Admin UI impersonation | mitigate | All three admin tab components contain zero impersonation UI strings | CLOSED | Admin components (0 grep hits) |
| T-02-32A | EoP | Admin-created user skips password change | mitigate | `password_must_change=True` set in `User` constructor on `POST /api/admin/users` | CLOSED | `api/admin.py:255` |
| T-02-32B | Info Disclosure | Admin panel renders sensitive data | mitigate | `AdminUsersTab.vue` binds safe fields only; zero `password_hash`/`credentials_enc` in template | CLOSED | `AdminUsersTab.vue` |
| T-02-33 | Tampering | Inline deactivation without confirmation | mitigate | `confirmDeactivate === user.id` inline block shows email before API call | CLOSED | `AdminUsersTab.vue:153-174` |
| T-02-34 | DoS | Admin creates unlimited users | accept | Admin is trusted role; single-tenant deployment — see Accepted Risks | CLOSED | intentional |
| T-02-GAP-01 | EoP | Router beforeEach admin guard | mitigate | `requiresAdmin` meta + role check; non-admin redirected to `/` | CLOSED | `router/index.js:42,91-93` |
| T-02-GAP-02 | Info Disclosure | AppSidebar on auth routes | mitigate | `<AuthLayout v-if="route.meta.layout === 'auth'">` — sidebar absent on all public routes | CLOSED | `App.vue:2` |
| T-02-GAP-03 | Tampering | admin.py create_user flush order | accept | `await session.flush()` present before `write_audit_log()`; regression test confirms — see Accepted Risks | CLOSED | `api/admin.py:265` |
| T-02-GAP-SC | Tampering | npm qrcode supply chain | mitigate | `qrcode@^1.5.4` canonical package (20M+/week downloads); verified at plan time | CLOSED | `frontend/package.json:13` |

*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*

---

## Accepted Risks Log

| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-02-01 | T-02-06 | HIBP network errors fail-open to keep registration/login available; warning logged; auth proceeds. Downside: a pwned password might slip through during HIBP outage. Risk: LOW — outages are rare and short. | GSD planner | 2026-06-01 |
| AR-02-02 | T-02-12 | Access token stored in Pinia `ref()` (in-memory) only — lost on page refresh, requiring silent refresh flow. Alternative (localStorage) would introduce XSS extraction risk rated HIGHER. | GSD planner | 2026-06-01 |
| AR-02-03 | T-02-23 | pyotp `verify()` uses Python string comparison on 6-digit numeric codes. Timing difference is negligible and unexploitable at this granularity. Rate limiting (10/min) is the primary brute-force control. | GSD planner | 2026-06-01 |
| AR-02-04 | T-02-31A | Quota endpoint (`GET /api/admin/users/{id}/quota`) exposes `limit_bytes` / `used_bytes`. These are operational metrics — no PII, no document content, no credentials. Acceptable admin-visible data. | GSD planner | 2026-06-01 |
| AR-02-05 | T-02-34 | Admin user creation has no rate limit. Admin is an explicitly trusted role. Unlimited user creation is intentional for single-tenant deployments where the admin is the operator. | GSD planner | 2026-06-01 |
| AR-02-06 | T-02-GAP-03 | `session.flush()` ordering in `create_user` was flagged as a potential FK race. Confirmed resolved: `await session.flush()` precedes `write_audit_log()`; regression test `test_create_user_sets_password_must_change` covers the ordering. | GSD planner | 2026-06-01 |

---

## Security Audit Trail

| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-06-01 | 43 | 43 | 0 | gsd-security-auditor (claude-sonnet-4-6) |

---

## Sign-Off

- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter

**Approval:** verified 2026-06-01
