mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-04 04:21:30 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd6a31cb0d | |||
| 445639d202 | |||
| f7e0df15e3 |
@@ -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
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)):
|
||||
"""
|
||||
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
""")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:")
|
||||
@@ -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]
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Generated
+16
-23
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'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't access your
|
||||
authenticator. <strong>They won'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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+76
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user