July 4 - Implement TOTP 2FA

This commit is contained in:
2026-07-04 13:40:03 -04:00
parent 07226b4878
commit d87c889ca2
23 changed files with 1336 additions and 10 deletions
+24 -5
View File
@@ -266,9 +266,13 @@ MAIL_USE_TLS = not MAIL_USE_SSL
```
users: id, username (unique, indexed), full_name, email (unique, indexed),
password_hash, role (ENUM), created_at, active,
password_set, set_password_token (indexed), set_password_token_expires
password_set, set_password_token (indexed), set_password_token_expires,
mfa_enabled BOOL default False, mfa_secret VARCHAR(64) NULL, ← phase35
mfa_recovery_codes JSON NULL ← phase35
```
**MFA (phase35):** Opt-in TOTP two-factor. `mfa_enabled` gates a second-factor step at login (`/auth/mfa`). `mfa_secret` is the base32 TOTP shared secret. `mfa_recovery_codes` is a JSON list of werkzeug-hashed one-time backup codes (never plaintext). Enrollment UI at `/auth/mfa/setup` is `@supervisor_required` (admin/director); the login challenge fires for **any** account with `mfa_enabled=1`. The superadmin panel has the mirrored flow on the `Superadmin` control-plane model.
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
**Key property:** `display_name``full_name.strip()` or falls back to `username`.
@@ -474,7 +478,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
| Blueprint | Prefix | Notable routes |
|---|---|---|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix`, `/mfa` (login 2FA challenge), `/mfa/setup` + `/mfa/disable` (phase35, `@supervisor_required` enroll/disable) |
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
| `facilities` | `/facilities` | CRUD + area management |
| `projects` | `/projects` | CRUD + customer assignment management |
@@ -757,7 +761,7 @@ limiter = Limiter(
## 17. Alembic Migration Chain
**Current HEAD:** `phase34_inspection_schedules` (32 migrations total).
**Current HEAD:** `phase35_user_mfa` (33 migrations total).
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
@@ -786,7 +790,21 @@ limiter = Limiter(
→ phase31_device_registry
→ phase32_device_token_columns
→ phase33_tenant_settings
→ phase34_inspection_schedules ← HEAD
→ phase34_inspection_schedules
→ phase35_user_mfa ← HEAD
```
### phase35_user_mfa
Adds opt-in TOTP two-factor columns to `users`: `mfa_enabled TINYINT(1) NOT NULL DEFAULT 0`, `mfa_secret VARCHAR(64) NULL`, `mfa_recovery_codes JSON NULL`. All nullable/defaulted — existing accounts are unaffected until a user enrolls. Enforced at login for any account with `mfa_enabled=1` (enrollment UI gated to admin/director). Recovery codes are stored only as werkzeug hashes. Guarded with `INFORMATION_SCHEMA` column checks — safe to re-run. The control-plane companion migration `control0005_superadmin_mfa` adds the same three columns to `superadmins`.
**Deploy order:**
```bash
pip install -r requirements.txt # adds pyotp + qrcode
flask db upgrade # tenant schema: phase35_user_mfa
# control plane (superadmin panel 2FA):
alembic -c control/migrations/alembic.ini upgrade head # control0005_superadmin_mfa
sudo systemctl restart gunicorn jqc-panel
```
### phase34_inspection_schedules
@@ -1270,6 +1288,7 @@ set -a; . /etc/jqc/control.env; set +a
| 84 | **Device registration is consolidated on `DeviceToken` / `api_device_tokens` — one handler only** | RESOLVED. There is exactly one `POST /api/v1/devices/register`, in `app/api/auth.py` (blueprint `api_auth`); it upserts `DeviceToken` (device_id, device_name, app_version, ios_version, apns_token, last_seen_at) which the admin Devices page reads. The former duplicate `api_devices` blueprint (`app/api/devices.py`) and the orphaned `DeviceRegistration` model / `device_registrations` table were **deleted** — that path wrote to a table phase31/32 drop. Do not reintroduce a second `/devices/register` route or a `device_registrations`-backed model. |
| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. **The apex check runs BEFORE the `MULTI_TENANT_ENABLED` gate** — it must work in single-tenant mode too, otherwise the app serves its default database (tenant-zero) for the apex host and the landing page never shows. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through, and `TENANT_BASE_DOMAIN` set correctly in the app environment. |
| 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. |
| 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. |
---
@@ -1627,7 +1646,7 @@ Ask: Does this change break any other code path that uses the modified function,
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
**Rule 14 — Migrations are required for any schema change.**
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase34_inspection_schedules`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase35_user_mfa`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.