Compare commits

...

3 Commits

Author SHA1 Message Date
taylanbakircioglu bd6a31cb0d 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
2026-05-19 04:35:16 +03:00
dependabot[bot] 445639d202 chore(deps-dev): bump @babel/plugin-transform-modules-systemjs (#15)
Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.29.0 to 7.29.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-modules-systemjs"
  dependency-version: 7.29.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 00:41:27 +03:00
taylanbakircioglu f7e0df15e3 ci(release): stage version.json into backend build context (drift fix)
The backend Docker image is built with `context: ./backend`, so the
repo-root `version.json` is outside the build context and never
reaches the container. Backend `main.py` falls back to a compile-
time `_version_info` constant when `/app/version.json` is missing.

In practice this produced a real production drift: a successful
redeploy of the v1.5.2 tree silently reported `"v1.5.0"` in
`/api/version` for a window of releases because the constant in
main.py had not been bumped in lockstep with `version.json`, and
the canonical file was never available to read inside the
container.

Fix is workflow-only:
  * New "stage version.json into backend build context" step
    (between `read product version` and `set up qemu`) that runs
    `cp version.json backend/version.json` so the next
    `docker buildx build` includes it.
  * `.gitignore` entry for `backend/version.json` keeps `git status`
    clean for developers (the canonical file remains at repo root;
    `backend/version.json` is a transient CI artefact).

Backend reading logic is unchanged: the loop in `main.py` first
tries `/app/version.json`, then falls back to the constant.
Post-fix, the first path WILL find the file and produce the
correct response; the constant becomes a pure defensive fallback
(rather than the production hot path it accidentally became).

No code or test changes needed: existing tests assert against the
`_version_info` dict regardless of whether it was populated from
JSON or the fallback constant.
2026-05-14 00:08:18 +03:00
33 changed files with 3675 additions and 221 deletions
+14
View File
@@ -26,6 +26,20 @@ jobs:
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
# The backend image is built with `context: ./backend`, so the
# repo-root version.json is OUTSIDE the build context and never
# reaches the container. Backend `main.py` falls back to a
# compile-time constant when /app/version.json is missing, which
# caused a real production drift: a redeploy of the v1.5.2 tree
# silently still reported "v1.5.0" in `/api/version` because the
# constant in main.py had been bumped but the file was not
# available to read. Stage version.json into the backend
# context here so the canonical file IS shipped and the
# constant only serves as a defensive fallback. The staged file
# is gitignored to keep `git status` clean for developers.
- name: stage version.json into backend build context
run: cp version.json backend/version.json
- name: set up qemu
uses: docker/setup-qemu-action@v3
+4 -2
View File
@@ -39,8 +39,10 @@ venv.bak/
*.sqlite
*.sqlite3
# Docker
.dockerignore
# Build-time staged version.json (CI `cp version.json backend/`).
# The canonical file lives at repo root; this path is a transient
# copy for the backend Docker build context.
backend/version.json
# IDE
.vscode/
+127 -5
View File
@@ -1065,6 +1065,115 @@ The IP Inventory page provides a unified view of all IP addresses across every c
- **API Keys**: User API key generation and management
- **Role Assignment**: Dynamic role assignment and permission updates
#### Multi-Factor Authentication (MFA) — v1.6.0 (Issue #18)
MFA is **optional per account** and **default OFF**. Existing users keep their
single-factor (username/password) login unless they choose to enable it. The
feature is fully additive: nothing changes for accounts that don't opt in.
**For end-users**
- Open **Users** → find your own row → click **Enable MFA**.
- A wizard opens with three steps:
1. **Set up** — scan the QR code with Google Authenticator / Authy /
1Password / Microsoft Authenticator, or paste the displayed secret manually.
2. **Verify** — enter the current 6-digit code from your app.
3. **Backup codes** — save the 10 single-use recovery codes (format
`XXXX-YYYY`). They are shown only once. Use the **Copy all** /
**Download .txt** buttons.
- After enrollment your sign-in becomes two-step: username/password →
6-digit TOTP (or a backup code).
- To turn MFA off again, open your row's **Disable MFA** action and enter a
current TOTP or backup code.
**For admins**
- On any user with MFA enabled, the **Reset MFA** action wipes the user's
TOTP secret, backup codes, and pending challenges. A required *reason* is
written to the audit log. After reset the user logs in with their password
and may re-enroll.
**Emergency: reset MFA for every user**
Use the CLI helper when an authenticator outage / mass key loss happens.
Double confirmation is required; the action is irreversible.
```bash
API_URL=https://hap.example.com ADMIN_TOKEN=eyJ... \
./scripts/admin-mfa-reset-all.sh
# Prompts ask for: 'yes' → 'RESET ALL MFA' → reason
# Audit log: action='mfa.disabled.admin_bulk_reset'
```
**Configuration**
- Backend env var `MFA_ENCRYPTION_KEY` — a 44-char Fernet key used to encrypt
TOTP secrets at rest. Generate with:
```bash
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
On Kubernetes the key lives in the `backend-secret` Secret
(`k8s/manifests/03-secrets.yaml`). The shipped manifest uses the placeholder
`mfa_encryption_key_replace_me`; replace it via your CI/CD pipeline (e.g.
`sed` step) before `kubectl apply`.
- Optional `MFA_ACCOUNT_LABEL_DOMAIN` — overrides the per-user otpauth label
domain so QR codes show e.g. `alice@hap.example.com` instead of the request
hostname.
**API endpoints (all additive)**
| Method | Path | Notes |
|---|---|---|
| `POST` | `/api/auth/login` | Returns `mfa_required:true`+`mfa_token` for MFA users; legacy shape otherwise |
| `POST` | `/api/auth/login/mfa-verify` | Submits TOTP or backup code; returns JWT |
| `GET` | `/api/mfa/status` | Self status (enabled / method / backup codes remaining) |
| `POST` | `/api/mfa/enroll/start` | Begin TOTP enrollment |
| `POST` | `/api/mfa/enroll/confirm` | Confirm enrollment, returns 10 backup codes once |
| `POST` | `/api/mfa/disable` | Self-disable (TOTP or backup required) |
| `POST` | `/api/mfa/backup-codes/regenerate` | Issue 10 fresh backup codes (TOTP only) |
| `GET` | `/api/mfa/admin/status/{id}` | Admin: any user's MFA status |
| `POST` | `/api/mfa/admin-reset/{id}` | Admin: reset a single user's MFA |
| `POST` | `/api/mfa/admin-reset-all` | Admin: emergency reset for all users |
**Rate limits (per-user, ingress-aware, operationally tunable)**
MFA endpoints are rate-limited via slowapi+Redis with a **user-aware key
function** (`backend/middleware/mfa_rate_limit_key.py`):
1. If the request carries a valid Bearer JWT → bucket is `user:<id>`.
Each operator gets an isolated bucket; an org-wide MFA rollout is no
longer bottlenecked by the shared ingress IP.
2. Else, if the TCP peer is in `MFA_TRUSTED_PROXY_CIDRS` → bucket is the
first `X-Forwarded-For` hop (real client IP behind the ingress).
3. Else → bucket is the TCP peer (slowapi default).
Defaults live in `backend/middleware/mfa_rate_limits.py` and are sized for
**enterprise-scale** rollouts (thousands of operators). Each one is
overridable via env var; an invalid string logs a `WARNING` and falls back
to the default without crashing.
| Endpoint | Env var | Default | Bucket |
|---|---|---|---|
| `POST /api/mfa/enroll/start` | `MFA_RATE_LIMIT_ENROLL_START` | `10/minute` | per user |
| `POST /api/mfa/enroll/confirm` | `MFA_RATE_LIMIT_ENROLL_CONFIRM` | `10/minute` | per user |
| `POST /api/mfa/disable` | `MFA_RATE_LIMIT_DISABLE` | `10/minute` | per user |
| `POST /api/mfa/backup-codes/regenerate` | `MFA_RATE_LIMIT_REGENERATE_BACKUP_CODES` | `5/hour` | per user |
| `POST /api/mfa/admin-reset/{id}` | `MFA_RATE_LIMIT_ADMIN_RESET` | `60/hour` | per admin |
| `POST /api/mfa/admin-reset-all` | `MFA_RATE_LIMIT_ADMIN_RESET_ALL` | `1/day` | per admin |
Limit string format follows slowapi: `<count>/<second|minute|hour|day>`.
**Trusted-proxy configuration**
`MFA_TRUSTED_PROXY_CIDRS` — comma-separated CIDR list, e.g.
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. Empty (default) disables XFF
parsing — XFF from any peer is then ignored, which is the safe choice when
the topology is unknown. Set this when your backend sits behind a known
ingress / load balancer so anonymous flows still get per-real-IP buckets.
The pre-existing `/api/auth/login` rate-limiting policy is unchanged. On
Kubernetes, see commented overrides in
`k8s/manifests/07-configmaps.yaml::backend-config`.
### Settings - System Configuration
- **Theme Settings**: Light/dark mode toggle and UI customization
- **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
```
#### Frontend Configuration
```bash
# API Endpoint (auto-detected if empty)
# Leave empty in production to use same-origin (window.location)
REACT_APP_API_URL="" # For development: "http://localhost:8000"
# Environment
The frontend is a Create-React-App single-page app served as a static bundle
(`serve -s build`). It uses **same-origin** (`window.location.host`) for all
`/api/*` calls — no env vars are needed in production. Routing is handled
entirely by the nginx reverse proxy in front of the frontend pod (Kubernetes
ingress + `nginx-config` ConfigMap, or `nginx/nginx.conf` for Docker Compose).
> ⚠️ **Do NOT set `REACT_APP_API_URL` in your CI/CD pipeline.** CRA inlines
> `REACT_APP_*` values into the bundle at **build time**, so any value baked
> in there overrides the runtime same-origin detection and breaks every
> deployment whose URL does not match the inlined string. Leave the variable
> unset; the bundle will resolve to whatever host the user is browsing.
```bash
# Optional, only when you intentionally need a cross-origin API
# (then CORS_ORIGINS on the backend must include the SPA's origin):
# REACT_APP_API_URL="https://api.example.com"
# Build settings
NODE_ENV="production"
GENERATE_SOURCEMAP="false"
```
+79
View File
@@ -1700,8 +1700,87 @@ async def run_all_migrations():
# (cluster_id, bind_address, bind_port) WHERE is_active.
await ensure_frontends_bind_unique_constraint()
# Issue #18 — TOTP MFA (v1.6.0): additive columns + 3 new tables
await ensure_mfa_columns()
logger.info("Database migrations completed successfully.")
async def ensure_mfa_columns():
"""Issue #18 — TOTP MFA (v1.6.0): additive columns on users + 3 new tables.
All operations are idempotent (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS).
Default behavior preserved: every existing user gets mfa_enabled=FALSE, so login
flow is byte-identical for accounts that don't opt in.
"""
conn = None
try:
conn = await get_database_connection()
await conn.execute("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE NOT NULL,
ADD COLUMN IF NOT EXISTS mfa_method VARCHAR(20),
ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT,
ADD COLUMN IF NOT EXISTS mfa_enrolled_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS mfa_last_used_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS mfa_last_used_totp_step BIGINT;
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS mfa_backup_codes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user
ON mfa_backup_codes(user_id);
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS mfa_pending_logins (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
challenge_token VARCHAR(64) UNIQUE NOT NULL,
attempts INTEGER DEFAULT 0 NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address INET
);
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mfa_pending_token ON mfa_pending_logins(challenge_token);"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mfa_pending_expires ON mfa_pending_logins(expires_at);"
)
await conn.execute("""
CREATE TABLE IF NOT EXISTS mfa_pending_enrollments (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
secret_encrypted TEXT NOT NULL,
attempts INTEGER DEFAULT 0 NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mfa_pending_enroll_expires ON mfa_pending_enrollments(expires_at);"
)
logger.info("✅ MFA migration completed (Issue #18 — Phase 1)")
except Exception as e:
logger.error(f"Failed to ensure MFA columns: {e}")
# Don't raise — follow the same defensive pattern as ensure_user_activity_logs_table
finally:
if conn:
await close_database_connection(conn)
async def add_ssl_certificate_id_to_backend_servers():
"""Add ssl_certificate_id column to backend_servers table for SSL certificate management"""
conn = None
+3 -1
View File
@@ -8,7 +8,7 @@ import redis
import asyncio
from datetime import datetime, timedelta
_version_info = {"version": "1.5.2", "releaseName": "ACME Diagnostics Panel Hardening", "releaseDate": "2026-05-13"}
_version_info = {"version": "1.6.0", "releaseName": "Multi-Factor Authentication (MFA)", "releaseDate": "2026-05-18"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
try:
with open(_vpath) as _vf:
@@ -40,6 +40,7 @@ from routers.settings import router as settings_router
from routers.letsencrypt import router as letsencrypt_router
from routers.acme_diagnostics import router as acme_diagnostics_router
from routers.site_wizard import router as site_wizard_router
from routers.mfa import router as mfa_router
# Production logging configuration
from utils.logging_config import setup_production_logging
@@ -820,6 +821,7 @@ app.include_router(config_router) # Configuration management
app.include_router(maintenance_router, prefix="/api", tags=["maintenance"]) # Database cleanup & maintenance
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(mfa_router)
app.include_router(frontend_router)
app.include_router(backend_router)
app.include_router(cluster_router)
+19 -3
View File
@@ -126,10 +126,26 @@ class GlobalExceptionHandler:
except Exception as body_error:
logger.debug(f"Could not extract raw body for debugging: {body_error}")
# Enhanced log message for agent heartbeats
log_message = f"Validation error: {str(exc)}"
# Build a sanitized log summary. The raw `str(exc)` from Pydantic
# contains the user-supplied `input` value for each failed field —
# which leaks secrets like TOTP codes, backup codes, mfa_token, and
# passwords to plaintext logs. Use only field NAMES + types here;
# `validation_details` (already sanitized to {field, message, type})
# is attached separately for downstream structured logging.
_field_names = [
err.get("field", "unknown")
for err in error_details.get("validation_errors", [])
]
_err_count = len(error_details.get("validation_errors", []))
log_message = (
f"Validation error: {_err_count} field(s) failed validation: "
f"[{', '.join(_field_names)}]"
)
if agent_name != "unknown":
log_message = f"Agent '{agent_name}' heartbeat validation error: {str(exc)}"
log_message = (
f"Agent '{agent_name}' heartbeat validation error: "
f"{_err_count} field(s) failed: [{', '.join(_field_names)}]"
)
# Log validation error with enhanced details
log_with_correlation(
+147
View File
@@ -0,0 +1,147 @@
"""MFA rate-limit key extraction (user-aware + ingress-aware).
Problem with the default ``slowapi.util.get_remote_address``:
* Behind an ingress / reverse proxy / load balancer, every request looks
like it originates from the same upstream IP (the proxy itself). A
5/minute IP-bucket therefore becomes a 5/minute *whole-organization*
bucket. In a 500-user enterprise rolling out MFA, this stalls the
rollout to a trickle.
Strategy (combined B + C from the design review):
1. **User-aware key (preferred):** if the request carries a valid Bearer
JWT, derive the bucket from ``user:<id>``. Each authenticated user
gets an isolated bucket, regardless of source IP. A malicious user
burning their own quota cannot starve the other 499.
2. **Trusted-proxy XFF fallback:** if the request is unauthenticated
(e.g. ``/login`` flow, future endpoints) and the TCP peer is in
``MFA_TRUSTED_PROXY_CIDRS``, peel the first hop off ``X-Forwarded-For``.
This preserves real-IP buckets behind a known ingress without
accepting spoofed headers from the public internet.
3. **Default fallback:** plain ``request.client.host`` (slowapi default).
JWT decode is intentionally signature-verified (replay/spoof protection)
and *sync* — slowapi's decorator hook is sync, and our JWT library
(python-jose) is sync as well. Failed verification silently downgrades
to IP-based bucketing — never crashes the decorator.
Env vars:
* ``MFA_TRUSTED_PROXY_CIDRS`` — comma-separated CIDR list of trusted
upstream proxies. Empty (default) disables XFF parsing entirely,
which is the safe choice when the deployment topology is unknown.
Examples:
``MFA_TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12``
``MFA_TRUSTED_PROXY_CIDRS=192.168.0.0/16``
"""
from __future__ import annotations
import logging
import os
from ipaddress import ip_address, ip_network
from typing import List
from fastapi import Request
from jose import jwt
logger = logging.getLogger(__name__)
def _parse_trusted_cidrs() -> List:
"""Parse the trusted-proxy CIDR list at import time.
A malformed entry is logged and skipped — we never crash the process
over a typo in operational config.
"""
raw = os.getenv("MFA_TRUSTED_PROXY_CIDRS", "").strip()
if not raw:
return []
nets = []
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
try:
nets.append(ip_network(entry, strict=False))
except ValueError:
logger.warning(
"MFA_TRUSTED_PROXY_CIDRS: ignoring invalid CIDR %r", entry
)
return nets
_TRUSTED_NETS = _parse_trusted_cidrs()
def _peer_ip(request: Request) -> str:
"""The TCP peer IP — never raises; falls back to ``0.0.0.0``."""
return request.client.host if request.client else "0.0.0.0"
def _is_trusted_peer(peer: str) -> bool:
if not _TRUSTED_NETS:
return False
try:
peer_ip = ip_address(peer)
except ValueError:
return False
return any(peer_ip in net for net in _TRUSTED_NETS)
def _real_ip(request: Request) -> str:
"""If TCP peer is in a trusted proxy CIDR, peel off the first
``X-Forwarded-For`` IP; otherwise return the peer.
``X-Forwarded-For`` from an *untrusted* peer is intentionally ignored —
accepting it would let any client spoof their bucket.
"""
peer = _peer_ip(request)
if not _is_trusted_peer(peer):
return peer
xff = request.headers.get("X-Forwarded-For")
if not xff:
return peer
first = xff.split(",")[0].strip()
return first or peer
def _user_id_from_jwt(request: Request) -> str | None:
"""Sync JWT decode → ``user_id`` (or ``sub``) claim. None on failure.
Uses the same secret + algorithm as ``auth_middleware`` so a token that
is valid for the API surface is also valid for the rate-limit key.
Bad / missing / expired tokens silently return None — slowapi falls
back to IP bucketing.
"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
token = auth[7:].strip()
if not token or token in {"null", "undefined"}:
return None
# Late import to avoid pulling jose into module-load when not needed.
try:
from config import JWT_ALGORITHM, JWT_SECRET_KEY
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
except Exception:
return None
uid = payload.get("user_id") or payload.get("sub")
if uid is None:
return None
return str(uid)
def mfa_rate_limit_key(request: Request) -> str:
"""slowapi ``key_func`` for MFA endpoints.
Order:
1. Authenticated → ``user:<id>``
2. Trusted-proxy XFF → ``ip:<first hop>``
3. TCP peer → ``ip:<peer>``
"""
uid = _user_id_from_jwt(request)
if uid is not None:
return f"user:{uid}"
return f"ip:{_real_ip(request)}"
+79
View File
@@ -0,0 +1,79 @@
"""MFA rate-limit configuration (env-overridable).
Best-practice pattern:
- Secure-by-default values live in code (kept in sync with the threat model).
- Operations can override per-environment via env vars (ConfigMap on K8s)
WITHOUT a code change / re-release.
- All limits funnel through a single named constant so the decorator stays
declarative (``@limiter.limit(MFA_LIMITS.enroll_start)``).
Env-var precedence::
MFA_RATE_LIMIT_<NAME> > default in code
slowapi limit string syntax: ``<count>/<period>`` where period is
``second|minute|hour|day``. Example: ``"5/minute"``.
NOTE: slowapi binds limits at import time. A change to an env var requires a
backend restart (rolling restart on K8s, ``docker compose restart backend``
locally). This is consistent with how ``SECRET_KEY`` / ``MFA_ENCRYPTION_KEY``
behave.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# slowapi limit-string format guard. Keeps a typo from silently disabling
# rate-limiting at process start.
_LIMIT_RE = re.compile(r"^\d+/(second|minute|hour|day)$")
def _env(name: str, default: str) -> str:
"""Read ``MFA_RATE_LIMIT_<NAME>``; fall back to ``default``.
Validates the limit string. On bad input, logs a warning and returns the
secure default instead of crashing the process.
"""
value = os.getenv(f"MFA_RATE_LIMIT_{name}", default).strip()
if not _LIMIT_RE.match(value):
logger.warning(
"MFA_RATE_LIMIT_%s='%s' is not a valid slowapi limit string "
"(expected '<n>/<second|minute|hour|day>'); using default '%s'.",
name, value, default,
)
return default
return value
@dataclass(frozen=True)
class MfaRateLimits:
"""Aggregate of MFA endpoint rate-limit strings (slowapi format).
Defaults assume the rate-limit ``key_func`` is ``mfa_rate_limit_key``
(user-aware + ingress-aware), NOT raw IP. Per-user buckets are safe to
keep generous because a misbehaving user only burns their own quota and
cannot starve the rest of the org. If you re-key on raw IP, retighten
these values (see README + ``MFA_RATE_LIMIT_<NAME>`` env overrides).
"""
# Enrollment lifecycle — per-user buckets, large enough for org-wide rollout
enroll_start: str = _env("ENROLL_START", "10/minute")
enroll_confirm: str = _env("ENROLL_CONFIRM", "10/minute")
# Self-service maintenance
disable: str = _env("DISABLE", "10/minute")
regenerate_backup_codes: str = _env("REGENERATE_BACKUP_CODES", "5/hour")
# Admin operations (per-admin bucket; bulk reset stays tight because
# it is an emergency-only flow).
admin_reset: str = _env("ADMIN_RESET", "60/hour")
admin_reset_all: str = _env("ADMIN_RESET_ALL", "1/day")
# Module-level singleton — import this from routers/mfa.py.
MFA_LIMITS = MfaRateLimits()
+8 -2
View File
@@ -2,6 +2,7 @@
Rate Limiting Middleware for Production Security
Protects API endpoints from abuse and DDoS attacks
"""
import os
import time
import logging
from typing import Callable
@@ -16,10 +17,15 @@ from database.connection import redis_client
logger = logging.getLogger(__name__)
# Rate limiter instance using Redis backend
# Resolve the Redis storage URI from REDIS_URL (set by docker-compose / k8s
# ConfigMap). Falls back to the compose service hostname for backward
# compatibility when REDIS_URL is unset.
_REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379").rstrip("/")
_LIMITER_STORAGE_URI = f"{_REDIS_URL}/0" if "/" not in _REDIS_URL.split("//", 1)[-1] else _REDIS_URL
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://redis:6379/0",
storage_uri=_LIMITER_STORAGE_URI,
default_limits=["1000/hour"], # Default global limit
retry_after=lambda name, t: int(t) + 10
)
+73
View File
@@ -0,0 +1,73 @@
"""MFA-specific Pydantic models — Issue #18, v1.6.0.
Kept in a separate module so the existing User / UserUpdate contracts in
``backend/models/user.py`` stay byte-identical for backwards compatibility.
"""
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class MfaVerifyRequest(BaseModel):
"""Body of POST /api/auth/login/mfa-verify (pre-auth — no JWT)."""
mfa_token: str = Field(..., min_length=64, max_length=64)
# 6 digits for TOTP or 8 alphanumerics (with optional dash) for backup codes
code: str = Field(..., min_length=6, max_length=10)
class MfaEnrollStartResponse(BaseModel):
"""Returned by POST /api/mfa/enroll/start."""
secret: str
otpauth_uri: str
expires_in: int # pending enrollment TTL in seconds
class MfaEnrollConfirmRequest(BaseModel):
"""Body of POST /api/mfa/enroll/confirm (TOTP only — backup codes not yet issued)."""
code: str = Field(..., min_length=6, max_length=6)
class MfaEnrollConfirmResponse(BaseModel):
enabled: bool
backup_codes: List[str]
method: Literal["totp"] = "totp"
class MfaDisableRequest(BaseModel):
"""Body of POST /api/mfa/disable — TOTP or backup."""
code: str = Field(..., min_length=6, max_length=10)
class MfaRegenerateBackupRequest(BaseModel):
"""Body of POST /api/mfa/backup-codes/regenerate — TOTP only."""
code: str = Field(..., min_length=6, max_length=6)
class MfaRegenerateBackupResponse(BaseModel):
backup_codes: List[str]
class MfaAdminResetRequest(BaseModel):
"""Body of POST /api/mfa/admin-reset/{user_id}."""
reason: str = Field(..., min_length=3, max_length=500)
class MfaAdminResetAllRequest(BaseModel):
"""Body of POST /api/mfa/admin-reset-all (emergency)."""
confirm: Literal["RESET ALL MFA"]
reason: str = Field(..., min_length=3, max_length=500)
class MfaStatusResponse(BaseModel):
enabled: bool
method: Optional[str] = None
enrolled_at: Optional[str] = None
last_used_at: Optional[str] = None
backup_codes_remaining: int = 0
+2 -1
View File
@@ -15,4 +15,5 @@ bcrypt>=4.0.1
slowapi>=0.1.9
psutil>=5.9.8
pytz>=2023.3
josepy>=1.14.0
josepy>=1.14.0
pyotp>=2.9.0
+427 -10
View File
@@ -9,14 +9,48 @@ from datetime import datetime, timedelta
# Import database and models
from database.connection import get_database_connection, close_database_connection
from models.user import LoginRequest, User, UserCreate, UserUpdate, UserPasswordUpdate
from models.mfa import MfaVerifyRequest
from utils.activity_log import log_user_activity
from auth_middleware import get_current_user_from_token
from services import mfa_service
# Rate limiting temporarily disabled
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
logger = logging.getLogger(__name__)
MFA_PENDING_TTL_SECONDS = 300 # 5 minutes — pre-verification challenge lifetime
MFA_PENDING_MAX_ATTEMPTS = 5 # invalidate token after this many wrong codes
async def _fetch_mfa_state(conn, user_id: int):
"""Return (mfa_enabled, mfa_secret_encrypted, mfa_last_used_totp_step) or None
when the MFA columns aren't yet present (pre-migration deploys).
"""
try:
return await conn.fetchrow(
"""
SELECT mfa_enabled, mfa_secret_encrypted, mfa_last_used_totp_step
FROM users
WHERE id = $1
""",
user_id,
)
except Exception as exc:
logger.warning(f"MFA columns not available (assuming disabled): {exc}")
return None
async def _cleanup_expired_pending_logins(conn, user_id: int) -> None:
"""Lazy cleanup of expired pending MFA challenges for this user."""
try:
await conn.execute(
"DELETE FROM mfa_pending_logins WHERE user_id = $1 AND expires_at < NOW()",
user_id,
)
except Exception as exc:
logger.debug(f"Pending-login cleanup skipped: {exc}")
# Security scheme
security = HTTPBearer()
@@ -96,7 +130,7 @@ async def login(login_request: LoginRequest, request: Request):
SELECT id, username, email, password_hash, is_active, role,
created_at, updated_at, last_login_at
FROM users
WHERE username = $1
WHERE username = $1 AND is_active = TRUE
""", login_request.username)
except Exception as schema_error:
logger.warning(f"Schema error, trying fallback query: {schema_error}")
@@ -106,7 +140,7 @@ async def login(login_request: LoginRequest, request: Request):
SELECT id, username, email, password_hash, is_active,
created_at, updated_at, last_login_at
FROM users
WHERE username = $1
WHERE username = $1 AND is_active = TRUE
""", login_request.username)
except Exception as column_error:
logger.warning(f"last_login_at column error, trying last_login: {column_error}")
@@ -115,26 +149,67 @@ async def login(login_request: LoginRequest, request: Request):
SELECT id, username, email, password_hash, is_active,
created_at, updated_at, last_login
FROM users
WHERE username = $1
WHERE username = $1 AND is_active = TRUE
""", login_request.username)
if not user:
# Covers both "no such user" and "soft-deleted (is_active=FALSE)".
# We deliberately return the same generic 401 in either case to
# avoid leaking whether an account exists (account enumeration
# prevention). Soft-deleted rows are filtered out by the
# `AND is_active = TRUE` predicate above.
await close_database_connection(conn)
logger.warning(f"Failed login attempt for username: {login_request.username}")
raise HTTPException(status_code=401, detail="Invalid username or password")
if not user['is_active']:
await close_database_connection(conn)
logger.warning(f"Login attempt for inactive account: {login_request.username}")
raise HTTPException(status_code=401, detail="Account is deactivated")
# Verify password
import bcrypt
if not bcrypt.checkpw(login_request.password.encode('utf-8'), user['password_hash'].encode('utf-8')):
await close_database_connection(conn)
logger.warning(f"Wrong password for user: {login_request.username}")
raise HTTPException(status_code=401, detail="Invalid username or password")
# Issue #18 — MFA branch (v1.6.0): if the user opted in, defer JWT mint and
# last_login update until /api/auth/login/mfa-verify completes.
mfa_state = await _fetch_mfa_state(conn, user['id'])
if mfa_state and mfa_state.get('mfa_enabled'):
await _cleanup_expired_pending_logins(conn, user['id'])
challenge_token = mfa_service.generate_challenge_token()
try:
await conn.execute(
"""
INSERT INTO mfa_pending_logins (user_id, challenge_token, expires_at, ip_address)
VALUES ($1, $2, NOW() + ($3 || ' seconds')::interval, $4)
""",
user['id'],
challenge_token,
str(MFA_PENDING_TTL_SECONDS),
str(request.client.host) if request.client else None,
)
except Exception as exc:
await close_database_connection(conn)
logger.error(f"Failed to create MFA pending login: {exc}")
raise HTTPException(status_code=500, detail="MFA challenge creation failed")
await close_database_connection(conn)
await log_user_activity(
user_id=user['id'],
action='mfa.login.challenge_issued',
resource_type='mfa',
resource_id=str(user['id']),
details={'login_method': 'username_password'},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get('user-agent'),
)
return {
"mfa_required": True,
"mfa_token": challenge_token,
"methods": ["totp", "backup"],
"expires_in": MFA_PENDING_TTL_SECONDS,
}
# Update last login (try different column names)
try:
await conn.execute("""
@@ -242,6 +317,348 @@ async def login(login_request: LoginRequest, request: Request):
logger.error(f"Login error: {e}")
raise HTTPException(status_code=500, detail="Login failed")
@router.post(
"/login/mfa-verify",
summary="MFA Verification (Step 2 of Login)",
response_description="JWT access token after successful TOTP/backup verification",
)
async def login_mfa_verify(payload: MfaVerifyRequest, request: Request):
"""
# MFA Verification — Step 2 of the two-step login flow
Submit a 6-digit TOTP code OR an 8-character backup code (with optional dash)
together with the ``mfa_token`` returned by ``POST /api/auth/login`` for an
MFA-enabled account. On success, returns the same response shape as a
non-MFA login (Branch A).
## Request Body
- **mfa_token**: 64-char challenge token from /login response
- **code**: 6 digits (TOTP) or `XXXX-YYYY` (backup)
## Error Responses
- **401**: Invalid code (attempts counter increments)
- **410**: Challenge expired or invalidated (too many wrong attempts)
"""
ip_address = str(request.client.host) if request.client else None
user_agent = request.headers.get('user-agent')
# Outcome captured from the transactional block so we can do JWT mint /
# activity logging AFTER commit (no side effects on rollback).
success_payload: Optional[dict] = None
failure: Optional[dict] = None # { user_id, attempts, invalidated, reason, http_status, detail }
conn = None
try:
conn = await get_database_connection()
# Round 1 audit fix — wrap the whole verify+update in a single
# transaction with row-level locks (FOR UPDATE) so two concurrent
# /mfa-verify calls cannot both consume the same TOTP step or the
# same pending challenge. We never raise inside the transaction once
# we've started mutating the pending row (would rollback the mark);
# instead we capture `failure` and raise after commit.
async with conn.transaction():
pending = await conn.fetchrow(
"""
SELECT id, user_id, attempts, expires_at, used_at
FROM mfa_pending_logins
WHERE challenge_token = $1
FOR UPDATE
""",
payload.mfa_token,
)
if not pending:
failure = {
'user_id': None,
'attempts': 0,
'invalidated': True,
'reason': 'challenge_not_found',
'http_status': 410,
'detail': 'MFA challenge not found or expired',
}
elif pending['used_at'] is not None:
failure = {
'user_id': pending['user_id'],
'attempts': pending['attempts'],
'invalidated': True,
'reason': 'challenge_already_used',
'http_status': 410,
'detail': 'MFA challenge already used',
}
elif pending['expires_at'] and pending['expires_at'] < datetime.utcnow():
failure = {
'user_id': pending['user_id'],
'attempts': pending['attempts'],
'invalidated': True,
'reason': 'challenge_expired',
'http_status': 410,
'detail': 'MFA challenge expired',
}
elif pending['attempts'] >= MFA_PENDING_MAX_ATTEMPTS:
await conn.execute(
"UPDATE mfa_pending_logins SET used_at = NOW() WHERE id = $1",
pending['id'],
)
failure = {
'user_id': pending['user_id'],
'attempts': pending['attempts'],
'invalidated': True,
'reason': 'too_many_attempts_pre_check',
'http_status': 410,
'detail': 'MFA challenge invalidated (too many attempts)',
}
if failure is None:
# Lock the user row so the atomic TOTP-step bump cannot race a
# parallel verify on a different pending challenge for the
# same account.
user_row = await conn.fetchrow(
"""
SELECT id, username, email, role, is_active,
created_at, updated_at, last_login_at,
mfa_secret_encrypted, mfa_last_used_totp_step
FROM users
WHERE id = $1
FOR UPDATE
""",
pending['user_id'],
)
if not user_row or not user_row['is_active']:
failure = {
'user_id': pending['user_id'],
'attempts': pending['attempts'],
'invalidated': False,
'reason': 'user_unavailable',
'http_status': 401,
'detail': 'User not available',
}
elif not user_row['mfa_secret_encrypted']:
failure = {
'user_id': user_row['id'],
'attempts': pending['attempts'],
'invalidated': True,
'reason': 'mfa_not_configured',
'http_status': 410,
'detail': 'MFA not configured for this user',
}
else:
secret_plain = mfa_service.decrypt_secret(user_row['mfa_secret_encrypted'])
verified_method: Optional[str] = None
codes_remaining: Optional[int] = None
if secret_plain:
ok, step = mfa_service.verify_totp_with_replay_guard(
secret_plain, payload.code, user_row['mfa_last_used_totp_step']
)
if ok:
# Atomic step bump — refuse if another request already
# consumed this (or a newer) TOTP step.
bumped = await conn.fetchval(
"""
UPDATE users
SET mfa_last_used_totp_step = $1,
mfa_last_used_at = NOW()
WHERE id = $2
AND (mfa_last_used_totp_step IS NULL
OR mfa_last_used_totp_step < $1)
RETURNING id
""",
step,
user_row['id'],
)
if bumped:
verified_method = 'totp'
if verified_method is None:
# Backup codes — atomic single-use consumption.
rows = await conn.fetch(
"""
SELECT id, code_hash FROM mfa_backup_codes
WHERE user_id = $1 AND used_at IS NULL
""",
user_row['id'],
)
for row in rows:
if await mfa_service.check_backup_code(payload.code, row['code_hash']):
consumed_id = await conn.fetchval(
"""
UPDATE mfa_backup_codes
SET used_at = NOW()
WHERE id = $1 AND used_at IS NULL
RETURNING id
""",
row['id'],
)
if consumed_id:
verified_method = 'backup'
codes_remaining = await conn.fetchval(
"SELECT COUNT(*) FROM mfa_backup_codes WHERE user_id = $1 AND used_at IS NULL",
user_row['id'],
)
break
if verified_method is None:
new_attempts = pending['attempts'] + 1
invalidated = new_attempts >= MFA_PENDING_MAX_ATTEMPTS
await conn.execute(
"""
UPDATE mfa_pending_logins
SET attempts = $1,
used_at = CASE WHEN $2 THEN NOW() ELSE used_at END
WHERE id = $3
""",
new_attempts,
invalidated,
pending['id'],
)
failure = {
'user_id': user_row['id'],
'attempts': new_attempts,
'invalidated': invalidated,
'reason': 'invalid_code',
'http_status': 410 if invalidated else 401,
'detail': 'MFA challenge invalidated (too many attempts)'
if invalidated else 'Invalid MFA code',
}
else:
# Verified — finalize state inside the transaction so a
# concurrent verify sees used_at on retry.
await conn.execute(
"UPDATE mfa_pending_logins SET used_at = NOW() WHERE id = $1",
pending['id'],
)
if verified_method != 'totp':
await conn.execute(
"UPDATE users SET mfa_last_used_at = NOW() WHERE id = $1",
user_row['id'],
)
try:
await conn.execute(
"UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = $1",
user_row['id'],
)
except Exception as exc:
logger.warning(f"last_login_at update failed (continuing): {exc}")
user_roles = await conn.fetch(
"""
SELECT r.id, r.name, r.display_name, r.permissions
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = $1 AND ur.is_active = TRUE AND r.is_active = TRUE
""",
user_row['id'],
)
permissions: dict = {}
roles_list: list = []
for role_row in user_roles:
roles_list.append({
'id': role_row['id'],
'name': role_row['name'],
'display_name': role_row['display_name'],
})
role_permissions = role_row['permissions']
if isinstance(role_permissions, str):
import json
role_permissions = json.loads(role_permissions)
if role_permissions:
for perm in role_permissions:
if '.' in perm:
resource, action = perm.split('.', 1)
if resource not in permissions:
permissions[resource] = {}
permissions[resource][action] = True
success_payload = {
'user': dict(user_row),
'roles_list': roles_list,
'permissions': permissions,
'method': verified_method,
'codes_remaining': codes_remaining,
}
# ------------------------------------------------------------------
# Transaction has committed. Side-effects (JWT mint, audit log) below.
# ------------------------------------------------------------------
await close_database_connection(conn)
conn = None
if failure is not None:
if failure['user_id'] is not None:
await log_user_activity(
user_id=failure['user_id'],
action='mfa.login.failed',
resource_type='mfa',
resource_id=str(failure['user_id']),
details={
'reason': failure['reason'],
'attempts': failure['attempts'],
'invalidated': failure['invalidated'],
},
ip_address=ip_address,
user_agent=user_agent,
)
raise HTTPException(status_code=failure['http_status'], detail=failure['detail'])
# Success path
assert success_payload is not None # for type checkers; transaction guarantees this
user_row = success_payload['user']
from jose import jwt
from config import JWT_SECRET_KEY, JWT_ALGORITHM
token_payload = {
"user_id": user_row['id'],
"username": user_row['username'],
"email": user_row['email'],
"role": user_row['role'] if 'role' in user_row.keys() else 'admin',
"exp": datetime.utcnow() + timedelta(hours=24),
}
token = jwt.encode(token_payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
await log_user_activity(
user_id=user_row['id'],
action='mfa.login.success',
resource_type='mfa',
resource_id=str(user_row['id']),
details={
'method': success_payload['method'],
'codes_remaining': success_payload['codes_remaining']
if success_payload['method'] == 'backup' else None,
},
ip_address=ip_address,
user_agent=user_agent,
)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": 86400,
"user": {
"id": user_row['id'],
"username": user_row['username'],
"email": user_row['email'],
"role": user_row['role'] if 'role' in user_row.keys() else 'admin',
"is_active": user_row['is_active'],
"created_at": user_row['created_at'].isoformat() if user_row.get('created_at') else None,
"last_login_at": datetime.utcnow().isoformat(),
},
"roles": success_payload['roles_list'],
"permissions": success_payload['permissions'],
}
except HTTPException:
raise
except Exception as exc:
logger.error(f"MFA verify error: {exc}")
raise HTTPException(status_code=500, detail="MFA verification failed")
finally:
if conn is not None:
await close_database_connection(conn)
@router.post("/logout", summary="User Logout", response_description="Logout confirmation")
async def logout(request: Request, authorization: str = Header(None)):
"""
+727
View File
@@ -0,0 +1,727 @@
"""MFA router — Issue #18, v1.6.0.
All endpoints are additive; nothing here breaks existing JWT or apply_service flows.
Authentication is JWT-based (Bearer). Admin endpoints additionally require
``users.is_admin == True`` (canonical super-admin flag).
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Header, HTTPException, Request
from auth_middleware import get_current_user_from_token
from database.connection import close_database_connection, get_database_connection
from middleware.mfa_rate_limit_key import mfa_rate_limit_key
from middleware.mfa_rate_limits import MFA_LIMITS
from middleware.rate_limiter import limiter
from models.mfa import (
MfaAdminResetAllRequest,
MfaAdminResetRequest,
MfaDisableRequest,
MfaEnrollConfirmRequest,
MfaEnrollConfirmResponse,
MfaEnrollStartResponse,
MfaRegenerateBackupRequest,
MfaRegenerateBackupResponse,
MfaStatusResponse,
)
from services import mfa_service
from utils.activity_log import log_user_activity
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/mfa", tags=["MFA"])
# Lifecycle constants (Plan section 5)
PENDING_ENROLLMENT_TTL_SECONDS = 600 # 10 minutes — QR scan + verify window
PENDING_ENROLLMENT_MAX_ATTEMPTS = 5
# ---------------------------------------------------------------------------
# Authentication helpers
# ---------------------------------------------------------------------------
async def _require_user(authorization: Optional[str]) -> dict:
user = await get_current_user_from_token(authorization)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
async def _require_admin(authorization: Optional[str]) -> dict:
user = await _require_user(authorization)
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin privileges required")
return user
async def _cleanup_expired_pending_enrollments(conn) -> None:
try:
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE expires_at < NOW()"
)
except Exception as exc:
logger.debug(f"Pending-enrollment cleanup skipped: {exc}")
# ---------------------------------------------------------------------------
# Self status
# ---------------------------------------------------------------------------
@router.get(
"/status",
summary="MFA status for the authenticated user",
response_model=MfaStatusResponse,
)
async def mfa_status(authorization: str = Header(None)):
current = await _require_user(authorization)
conn = await get_database_connection()
try:
row = await conn.fetchrow(
"""
SELECT mfa_enabled, mfa_method, mfa_enrolled_at, mfa_last_used_at
FROM users
WHERE id = $1
""",
current["id"],
)
remaining = await conn.fetchval(
"""
SELECT COUNT(*) FROM mfa_backup_codes
WHERE user_id = $1 AND used_at IS NULL
""",
current["id"],
)
finally:
await close_database_connection(conn)
if not row:
raise HTTPException(status_code=404, detail="User not found")
return MfaStatusResponse(
enabled=bool(row["mfa_enabled"]),
method=row["mfa_method"],
enrolled_at=row["mfa_enrolled_at"].isoformat() if row["mfa_enrolled_at"] else None,
last_used_at=row["mfa_last_used_at"].isoformat() if row["mfa_last_used_at"] else None,
backup_codes_remaining=int(remaining or 0),
)
@router.get(
"/admin/status/{user_id}",
summary="Admin: MFA status of any user",
response_model=MfaStatusResponse,
)
async def mfa_admin_status(user_id: int, authorization: str = Header(None)):
await _require_admin(authorization)
conn = await get_database_connection()
try:
row = await conn.fetchrow(
"""
SELECT mfa_enabled, mfa_method, mfa_enrolled_at, mfa_last_used_at
FROM users
WHERE id = $1
""",
user_id,
)
remaining = await conn.fetchval(
"""
SELECT COUNT(*) FROM mfa_backup_codes
WHERE user_id = $1 AND used_at IS NULL
""",
user_id,
)
finally:
await close_database_connection(conn)
if not row:
raise HTTPException(status_code=404, detail="User not found")
return MfaStatusResponse(
enabled=bool(row["mfa_enabled"]),
method=row["mfa_method"],
enrolled_at=row["mfa_enrolled_at"].isoformat() if row["mfa_enrolled_at"] else None,
last_used_at=row["mfa_last_used_at"].isoformat() if row["mfa_last_used_at"] else None,
backup_codes_remaining=int(remaining or 0),
)
# ---------------------------------------------------------------------------
# Enrollment
# ---------------------------------------------------------------------------
@router.post(
"/enroll/start",
summary="Begin TOTP enrollment (returns secret + otpauth URI)",
response_model=MfaEnrollStartResponse,
)
@limiter.limit(MFA_LIMITS.enroll_start, key_func=mfa_rate_limit_key)
async def mfa_enroll_start(request: Request, authorization: str = Header(None)):
current = await _require_user(authorization)
# Round 7 audit fix — REFUSE re-enrollment if the user is already MFA-on.
# Without this guard a stolen JWT could silently rotate the victim's TOTP
# secret + invalidate all their backup codes via /enroll/start ->
# /enroll/confirm (overwriting `users.mfa_secret_encrypted` and replacing
# `mfa_backup_codes`). To re-enroll, the user must first call /api/mfa/disable
# (which requires a fresh TOTP) or an admin must run /api/mfa/admin-reset.
secret_plain = mfa_service.generate_totp_secret()
secret_encrypted = mfa_service.encrypt_secret(secret_plain)
conn = await get_database_connection()
blocked = False
try:
# Single transaction with SELECT FOR UPDATE closes the TOCTOU window
# between the mfa_enabled check and the pending_enrollment upsert.
async with conn.transaction():
row = await conn.fetchrow(
"SELECT mfa_enabled FROM users WHERE id = $1 FOR UPDATE",
current["id"],
)
if row and row["mfa_enabled"]:
blocked = True
else:
await _cleanup_expired_pending_enrollments(conn)
await conn.execute(
"""
INSERT INTO mfa_pending_enrollments
(user_id, secret_encrypted, attempts, expires_at)
VALUES ($1, $2, 0, NOW() + ($3 || ' seconds')::interval)
ON CONFLICT (user_id) DO UPDATE
SET secret_encrypted = EXCLUDED.secret_encrypted,
attempts = 0,
expires_at = EXCLUDED.expires_at,
created_at = CURRENT_TIMESTAMP
""",
current["id"],
secret_encrypted,
str(PENDING_ENROLLMENT_TTL_SECONDS),
)
finally:
await close_database_connection(conn)
if blocked:
raise HTTPException(
status_code=400,
detail="MFA is already enabled. Disable it first (via /api/mfa/disable or admin reset) to re-enroll.",
)
hostname_hint = request.url.hostname if request.url else None
label = mfa_service.build_account_label(current["username"], hostname_hint)
otpauth_uri = mfa_service.build_otpauth_uri(label, secret_plain)
await log_user_activity(
user_id=current["id"],
action="mfa.enrollment.started",
resource_type="mfa",
resource_id=str(current["id"]),
details={"secret_len": len(secret_plain)}, # NEVER log the secret itself
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return MfaEnrollStartResponse(
secret=secret_plain,
otpauth_uri=otpauth_uri,
expires_in=PENDING_ENROLLMENT_TTL_SECONDS,
)
@router.post(
"/enroll/confirm",
summary="Confirm enrollment with a TOTP code; returns 10 backup codes once",
response_model=MfaEnrollConfirmResponse,
)
@limiter.limit(MFA_LIMITS.enroll_confirm, key_func=mfa_rate_limit_key)
async def mfa_enroll_confirm(
payload: MfaEnrollConfirmRequest,
request: Request,
authorization: str = Header(None),
):
current = await _require_user(authorization)
# Pre-generate plain codes & hashes outside the DB transaction so we
# never hold a row lock for ~2-3s of bcrypt work.
plain_codes = mfa_service.generate_backup_codes()
hashes = await mfa_service.hash_backup_codes(plain_codes)
ip = str(request.client.host) if request.client else None
ua = request.headers.get("user-agent")
# failure: { reason, attempts, http_status, detail }; success when None.
failure: Optional[dict] = None
conn = await get_database_connection()
try:
async with conn.transaction():
# Lock the pending row so concurrent /enroll/confirm calls for the
# same user can't both consume the same pending enrollment.
pending = await conn.fetchrow(
"""
SELECT secret_encrypted, attempts, expires_at
FROM mfa_pending_enrollments
WHERE user_id = $1
FOR UPDATE
""",
current["id"],
)
if not pending:
failure = {
"reason": "no_pending",
"http_status": 410,
"detail": "No pending enrollment; start again",
}
else:
from datetime import datetime as _dt
if pending["expires_at"] and pending["expires_at"] < _dt.utcnow():
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE user_id = $1",
current["id"],
)
failure = {
"reason": "expired",
"http_status": 410,
"detail": "Enrollment expired; start again",
}
else:
secret_plain = mfa_service.decrypt_secret(pending["secret_encrypted"])
if not secret_plain:
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE user_id = $1",
current["id"],
)
failure = {
"reason": "unreadable",
"http_status": 500,
"detail": "Pending enrollment unreadable; start again",
}
else:
ok, _step = mfa_service.verify_totp_with_replay_guard(
secret_plain, payload.code, None
)
if not ok:
new_attempts = (pending["attempts"] or 0) + 1
if new_attempts >= PENDING_ENROLLMENT_MAX_ATTEMPTS:
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE user_id = $1",
current["id"],
)
failure = {
"reason": "too_many_attempts",
"attempts": new_attempts,
"http_status": 410,
"detail": "Enrollment invalidated; start again",
}
else:
await conn.execute(
"UPDATE mfa_pending_enrollments SET attempts = $1 WHERE user_id = $2",
new_attempts,
current["id"],
)
failure = {
"reason": "invalid_code",
"attempts": new_attempts,
"http_status": 401,
"detail": "Invalid code",
}
else:
# Verified — finalize state inside the transaction.
await conn.execute(
"""
UPDATE users
SET mfa_enabled = TRUE,
mfa_method = 'totp',
mfa_secret_encrypted = $1,
mfa_enrolled_at = NOW(),
mfa_last_used_totp_step = NULL,
mfa_last_used_at = NULL
WHERE id = $2
""",
pending["secret_encrypted"],
current["id"],
)
await conn.execute(
"DELETE FROM mfa_backup_codes WHERE user_id = $1",
current["id"],
)
await conn.executemany(
"INSERT INTO mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)",
[(current["id"], h) for h in hashes],
)
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE user_id = $1",
current["id"],
)
finally:
await close_database_connection(conn)
if failure is not None:
# Log AFTER commit so the audit row reflects what actually persisted.
await log_user_activity(
user_id=current["id"],
action="mfa.enrollment.failed",
resource_type="mfa",
resource_id=str(current["id"]),
details={
"reason": failure["reason"],
"attempts": failure.get("attempts"),
},
ip_address=ip,
user_agent=ua,
)
raise HTTPException(status_code=failure["http_status"], detail=failure["detail"])
await log_user_activity(
user_id=current["id"],
action="mfa.enrollment.confirmed",
resource_type="mfa",
resource_id=str(current["id"]),
details={"method": "totp"},
ip_address=ip,
user_agent=ua,
)
return MfaEnrollConfirmResponse(enabled=True, backup_codes=plain_codes, method="totp")
# ---------------------------------------------------------------------------
# Disable + regenerate
# ---------------------------------------------------------------------------
async def _verify_user_code(conn, user_row: dict, code: str) -> Optional[str]:
"""Verify a TOTP-or-backup code against the user's stored secret.
Returns the method used ('totp' / 'backup') on success, None on failure.
On TOTP success the step counter is bumped *atomically* — the UPDATE
only succeeds if no other request consumed the same (or a newer) step
in between. On backup success the consumed row's used_at is set with
an atomic ``WHERE used_at IS NULL RETURNING id`` pattern.
"""
secret_plain = mfa_service.decrypt_secret(user_row["mfa_secret_encrypted"])
if secret_plain:
ok, step = mfa_service.verify_totp_with_replay_guard(
secret_plain, code, user_row["mfa_last_used_totp_step"]
)
if ok:
bumped = await conn.fetchval(
"""
UPDATE users
SET mfa_last_used_totp_step = $1, mfa_last_used_at = NOW()
WHERE id = $2
AND (mfa_last_used_totp_step IS NULL
OR mfa_last_used_totp_step < $1)
RETURNING id
""",
step,
user_row["id"],
)
if bumped:
return "totp"
rows = await conn.fetch(
"""
SELECT id, code_hash FROM mfa_backup_codes
WHERE user_id = $1 AND used_at IS NULL
""",
user_row["id"],
)
for row in rows:
if await mfa_service.check_backup_code(code, row["code_hash"]):
consumed = await conn.fetchval(
"""
UPDATE mfa_backup_codes
SET used_at = NOW()
WHERE id = $1 AND used_at IS NULL
RETURNING id
""",
row["id"],
)
if consumed:
return "backup"
return None
@router.post("/disable", summary="Disable MFA (requires current TOTP or backup)")
@limiter.limit(MFA_LIMITS.disable, key_func=mfa_rate_limit_key)
async def mfa_disable(
payload: MfaDisableRequest,
request: Request,
authorization: str = Header(None),
):
current = await _require_user(authorization)
conn = await get_database_connection()
try:
user_row = await conn.fetchrow(
"""
SELECT id, mfa_enabled, mfa_secret_encrypted, mfa_last_used_totp_step
FROM users WHERE id = $1
""",
current["id"],
)
if not user_row or not user_row["mfa_enabled"]:
raise HTTPException(status_code=400, detail="MFA is not enabled")
method_used = await _verify_user_code(conn, dict(user_row), payload.code)
if not method_used:
await log_user_activity(
user_id=current["id"],
action="mfa.disable.failed",
resource_type="mfa",
resource_id=str(current["id"]),
details={"reason": "invalid_code"},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get("user-agent"),
)
raise HTTPException(status_code=401, detail="Invalid code")
async with conn.transaction():
await conn.execute(
"""
UPDATE users
SET mfa_enabled = FALSE,
mfa_method = NULL,
mfa_secret_encrypted = NULL,
mfa_enrolled_at = NULL,
mfa_last_used_at = NULL,
mfa_last_used_totp_step = NULL
WHERE id = $1
""",
current["id"],
)
await conn.execute(
"DELETE FROM mfa_backup_codes WHERE user_id = $1", current["id"]
)
await conn.execute(
"DELETE FROM mfa_pending_logins WHERE user_id = $1", current["id"]
)
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE user_id = $1", current["id"]
)
finally:
await close_database_connection(conn)
await log_user_activity(
user_id=current["id"],
action="mfa.disabled.self",
resource_type="mfa",
resource_id=str(current["id"]),
details={"verified_via": method_used},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return {"enabled": False}
@router.post(
"/backup-codes/regenerate",
summary="Issue 10 fresh backup codes (TOTP required)",
response_model=MfaRegenerateBackupResponse,
)
@limiter.limit(MFA_LIMITS.regenerate_backup_codes, key_func=mfa_rate_limit_key)
async def mfa_regenerate_backup_codes(
payload: MfaRegenerateBackupRequest,
request: Request,
authorization: str = Header(None),
):
current = await _require_user(authorization)
# bcrypt-hash the new codes outside the DB transaction (~2-3s of CPU work).
plain_codes = mfa_service.generate_backup_codes()
hashes = await mfa_service.hash_backup_codes(plain_codes)
ip = str(request.client.host) if request.client else None
ua = request.headers.get("user-agent")
failure: Optional[dict] = None
conn = await get_database_connection()
try:
async with conn.transaction():
user_row = await conn.fetchrow(
"""
SELECT id, mfa_enabled, mfa_secret_encrypted, mfa_last_used_totp_step
FROM users WHERE id = $1
FOR UPDATE
""",
current["id"],
)
if not user_row or not user_row["mfa_enabled"]:
failure = {"http_status": 400, "detail": "MFA is not enabled"}
else:
secret_plain = mfa_service.decrypt_secret(user_row["mfa_secret_encrypted"])
if not secret_plain:
failure = {"http_status": 500, "detail": "MFA secret unreadable"}
else:
ok, step = mfa_service.verify_totp_with_replay_guard(
secret_plain, payload.code, user_row["mfa_last_used_totp_step"]
)
if not ok:
failure = {"http_status": 401, "detail": "Invalid TOTP code"}
else:
bumped = await conn.fetchval(
"""
UPDATE users
SET mfa_last_used_totp_step = $1, mfa_last_used_at = NOW()
WHERE id = $2
AND (mfa_last_used_totp_step IS NULL
OR mfa_last_used_totp_step < $1)
RETURNING id
""",
step,
current["id"],
)
if not bumped:
failure = {"http_status": 401, "detail": "Invalid TOTP code"}
else:
await conn.execute(
"DELETE FROM mfa_backup_codes WHERE user_id = $1",
current["id"],
)
await conn.executemany(
"INSERT INTO mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)",
[(current["id"], h) for h in hashes],
)
finally:
await close_database_connection(conn)
if failure is not None:
raise HTTPException(status_code=failure["http_status"], detail=failure["detail"])
await log_user_activity(
user_id=current["id"],
action="mfa.backup_codes.regenerated",
resource_type="mfa",
resource_id=str(current["id"]),
details={"codes_count": len(plain_codes)},
ip_address=ip,
user_agent=ua,
)
return MfaRegenerateBackupResponse(backup_codes=plain_codes)
# ---------------------------------------------------------------------------
# Admin operations
# ---------------------------------------------------------------------------
@router.post("/admin-reset/{user_id}", summary="Admin: reset a single user's MFA")
@limiter.limit(MFA_LIMITS.admin_reset, key_func=mfa_rate_limit_key)
async def mfa_admin_reset(
user_id: int,
payload: MfaAdminResetRequest,
request: Request,
authorization: str = Header(None),
):
admin = await _require_admin(authorization)
if user_id == admin["id"]:
raise HTTPException(status_code=400, detail="Use /api/mfa/disable for self-reset")
conn = await get_database_connection()
try:
target = await conn.fetchrow(
"SELECT id, username, mfa_enabled FROM users WHERE id = $1", user_id
)
if not target:
raise HTTPException(status_code=404, detail="User not found")
async with conn.transaction():
await conn.execute(
"""
UPDATE users
SET mfa_enabled = FALSE,
mfa_method = NULL,
mfa_secret_encrypted = NULL,
mfa_enrolled_at = NULL,
mfa_last_used_at = NULL,
mfa_last_used_totp_step = NULL
WHERE id = $1
""",
user_id,
)
await conn.execute(
"DELETE FROM mfa_backup_codes WHERE user_id = $1", user_id
)
await conn.execute(
"DELETE FROM mfa_pending_logins WHERE user_id = $1", user_id
)
await conn.execute(
"DELETE FROM mfa_pending_enrollments WHERE user_id = $1", user_id
)
finally:
await close_database_connection(conn)
await log_user_activity(
user_id=admin["id"],
action="mfa.disabled.admin_reset",
resource_type="mfa",
resource_id=str(user_id),
details={
"target_user_id": user_id,
"target_username": target["username"],
"admin_user_id": admin["id"],
"reason": payload.reason,
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return {"reset": True, "user_id": user_id}
@router.post(
"/admin-reset-all",
summary="Admin: emergency reset of MFA for all users (double confirm)",
)
@limiter.limit(MFA_LIMITS.admin_reset_all, key_func=mfa_rate_limit_key)
async def mfa_admin_reset_all(
payload: MfaAdminResetAllRequest,
request: Request,
authorization: str = Header(None),
):
admin = await _require_admin(authorization)
# Pydantic's Literal already enforces the magic string, but check defensively too.
if payload.confirm != "RESET ALL MFA":
raise HTTPException(status_code=400, detail="Invalid confirmation string")
conn = await get_database_connection()
try:
async with conn.transaction():
reset_count = await conn.fetchval(
"""
WITH affected AS (
UPDATE users
SET mfa_enabled = FALSE,
mfa_method = NULL,
mfa_secret_encrypted = NULL,
mfa_enrolled_at = NULL,
mfa_last_used_at = NULL,
mfa_last_used_totp_step = NULL
WHERE mfa_enabled = TRUE
RETURNING id
)
SELECT COUNT(*) FROM affected
"""
)
await conn.execute("DELETE FROM mfa_backup_codes")
await conn.execute("DELETE FROM mfa_pending_logins")
await conn.execute("DELETE FROM mfa_pending_enrollments")
finally:
await close_database_connection(conn)
await log_user_activity(
user_id=admin["id"],
action="mfa.disabled.admin_bulk_reset",
resource_type="mfa",
resource_id=str(admin["id"]),
details={
"reset_count": int(reset_count or 0),
"reason": payload.reason,
"admin_user_id": admin["id"],
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return {"reset_count": int(reset_count or 0)}
+5 -4
View File
@@ -38,18 +38,19 @@ async def get_users(authorization: str = Header(None)):
# Get users with their roles (only active users)
try:
users = await conn.fetch("""
SELECT u.id, u.username, u.email, u.full_name, u.phone, u.role, u.is_active,
u.is_admin, u.is_verified, u.created_at, u.updated_at, u.last_login_at
SELECT u.id, u.username, u.email, u.full_name, u.phone, u.role, u.is_active,
u.is_admin, u.is_verified, u.created_at, u.updated_at, u.last_login_at,
COALESCE(u.mfa_enabled, FALSE) AS mfa_enabled
FROM users u
WHERE u.is_active = TRUE
ORDER BY u.username
""")
except Exception as schema_error:
logger.warning(f"Schema error in users query, using fallback: {schema_error}")
# Fallback query with minimal columns
# Fallback query with minimal columns (pre-MFA-migration deploys)
users = await conn.fetch("""
SELECT id, username, email, is_active, is_admin, created_at
FROM users
FROM users
WHERE is_active = TRUE
ORDER BY username
""")
+238
View File
@@ -0,0 +1,238 @@
"""MFA (TOTP + backup codes) service layer — Issue #18, v1.6.0.
Owns the cryptographic and persistence-shape concerns of multi-factor auth:
- TOTP secret generation / verification with replay protection (RFC 6238)
- Backup code generation, hashing (bcrypt) and atomic single-use consumption
- Fernet-based encryption of TOTP secrets at rest
Strictly no logging of secrets — only metadata (lengths, counts) is logged.
"""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import secrets as _secrets
import time
from typing import List, Optional, Tuple
from urllib.parse import quote
import bcrypt
import pyotp
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from config import SECRET_KEY
logger = logging.getLogger(__name__)
# RFC 6238 parameters — kept conservative for widest authenticator app compatibility.
TOTP_DIGITS = 6
TOTP_PERIOD = 30
TOTP_DIGEST = "sha1"
TOTP_VALID_WINDOW_STEPS = 1 # ±1 step (±30s) tolerance
# Backup code spec (Plan section 5).
# Alphabet drops the confusing pairs: 0/O, 1/I, L. Resulting size is 31, which
# still yields 31**8 ≈ 8.5×10^11 combinations per half — far beyond brute-force.
BACKUP_CODE_COUNT = 10
BACKUP_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
BACKUP_CODE_HALF_LEN = 4 # XXXX-YYYY
# OTP URI defaults.
DEFAULT_ISSUER = "HAProxy OpenManager"
ACCOUNT_LABEL_DOMAIN_FALLBACK = "haproxy-openmanager"
# ---------------------------------------------------------------------------
# Fernet key resolution
# ---------------------------------------------------------------------------
_fernet_instance: Optional[Fernet] = None
def _resolve_fernet_key() -> bytes:
"""Resolve the Fernet key, preferring the explicit env var.
Falls back to HKDF over SECRET_KEY with a versioned info string so a future
rotation can be expressed by bumping the version suffix.
"""
explicit = os.getenv("MFA_ENCRYPTION_KEY", "").strip()
if explicit:
try:
Fernet(explicit.encode())
return explicit.encode()
except Exception as exc:
logger.error("MFA_ENCRYPTION_KEY env var present but invalid: %s", exc)
# fall through to HKDF derivation rather than crashing the app
logger.warning(
"MFA_ENCRYPTION_KEY env var not set or invalid; deriving from SECRET_KEY (v1). "
"Set an explicit MFA_ENCRYPTION_KEY in production to enable key rotation."
)
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"mfa-totp-secret-v1",
)
derived = hkdf.derive(SECRET_KEY.encode("utf-8"))
return base64.urlsafe_b64encode(derived)
def _get_fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(_resolve_fernet_key())
return _fernet_instance
def reset_fernet_for_tests() -> None:
"""Test-only hook to force re-resolution of the Fernet key after env mutation."""
global _fernet_instance
_fernet_instance = None
# ---------------------------------------------------------------------------
# TOTP secrets
# ---------------------------------------------------------------------------
def generate_totp_secret() -> str:
"""Return a fresh base32 TOTP secret (32 chars)."""
return pyotp.random_base32()
def encrypt_secret(secret_plain: str) -> str:
"""Fernet-encrypt the base32 secret. Returns str for direct DB storage."""
token = _get_fernet().encrypt(secret_plain.encode("utf-8"))
return token.decode("utf-8")
def decrypt_secret(secret_encrypted: str) -> Optional[str]:
"""Decrypt a previously stored secret. Returns None when the token can't be
decrypted (e.g. key rotated without re-enroll). Never raises to the caller.
"""
try:
return _get_fernet().decrypt(secret_encrypted.encode("utf-8")).decode("utf-8")
except InvalidToken:
logger.warning("Failed to decrypt MFA secret (invalid Fernet token)")
return None
except Exception as exc:
logger.error("Unexpected error decrypting MFA secret: %s", exc)
return None
def build_otpauth_uri(account_label: str, secret_plain: str, issuer: str = DEFAULT_ISSUER) -> str:
"""Build an otpauth:// URI that all major authenticator apps accept.
Format: otpauth://totp/<issuer>:<account>?secret=<b32>&issuer=<issuer>&algorithm=SHA1&digits=6&period=30
"""
issuer_q = quote(issuer, safe="")
label = f"{issuer}:{account_label}"
label_q = quote(label, safe=":@")
return (
f"otpauth://totp/{label_q}?secret={secret_plain}"
f"&issuer={issuer_q}&algorithm=SHA1&digits={TOTP_DIGITS}&period={TOTP_PERIOD}"
)
def build_account_label(username: str, hostname_hint: Optional[str] = None) -> str:
"""Compose the per-user otpauth label, respecting env > hostname > fallback."""
domain = (
os.getenv("MFA_ACCOUNT_LABEL_DOMAIN", "").strip()
or (hostname_hint or "").strip()
or ACCOUNT_LABEL_DOMAIN_FALLBACK
)
return f"{username}@{domain}"
def verify_totp_with_replay_guard(
secret_plain: str,
code: str,
last_used_step: Optional[int],
) -> Tuple[bool, Optional[int]]:
"""Verify a 6-digit TOTP code with explicit per-step replay protection.
Returns (success, step_consumed). Caller persists the consumed step on success.
Implementation notes:
- pyotp.TOTP.at(seconds_since_epoch) — to target step N we pass step*PERIOD.
- secrets.compare_digest is used for constant-time comparison.
- Replay guard rejects codes whose step is <= the previously consumed step.
"""
if not secret_plain or not code:
return (False, None)
code = code.strip()
if len(code) != TOTP_DIGITS or not code.isdigit():
return (False, None)
totp = pyotp.TOTP(secret_plain, digits=TOTP_DIGITS, interval=TOTP_PERIOD, digest=TOTP_DIGEST)
now = int(time.time())
current_step = now // TOTP_PERIOD
for offset in (0, -1, 1):
step = current_step + offset
expected = totp.at(step * TOTP_PERIOD)
if len(expected) == len(code) and _secrets.compare_digest(expected, code):
if last_used_step is not None and step <= last_used_step:
return (False, None)
return (True, step)
return (False, None)
# ---------------------------------------------------------------------------
# Backup codes
# ---------------------------------------------------------------------------
def generate_backup_codes(count: int = BACKUP_CODE_COUNT) -> List[str]:
"""Return ``count`` plain-text backup codes formatted as ``XXXX-YYYY``."""
codes: List[str] = []
for _ in range(count):
left = "".join(_secrets.choice(BACKUP_CODE_ALPHABET) for _ in range(BACKUP_CODE_HALF_LEN))
right = "".join(_secrets.choice(BACKUP_CODE_ALPHABET) for _ in range(BACKUP_CODE_HALF_LEN))
codes.append(f"{left}-{right}")
return codes
def normalize_backup_code(user_input: str) -> str:
"""Canonical form for comparison: uppercase, strip dashes/spaces."""
if not user_input:
return ""
return user_input.strip().upper().replace("-", "").replace(" ", "")
async def _hash_one_backup_code(code_plain: str) -> str:
"""Bcrypt-hash a single backup code on a worker thread."""
normalized = normalize_backup_code(code_plain)
hashed = await asyncio.to_thread(bcrypt.hashpw, normalized.encode("utf-8"), bcrypt.gensalt())
return hashed.decode("utf-8")
async def hash_backup_codes(codes_plain: List[str]) -> List[str]:
"""Hash backup codes in parallel (each bcrypt op runs in its own thread)."""
return await asyncio.gather(*(_hash_one_backup_code(c) for c in codes_plain))
async def check_backup_code(user_input: str, code_hash: str) -> bool:
"""Run a single bcrypt verify on the worker pool."""
normalized = normalize_backup_code(user_input)
if not normalized:
return False
return await asyncio.to_thread(
bcrypt.checkpw, normalized.encode("utf-8"), code_hash.encode("utf-8")
)
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def generate_challenge_token() -> str:
"""64-char hex challenge token for /api/auth/login → /mfa-verify hand-off."""
return _secrets.token_hex(32)
+150
View File
@@ -0,0 +1,150 @@
"""Backwards-compatibility regression tests for the MFA rollout (Issue #18).
These tests don't hit a real database — they exercise the authoritative
contract surfaces (login response shape, auth_middleware behaviour) using
mocks where needed so the suite stays fast and deterministic.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class TestAuthMiddlewareUnchanged:
"""auth_middleware MUST NOT look for MFA claims — Madde 2 of the plan."""
def test_decoder_imports_without_mfa_dependencies(self):
import auth_middleware
# The middleware's verification function exists and is callable.
assert callable(getattr(auth_middleware, "get_current_user_from_token", None))
def test_middleware_source_has_no_mfa_claim_check(self):
"""The middleware source must not reference ``mfa`` claims directly."""
with open(
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"auth_middleware.py",
),
"r",
encoding="utf-8",
) as fh:
source = fh.read()
# Allow incidental occurrences (e.g. comments); but never a claim lookup.
assert "payload.get('mfa'" not in source
assert 'payload.get("mfa"' not in source
assert "claims['mfa'" not in source
assert 'claims["mfa"' not in source
class TestMfaModelsCoexistWithUserModels:
def test_models_user_module_unchanged_pydantic_shape(self):
from models import user as user_mod
# Ensure that User / UserUpdate / LoginRequest still load and still
# don't expose mfa-related fields (kept in models.mfa).
for cls in (user_mod.User, user_mod.UserUpdate, user_mod.LoginRequest):
fields = set(cls.model_fields.keys())
assert not {"mfa_enabled", "mfa_required", "mfa_token"} & fields, (
f"{cls.__name__} unexpectedly exposes MFA field; should stay byte-identical."
)
def test_models_mfa_module_exposes_expected_models(self):
from models import mfa as mfa_mod
for name in (
"MfaVerifyRequest",
"MfaEnrollStartResponse",
"MfaEnrollConfirmRequest",
"MfaEnrollConfirmResponse",
"MfaDisableRequest",
"MfaRegenerateBackupRequest",
"MfaRegenerateBackupResponse",
"MfaAdminResetRequest",
"MfaAdminResetAllRequest",
"MfaStatusResponse",
):
assert hasattr(mfa_mod, name), f"Missing model: {name}"
class TestRouterIncluded:
def test_main_includes_mfa_router(self):
with open(
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"main.py",
),
"r",
encoding="utf-8",
) as fh:
source = fh.read()
assert "from routers.mfa import router as mfa_router" in source
assert "app.include_router(mfa_router)" in source
class TestLoginResponseShapeForNonMfaUser:
"""When MFA columns are missing or mfa_enabled=FALSE, /login returns the
pre-MFA response shape — no ``mfa_required`` / ``mfa_token`` keys leak through.
"""
def test_login_without_mfa_returns_legacy_shape(self):
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
async def _fetch_mfa_state_none(conn, user_id):
return None
async def _no_log(*args, **kwargs):
return None
fake_user = {
"id": 1,
"username": "admin",
"email": "admin@example.com",
"password_hash": "$2b$12$placeholder",
"is_active": True,
"role": "admin",
"created_at": None,
"updated_at": None,
"last_login_at": None,
}
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value=fake_user)
mock_conn.fetch = AsyncMock(return_value=[])
mock_conn.execute = AsyncMock(return_value=None)
async def _get_conn():
return mock_conn
async def _close(conn):
return None
with patch(
"routers.auth.get_database_connection", _get_conn
), patch("routers.auth.close_database_connection", _close), patch(
"routers.auth._fetch_mfa_state", _fetch_mfa_state_none
), patch("routers.auth.log_user_activity", _no_log), patch(
"bcrypt.checkpw", return_value=True
):
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "anything"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "access_token" in body
assert "token_type" in body
assert "expires_in" in body
assert "user" in body
assert "roles" in body
assert "permissions" in body
# CRITICAL — pre-MFA contract must not be polluted with MFA fields.
assert "mfa_required" not in body
assert "mfa_token" not in body
assert "methods" not in body
+200
View File
@@ -0,0 +1,200 @@
"""Tests for middleware.mfa_rate_limit_key — user-aware + ingress-aware key."""
import importlib
from datetime import datetime, timedelta
from typing import Dict, Optional
import pytest
from fastapi import Request
from jose import jwt
def _make_request(
headers: Optional[Dict[str, str]] = None,
peer: str = "127.0.0.1",
) -> Request:
"""Tiny ASGI scope shim — enough for the key_func surface."""
raw_headers = []
if headers:
raw_headers = [
(k.encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()
]
scope = {
"type": "http",
"headers": raw_headers,
"client": (peer, 12345),
"method": "POST",
"path": "/api/mfa/enroll/start",
"query_string": b"",
}
return Request(scope)
@pytest.fixture
def reload_key(monkeypatch):
"""Reload the key module so MFA_TRUSTED_PROXY_CIDRS is re-parsed."""
def _reload(**env):
for var in ("MFA_TRUSTED_PROXY_CIDRS",):
monkeypatch.delenv(var, raising=False)
for k, v in env.items():
monkeypatch.setenv(k, v)
from middleware import mfa_rate_limit_key as m
return importlib.reload(m)
return _reload
# ---------------------------------------------------------------------------
# User-aware key extraction
# ---------------------------------------------------------------------------
def _mint_jwt(user_id, claim: str = "user_id") -> str:
"""Mint a test JWT. Note: RFC 7519 says ``sub`` is a StringOrURI,
and python-jose validates that type when decoding, so callers that use
``claim='sub'`` must pass a string user_id (matches production behavior
where auth_middleware also accepts string ``sub``)."""
from config import JWT_ALGORITHM, JWT_SECRET_KEY
payload = {
claim: user_id,
"exp": datetime.utcnow() + timedelta(minutes=10),
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def test_user_aware_via_user_id_claim(reload_key):
m = reload_key()
token = _mint_jwt(42, claim="user_id")
req = _make_request(headers={"authorization": f"Bearer {token}"})
assert m.mfa_rate_limit_key(req) == "user:42"
def test_user_aware_via_sub_claim(reload_key):
m = reload_key()
token = _mint_jwt("7", claim="sub") # JWT spec: sub is a string
req = _make_request(headers={"authorization": f"Bearer {token}"})
assert m.mfa_rate_limit_key(req) == "user:7"
def test_no_auth_header_falls_back_to_ip(reload_key):
m = reload_key()
req = _make_request(peer="203.0.113.5")
assert m.mfa_rate_limit_key(req) == "ip:203.0.113.5"
def test_missing_bearer_prefix_falls_back_to_ip(reload_key):
m = reload_key()
req = _make_request(headers={"authorization": "abc.def.ghi"}, peer="203.0.113.5")
assert m.mfa_rate_limit_key(req) == "ip:203.0.113.5"
def test_bearer_null_or_undefined_falls_back_to_ip(reload_key):
m = reload_key()
for bogus in ("null", "undefined", "", " "):
req = _make_request(
headers={"authorization": f"Bearer {bogus}"}, peer="198.51.100.9"
)
assert m.mfa_rate_limit_key(req) == "ip:198.51.100.9"
def test_tampered_jwt_falls_back_to_ip(reload_key):
"""A token with a forged signature must NOT be honored — fallback to IP."""
m = reload_key()
bad = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjogMTIzfQ.NOT_A_VALID_SIGNATURE"
req = _make_request(headers={"authorization": f"Bearer {bad}"}, peer="10.1.2.3")
assert m.mfa_rate_limit_key(req) == "ip:10.1.2.3"
def test_expired_jwt_falls_back_to_ip(reload_key):
m = reload_key()
from config import JWT_ALGORITHM, JWT_SECRET_KEY
payload = {
"user_id": 9,
"exp": datetime.utcnow() - timedelta(minutes=5),
}
expired = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
req = _make_request(headers={"authorization": f"Bearer {expired}"}, peer="10.0.0.7")
assert m.mfa_rate_limit_key(req) == "ip:10.0.0.7"
# ---------------------------------------------------------------------------
# Trusted-proxy X-Forwarded-For handling
# ---------------------------------------------------------------------------
def test_untrusted_peer_xff_is_ignored(reload_key):
"""X-Forwarded-For from an untrusted client cannot move buckets."""
m = reload_key() # no trusted CIDRs
req = _make_request(
headers={"x-forwarded-for": "1.2.3.4"},
peer="203.0.113.5",
)
assert m.mfa_rate_limit_key(req) == "ip:203.0.113.5"
def test_trusted_peer_xff_is_honored(reload_key):
"""Peer in trusted CIDR → first XFF hop becomes the bucket."""
m = reload_key(MFA_TRUSTED_PROXY_CIDRS="10.0.0.0/8")
req = _make_request(
headers={"x-forwarded-for": "203.0.113.42, 10.0.0.99"},
peer="10.0.0.99",
)
assert m.mfa_rate_limit_key(req) == "ip:203.0.113.42"
def test_trusted_cidr_multiple_ranges(reload_key):
m = reload_key(MFA_TRUSTED_PROXY_CIDRS="10.0.0.0/8, 172.16.0.0/12")
req = _make_request(
headers={"x-forwarded-for": "198.51.100.4"},
peer="172.16.5.5",
)
assert m.mfa_rate_limit_key(req) == "ip:198.51.100.4"
def test_trusted_peer_no_xff_falls_back_to_peer(reload_key):
m = reload_key(MFA_TRUSTED_PROXY_CIDRS="10.0.0.0/8")
req = _make_request(peer="10.0.0.99")
assert m.mfa_rate_limit_key(req) == "ip:10.0.0.99"
def test_invalid_cidr_in_env_is_logged_and_ignored(reload_key, caplog):
import logging
with caplog.at_level(logging.WARNING, logger="middleware.mfa_rate_limit_key"):
m = reload_key(MFA_TRUSTED_PROXY_CIDRS="not-a-cidr, 10.0.0.0/8")
assert any("ignoring invalid CIDR" in r.message for r in caplog.records)
# The valid one is still effective.
req = _make_request(
headers={"x-forwarded-for": "9.9.9.9"},
peer="10.0.0.1",
)
assert m.mfa_rate_limit_key(req) == "ip:9.9.9.9"
def test_user_bucket_wins_over_xff(reload_key):
"""Auth always wins, even from a trusted proxy."""
m = reload_key(MFA_TRUSTED_PROXY_CIDRS="10.0.0.0/8")
token = _mint_jwt(99) # integer user_id claim
req = _make_request(
headers={
"authorization": f"Bearer {token}",
"x-forwarded-for": "1.1.1.1",
},
peer="10.0.0.1",
)
assert m.mfa_rate_limit_key(req) == "user:99"
def test_no_client_in_scope_does_not_crash(reload_key):
m = reload_key()
scope = {
"type": "http",
"headers": [],
"client": None,
"method": "POST",
"path": "/api/mfa/enroll/start",
"query_string": b"",
}
req = Request(scope)
# Whatever it returns, it must be deterministic and not raise.
out = m.mfa_rate_limit_key(req)
assert out.startswith("ip:")
+93
View File
@@ -0,0 +1,93 @@
"""Tests for middleware.mfa_rate_limits — env-driven MFA rate-limit config."""
import importlib
import logging
import pytest
@pytest.fixture
def reload_module(monkeypatch):
"""Helper: reload the module after env mutation so dataclass defaults
pick up the new values."""
def _reload(**env):
for key in list(globals().get('_OVERRIDDEN_ENVS', set())):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
from middleware import mfa_rate_limits as m
return importlib.reload(m)
return _reload
def test_defaults_when_no_env(reload_module, monkeypatch):
"""No env var set → secure defaults applied (user-aware key assumption)."""
for key in (
"MFA_RATE_LIMIT_ENROLL_START",
"MFA_RATE_LIMIT_ENROLL_CONFIRM",
"MFA_RATE_LIMIT_DISABLE",
"MFA_RATE_LIMIT_REGENERATE_BACKUP_CODES",
"MFA_RATE_LIMIT_ADMIN_RESET",
"MFA_RATE_LIMIT_ADMIN_RESET_ALL",
):
monkeypatch.delenv(key, raising=False)
m = reload_module()
assert m.MFA_LIMITS.enroll_start == "10/minute"
assert m.MFA_LIMITS.enroll_confirm == "10/minute"
assert m.MFA_LIMITS.disable == "10/minute"
assert m.MFA_LIMITS.regenerate_backup_codes == "5/hour"
assert m.MFA_LIMITS.admin_reset == "60/hour"
assert m.MFA_LIMITS.admin_reset_all == "1/day"
def test_env_override_per_endpoint(reload_module):
m = reload_module(
MFA_RATE_LIMIT_ENROLL_START="100/hour",
MFA_RATE_LIMIT_ADMIN_RESET_ALL="3/day",
)
assert m.MFA_LIMITS.enroll_start == "100/hour"
assert m.MFA_LIMITS.admin_reset_all == "3/day"
# Untouched values still default.
assert m.MFA_LIMITS.disable == "10/minute"
@pytest.mark.parametrize(
"bad",
[
"totally-bogus",
"5/lightyear",
"abc/minute",
"5",
"/minute",
"5//minute",
"",
],
)
def test_invalid_format_falls_back_to_default(reload_module, caplog, bad):
with caplog.at_level(logging.WARNING, logger="middleware.mfa_rate_limits"):
m = reload_module(MFA_RATE_LIMIT_ENROLL_START=bad)
# Falls back to the secure default for enroll_start.
assert m.MFA_LIMITS.enroll_start == "10/minute"
assert any("not a valid slowapi limit string" in r.message for r in caplog.records)
def test_whitespace_around_value_is_tolerated(reload_module):
m = reload_module(MFA_RATE_LIMIT_DISABLE=" 30/minute ")
assert m.MFA_LIMITS.disable == "30/minute"
@pytest.mark.parametrize(
"valid",
["1/second", "100/minute", "1000/hour", "10/day"],
)
def test_all_valid_periods_accepted(reload_module, valid):
m = reload_module(MFA_RATE_LIMIT_DISABLE=valid)
assert m.MFA_LIMITS.disable == valid
def test_dataclass_is_frozen(reload_module):
"""Frozen dataclass guards against accidental mutation after import."""
m = reload_module()
with pytest.raises((AttributeError, Exception)):
m.MFA_LIMITS.disable = "999/second" # type: ignore[misc]
+223
View File
@@ -0,0 +1,223 @@
"""Unit tests for the MFA service layer (Issue #18, v1.6.0).
These tests cover the pure-Python side of MFA — no DB, no FastAPI.
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
import time
import pytest
# Repo path setup (mirrors other tests in this folder).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pyotp # noqa: E402
from services import mfa_service # noqa: E402
# ---------------------------------------------------------------------------
# TOTP
# ---------------------------------------------------------------------------
class TestTotpSecret:
def test_secret_is_base32(self):
secret = mfa_service.generate_totp_secret()
# pyotp.random_base32() returns 32-character base32 strings.
assert len(secret) == 32
assert re.fullmatch(r"[A-Z2-7]+", secret), "secret must be valid base32"
def test_secrets_are_unique(self):
secrets = {mfa_service.generate_totp_secret() for _ in range(50)}
assert len(secrets) == 50
class TestVerifyTotp:
def setup_method(self):
self.secret = mfa_service.generate_totp_secret()
self.totp = pyotp.TOTP(self.secret, digits=6, interval=30, digest="sha1")
def test_happy_path(self):
code = self.totp.now()
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, code, None)
assert ok is True
assert step == int(time.time()) // 30
def test_invalid_code_format_rejected(self):
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, "abc", None)
assert ok is False and step is None
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, "12345", None)
assert ok is False and step is None
def test_tolerance_minus_30s(self):
now = int(time.time())
previous_step = (now // 30) - 1
prev_code = self.totp.at(previous_step * 30)
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, prev_code, None)
assert ok is True
assert step == previous_step
def test_tolerance_plus_30s(self):
now = int(time.time())
next_step = (now // 30) + 1
next_code = self.totp.at(next_step * 30)
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, next_code, None)
assert ok is True
assert step == next_step
def test_replay_rejected(self):
code = self.totp.now()
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, code, None)
assert ok is True
# Submit again with the previously-consumed step: must be rejected.
ok2, step2 = mfa_service.verify_totp_with_replay_guard(self.secret, code, step)
assert ok2 is False
assert step2 is None
def test_wrong_code_rejected(self):
ok, step = mfa_service.verify_totp_with_replay_guard(self.secret, "000000", None)
assert ok is False
assert step is None
# ---------------------------------------------------------------------------
# Fernet + key resolution
# ---------------------------------------------------------------------------
class TestFernet:
def setup_method(self):
mfa_service.reset_fernet_for_tests()
def teardown_method(self):
mfa_service.reset_fernet_for_tests()
def test_encrypt_decrypt_roundtrip_with_env_key(self, monkeypatch):
from cryptography.fernet import Fernet
key = Fernet.generate_key().decode()
monkeypatch.setenv("MFA_ENCRYPTION_KEY", key)
mfa_service.reset_fernet_for_tests()
secret = "JBSWY3DPEHPK3PXP" * 2
token = mfa_service.encrypt_secret(secret)
assert token and token != secret
recovered = mfa_service.decrypt_secret(token)
assert recovered == secret
def test_decrypt_invalid_token_returns_none(self, monkeypatch):
from cryptography.fernet import Fernet
monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
mfa_service.reset_fernet_for_tests()
assert mfa_service.decrypt_secret("not-a-valid-fernet-token") is None
def test_hkdf_fallback_when_env_unset(self, monkeypatch, caplog):
monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False)
mfa_service.reset_fernet_for_tests()
with caplog.at_level("WARNING"):
secret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
token = mfa_service.encrypt_secret(secret)
recovered = mfa_service.decrypt_secret(token)
assert recovered == secret
assert any("MFA_ENCRYPTION_KEY" in r.message for r in caplog.records), (
"expected a WARN log when falling back to SECRET_KEY derivation"
)
# ---------------------------------------------------------------------------
# Backup codes
# ---------------------------------------------------------------------------
class TestBackupCodes:
def test_generate_count_and_format(self):
codes = mfa_service.generate_backup_codes()
assert len(codes) == 10
# 31-char alphabet: A-H J K M N P-Z 2-9 (excludes I, L, O, 0, 1).
for code in codes:
assert re.fullmatch(r"[A-HJKM-NP-Z2-9]{4}-[A-HJKM-NP-Z2-9]{4}", code), code
def test_alphabet_excludes_confusing_characters(self):
# Generate enough codes to virtually guarantee any forbidden char would surface.
for _ in range(20):
codes = mfa_service.generate_backup_codes()
for code in codes:
for ch in code.replace("-", ""):
assert ch not in "0O1IL", f"forbidden char {ch!r} in {code!r}"
def test_codes_are_unique(self):
codes = mfa_service.generate_backup_codes()
assert len(set(codes)) == len(codes)
def test_normalize_strips_case_dash_space(self):
assert mfa_service.normalize_backup_code("abcd-efgh") == "ABCDEFGH"
assert mfa_service.normalize_backup_code(" ab cd-ef gh ") == "ABCDEFGH"
assert mfa_service.normalize_backup_code("") == ""
assert mfa_service.normalize_backup_code(None) == "" # type: ignore[arg-type]
def test_hash_and_check_async(self):
async def _run():
plain = mfa_service.generate_backup_codes()[:1]
hashes = await mfa_service.hash_backup_codes(plain)
assert len(hashes) == 1
assert await mfa_service.check_backup_code(plain[0], hashes[0]) is True
assert await mfa_service.check_backup_code("WRONG-CODE", hashes[0]) is False
# Case + dash normalization
assert await mfa_service.check_backup_code(plain[0].lower(), hashes[0]) is True
assert (
await mfa_service.check_backup_code(plain[0].replace("-", ""), hashes[0])
is True
)
asyncio.run(_run())
# ---------------------------------------------------------------------------
# otpauth URI
# ---------------------------------------------------------------------------
class TestOtpAuthUri:
def test_uri_shape(self):
uri = mfa_service.build_otpauth_uri("alice@example.com", "JBSWY3DPEHPK3PXP")
assert uri.startswith("otpauth://totp/")
assert "secret=JBSWY3DPEHPK3PXP" in uri
assert "issuer=" in uri
assert "algorithm=SHA1" in uri
assert "digits=6" in uri
assert "period=30" in uri
def test_account_label_env_override(self, monkeypatch):
monkeypatch.setenv("MFA_ACCOUNT_LABEL_DOMAIN", "ops.example.com")
label = mfa_service.build_account_label("alice", hostname_hint="ignored.com")
assert label == "alice@ops.example.com"
def test_account_label_hostname_hint(self, monkeypatch):
monkeypatch.delenv("MFA_ACCOUNT_LABEL_DOMAIN", raising=False)
label = mfa_service.build_account_label("alice", hostname_hint="api.local")
assert label == "alice@api.local"
def test_account_label_fallback(self, monkeypatch):
monkeypatch.delenv("MFA_ACCOUNT_LABEL_DOMAIN", raising=False)
label = mfa_service.build_account_label("alice", hostname_hint=None)
assert label == "alice@haproxy-openmanager"
# ---------------------------------------------------------------------------
# Challenge token
# ---------------------------------------------------------------------------
class TestChallengeToken:
def test_length_and_uniqueness(self):
tokens = {mfa_service.generate_challenge_token() for _ in range(50)}
assert len(tokens) == 50
for t in tokens:
assert len(t) == 64
assert re.fullmatch(r"[0-9a-f]{64}", t)
+5 -3
View File
@@ -71,15 +71,17 @@ services:
# Frontend React App - pulls from Docker Hub by default
# To build locally instead, uncomment the build section and run: docker compose build frontend
#
# NOTE: REACT_APP_* env vars are BUILD-time only for Create-React-App. The
# runtime container (serve -s build) does NOT consume them. The frontend
# uses same-origin (window.location) for /api/* and is routed by the nginx
# service below to the backend container. No env vars are required here.
frontend:
image: taylanbakircioglu/haproxy-openmanager-frontend:latest
# build:
# context: ./frontend
# dockerfile: Dockerfile
container_name: haproxy-openmanager-frontend
environment:
- REACT_APP_API_URL=
- NODE_ENV=production
expose:
- "3000"
depends_on:
+34
View File
@@ -0,0 +1,34 @@
# Build artifacts (regenerated inside the multi-stage builder)
build
node_modules
coverage
# Local dev-only overrides — MUST be excluded so the host's
# `.env.local` (e.g. REACT_APP_API_URL=http://localhost:8000) does NOT
# bleed into a production bundle via build-time inline-replace.
.env
.env.local
.env.*.local
.env.development
.env.development.local
.env.test
.env.test.local
# VCS / IDE / OS noise
.git
.gitignore
.vscode
.idea
.DS_Store
*.log
# Tests
**/__tests__
**/*.test.js
**/*.test.jsx
**/*.test.ts
**/*.test.tsx
# Misc
README.md
.eslintcache
+16 -23
View File
@@ -1,12 +1,13 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "haproxy-openmanager-frontend",
"version": "1.5.0",
"version": "1.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@monaco-editor/react": "^4.6.0",
@@ -15,6 +16,7 @@
"axios": "^1.3.0",
"moment": "^2.29.0",
"monaco-editor": "^0.36.0",
"qrcode.react": "^4.0.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.2.0",
@@ -1500,9 +1502,9 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
"integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
"version": "7.29.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz",
"integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18237,6 +18239,15 @@
"teleport": ">=0.2.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -21325,24 +21336,6 @@
}
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.5.2",
"version": "1.6.0",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
@@ -17,7 +17,8 @@
"@monaco-editor/react": "^4.6.0",
"react-ace": "^10.1.0",
"ace-builds": "^1.23.4",
"react-window": "^1.8.10"
"react-window": "^1.8.10",
"qrcode.react": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
+244 -104
View File
@@ -1,20 +1,20 @@
import React, { useState } from 'react';
import {
Card,
Form,
Input,
Button,
message,
Typography,
Row,
import React, { useEffect, useRef, useState } from 'react';
import {
Card,
Form,
Input,
Button,
message,
Typography,
Row,
Col,
Alert,
Spin
} from 'antd';
import {
UserOutlined,
LockOutlined,
ClusterOutlined
import {
UserOutlined,
LockOutlined,
ClusterOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useAuth } from '../contexts/AuthContext';
@@ -23,77 +23,260 @@ import './Login.css';
const { Title, Text } = Typography;
const PHASE_CREDENTIALS = 'credentials';
const PHASE_MFA = 'mfa';
const PHASE_SUBMITTING = 'submitting';
const Login = () => {
const [form] = Form.useForm();
const [credentialsForm] = Form.useForm();
const [mfaForm] = Form.useForm();
const [phase, setPhase] = useState(PHASE_CREDENTIALS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (values) => {
// MFA-specific state — RAM only, never persisted.
const mfaTokenRef = useRef(null);
const [mfaExpiresAt, setMfaExpiresAt] = useState(null);
const [mfaCountdown, setMfaCountdown] = useState(0);
useEffect(() => {
if (phase !== PHASE_MFA || !mfaExpiresAt) return undefined;
const id = setInterval(() => {
const remaining = Math.max(0, Math.floor((mfaExpiresAt - Date.now()) / 1000));
setMfaCountdown(remaining);
if (remaining <= 0) {
clearInterval(id);
resetToCredentials('MFA session expired. Please log in again.');
}
}, 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, mfaExpiresAt]);
const resetToCredentials = (errMessage) => {
mfaTokenRef.current = null;
setMfaExpiresAt(null);
setMfaCountdown(0);
mfaForm.resetFields();
setPhase(PHASE_CREDENTIALS);
if (errMessage) setError(errMessage);
};
const completeAuth = (authData) => {
// Write storage + axios header ONLY after a full, MFA-cleared response.
localStorage.setItem('token', authData.access_token);
localStorage.setItem('authToken', authData.access_token);
localStorage.setItem('userData', JSON.stringify(authData.user));
localStorage.setItem('userRoles', JSON.stringify([]));
localStorage.setItem('userPermissions', JSON.stringify({}));
const expiryDate = new Date();
expiryDate.setSeconds(expiryDate.getSeconds() + authData.expires_in);
localStorage.setItem('tokenExpiry', expiryDate.toISOString());
const loginSuccess = login(authData);
if (loginSuccess) {
message.success(`Welcome back, ${authData.user.username}!`);
} else {
throw new Error('Failed to update authentication state');
}
};
const handleCredentialsSubmit = async (values) => {
setLoading(true);
setPhase(PHASE_SUBMITTING);
setError('');
try {
const response = await axios.post('/api/auth/login', {
username: values.username,
password: values.password
password: values.password,
});
// Store authentication data - API returns access_token, not session_token!
localStorage.setItem('token', response.data.access_token);
localStorage.setItem('authToken', response.data.access_token);
localStorage.setItem('userData', JSON.stringify(response.data.user));
localStorage.setItem('userRoles', JSON.stringify([])); // API doesn't return roles directly
localStorage.setItem('userPermissions', JSON.stringify({})); // API doesn't return permissions directly
// Calculate expiry from expires_in (seconds)
const expiryDate = new Date();
expiryDate.setSeconds(expiryDate.getSeconds() + response.data.expires_in);
localStorage.setItem('tokenExpiry', expiryDate.toISOString());
// Update authentication context
const loginSuccess = login(response.data);
if (loginSuccess) {
message.success(`Welcome back, ${response.data.user.username}!`);
// The authentication context will automatically trigger a re-render
// and the user will be redirected to the main dashboard
} else {
throw new Error('Failed to update authentication state');
if (response.data && response.data.mfa_required) {
// Phase 2 — TOTP / backup code challenge. Keep credentials secret-free.
mfaTokenRef.current = response.data.mfa_token;
const ttlSeconds = response.data.expires_in || 300;
setMfaExpiresAt(Date.now() + ttlSeconds * 1000);
setMfaCountdown(ttlSeconds);
setPhase(PHASE_MFA);
return;
}
} catch (error) {
const errorMessage = extractApiError(error, 'Login failed. Please try again.');
completeAuth(response.data);
} catch (err) {
const errorMessage = extractApiError(err, 'Login failed. Please try again.');
setError(errorMessage);
message.error(errorMessage);
setPhase(PHASE_CREDENTIALS);
} finally {
setLoading(false);
}
};
const handleMfaSubmit = async (values) => {
if (!mfaTokenRef.current) {
resetToCredentials('MFA session lost. Please log in again.');
return;
}
setLoading(true);
setError('');
try {
const response = await axios.post(
'/api/auth/login/mfa-verify',
{
mfa_token: mfaTokenRef.current,
code: (values.code || '').trim(),
},
// Explicit opt-out: never attach a stale Authorization header here.
{ headers: { Authorization: undefined } },
);
completeAuth(response.data);
} catch (err) {
const status = err && err.response && err.response.status;
const errorMessage = extractApiError(err, 'Verification failed.');
if (status === 410) {
resetToCredentials(errorMessage || 'MFA challenge invalidated. Please log in again.');
} else {
setError(errorMessage);
mfaForm.setFieldsValue({ code: '' });
}
} finally {
setLoading(false);
}
};
const renderCredentialsForm = () => (
<Form
form={credentialsForm}
name="login"
onFinish={handleCredentialsSubmit}
layout="vertical"
autoComplete="off"
>
<Form.Item
name="username"
rules={[
{ required: true, message: 'Please enter your username!' },
{ min: 3, message: 'Username must be at least 3 characters!' },
]}
>
<Input prefix={<UserOutlined />} placeholder="Username" autoComplete="username" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please enter your password!' },
{ min: 6, message: 'Password must be at least 6 characters!' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Password"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</Form.Item>
</Form>
);
const renderMfaForm = () => {
const minutes = Math.floor(mfaCountdown / 60);
const seconds = String(mfaCountdown % 60).padStart(2, '0');
return (
<Form form={mfaForm} name="mfa" onFinish={handleMfaSubmit} layout="vertical" autoComplete="off">
<Alert
message="Multi-Factor Authentication"
description={
<span>
Enter the 6-digit code from your authenticator app, or use a backup code
(format: <code>XXXX-YYYY</code>).
{mfaCountdown > 0 && (
<>
{' '}Session expires in <strong>{minutes}:{seconds}</strong>.
</>
)}
</span>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item
name="code"
rules={[
{ required: true, message: 'Please enter your MFA code.' },
{ min: 6, message: 'Code must be at least 6 characters.' },
{ max: 10, message: 'Code is too long.' },
]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="123456 or XXXX-YYYY"
autoComplete="one-time-code"
inputMode="text"
maxLength={10}
autoFocus
/>
</Form.Item>
<Form.Item style={{ marginBottom: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
{loading ? 'Verifying...' : 'Verify'}
</Button>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="default"
block
onClick={() => resetToCredentials('')}
disabled={loading}
>
Use a different account
</Button>
</Form.Item>
</Form>
);
};
return (
<div className="login-container">
<Row
justify="center"
align="middle"
style={{
<Row
justify="center"
align="middle"
style={{
minHeight: '100vh',
minHeight: '100dvh',
width: '100%',
margin: 0
margin: 0,
}}
>
<Col
xs={24}
sm={20}
md={16}
lg={12}
xl={10}
<Col
xs={24}
sm={20}
md={16}
lg={12}
xl={10}
xxl={8}
style={{
style={{
display: 'flex',
justifyContent: 'center',
padding: '0 8px'
padding: '0 8px',
}}
>
<Card className="login-card">
@@ -118,56 +301,13 @@ const Login = () => {
/>
)}
<Form
form={form}
name="login"
onFinish={handleSubmit}
layout="vertical"
autoComplete="off"
>
<Form.Item
name="username"
rules={[
{ required: true, message: 'Please enter your username!' },
{ min: 3, message: 'Username must be at least 3 characters!' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="Username"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please enter your password!' },
{ min: 6, message: 'Password must be at least 6 characters!' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Password"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</Form.Item>
</Form>
{phase === PHASE_MFA ? renderMfaForm() : renderCredentialsForm()}
<div className="login-footer">
<Text type="secondary" style={{ fontSize: 12, display: 'block', textAlign: 'center' }}>
<Text
type="secondary"
style={{ fontSize: 12, display: 'block', textAlign: 'center' }}
>
Centralized management for multiple HAProxy clusters
</Text>
</div>
@@ -178,4 +318,4 @@ const Login = () => {
);
};
export default Login;
export default Login;
+306
View File
@@ -0,0 +1,306 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
Steps,
Form,
Input,
Button,
Alert,
Typography,
Space,
Checkbox,
message,
} from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import axios from 'axios';
import { extractApiError } from '../utils/apiError';
const { Text, Paragraph } = Typography;
const STEP_SETUP = 0;
const STEP_VERIFY = 1;
const STEP_BACKUP = 2;
/**
* MFA enrollment wizard. Strictly modal-controlled: the modal cannot be
* dismissed via the X / mask in step 2/3 — backup codes are shown only once
* and the server-side pending row is opaque after enrollment confirms.
*/
const MFAEnrollModal = ({ open, onClose, onEnrolled }) => {
const [verifyForm] = Form.useForm();
const [step, setStep] = useState(STEP_SETUP);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [otpauthUri, setOtpauthUri] = useState('');
const [secret, setSecret] = useState('');
const [backupCodes, setBackupCodes] = useState([]);
const [savedAcknowledged, setSavedAcknowledged] = useState(false);
const startedRef = useRef(false);
useEffect(() => {
if (!open) return undefined;
if (startedRef.current) return undefined;
startedRef.current = true;
startEnrollment();
return () => {
// No-op: cleanup happens via the explicit handleClose path.
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const resetState = () => {
setStep(STEP_SETUP);
setLoading(false);
setError('');
setOtpauthUri('');
setSecret('');
setBackupCodes([]);
setSavedAcknowledged(false);
startedRef.current = false;
verifyForm.resetFields();
};
const startEnrollment = async () => {
setLoading(true);
setError('');
try {
const response = await axios.post('/api/mfa/enroll/start', {});
setOtpauthUri(response.data.otpauth_uri);
setSecret(response.data.secret);
} catch (err) {
setError(extractApiError(err, 'Could not start MFA enrollment.'));
} finally {
setLoading(false);
}
};
const handleVerify = async (values) => {
setLoading(true);
setError('');
try {
const response = await axios.post('/api/mfa/enroll/confirm', {
code: (values.code || '').trim(),
});
setBackupCodes(response.data.backup_codes || []);
setStep(STEP_BACKUP);
} catch (err) {
setError(extractApiError(err, 'Verification failed.'));
verifyForm.setFieldsValue({ code: '' });
} finally {
setLoading(false);
}
};
const handleCopyAll = () => {
const text = backupCodes.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => message.success('Backup codes copied to clipboard'),
() => message.error('Could not copy. Please copy manually.'),
);
} else {
message.warning('Clipboard API unavailable. Please copy manually.');
}
};
const handleDownload = () => {
const blob = new Blob(
[
'HAProxy OpenManager — MFA backup codes\n',
'Generated: ' + new Date().toISOString() + '\n',
'Each code is single-use. Store them somewhere safe and offline.\n\n',
...backupCodes.map((c) => c + '\n'),
],
{ type: 'text/plain;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'haproxy-openmanager-mfa-backup-codes.txt';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleClose = (force = false) => {
if (step === STEP_BACKUP && !savedAcknowledged && !force) return;
resetState();
if (step === STEP_BACKUP) {
if (typeof onEnrolled === 'function') onEnrolled();
} else if (typeof onClose === 'function') {
onClose();
}
};
const renderSetup = () => (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Paragraph>
Open your authenticator app (Google Authenticator, Authy, 1Password, Microsoft
Authenticator) and scan this QR code, or enter the secret manually.
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{otpauthUri ? (
<QRCodeSVG value={otpauthUri} size={220} level="M" includeMargin />
) : (
<Text type="secondary">Generating…</Text>
)}
</div>
{secret && (
<Alert
message="Trouble scanning?"
description={
<Space direction="vertical" size={4}>
<Text>Enter this secret manually in your authenticator app:</Text>
<Text code copyable={{ text: secret }} style={{ fontSize: 16 }}>
{secret}
</Text>
</Space>
}
type="info"
showIcon
/>
)}
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => handleClose(true)}>Cancel</Button>
<Button
type="primary"
onClick={() => setStep(STEP_VERIFY)}
disabled={!otpauthUri}
>
I&apos;ve added the account
</Button>
</Space>
</div>
</Space>
);
const renderVerify = () => (
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>
<Alert
message="Verify your authenticator"
description="Enter the 6-digit code displayed by your authenticator app. You have 5 attempts before the enrollment is invalidated and you'll need to start over."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item
name="code"
label="Authenticator code"
rules={[
{ required: true, message: 'Please enter the 6-digit code.' },
{ len: 6, message: 'Code must be exactly 6 digits.' },
]}
>
<Input
placeholder="123456"
autoComplete="one-time-code"
inputMode="numeric"
maxLength={6}
autoFocus
/>
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => setStep(STEP_SETUP)} disabled={loading}>
Back
</Button>
<Button onClick={() => handleClose(true)} disabled={loading}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
Verify
</Button>
</Space>
</div>
</Form>
);
const renderBackup = () => (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
message="Save your backup codes now"
description={
<>
Each code can be used <strong>once</strong> when you can&apos;t access your
authenticator. <strong>They won&apos;t be shown again.</strong> If you lose
them, ask an administrator to reset your MFA.
</>
}
type="warning"
showIcon
/>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 16px',
padding: '12px',
backgroundColor: 'var(--ant-color-fill-quaternary, #fafafa)',
borderRadius: 6,
}}
>
{backupCodes.map((code) => (
<Text key={code} code style={{ fontSize: 15, letterSpacing: 1 }}>
{code}
</Text>
))}
</div>
<Space>
<Button onClick={handleCopyAll}>Copy all</Button>
<Button onClick={handleDownload}>Download .txt</Button>
</Space>
<Checkbox
checked={savedAcknowledged}
onChange={(e) => setSavedAcknowledged(e.target.checked)}
>
I have saved my backup codes somewhere safe.
</Checkbox>
<div style={{ textAlign: 'right' }}>
<Button
type="primary"
disabled={!savedAcknowledged}
onClick={() => handleClose(false)}
>
Close
</Button>
</div>
</Space>
);
return (
<Modal
open={open}
title="Enable Multi-Factor Authentication"
width={520}
footer={null}
closable={false}
maskClosable={false}
destroyOnClose
keyboard={false}
>
<Steps
size="small"
current={step}
items={[{ title: 'Set up' }, { title: 'Verify' }, { title: 'Backup codes' }]}
style={{ marginBottom: 24 }}
/>
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => setError('')}
style={{ marginBottom: 16 }}
/>
)}
{step === STEP_SETUP && renderSetup()}
{step === STEP_VERIFY && renderVerify()}
{step === STEP_BACKUP && renderBackup()}
</Modal>
);
};
export default MFAEnrollModal;
+243 -33
View File
@@ -32,11 +32,15 @@ import {
HistoryOutlined,
UserAddOutlined,
KeyOutlined,
DownloadOutlined
DownloadOutlined,
SafetyCertificateOutlined,
ReloadOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useAuth } from '../contexts/AuthContext';
import { extractApiError } from '../utils/apiError';
import MFAEnrollModal from './MFAEnrollModal';
const { TabPane } = Tabs;
const { Option } = Select;
@@ -225,8 +229,16 @@ const PERMISSION_TREE = [
];
const UserManagement = () => {
const { isAdmin } = useAuth(); // Get admin status from auth context
const { isAdmin, user: currentUser } = useAuth(); // Get admin status from auth context
const { token } = theme.useToken();
// MFA UI state (Issue #18, v1.6.0)
const [mfaEnrollOpen, setMfaEnrollOpen] = useState(false);
const [mfaDisableOpen, setMfaDisableOpen] = useState(false);
const [mfaDisableForm] = Form.useForm();
const [mfaActionLoading, setMfaActionLoading] = useState(false);
const [mfaResetTarget, setMfaResetTarget] = useState(null);
const [mfaResetForm] = Form.useForm();
const [activeTab, setActiveTab] = useState('users');
// Users state
@@ -525,6 +537,47 @@ const UserManagement = () => {
}
};
// ---- MFA handlers (Issue #18, v1.6.0) -----------------------------------
const handleMfaEnrolled = () => {
setMfaEnrollOpen(false);
message.success('MFA enabled for your account.');
fetchUsers();
};
const handleMfaDisableSubmit = async (values) => {
setMfaActionLoading(true);
try {
await axios.post('/api/mfa/disable', { code: (values.code || '').trim() });
message.success('MFA disabled for your account.');
setMfaDisableOpen(false);
mfaDisableForm.resetFields();
fetchUsers();
} catch (error) {
message.error(extractApiError(error, 'Failed to disable MFA'));
} finally {
setMfaActionLoading(false);
}
};
const handleMfaAdminResetSubmit = async (values) => {
if (!mfaResetTarget) return;
setMfaActionLoading(true);
try {
await axios.post(`/api/mfa/admin-reset/${mfaResetTarget.id}`, {
reason: (values.reason || '').trim(),
});
message.success(`MFA reset for user '${mfaResetTarget.username}'.`);
setMfaResetTarget(null);
mfaResetForm.resetFields();
fetchUsers();
} catch (error) {
message.error(extractApiError(error, 'Failed to reset MFA'));
} finally {
setMfaActionLoading(false);
}
};
const handleChangePassword = (user) => {
setSelectedUser(user);
passwordForm.resetFields();
@@ -717,6 +770,15 @@ const UserManagement = () => {
</Space>
)
},
{
title: 'MFA',
dataIndex: 'mfa_enabled',
key: 'mfa_enabled',
width: 90,
render: (enabled) => enabled
? <Tag icon={<SafetyCertificateOutlined />} color="green">ON</Tag>
: <Tag color="default">OFF</Tag>
},
{
title: 'Last Login',
dataIndex: 'last_login_at',
@@ -726,31 +788,94 @@ const UserManagement = () => {
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space>
{isAdmin() && (
<>
<Tooltip title="Edit User">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEditUser(record)}
render: (_, record) => {
const isSelf = currentUser && record.id === currentUser.id;
return (
<Space>
{isAdmin() && (
<>
<Tooltip title="Edit User">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEditUser(record)}
/>
</Tooltip>
<Tooltip title="Assign Roles">
<Button
icon={<TeamOutlined />}
size="small"
onClick={() => handleAssignRoles(record)}
/>
</Tooltip>
<Tooltip title="Change Password">
<Button
icon={<KeyOutlined />}
size="small"
onClick={() => handleChangePassword(record)}
/>
</Tooltip>
</>
)}
{isSelf && !record.mfa_enabled && (
<Tooltip title="Enable MFA for your account">
<Button
icon={<SafetyCertificateOutlined />}
size="small"
type="primary"
ghost
onClick={() => setMfaEnrollOpen(true)}
>
Enable MFA
</Button>
</Tooltip>
)}
{isSelf && record.mfa_enabled && (
<Tooltip title="Disable MFA for your account">
<Button
icon={<SafetyCertificateOutlined />}
size="small"
onClick={() => {
mfaDisableForm.resetFields();
setMfaDisableOpen(true);
}}
>
Disable MFA
</Button>
</Tooltip>
)}
{isAdmin() && !isSelf && record.mfa_enabled && (
<Tooltip title="Reset this user's MFA (admin only)">
<Button
icon={<ReloadOutlined />}
size="small"
danger
onClick={() => {
mfaResetForm.resetFields();
setMfaResetTarget(record);
}}
/>
</Tooltip>
<Tooltip title="Assign Roles">
<Button
icon={<TeamOutlined />}
size="small"
onClick={() => handleAssignRoles(record)}
/>
</Tooltip>
<Tooltip title="Change Password">
<Button
icon={<KeyOutlined />}
size="small"
onClick={() => handleChangePassword(record)}
)}
{isAdmin() && !isSelf && !record.mfa_enabled && (
<Tooltip
title={
<span>
Only this user can enable their own MFA — the TOTP secret
must be set up from their device. Ask them to sign in and
click <b>Enable MFA</b> in their own row.
</span>
}
>
<Button
icon={<InfoCircleOutlined />}
size="small"
type="text"
style={{ color: '#8c8c8c' }}
/>
</Tooltip>
)}
{isAdmin() && (
<Popconfirm
title="Are you sure you want to delete this user?"
onConfirm={() => handleDeleteUser(record)}
@@ -758,20 +883,18 @@ const UserManagement = () => {
cancelText="No"
>
<Tooltip title="Delete User">
<Button
icon={<DeleteOutlined />}
danger
<Button
icon={<DeleteOutlined />}
danger
size="small"
/>
</Tooltip>
</Popconfirm>
</>
)}
{!isAdmin() && (
<Text type="secondary">View Only</Text>
)}
</Space>
)
)}
{!isAdmin() && !isSelf && <Text type="secondary">View Only</Text>}
</Space>
);
}
}
];
@@ -1512,6 +1635,93 @@ const UserManagement = () => {
</Form.Item>
</Form>
</Modal>
{/* MFA enrollment wizard (self) */}
<MFAEnrollModal
open={mfaEnrollOpen}
onClose={() => setMfaEnrollOpen(false)}
onEnrolled={handleMfaEnrolled}
/>
{/* MFA self-disable modal */}
<Modal
title="Disable Multi-Factor Authentication"
open={mfaDisableOpen}
onCancel={() => setMfaDisableOpen(false)}
footer={null}
destroyOnClose
>
<Form form={mfaDisableForm} layout="vertical" onFinish={handleMfaDisableSubmit}>
<p>
Enter a current code from your authenticator app, or one of your backup
codes (format: <code>XXXX-YYYY</code>), to disable MFA.
</p>
<Form.Item
name="code"
label="MFA code"
rules={[
{ required: true, message: 'Please enter your MFA code.' },
{ min: 6, message: 'Code is too short.' },
{ max: 10, message: 'Code is too long.' },
]}
>
<Input
placeholder="123456 or XXXX-YYYY"
autoComplete="one-time-code"
maxLength={10}
autoFocus
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setMfaDisableOpen(false)} disabled={mfaActionLoading}>
Cancel
</Button>
<Button type="primary" danger htmlType="submit" loading={mfaActionLoading}>
Disable MFA
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* Admin: reset another user's MFA */}
<Modal
title={mfaResetTarget ? `Reset MFA for '${mfaResetTarget.username}'` : 'Reset MFA'}
open={!!mfaResetTarget}
onCancel={() => setMfaResetTarget(null)}
footer={null}
destroyOnClose
>
<Form form={mfaResetForm} layout="vertical" onFinish={handleMfaAdminResetSubmit}>
<p>
This will disable MFA and invalidate all backup codes for{' '}
<strong>{mfaResetTarget && mfaResetTarget.username}</strong>. The user must
re-enroll on their next login. The action is recorded in the audit log.
</p>
<Form.Item
name="reason"
label="Reason (required, will be audit-logged)"
rules={[
{ required: true, message: 'Please provide a reason.' },
{ min: 3, message: 'Reason is too short.' },
{ max: 500, message: 'Reason is too long (max 500 characters).' },
]}
>
<Input.TextArea rows={3} placeholder="e.g. Lost phone — verified identity via ticket #12345." />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setMfaResetTarget(null)} disabled={mfaActionLoading}>
Cancel
</Button>
<Button type="primary" danger htmlType="submit" loading={mfaActionLoading}>
Reset MFA
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
+31
View File
@@ -0,0 +1,31 @@
/**
* CRA dev-server proxy.
*
* This file is consumed by Webpack DevServer (via `react-scripts start`)
* EXCLUSIVELY. It is NOT included in the production bundle (`npm run
* build` / `serve -s build`) and has zero effect in Kubernetes / Docker
* Compose deployments where nginx (`/api/*` → backend) handles routing.
*
* Default target is `http://localhost:8000` because the only realistic
* use of `npm start` is on a developer's host machine, where the backend
* is reachable on localhost. The target can be overridden with the
* `PROXY_TARGET` environment variable (e.g. `PROXY_TARGET=http://other-host:8000 npm start`).
*
* Why not `http://backend:8000`? That hostname only resolves inside the
* Docker Compose network. Defaulting to it caused all `/api/*` requests
* to fail with ENOTFOUND when running the dev-server on the host.
*/
const { createProxyMiddleware } = require('http-proxy-middleware');
const target = process.env.PROXY_TARGET || 'http://localhost:8000';
module.exports = function (app) {
app.use(
'/api',
createProxyMiddleware({
target,
changeOrigin: true,
logLevel: 'warn',
})
);
};
+15 -19
View File
@@ -1,32 +1,28 @@
/**
* API Configuration
* Centralized API URL management for the application
*
* Environment Variables:
* - REACT_APP_API_URL: Full API URL (e.g., https://api.example.com)
* - NODE_ENV: Environment (development, production)
* Centralized API URL management for the application.
*
* Resolution strategy (same in dev and prod — keeps SPA same-origin):
* 1) REACT_APP_API_URL — explicit override at BUILD time. Use only when
* the SPA must call a cross-origin API (CORS must be enabled there).
* 2) window.location.{protocol,host} — same-origin. In dev this routes
* through CRA dev-server proxy (see frontend/src/setupProxy.js); in
* prod through nginx ingress (`/api/*` → backend service).
* 3) Empty string — non-browser env (SSR/tests). Yields relative URLs.
*
* NOTE: Do NOT hardcode `http://localhost:8000` here. Even in unreachable
* branches CRA/Terser keeps string literals in the bundle, which would
* confuse anyone auditing the production artifact.
*/
// Get API URL from environment or use default based on environment
const getApiUrl = () => {
// Priority 1: Explicit environment variable
if (process.env.REACT_APP_API_URL) {
return process.env.REACT_APP_API_URL;
}
// Priority 2: Detect from window location (for production deployments)
// Use window.location.host (includes port) instead of hostname (excludes port)
// so non-standard ports like :8080 are preserved, preventing CORS issues
if (typeof window !== 'undefined' && window.location) {
const { protocol, host } = window.location;
if (process.env.NODE_ENV === 'production') {
return `${protocol}//${host}`;
}
return `${protocol}//${host}`;
}
// Priority 3: Development default
return 'http://localhost:8000';
return '';
};
// API Base URL
+5 -3
View File
@@ -23,6 +23,8 @@ metadata:
app: haproxy-openmanager
component: backend
type: Opaque
data:
# your-secret-key-change-this-in-production
SECRET_KEY: eW91ci1zZWNyZXQta2V5LWNoYW5nZS10aGlzLWluLXByb2R1Y3Rpb24=
# Both values are replaced at deploy time by the CI/CD pipeline (sed step)
# before `kubectl apply` runs. Never commit real secrets — use the placeholders.
stringData:
SECRET_KEY: secret_key_replace_me
MFA_ENCRYPTION_KEY: mfa_encryption_key_replace_me
+41 -3
View File
@@ -15,6 +15,33 @@ data:
PUBLIC_URL: 'https://haproxy-openmanager.example.com'
MANAGEMENT_BASE_URL: 'https://haproxy-openmanager.example.com'
# ---------------------------------------------------------------------------
# MFA rate-limits (slowapi format: "<count>/<second|minute|hour|day>").
# The shipped defaults assume the rate-limit key is per-USER (Bearer JWT)
# with a per-IP fallback. They are sized for thousands of concurrent
# operators in an org-wide MFA rollout. Pod restart required to apply.
#
# Defaults live in backend/middleware/mfa_rate_limits.py:
# ENROLL_START='10/minute' ENROLL_CONFIRM='10/minute'
# DISABLE='10/minute' REGENERATE_BACKUP_CODES='5/hour'
# ADMIN_RESET='60/hour' ADMIN_RESET_ALL='1/day'
# ---------------------------------------------------------------------------
# MFA_RATE_LIMIT_ENROLL_START: '10/minute'
# MFA_RATE_LIMIT_ENROLL_CONFIRM: '10/minute'
# MFA_RATE_LIMIT_DISABLE: '10/minute'
# MFA_RATE_LIMIT_REGENERATE_BACKUP_CODES: '5/hour'
# MFA_RATE_LIMIT_ADMIN_RESET: '60/hour'
# MFA_RATE_LIMIT_ADMIN_RESET_ALL: '1/day'
# ---------------------------------------------------------------------------
# Trusted reverse-proxy CIDRs for X-Forwarded-For. When the request peer
# falls inside one of these CIDRs and the user is NOT authenticated, the
# first hop of XFF becomes the rate-limit bucket. Empty (default) disables
# XFF parsing entirely — the safe choice when the topology is unknown.
# Example (in-cluster service mesh + nginx ingress on the default ranges):
# ---------------------------------------------------------------------------
# MFA_TRUSTED_PROXY_CIDRS: '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
---
apiVersion: v1
kind: ConfigMap
@@ -25,9 +52,20 @@ metadata:
app: haproxy-openmanager
component: frontend
data:
# Leave empty to use same-origin (window.location) in production
# For development, set to: 'http://localhost:8000'
REACT_APP_API_URL: ''
# NOTE — REACT_APP_* env vars are RUNTIME no-ops here.
# Create-React-App inlines REACT_APP_* values into the bundle at BUILD time
# (`npm run build`). The static bundle served by `serve -s build` does not
# consume runtime env vars, so anything set here has zero effect on
# window.location-based same-origin routing.
#
# The frontend always issues `/api/*` against window.location, which is
# routed by the ingress + nginx ConfigMap below to the backend service.
# No env vars are required for the frontend deployment.
#
# WARNING: Do NOT set REACT_APP_API_URL in your CI/CD pipeline either —
# if a value is supplied at build time it gets HARDCODED into the bundle
# and breaks every deployment that does not match that exact URL.
{}
---
apiVersion: v1
+35
View File
@@ -75,6 +75,35 @@ Test HAProxy stats parsing
### test-build.sh
Run build tests for the project
## 🔐 MFA Admin Scripts
### admin-mfa-reset-all.sh
**Purpose:** Emergency — disable Multi-Factor Authentication for **every** user
in one call. Use only when there is a mass loss of authenticator devices /
inherited platform without working operators (Issue #18, v1.6.0).
**Usage:**
```bash
# Interactive prompts ask for the admin Bearer token + reason
./scripts/admin-mfa-reset-all.sh
# Non-interactive (still requires double confirmation typed at the keyboard)
API_URL=https://hap.example.com \
ADMIN_TOKEN=eyJhbGciOi... \
./scripts/admin-mfa-reset-all.sh
```
**Features:**
- Calls `POST /api/mfa/admin-reset-all` (requires `users.is_admin = TRUE`)
- Double confirmation: type `yes`, then `RESET ALL MFA` exactly
- Required reason is recorded in `user_activity_logs`
(`action='mfa.disabled.admin_bulk_reset'`)
- Deletes every backup code and invalidates pending MFA challenges
**Safety:**
- Irreversible — all users must re-enroll MFA afterwards
- All other authentication (password, JWT, roles) is unaffected
## 🚨 Emergency Use Cases
**1. Cluster Migration/Cleanup:**
@@ -101,6 +130,12 @@ Run build tests for the project
./scripts/debug-agent-stats-function.sh
```
**4. MFA Outage (mass lost authenticators):**
```bash
# Disable MFA for every user, then ask them to re-enroll
./scripts/admin-mfa-reset-all.sh
```
## ⚠️ Safety Notes
- All cleanup scripts require admin authentication
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# scripts/admin-mfa-reset-all.sh
# Issue #18 — v1.6.0 — Emergency: disable MFA for ALL users in one call.
#
# This is a break-glass tool. It calls POST /api/mfa/admin-reset-all on the
# backend with a strict, double-confirmed body and writes the action into the
# server's audit log (action=mfa.disabled.admin_bulk_reset). All users will be
# able to log in with username/password alone afterwards and must re-enroll if
# they want MFA again.
#
# Usage:
# ./scripts/admin-mfa-reset-all.sh
# API_URL=https://hap.example.com ADMIN_TOKEN=ey... ./scripts/admin-mfa-reset-all.sh
#
# Required: a JWT bearer token belonging to a user whose `users.is_admin = TRUE`.
set -euo pipefail
API_URL="${API_URL:-http://localhost:8000}"
ADMIN_TOKEN="${ADMIN_TOKEN:-}"
if [ -z "$ADMIN_TOKEN" ]; then
read -rsp "Admin Bearer token (user with is_admin=TRUE): " ADMIN_TOKEN
echo
fi
if [ -z "$ADMIN_TOKEN" ]; then
echo "ERROR: no admin token provided." >&2
exit 1
fi
cat <<'WARN'
========================================================================
WARNING — IRREVERSIBLE BULK ACTION
========================================================================
This will:
* set mfa_enabled = FALSE for every user
* delete every backup code
* invalidate all pending MFA login challenges and enrollments
* write a permanent entry to user_activity_logs
After this, all users can sign in with username/password only and must
re-enroll MFA from the Users page.
========================================================================
WARN
read -rp "Type 'yes' to proceed: " confirm1
if [ "$confirm1" != "yes" ]; then
echo "Aborted."
exit 1
fi
read -rp "Type 'RESET ALL MFA' (exact) to confirm: " confirm2
if [ "$confirm2" != "RESET ALL MFA" ]; then
echo "Aborted."
exit 1
fi
read -rp "Reason (logged in audit): " reason
if [ -z "$reason" ]; then
echo "Aborted: reason is required."
exit 1
fi
# Compose JSON safely (python3 for proper JSON escaping; reliably available
# everywhere the backend already runs).
payload=$(python3 -c "
import json, sys
print(json.dumps({'confirm': 'RESET ALL MFA', 'reason': sys.argv[1]}))
" "$reason")
echo "Calling $API_URL/api/mfa/admin-reset-all ..."
http_response=$(curl -fsS -X POST "$API_URL/api/mfa/admin-reset-all" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload")
echo "$http_response"
echo
echo "Done. Verify in user_activity_logs: action='mfa.disabled.admin_bulk_reset'."
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.5.2",
"releaseName": "ACME Diagnostics Panel Hardening",
"releaseDate": "2026-05-13"
"version": "1.6.0",
"releaseName": "Multi-Factor Authentication (MFA)",
"releaseDate": "2026-05-18"
}