Add admin authentication: local accounts, revocable sessions, mandatory TOTP #18

Merged
hannah-vernon merged 1 commit from feat/admin-auth into dev 2026-08-25 21:13:32 -05:00

What does this PR do?

Phase 8 increment 1: the admin service gains real authentication, implementing D-0032 (auth model) and D-0033 (exposure/TLS), both recorded in DECISIONS.md in this PR.

Authentication. Local accounts with a two-stage login: password, then TOTP or a recovery code. The server-side session and its cookie exist only after the second factor succeeds (fixation-safe); between stages a Data Protection-encrypted, 5-minute pending cookie carries the user id. TOTP is first-party RFC 6238 (verified against the RFC test vectors), enrollment is manual-entry via otpauth URI + base32 secret, and ten single-use SHA-256-hashed recovery codes round out the second factor - no SMS, no email, no QR dependency. Passwords hash with PasswordHasher (PBKDF2-HMAC-SHA512) at 210,000 iterations, minimum length 20.

Sessions. The cookie carries only user + session ids; every request validates against the sessions registry: idle timeout (48 h default), absolute lifetime (14 d default, 30 d configurable ceiling), revocation with sign-out-everywhere and a visible session list, and IP binding in strict | subnet | log-only modes (default strict). Step-up 2FA guards sensitive operations from day one (TOTP re-enrollment and recovery-code regeneration demand a fresh step-up).

