da526cb727
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
11 KiB
phase, slug, status, threats_open, asvs_level, created
| phase | slug | status | threats_open | asvs_level | created |
|---|---|---|---|---|---|
| 2 | users-authentication | verified | 0 | L2 | 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
- All threats have a disposition (mitigate / accept / transfer)
- Accepted risks documented in Accepted Risks Log
threats_open: 0confirmedstatus: verifiedset in frontmatter
Approval: verified 2026-06-01