Skip to content

Commit ba8fef0

Browse files
feat: v1.6.0 — Multi-Factor Authentication (Issue #18)
Adds opt-in TOTP-based Multi-Factor Authentication that is fully backwards compatible with existing logins. Operators choose to enable MFA per account; nothing changes for users who do not opt in. Highlights ========== * RFC 6238 TOTP (6 digits, 30s period, SHA1) with ±30s skew tolerance, compatible with Microsoft / Google Authenticator, Authy, Duo, 1Password. * Per-step replay protection (`mfa_last_used_totp_step`) so a captured code cannot be reused inside the same window. * Fernet-encrypted TOTP secrets at rest, key resolution via `MFA_ENCRYPTION_KEY` env (HKDF-derived from `SECRET_KEY` as fallback). * 10 single-use, bcrypt-hashed backup codes per user, formatted `XXXX-YYYY` from a confusion-free alphabet (no 0/O/1/I/L). * Two-step login flow: `POST /api/auth/login` returns `mfa_required` + `mfa_token`, then `POST /api/auth/login/mfa-verify` accepts a TOTP code OR a backup code. JWT is minted only after MFA succeeds. * Self-service: users enable / disable MFA from their own row in the Users page; admins reset (single user or bulk) but never enable on behalf of someone else (matches AWS IAM / GitHub / Google Workspace). * Bulk emergency reset CLI: `scripts/admin-mfa-reset-all.sh`. Security hardening ================== * Atomic transactions with `SELECT … FOR UPDATE` on `mfa_pending_logins` and `users` rows so concurrent verify / enroll calls cannot race. * `/api/mfa/enroll/start` refuses re-enrollment when MFA is already on (prevents silent secret rotation via a stolen JWT). * Pydantic `ValidationError` messages are sanitized before reaching the audit log so request bodies (TOTP / backup codes in flight) never appear in plaintext. * Slowapi rate limits are per-USER, not per-IP, with a trusted-proxy XFF strategy so a single ingress address cannot exhaust the bucket for thousands of operators (`MFA_TRUSTED_PROXY_CIDRS`, `MFA_RATE_LIMIT_*` env-overridable). * Login query now scopes to `is_active = TRUE` so a soft-deleted row with the same username can no longer occlude the active user (also closes a small account-enumeration side channel). Database ======== Additive migrations (idempotent `ADD COLUMN IF NOT EXISTS`, `CREATE TABLE IF NOT EXISTS`): - users: mfa_enabled, mfa_method, mfa_secret_encrypted, mfa_enrolled_at, mfa_last_used_at, mfa_last_used_totp_step - mfa_backup_codes (user_id ON DELETE CASCADE) - mfa_pending_logins (user_id ON DELETE CASCADE, challenge_token, attempts, expires_at) - mfa_pending_enrollments (user_id ON DELETE CASCADE) Frontend ======== * Login page becomes a 3-phase state machine (credentials → MFA → submitting); legacy single-step login is preserved for users who haven't enrolled. * New MFAEnrollModal (3-step wizard: QR + secret → verify → backup codes) using `qrcode.react`. * Users page shows MFA column + per-row enable/disable/reset actions. Admins viewing other users with MFA off see a non-actionable info icon explaining that only the user themselves can enable MFA. Deployment ========== * `MFA_ENCRYPTION_KEY` is added to `k8s/manifests/03-secrets.yaml` as a placeholder; `SECRET_KEY` is also placeholder-ized so both are injected by the existing pipeline pattern (sed-replace + apply). * No new build-time env vars are required for the frontend. The SPA uses `window.location.host` for `/api/*` and is routed by the existing nginx ingress configuration. * `frontend/.dockerignore` ensures host `.env*` files cannot bleed into the production bundle. Tests ===== * New unit suites: - `test_mfa_service.py` (TOTP, encryption, backup codes) - `test_mfa_backwards_compat.py` (regression — non-MFA flow unchanged) - `test_mfa_rate_limits.py` (env override + dataclass immutability) - `test_mfa_rate_limit_key.py` (JWT key, trusted-proxy XFF, fallbacks) * All existing 1000+ unit tests continue to pass. Documentation ============= * README MFA section (overview, day-to-day operations, emergency reset CLI, env variables, rate-limit tuning). * `scripts/README.md` documents the bulk reset script. Issue: #18
1 parent 445639d commit ba8fef0

32 files changed

Lines changed: 3651 additions & 199 deletions

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,6 @@ venv.bak/
3939
*.sqlite
4040
*.sqlite3
4141

42-
# Docker
43-
.dockerignore
44-
4542
# Build-time staged version.json (CI `cp version.json backend/`).
4643
# The canonical file lives at repo root; this path is a transient
4744
# copy for the backend Docker build context.

README.md

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,115 @@ The IP Inventory page provides a unified view of all IP addresses across every c
10651065
- **API Keys**: User API key generation and management
10661066
- **Role Assignment**: Dynamic role assignment and permission updates
10671067

1068+
#### Multi-Factor Authentication (MFA) — v1.6.0 (Issue #18)
1069+
1070+
MFA is **optional per account** and **default OFF**. Existing users keep their
1071+
single-factor (username/password) login unless they choose to enable it. The
1072+
feature is fully additive: nothing changes for accounts that don't opt in.
1073+
1074+
**For end-users**
1075+
1076+
- Open **Users** → find your own row → click **Enable MFA**.
1077+
- A wizard opens with three steps:
1078+
1. **Set up** — scan the QR code with Google Authenticator / Authy /
1079+
1Password / Microsoft Authenticator, or paste the displayed secret manually.
1080+
2. **Verify** — enter the current 6-digit code from your app.
1081+
3. **Backup codes** — save the 10 single-use recovery codes (format
1082+
`XXXX-YYYY`). They are shown only once. Use the **Copy all** /
1083+
**Download .txt** buttons.
1084+
- After enrollment your sign-in becomes two-step: username/password →
1085+
6-digit TOTP (or a backup code).
1086+
- To turn MFA off again, open your row's **Disable MFA** action and enter a
1087+
current TOTP or backup code.
1088+
1089+
**For admins**
1090+
1091+
- On any user with MFA enabled, the **Reset MFA** action wipes the user's
1092+
TOTP secret, backup codes, and pending challenges. A required *reason* is
1093+
written to the audit log. After reset the user logs in with their password
1094+
and may re-enroll.
1095+
1096+
**Emergency: reset MFA for every user**
1097+
1098+
Use the CLI helper when an authenticator outage / mass key loss happens.
1099+
Double confirmation is required; the action is irreversible.
1100+
1101+
```bash
1102+
API_URL=https://hap.example.com ADMIN_TOKEN=eyJ... \
1103+
./scripts/admin-mfa-reset-all.sh
1104+
# Prompts ask for: 'yes' → 'RESET ALL MFA' → reason
1105+
# Audit log: action='mfa.disabled.admin_bulk_reset'
1106+
```
1107+
1108+
**Configuration**
1109+
1110+
- Backend env var `MFA_ENCRYPTION_KEY` — a 44-char Fernet key used to encrypt
1111+
TOTP secrets at rest. Generate with:
1112+
```bash
1113+
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
1114+
```
1115+
On Kubernetes the key lives in the `backend-secret` Secret
1116+
(`k8s/manifests/03-secrets.yaml`). The shipped manifest uses the placeholder
1117+
`mfa_encryption_key_replace_me`; replace it via your CI/CD pipeline (e.g.
1118+
`sed` step) before `kubectl apply`.
1119+
- Optional `MFA_ACCOUNT_LABEL_DOMAIN` — overrides the per-user otpauth label
1120+
domain so QR codes show e.g. `alice@hap.example.com` instead of the request
1121+
hostname.
1122+
1123+
**API endpoints (all additive)**
1124+
1125+
| Method | Path | Notes |
1126+
|---|---|---|
1127+
| `POST` | `/api/auth/login` | Returns `mfa_required:true`+`mfa_token` for MFA users; legacy shape otherwise |
1128+
| `POST` | `/api/auth/login/mfa-verify` | Submits TOTP or backup code; returns JWT |
1129+
| `GET` | `/api/mfa/status` | Self status (enabled / method / backup codes remaining) |
1130+
| `POST` | `/api/mfa/enroll/start` | Begin TOTP enrollment |
1131+
| `POST` | `/api/mfa/enroll/confirm` | Confirm enrollment, returns 10 backup codes once |
1132+
| `POST` | `/api/mfa/disable` | Self-disable (TOTP or backup required) |
1133+
| `POST` | `/api/mfa/backup-codes/regenerate` | Issue 10 fresh backup codes (TOTP only) |
1134+
| `GET` | `/api/mfa/admin/status/{id}` | Admin: any user's MFA status |
1135+
| `POST` | `/api/mfa/admin-reset/{id}` | Admin: reset a single user's MFA |
1136+
| `POST` | `/api/mfa/admin-reset-all` | Admin: emergency reset for all users |
1137+
1138+
**Rate limits (per-user, ingress-aware, operationally tunable)**
1139+
1140+
MFA endpoints are rate-limited via slowapi+Redis with a **user-aware key
1141+
function** (`backend/middleware/mfa_rate_limit_key.py`):
1142+
1143+
1. If the request carries a valid Bearer JWT → bucket is `user:<id>`.
1144+
Each operator gets an isolated bucket; an org-wide MFA rollout is no
1145+
longer bottlenecked by the shared ingress IP.
1146+
2. Else, if the TCP peer is in `MFA_TRUSTED_PROXY_CIDRS` → bucket is the
1147+
first `X-Forwarded-For` hop (real client IP behind the ingress).
1148+
3. Else → bucket is the TCP peer (slowapi default).
1149+
1150+
Defaults live in `backend/middleware/mfa_rate_limits.py` and are sized for
1151+
**enterprise-scale** rollouts (thousands of operators). Each one is
1152+
overridable via env var; an invalid string logs a `WARNING` and falls back
1153+
to the default without crashing.
1154+
1155+
| Endpoint | Env var | Default | Bucket |
1156+
|---|---|---|---|
1157+
| `POST /api/mfa/enroll/start` | `MFA_RATE_LIMIT_ENROLL_START` | `10/minute` | per user |
1158+
| `POST /api/mfa/enroll/confirm` | `MFA_RATE_LIMIT_ENROLL_CONFIRM` | `10/minute` | per user |
1159+
| `POST /api/mfa/disable` | `MFA_RATE_LIMIT_DISABLE` | `10/minute` | per user |
1160+
| `POST /api/mfa/backup-codes/regenerate` | `MFA_RATE_LIMIT_REGENERATE_BACKUP_CODES` | `5/hour` | per user |
1161+
| `POST /api/mfa/admin-reset/{id}` | `MFA_RATE_LIMIT_ADMIN_RESET` | `60/hour` | per admin |
1162+
| `POST /api/mfa/admin-reset-all` | `MFA_RATE_LIMIT_ADMIN_RESET_ALL` | `1/day` | per admin |
1163+
1164+
Limit string format follows slowapi: `<count>/<second|minute|hour|day>`.
1165+
1166+
**Trusted-proxy configuration**
1167+
`MFA_TRUSTED_PROXY_CIDRS` — comma-separated CIDR list, e.g.
1168+
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. Empty (default) disables XFF
1169+
parsing — XFF from any peer is then ignored, which is the safe choice when
1170+
the topology is unknown. Set this when your backend sits behind a known
1171+
ingress / load balancer so anonymous flows still get per-real-IP buckets.
1172+
1173+
The pre-existing `/api/auth/login` rate-limiting policy is unchanged. On
1174+
Kubernetes, see commented overrides in
1175+
`k8s/manifests/07-configmaps.yaml::backend-config`.
1176+
10681177
### Settings - System Configuration
10691178
- **Theme Settings**: Light/dark mode toggle and UI customization
10701179
- **ACME / SSL Automation**: Configure ACME provider, directory URL, staging mode, auto-renewal, EAB credentials, and test CA connectivity
@@ -1524,12 +1633,25 @@ AGENT_CONFIG_SYNC_INTERVAL_SECONDS=30
15241633
```
15251634

15261635
#### Frontend Configuration
1636+
1637+
The frontend is a Create-React-App single-page app served as a static bundle
1638+
(`serve -s build`). It uses **same-origin** (`window.location.host`) for all
1639+
`/api/*` calls — no env vars are needed in production. Routing is handled
1640+
entirely by the nginx reverse proxy in front of the frontend pod (Kubernetes
1641+
ingress + `nginx-config` ConfigMap, or `nginx/nginx.conf` for Docker Compose).
1642+
1643+
> ⚠️ **Do NOT set `REACT_APP_API_URL` in your CI/CD pipeline.** CRA inlines
1644+
> `REACT_APP_*` values into the bundle at **build time**, so any value baked
1645+
> in there overrides the runtime same-origin detection and breaks every
1646+
> deployment whose URL does not match the inlined string. Leave the variable
1647+
> unset; the bundle will resolve to whatever host the user is browsing.
1648+
15271649
```bash
1528-
# API Endpoint (auto-detected if empty)
1529-
# Leave empty in production to use same-origin (window.location)
1530-
REACT_APP_API_URL="" # For development: "http://localhost:8000"
1650+
# Optional, only when you intentionally need a cross-origin API
1651+
# (then CORS_ORIGINS on the backend must include the SPA's origin):
1652+
# REACT_APP_API_URL="https://api.example.com"
15311653

1532-
# Environment
1654+
# Build settings
15331655
NODE_ENV="production"
15341656
GENERATE_SOURCEMAP="false"
15351657
```

backend/database/migrations.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,8 +1700,87 @@ async def run_all_migrations():
17001700
# (cluster_id, bind_address, bind_port) WHERE is_active.
17011701
await ensure_frontends_bind_unique_constraint()
17021702

1703+
# Issue #18 — TOTP MFA (v1.6.0): additive columns + 3 new tables
1704+
await ensure_mfa_columns()
1705+
17031706
logger.info("Database migrations completed successfully.")
17041707

1708+
1709+
async def ensure_mfa_columns():
1710+
"""Issue #18 — TOTP MFA (v1.6.0): additive columns on users + 3 new tables.
1711+
1712+
All operations are idempotent (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS).
1713+
Default behavior preserved: every existing user gets mfa_enabled=FALSE, so login
1714+
flow is byte-identical for accounts that don't opt in.
1715+
"""
1716+
conn = None
1717+
try:
1718+
conn = await get_database_connection()
1719+
1720+
await conn.execute("""
1721+
ALTER TABLE users
1722+
ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE NOT NULL,
1723+
ADD COLUMN IF NOT EXISTS mfa_method VARCHAR(20),
1724+
ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT,
1725+
ADD COLUMN IF NOT EXISTS mfa_enrolled_at TIMESTAMP,
1726+
ADD COLUMN IF NOT EXISTS mfa_last_used_at TIMESTAMP,
1727+
ADD COLUMN IF NOT EXISTS mfa_last_used_totp_step BIGINT;
1728+
""")
1729+
1730+
await conn.execute("""
1731+
CREATE TABLE IF NOT EXISTS mfa_backup_codes (
1732+
id SERIAL PRIMARY KEY,
1733+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
1734+
code_hash VARCHAR(255) NOT NULL,
1735+
used_at TIMESTAMP,
1736+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1737+
);
1738+
""")
1739+
await conn.execute("""
1740+
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user
1741+
ON mfa_backup_codes(user_id);
1742+
""")
1743+
1744+
await conn.execute("""
1745+
CREATE TABLE IF NOT EXISTS mfa_pending_logins (
1746+
id SERIAL PRIMARY KEY,
1747+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
1748+
challenge_token VARCHAR(64) UNIQUE NOT NULL,
1749+
attempts INTEGER DEFAULT 0 NOT NULL,
1750+
expires_at TIMESTAMP NOT NULL,
1751+
used_at TIMESTAMP,
1752+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1753+
ip_address INET
1754+
);
1755+
""")
1756+
await conn.execute(
1757+
"CREATE INDEX IF NOT EXISTS idx_mfa_pending_token ON mfa_pending_logins(challenge_token);"
1758+
)
1759+
await conn.execute(
1760+
"CREATE INDEX IF NOT EXISTS idx_mfa_pending_expires ON mfa_pending_logins(expires_at);"
1761+
)
1762+
1763+
await conn.execute("""
1764+
CREATE TABLE IF NOT EXISTS mfa_pending_enrollments (
1765+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
1766+
secret_encrypted TEXT NOT NULL,
1767+
attempts INTEGER DEFAULT 0 NOT NULL,
1768+
expires_at TIMESTAMP NOT NULL,
1769+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1770+
);
1771+
""")
1772+
await conn.execute(
1773+
"CREATE INDEX IF NOT EXISTS idx_mfa_pending_enroll_expires ON mfa_pending_enrollments(expires_at);"
1774+
)
1775+
1776+
logger.info("✅ MFA migration completed (Issue #18 — Phase 1)")
1777+
except Exception as e:
1778+
logger.error(f"Failed to ensure MFA columns: {e}")
1779+
# Don't raise — follow the same defensive pattern as ensure_user_activity_logs_table
1780+
finally:
1781+
if conn:
1782+
await close_database_connection(conn)
1783+
17051784
async def add_ssl_certificate_id_to_backend_servers():
17061785
"""Add ssl_certificate_id column to backend_servers table for SSL certificate management"""
17071786
conn = None

backend/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import asyncio
99
from datetime import datetime, timedelta
1010

11-
_version_info = {"version": "1.5.2", "releaseName": "ACME Diagnostics Panel Hardening", "releaseDate": "2026-05-13"}
11+
_version_info = {"version": "1.6.0", "releaseName": "Multi-Factor Authentication (MFA)", "releaseDate": "2026-05-18"}
1212
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
1313
try:
1414
with open(_vpath) as _vf:
@@ -40,6 +40,7 @@
4040
from routers.letsencrypt import router as letsencrypt_router
4141
from routers.acme_diagnostics import router as acme_diagnostics_router
4242
from routers.site_wizard import router as site_wizard_router
43+
from routers.mfa import router as mfa_router
4344

4445
# Production logging configuration
4546
from utils.logging_config import setup_production_logging
@@ -820,6 +821,7 @@ async def global_exception_handler(request: Request, exc: Exception):
820821
app.include_router(maintenance_router, prefix="/api", tags=["maintenance"]) # Database cleanup & maintenance
821822
app.include_router(auth_router)
822823
app.include_router(user_router)
824+
app.include_router(mfa_router)
823825
app.include_router(frontend_router)
824826
app.include_router(backend_router)
825827
app.include_router(cluster_router)

backend/middleware/error_handler.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,26 @@ async def handle_validation_error(request: Request, exc: Exception) -> JSONRespo
126126
except Exception as body_error:
127127
logger.debug(f"Could not extract raw body for debugging: {body_error}")
128128

129-
# Enhanced log message for agent heartbeats
130-
log_message = f"Validation error: {str(exc)}"
129+
# Build a sanitized log summary. The raw `str(exc)` from Pydantic
130+
# contains the user-supplied `input` value for each failed field —
131+
# which leaks secrets like TOTP codes, backup codes, mfa_token, and
132+
# passwords to plaintext logs. Use only field NAMES + types here;
133+
# `validation_details` (already sanitized to {field, message, type})
134+
# is attached separately for downstream structured logging.
135+
_field_names = [
136+
err.get("field", "unknown")
137+
for err in error_details.get("validation_errors", [])
138+
]
139+
_err_count = len(error_details.get("validation_errors", []))
140+
log_message = (
141+
f"Validation error: {_err_count} field(s) failed validation: "
142+
f"[{', '.join(_field_names)}]"
143+
)
131144
if agent_name != "unknown":
132-
log_message = f"Agent '{agent_name}' heartbeat validation error: {str(exc)}"
145+
log_message = (
146+
f"Agent '{agent_name}' heartbeat validation error: "
147+
f"{_err_count} field(s) failed: [{', '.join(_field_names)}]"
148+
)
133149

134150
# Log validation error with enhanced details
135151
log_with_correlation(

0 commit comments

Comments
 (0)