Exposure (D-0033). Modes: loopback (default), direct (Kestrel terminates TLS from mounted PEM), proxy (fail-closed forwarded-header trust). Startup refuses misconfigured direct/proxy modes. A fail-closed AllowedSources CIDR gate runs ahead of authentication (empty list = loopback only); the compose example carries a strong warning against 0.0.0.0/0 and recommends a self-hosted VPN. Per-IP rate limiting on /auth/*, tightened Kestrel limits, CSP/nosniff/no-referrer headers, HSTS outside loopback.

Self-monitoring. Every auth event is appended to the audit ledger; failures are additionally persisted as AdminAuthEvent pipeline events and enqueued for correlation - Viegard watches attacks on its own gate, and Phase 7 action providers will be able to close that loop.

Review hardening (applied after independent review of the generated implementation): unknown/locked usernames burn a real hash verification so there is no username-existence timing oracle; second-factor failures count toward the same lockout as password failures, and lockout is enforced at the 2FA stage; session revocation verifies the posted session id belongs to the caller.

Increments 2 and 3 (WebAuthn via fido2-net-lib, in-process ACME) follow after their supply-chain reviews; tracked in TODO.md.

How was this tested?

  • dotnet build - 0 errors, 0 warnings
  • dotnet test - 301 passed (22 new), 9 skipped (live-PostgreSQL integration tests)
  • New tests: RFC 6238 vectors, TOTP tolerance + replay guard, Base32 round-trip, recovery-code single-use/regeneration, session expiry/revocation/IP-binding (IPv4+IPv6), password policy, options validation (30 d cap), AllowedSources gate, AdminAuthEvent payload round-trip
  • Live verification on the Debian VM post-merge: bootstrap flow, forced password change + TOTP enrollment with a real authenticator, session behavior

Checklist

  • I have read the Contributing Guide
  • Changes are focused - one logical change per PR
  • Documentation updated (README.md / DECISIONS.md / TODO.md / AGENT-README.md, if applicable)
  • New dependencies are MIT/Apache-2.0/BSD, security-vetted, and recorded in THIRD-PARTY-NOTICES.md (none added)
  • No secrets, credentials, or real infrastructure identifiers in the diff
  • No commented-out code or debug leftovers
## What does this PR do? Phase 8 increment 1: the admin service gains real authentication, implementing **D-0032** (auth model) and **D-0033** (exposure/TLS), both recorded in DECISIONS.md in this PR. **Authentication.** Local accounts with a two-stage login: password, then TOTP or a recovery code. The server-side session and its cookie exist only after the second factor succeeds (fixation-safe); between stages a Data Protection-encrypted, 5-minute pending cookie carries the user id. TOTP is first-party RFC 6238 (verified against the RFC test vectors), enrollment is manual-entry via otpauth URI + base32 secret, and ten single-use SHA-256-hashed recovery codes round out the second factor - no SMS, no email, no QR dependency. Passwords hash with PasswordHasher (PBKDF2-HMAC-SHA512) at 210,000 iterations, minimum length 20. **Sessions.** The cookie carries only user + session ids; every request validates against the sessions registry: idle timeout (48 h default), absolute lifetime (14 d default, 30 d configurable ceiling), revocation with sign-out-everywhere and a visible session list, and IP binding in strict | subnet | log-only modes (default strict). Step-up 2FA guards sensitive operations from day one (TOTP re-enrollment and recovery-code regeneration demand a fresh step-up). **Exposure (D-0033).** Modes: loopback (default), direct (Kestrel terminates TLS from mounted PEM), proxy (fail-closed forwarded-header trust). Startup refuses misconfigured direct/proxy modes. A fail-closed AllowedSources CIDR gate runs ahead of authentication (empty list = loopback only); the compose example carries a strong warning against 0.0.0.0/0 and recommends a self-hosted VPN. Per-IP rate limiting on /auth/*, tightened Kestrel limits, CSP/nosniff/no-referrer headers, HSTS outside loopback. **Self-monitoring.** Every auth event is appended to the audit ledger; failures are additionally persisted as `AdminAuthEvent` pipeline events and enqueued for correlation - Viegard watches attacks on its own gate, and Phase 7 action providers will be able to close that loop. **Review hardening** (applied after independent review of the generated implementation): unknown/locked usernames burn a real hash verification so there is no username-existence timing oracle; second-factor failures count toward the same lockout as password failures, and lockout is enforced at the 2FA stage; session revocation verifies the posted session id belongs to the caller. Increments 2 and 3 (WebAuthn via fido2-net-lib, in-process ACME) follow after their supply-chain reviews; tracked in TODO.md. ## How was this tested? - [x] `dotnet build` - 0 errors, 0 warnings - [x] `dotnet test` - 301 passed (22 new), 9 skipped (live-PostgreSQL integration tests) - [x] New tests: RFC 6238 vectors, TOTP tolerance + replay guard, Base32 round-trip, recovery-code single-use/regeneration, session expiry/revocation/IP-binding (IPv4+IPv6), password policy, options validation (30 d cap), AllowedSources gate, AdminAuthEvent payload round-trip - [ ] Live verification on the Debian VM post-merge: bootstrap flow, forced password change + TOTP enrollment with a real authenticator, session behavior ## Checklist - [x] I have read the [Contributing Guide](../CONTRIBUTING.md) - [x] Changes are focused - one logical change per PR - [x] Documentation updated (README.md / DECISIONS.md / TODO.md / AGENT-README.md, if applicable) - [x] New dependencies are MIT/Apache-2.0/BSD, security-vetted, and recorded in THIRD-PARTY-NOTICES.md (none added) - [x] No secrets, credentials, or real infrastructure identifiers in the diff - [x] No commented-out code or debug leftovers
Phase 8 increment 1, implementing D-0032/D-0033 (recorded here).

Authentication:
- Local accounts; two-stage login (password, then TOTP or recovery
  code) with a Data Protection-encrypted 5-minute pending-2FA cookie;
  the server-side session and cookie are minted only after the second
  factor succeeds (fixation-safe)
- First-party TOTP (RFC 6238, HMAC-SHA1, 30s step, +/-1 tolerance,
  replay guard on last accepted step) verified against the RFC test
  vectors; first-party Base32; otpauth URI for manual authenticator
  entry (no QR dependency)
- Ten single-use recovery codes, SHA-256 hashed at rest, regeneration
  invalidates all; displayed once via an encrypted short-lived cookie
- PasswordHasher (PBKDF2-HMAC-SHA512) at 210,000 iterations per OWASP;
  minimum password length 20; rehash-on-verify upgrade path
- Server-side sessions: cookie carries only user+session ids;
  per-request registry validation, idle (48h default) and absolute
  (14d default, 30d max) expiry, revocation + sign-out-everywhere,
  IP binding strict|subnet|log-only (default strict), last-seen
  writes throttled to once per minute
- Step-up 2FA from day one: TOTP re-enrollment and recovery-code
  regeneration require a fresh (5 min) step-up verification
- Bootstrap: first user created from a mounted secret, forced
  password change + TOTP enrollment before anything else

Exposure (D-0033):
- Exposure modes loopback (default) | direct (Kestrel PEM TLS) |
  proxy (fail-closed forwarded-header trust); startup refuses direct
  without readable cert/key and proxy without trusted networks
- Fail-closed AllowedSources CIDR gate ahead of auth (empty = loopback
  only); compose example warns strongly against 0.0.0.0/0 and
  recommends a self-hosted VPN
- Per-IP rate limiting on /auth/*, global limiter, tightened Kestrel
  limits, CSP/nosniff/no-referrer headers, HSTS outside loopback mode

Audit + self-monitoring:
- Every auth event goes to the audit ledger; failures (login, TOTP,
  lockout, step-up) are also stored as AdminAuthEvent pipeline events
  and enqueued for correlation, so Viegard watches its own gate

Review hardening on top of the initial implementation:
- Unknown/locked usernames burn a real hash verification (no timing
  oracle); second-factor failures count toward the same lockout as
  password failures and lockout is enforced at the 2FA stage;
  session revocation verifies ownership of the posted session id

301 tests passing (22 new), 9 skipped (live-Postgres).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
hannah-vernon/viegard-sentinel!18
No description provided.