July 4 - Implement TOTP 2FA
This commit is contained in:
@@ -266,9 +266,13 @@ MAIL_USE_TLS = not MAIL_USE_SSL
|
|||||||
```
|
```
|
||||||
users: id, username (unique, indexed), full_name, email (unique, indexed),
|
users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||||||
password_hash, role (ENUM), created_at, active,
|
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`
|
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
|
||||||
|
|
||||||
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`.
|
**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 |
|
| 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) |
|
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||||
| `facilities` | `/facilities` | CRUD + area management |
|
| `facilities` | `/facilities` | CRUD + area management |
|
||||||
| `projects` | `/projects` | CRUD + customer assignment management |
|
| `projects` | `/projects` | CRUD + customer assignment management |
|
||||||
@@ -757,7 +761,7 @@ limiter = Limiter(
|
|||||||
|
|
||||||
## 17. Alembic Migration Chain
|
## 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`.
|
**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
|
→ phase31_device_registry
|
||||||
→ phase32_device_token_columns
|
→ phase32_device_token_columns
|
||||||
→ phase33_tenant_settings
|
→ 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
|
### 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. |
|
| 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. |
|
| 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. |
|
| 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 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.**
|
**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/`.
|
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ tenant_domains
|
|||||||
tls_status ENUM(pending, active, failed), created_at
|
tls_status ENUM(pending, active, failed), created_at
|
||||||
|
|
||||||
superadmins -- cross-tenant accounts, control-plane only
|
superadmins -- cross-tenant accounts, control-plane only
|
||||||
id, username, email, password_hash, active, created_at
|
id, username, email, password_hash, active, created_at,
|
||||||
|
mfa_enabled, mfa_secret, mfa_recovery_codes -- control0005: opt-in TOTP 2FA
|
||||||
|
|
||||||
provisioning_jobs
|
provisioning_jobs
|
||||||
id, tenant_id (FK), action ENUM(create_db, migrate, seed, suspend, delete),
|
id, tenant_id (FK), action ENUM(create_db, migrate, seed, suspend, delete),
|
||||||
@@ -115,7 +116,7 @@ db = SQLAlchemy(session_options={'class_': RoutingSession})
|
|||||||
Two independent Alembic chains:
|
Two independent Alembic chains:
|
||||||
|
|
||||||
1. **Tenant schema** — existing chain (HEAD: **`phase34_inspection_schedules`**). Runs per-tenant DB. New tenant features continue as `phase35_…` per existing naming.
|
1. **Tenant schema** — existing chain (HEAD: **`phase34_inspection_schedules`**). Runs per-tenant DB. New tenant features continue as `phase35_…` per existing naming.
|
||||||
2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0004_dunning_tracking` (`control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking`).
|
2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0005_superadmin_mfa` (`control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking → control0005_superadmin_mfa`).
|
||||||
|
|
||||||
**CLI (always source env first):**
|
**CLI (always source env first):**
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ class User(UserMixin, db.Model):
|
|||||||
set_password_token = db.Column(db.String(64), nullable=True, index=True)
|
set_password_token = db.Column(db.String(64), nullable=True, index=True)
|
||||||
set_password_token_expires = db.Column(db.DateTime, nullable=True)
|
set_password_token_expires = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# ── Two-factor auth (phase35) — opt-in TOTP ───────────────────────────
|
||||||
|
# mfa_enabled gates the second-factor step at login. mfa_secret is the
|
||||||
|
# base32 TOTP shared secret. mfa_recovery_codes is a JSON list of hashed
|
||||||
|
# one-time backup codes (never stored in plaintext). All default off so
|
||||||
|
# existing accounts are unaffected until a user enrolls.
|
||||||
|
mfa_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
mfa_secret = db.Column(db.String(64), nullable=True)
|
||||||
|
mfa_recovery_codes = db.Column(db.JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||||
|
|
||||||
|
|||||||
+130
-1
@@ -1,10 +1,11 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
|
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
|
||||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||||
|
from app.utils import mfa
|
||||||
import logging
|
import logging
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||||
from app.tenancy.gates import quota_soft_check
|
from app.tenancy.gates import quota_soft_check
|
||||||
@@ -35,6 +36,17 @@ def login():
|
|||||||
'warning'
|
'warning'
|
||||||
)
|
)
|
||||||
return render_template('auth/login.html', form=form)
|
return render_template('auth/login.html', form=form)
|
||||||
|
# ── Two-factor gate (phase35) ────────────────────────────────
|
||||||
|
# If this account has TOTP enabled, defer login_user() to the
|
||||||
|
# second-factor step. Password is verified; identity is NOT yet
|
||||||
|
# established until the code is confirmed at /auth/mfa.
|
||||||
|
if user.mfa_enabled and user.mfa_secret:
|
||||||
|
session['mfa_pending_user_id'] = user.id
|
||||||
|
session['mfa_pending_remember'] = bool(form.remember_me.data)
|
||||||
|
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
|
||||||
|
logger.info('MFA_CHALLENGE | user=%s', user.username)
|
||||||
|
return redirect(url_for('auth.mfa_challenge'))
|
||||||
|
|
||||||
login_user(user, remember=form.remember_me.data)
|
login_user(user, remember=form.remember_me.data)
|
||||||
# Use validated next URL — never redirect blindly to request.args['next']
|
# Use validated next URL — never redirect blindly to request.args['next']
|
||||||
next_page = safe_redirect_url(request.args.get('next'))
|
next_page = safe_redirect_url(request.args.get('next'))
|
||||||
@@ -59,6 +71,123 @@ def logout():
|
|||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Two-factor authentication (phase35) ─────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/mfa', methods=['GET', 'POST'])
|
||||||
|
@limiter.limit('10 per minute; 3 per second')
|
||||||
|
def mfa_challenge():
|
||||||
|
"""Second-factor step during login. Reached only after a correct password
|
||||||
|
for an MFA-enabled account (identity is held pending in the session)."""
|
||||||
|
uid = session.get('mfa_pending_user_id')
|
||||||
|
if not uid:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
user = db.session.get(User, uid)
|
||||||
|
if user is None or not user.mfa_enabled or not user.active:
|
||||||
|
session.pop('mfa_pending_user_id', None)
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
code = request.form.get('code', '')
|
||||||
|
use_recovery = bool(request.form.get('recovery'))
|
||||||
|
verified = False
|
||||||
|
via = 'totp'
|
||||||
|
|
||||||
|
if use_recovery:
|
||||||
|
matched, remaining = mfa.check_and_consume_recovery(user.mfa_recovery_codes, code)
|
||||||
|
if matched:
|
||||||
|
user.mfa_recovery_codes = remaining
|
||||||
|
db.session.commit()
|
||||||
|
verified = True
|
||||||
|
via = 'recovery'
|
||||||
|
else:
|
||||||
|
verified = mfa.verify_totp(user.mfa_secret, code)
|
||||||
|
|
||||||
|
if verified:
|
||||||
|
remember = session.pop('mfa_pending_remember', False)
|
||||||
|
next_page = session.pop('mfa_pending_next', None)
|
||||||
|
session.pop('mfa_pending_user_id', None)
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
|
||||||
|
if via == 'recovery':
|
||||||
|
remaining_n = len(user.mfa_recovery_codes or [])
|
||||||
|
flash(f'Signed in with a recovery code. {remaining_n} recovery '
|
||||||
|
f'code(s) remaining.', 'warning')
|
||||||
|
else:
|
||||||
|
flash(f'Welcome back, {user.username}!', 'success')
|
||||||
|
return redirect(safe_redirect_url(next_page))
|
||||||
|
|
||||||
|
logger.warning('MFA_FAILED | user=%s ip=%s recovery=%s',
|
||||||
|
user.username, request.remote_addr, use_recovery)
|
||||||
|
flash('Invalid verification code. Please try again.', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/mfa_challenge.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/mfa/setup', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@supervisor_required # admin + director
|
||||||
|
def mfa_setup():
|
||||||
|
"""Enroll the current account in TOTP two-factor. Opt-in.
|
||||||
|
|
||||||
|
The candidate secret is held in the session until the user proves they can
|
||||||
|
generate a valid code, so a half-finished enrollment never locks anyone out.
|
||||||
|
"""
|
||||||
|
if current_user.mfa_enabled:
|
||||||
|
flash('Two-factor authentication is already enabled on your account.', 'info')
|
||||||
|
return redirect(url_for('auth.profile'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
secret = session.get('mfa_setup_secret')
|
||||||
|
code = request.form.get('code', '')
|
||||||
|
if not secret:
|
||||||
|
flash('Your setup session expired. Please start again.', 'warning')
|
||||||
|
return redirect(url_for('auth.mfa_setup'))
|
||||||
|
if mfa.verify_totp(secret, code):
|
||||||
|
plaintext, hashed = mfa.generate_recovery_codes()
|
||||||
|
current_user.mfa_secret = secret
|
||||||
|
current_user.mfa_enabled = True
|
||||||
|
current_user.mfa_recovery_codes = hashed
|
||||||
|
db.session.commit()
|
||||||
|
session.pop('mfa_setup_secret', None)
|
||||||
|
log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
|
||||||
|
'enabled two-factor authentication')
|
||||||
|
logger.info('MFA_ENABLED | user=%s', current_user.username)
|
||||||
|
# Recovery codes are shown exactly once, right here.
|
||||||
|
return render_template('auth/mfa_recovery.html', codes=plaintext)
|
||||||
|
flash('That code did not match. Make sure your device clock is correct '
|
||||||
|
'and try again.', 'danger')
|
||||||
|
|
||||||
|
# GET, or a failed POST: (re)present the QR for the pending secret.
|
||||||
|
secret = session.get('mfa_setup_secret') or mfa.new_secret()
|
||||||
|
session['mfa_setup_secret'] = secret
|
||||||
|
uri = mfa.provisioning_uri(secret, current_user.email or current_user.username)
|
||||||
|
return render_template('auth/mfa_setup.html', secret=secret, qr_svg=mfa.qr_svg(uri))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/mfa/disable', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def mfa_disable():
|
||||||
|
"""Turn off two-factor. Requires a current authenticator code OR the account
|
||||||
|
password, so a merely-hijacked session can't silently strip 2FA."""
|
||||||
|
if not current_user.mfa_enabled:
|
||||||
|
return redirect(url_for('auth.profile'))
|
||||||
|
code = request.form.get('code', '')
|
||||||
|
pw = request.form.get('password', '')
|
||||||
|
if not (mfa.verify_totp(current_user.mfa_secret, code)
|
||||||
|
or (pw and current_user.check_password(pw))):
|
||||||
|
flash('Enter a valid authenticator code or your password to disable 2FA.', 'danger')
|
||||||
|
return redirect(url_for('auth.profile'))
|
||||||
|
current_user.mfa_enabled = False
|
||||||
|
current_user.mfa_secret = None
|
||||||
|
current_user.mfa_recovery_codes = None
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
|
||||||
|
'disabled two-factor authentication')
|
||||||
|
logger.info('MFA_DISABLED | user=%s', current_user.username)
|
||||||
|
flash('Two-factor authentication has been disabled.', 'success')
|
||||||
|
return redirect(url_for('auth.profile'))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/profile', methods=['GET', 'POST'])
|
@bp.route('/profile', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Two-Factor Verification{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-4">
|
||||||
|
<div class="col-md-5 col-lg-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<i class="bi bi-shield-lock fs-1 text-primary"></i>
|
||||||
|
<h4 class="mt-2 mb-1">Two-factor verification</h4>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
Enter the 6-digit code from your authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.mfa_challenge') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="code" class="form-control form-control-lg text-center"
|
||||||
|
inputmode="numeric" autocomplete="one-time-code" autofocus
|
||||||
|
placeholder="123456" style="letter-spacing:.4em;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="recovery" id="useRecovery" value="1">
|
||||||
|
<label class="form-check-label small" for="useRecovery">
|
||||||
|
I'll use a recovery code instead
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-check2-circle"></i> Verify
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('auth.login') }}" class="small text-muted">Back to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// When "use recovery code" is toggled, relax the numeric hints for the code box.
|
||||||
|
(function () {
|
||||||
|
var chk = document.getElementById('useRecovery');
|
||||||
|
var box = document.querySelector('input[name="code"]');
|
||||||
|
if (!chk || !box) return;
|
||||||
|
chk.addEventListener('change', function () {
|
||||||
|
if (chk.checked) {
|
||||||
|
box.setAttribute('inputmode', 'text');
|
||||||
|
box.setAttribute('placeholder', 'xxxx-xxxx');
|
||||||
|
box.style.letterSpacing = '.15em';
|
||||||
|
} else {
|
||||||
|
box.setAttribute('inputmode', 'numeric');
|
||||||
|
box.setAttribute('placeholder', '123456');
|
||||||
|
box.style.letterSpacing = '.4em';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Recovery Codes{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-3">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="bi bi-shield-check"></i>
|
||||||
|
<strong>Two-factor authentication is now enabled.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="mb-2"><i class="bi bi-key"></i> Save your recovery codes</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
Each code works <strong>once</strong> if you lose access to your authenticator
|
||||||
|
app. Store them somewhere safe — <strong>they will not be shown again.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-light border rounded p-3 mb-3">
|
||||||
|
<div class="row row-cols-2 g-2 font-monospace text-center" id="codeList">
|
||||||
|
{% for code in codes %}
|
||||||
|
<div class="col"><span class="badge bg-white text-dark border fs-6 w-100 py-2">{{ code }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="copyCodes()">
|
||||||
|
<i class="bi bi-clipboard"></i> Copy codes
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="btn btn-primary ms-auto">
|
||||||
|
<i class="bi bi-check-lg"></i> I've saved them — Done
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyCodes() {
|
||||||
|
var codes = Array.from(document.querySelectorAll('#codeList .badge'))
|
||||||
|
.map(function (b) { return b.textContent.trim(); }).join('\n');
|
||||||
|
navigator.clipboard.writeText(codes);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Enable Two-Factor{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-3">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<h3 class="mb-3"><i class="bi bi-shield-lock"></i> Enable two-factor authentication</h3>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<ol class="mb-3 ps-3">
|
||||||
|
<li class="mb-1">Install an authenticator app (Google Authenticator, Authy, 1Password, …).</li>
|
||||||
|
<li class="mb-1">Scan the QR code below, or enter the setup key manually.</li>
|
||||||
|
<li>Enter the 6-digit code the app shows to confirm and finish.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<div class="d-inline-block p-2 bg-white border rounded" style="width:220px;height:220px;">
|
||||||
|
{{ qr_svg | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted mb-1">Manual setup key</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" id="secretKey"
|
||||||
|
value="{{ secret }}" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copySecret()">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.mfa_setup') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="form-label fw-semibold">Enter the 6-digit code to confirm</label>
|
||||||
|
<div class="input-group input-group-lg mb-3">
|
||||||
|
<input type="text" name="code" class="form-control text-center"
|
||||||
|
inputmode="numeric" autocomplete="one-time-code" autofocus
|
||||||
|
placeholder="123456" style="letter-spacing:.4em;">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check2-circle"></i> Enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-lg"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copySecret() {
|
||||||
|
var el = document.getElementById('secretKey');
|
||||||
|
el.select();
|
||||||
|
el.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(el.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -157,6 +157,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<!-- Two-factor authentication (phase35) -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>Two-Factor Authentication</h6>
|
||||||
|
{% if current_user.mfa_enabled %}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if current_user.mfa_enabled %}
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Your account is protected with an authenticator app. You'll be asked
|
||||||
|
for a 6-digit code each time you sign in.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ url_for('auth.mfa_disable') }}"
|
||||||
|
onsubmit="return confirm('Disable two-factor authentication? Your account will be less secure.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="form-label small">Enter a current code or your password to disable:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="code" class="form-control" placeholder="6-digit code"
|
||||||
|
inputmode="numeric" autocomplete="off">
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="…or password"
|
||||||
|
autocomplete="off">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">Disable 2FA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Add a second layer of security. After entering your password you'll
|
||||||
|
confirm a one-time code from an authenticator app.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('auth.mfa_setup') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-shield-plus me-1"></i>Enable Two-Factor
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recent Inspections -->
|
<!-- Recent Inspections -->
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
app/utils/mfa.py
|
||||||
|
----------------
|
||||||
|
TOTP two-factor helpers, shared by the main app (admin/director accounts) and
|
||||||
|
the superadmin control panel.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
* TOTP secret is a base32 string (RFC 6238). It is a *shared* secret by nature;
|
||||||
|
we store it as-is, exactly like every standard authenticator integration.
|
||||||
|
* Recovery codes are one-time backup codes shown ONCE at enrollment and stored
|
||||||
|
only as salted hashes (werkzeug). A consumed code is removed from the list.
|
||||||
|
* The QR is rendered as an inline SVG (no Pillow / no external request), so it
|
||||||
|
works under the app's strict CSP and in the standalone panel app alike.
|
||||||
|
|
||||||
|
This module has no Flask-app or model dependencies — pure functions — so both
|
||||||
|
apps and the test-suite can use it directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
ISSUER = 'JQC'
|
||||||
|
_RECOVERY_CODE_COUNT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def new_secret() -> str:
|
||||||
|
"""Return a fresh base32 TOTP secret."""
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
|
def provisioning_uri(secret: str, account_name: str, issuer: str = ISSUER) -> str:
|
||||||
|
"""otpauth:// URI to encode in the enrollment QR / manual entry."""
|
||||||
|
return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_totp(secret: str, code: str) -> bool:
|
||||||
|
"""Validate a 6-digit TOTP code. valid_window=1 tolerates ±30s clock drift."""
|
||||||
|
if not secret or not code:
|
||||||
|
return False
|
||||||
|
code = code.strip().replace(' ', '')
|
||||||
|
if not code.isdigit():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return pyotp.totp.TOTP(secret).verify(code, valid_window=1)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def qr_svg(uri: str) -> str:
|
||||||
|
"""Return an inline SVG string for the given otpauth URI (no Pillow needed)."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage).save(buf)
|
||||||
|
return buf.getvalue().decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recovery_codes(n: int = _RECOVERY_CODE_COUNT):
|
||||||
|
"""Return (plaintext_codes, hashed_codes).
|
||||||
|
|
||||||
|
Plaintext is shown to the user ONCE. Only the hashes are persisted.
|
||||||
|
Codes are formatted xxxx-xxxx for readability.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
plaintext, hashed = [], []
|
||||||
|
for _ in range(n):
|
||||||
|
raw = secrets.token_hex(4) # 8 hex chars
|
||||||
|
code = f'{raw[:4]}-{raw[4:]}'
|
||||||
|
plaintext.append(code)
|
||||||
|
hashed.append(generate_password_hash(code))
|
||||||
|
return plaintext, hashed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise(code: str) -> str:
|
||||||
|
return (code or '').strip().lower().replace(' ', '')
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_consume_recovery(hashed_codes, code):
|
||||||
|
"""Check a recovery code against the stored hashes.
|
||||||
|
|
||||||
|
Returns (matched: bool, remaining_hashes: list). On a match the consumed
|
||||||
|
hash is removed so each recovery code works exactly once. `hashed_codes`
|
||||||
|
is never mutated in place.
|
||||||
|
"""
|
||||||
|
remaining = list(hashed_codes or [])
|
||||||
|
candidate = _normalise(code)
|
||||||
|
if not candidate:
|
||||||
|
return False, remaining
|
||||||
|
for h in list(remaining):
|
||||||
|
if check_password_hash(h, candidate):
|
||||||
|
remaining.remove(h)
|
||||||
|
return True, remaining
|
||||||
|
return False, remaining
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
control/mfa.py
|
||||||
|
--------------
|
||||||
|
TOTP two-factor helpers for the standalone superadmin panel.
|
||||||
|
|
||||||
|
Mirrors app/utils/mfa.py (the panel must not import from app/, per the MT-4
|
||||||
|
standalone-app boundary — same rationale as control/time_utils.py). Pure
|
||||||
|
functions; no Flask-app or model dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
ISSUER = 'JQC Admin'
|
||||||
|
_RECOVERY_CODE_COUNT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def new_secret() -> str:
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
|
def provisioning_uri(secret: str, account_name: str, issuer: str = ISSUER) -> str:
|
||||||
|
return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_totp(secret: str, code: str) -> bool:
|
||||||
|
if not secret or not code:
|
||||||
|
return False
|
||||||
|
code = code.strip().replace(' ', '')
|
||||||
|
if not code.isdigit():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return pyotp.totp.TOTP(secret).verify(code, valid_window=1)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def qr_svg(uri: str) -> str:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage).save(buf)
|
||||||
|
return buf.getvalue().decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recovery_codes(n: int = _RECOVERY_CODE_COUNT):
|
||||||
|
plaintext, hashed = [], []
|
||||||
|
for _ in range(n):
|
||||||
|
raw = secrets.token_hex(4)
|
||||||
|
code = f'{raw[:4]}-{raw[4:]}'
|
||||||
|
plaintext.append(code)
|
||||||
|
hashed.append(generate_password_hash(code))
|
||||||
|
return plaintext, hashed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise(code: str) -> str:
|
||||||
|
return (code or '').strip().lower().replace(' ', '')
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_consume_recovery(hashed_codes, code):
|
||||||
|
remaining = list(hashed_codes or [])
|
||||||
|
candidate = _normalise(code)
|
||||||
|
if not candidate:
|
||||||
|
return False, remaining
|
||||||
|
for h in list(remaining):
|
||||||
|
if check_password_hash(h, candidate):
|
||||||
|
remaining.remove(h)
|
||||||
|
return True, remaining
|
||||||
|
return False, remaining
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""control0005_superadmin_mfa
|
||||||
|
|
||||||
|
Adds opt-in TOTP two-factor columns to the `superadmins` table:
|
||||||
|
- mfa_enabled TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
- mfa_secret VARCHAR(64) NULL — base32 TOTP shared secret
|
||||||
|
- mfa_recovery_codes JSON NULL — hashed one-time backup codes
|
||||||
|
|
||||||
|
All columns are guarded by INFORMATION_SCHEMA existence checks so this
|
||||||
|
migration is safe to re-run. Existing superadmins default to disabled, so
|
||||||
|
nothing changes until an operator enrolls.
|
||||||
|
|
||||||
|
Revision ID: control0005_superadmin_mfa
|
||||||
|
Revises: control0004_dunning_tracking
|
||||||
|
Create Date: 2026-07-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'control0005_superadmin_mfa'
|
||||||
|
down_revision = 'control0004_dunning_tracking'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table, column):
|
||||||
|
result = op.get_bind().execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||||
|
" AND TABLE_NAME = :tbl "
|
||||||
|
" AND COLUMN_NAME = :col"
|
||||||
|
), {'tbl': table, 'col': column})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if not _column_exists('superadmins', 'mfa_enabled'):
|
||||||
|
op.add_column('superadmins', sa.Column('mfa_enabled', sa.Boolean(),
|
||||||
|
nullable=False, server_default='0'))
|
||||||
|
if not _column_exists('superadmins', 'mfa_secret'):
|
||||||
|
op.add_column('superadmins', sa.Column('mfa_secret', sa.String(64),
|
||||||
|
nullable=True))
|
||||||
|
if not _column_exists('superadmins', 'mfa_recovery_codes'):
|
||||||
|
op.add_column('superadmins', sa.Column('mfa_recovery_codes', sa.JSON(),
|
||||||
|
nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
for col in ('mfa_recovery_codes', 'mfa_secret', 'mfa_enabled'):
|
||||||
|
if _column_exists('superadmins', col):
|
||||||
|
op.drop_column('superadmins', col)
|
||||||
+6
-1
@@ -9,7 +9,7 @@ data-plane house style (db.Enum('a','b',...)).
|
|||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Boolean, DateTime, Text,
|
Column, Integer, String, Boolean, DateTime, Text, JSON,
|
||||||
ForeignKey, Enum, UniqueConstraint,
|
ForeignKey, Enum, UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -179,6 +179,11 @@ class Superadmin(ControlBase):
|
|||||||
active = Column(Boolean, nullable=False, default=True)
|
active = Column(Boolean, nullable=False, default=True)
|
||||||
created_at = Column(DateTime, nullable=False, default=now_eastern)
|
created_at = Column(DateTime, nullable=False, default=now_eastern)
|
||||||
|
|
||||||
|
# ── Two-factor (control0005) — opt-in TOTP, mirrors the data-plane User ──
|
||||||
|
mfa_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
mfa_secret = Column(String(64), nullable=True)
|
||||||
|
mfa_recovery_codes = Column(JSON, nullable=True)
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
|||||||
+156
-1
@@ -19,6 +19,8 @@ from flask import (
|
|||||||
from control.base import control_session
|
from control.base import control_session
|
||||||
from control.models import Superadmin, TenantAudit
|
from control.models import Superadmin, TenantAudit
|
||||||
from control.time_utils import now_eastern
|
from control.time_utils import now_eastern
|
||||||
|
from control import mfa
|
||||||
|
from control.panel.decorators import superadmin_required
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -58,6 +60,16 @@ def login():
|
|||||||
with control_session() as s:
|
with control_session() as s:
|
||||||
sa = s.query(Superadmin).filter_by(username=username, active=True).first()
|
sa = s.query(Superadmin).filter_by(username=username, active=True).first()
|
||||||
if sa and sa.check_password(password):
|
if sa and sa.check_password(password):
|
||||||
|
# ── Two-factor gate (control0005) ───────────────────────────
|
||||||
|
# Defer session establishment to the second-factor step when
|
||||||
|
# this superadmin has TOTP enabled.
|
||||||
|
if sa.mfa_enabled and sa.mfa_secret:
|
||||||
|
session['sa_mfa_pending_id'] = sa.id
|
||||||
|
session['sa_mfa_pending_username'] = sa.username
|
||||||
|
session['sa_mfa_next'] = _safe_next(request.args.get('next'))
|
||||||
|
logger.info('PANEL | mfa_challenge | superadmin=%s', username)
|
||||||
|
return redirect(url_for('auth.mfa_challenge'))
|
||||||
|
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
session['sa_id'] = sa.id
|
session['sa_id'] = sa.id
|
||||||
session['sa_username'] = sa.username
|
session['sa_username'] = sa.username
|
||||||
@@ -65,7 +77,7 @@ def login():
|
|||||||
_log_audit('LOGIN', superadmin_id=sa.id,
|
_log_audit('LOGIN', superadmin_id=sa.id,
|
||||||
details=f'superadmin={username}')
|
details=f'superadmin={username}')
|
||||||
flash(f'Welcome, {sa.username}!', 'success')
|
flash(f'Welcome, {sa.username}!', 'success')
|
||||||
next_url = request.args.get('next') or url_for('tenants.list_tenants')
|
next_url = _safe_next(request.args.get('next'))
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
logger.warning('PANEL | login_fail | username=%s', username)
|
logger.warning('PANEL | login_fail | username=%s', username)
|
||||||
@@ -74,6 +86,64 @@ def login():
|
|||||||
return render_template('panel/login.html', error=error)
|
return render_template('panel/login.html', error=error)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(target):
|
||||||
|
"""Only allow same-app relative redirects (open-redirect guard)."""
|
||||||
|
if target and target.startswith('/') and not target.startswith('//'):
|
||||||
|
return target
|
||||||
|
return url_for('tenants.list_tenants')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Two-factor challenge (control0005) ──────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/mfa', methods=['GET', 'POST'])
|
||||||
|
def mfa_challenge():
|
||||||
|
"""Second-factor step during panel login. Reached only after a correct
|
||||||
|
password for an MFA-enabled superadmin (identity held pending in session)."""
|
||||||
|
sa_id = session.get('sa_mfa_pending_id')
|
||||||
|
if not sa_id:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
code = request.form.get('code', '')
|
||||||
|
use_recovery = bool(request.form.get('recovery'))
|
||||||
|
verified, via, uname = False, 'totp', None
|
||||||
|
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.get(Superadmin, sa_id)
|
||||||
|
if sa is None or not sa.active or not sa.mfa_enabled:
|
||||||
|
session.pop('sa_mfa_pending_id', None)
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
if use_recovery:
|
||||||
|
matched, remaining = mfa.check_and_consume_recovery(
|
||||||
|
sa.mfa_recovery_codes, code)
|
||||||
|
if matched:
|
||||||
|
sa.mfa_recovery_codes = remaining # committed on block exit
|
||||||
|
verified, via = True, 'recovery'
|
||||||
|
elif mfa.verify_totp(sa.mfa_secret, code):
|
||||||
|
verified = True
|
||||||
|
uname = sa.username
|
||||||
|
|
||||||
|
if verified:
|
||||||
|
session.pop('sa_mfa_pending_id', None)
|
||||||
|
session.pop('sa_mfa_pending_username', None)
|
||||||
|
next_url = session.pop('sa_mfa_next', None) or url_for('tenants.list_tenants')
|
||||||
|
session.permanent = True
|
||||||
|
session['sa_id'] = sa_id
|
||||||
|
session['sa_username'] = uname
|
||||||
|
logger.info('PANEL | login | superadmin=%s | 2fa=%s', uname, via)
|
||||||
|
_log_audit('LOGIN', superadmin_id=sa_id,
|
||||||
|
details=f'superadmin={uname}; 2fa={via}')
|
||||||
|
flash(f'Welcome, {uname}!', 'success')
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
logger.warning('PANEL | mfa_fail | superadmin_id=%s | recovery=%s',
|
||||||
|
sa_id, use_recovery)
|
||||||
|
error = 'Invalid verification code.'
|
||||||
|
|
||||||
|
return render_template('panel/mfa_challenge.html', error=error)
|
||||||
|
|
||||||
|
|
||||||
# ── Logout ────────────────────────────────────────────────────────────────────
|
# ── Logout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/logout')
|
@bp.route('/logout')
|
||||||
@@ -86,3 +156,88 @@ def logout():
|
|||||||
_log_audit('LOGOUT', superadmin_id=sa_id, details=f'superadmin={sa_name}')
|
_log_audit('LOGOUT', superadmin_id=sa_id, details=f'superadmin={sa_name}')
|
||||||
flash('Logged out.', 'info')
|
flash('Logged out.', 'info')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Two-factor enrollment / management (control0005) ────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/security')
|
||||||
|
@superadmin_required
|
||||||
|
def security():
|
||||||
|
"""Superadmin security page — two-factor status + enable/disable controls."""
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.get(Superadmin, sa_id)
|
||||||
|
mfa_enabled = bool(sa and sa.mfa_enabled)
|
||||||
|
recovery_remaining = len(sa.mfa_recovery_codes or []) if sa else 0
|
||||||
|
return render_template('panel/security.html',
|
||||||
|
mfa_enabled=mfa_enabled,
|
||||||
|
recovery_remaining=recovery_remaining,
|
||||||
|
sa_username=session.get('sa_username'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/mfa/setup', methods=['GET', 'POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def mfa_setup():
|
||||||
|
"""Enroll the logged-in superadmin in TOTP. Candidate secret is held in the
|
||||||
|
session until a valid code proves enrollment, so a half-finished setup can
|
||||||
|
never lock the operator out."""
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.get(Superadmin, sa_id)
|
||||||
|
already = bool(sa and sa.mfa_enabled)
|
||||||
|
account_name = sa.email or sa.username if sa else 'superadmin'
|
||||||
|
|
||||||
|
if already:
|
||||||
|
flash('Two-factor is already enabled on your account.', 'info')
|
||||||
|
return redirect(url_for('auth.security'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
secret = session.get('sa_setup_secret')
|
||||||
|
code = request.form.get('code', '')
|
||||||
|
if secret and mfa.verify_totp(secret, code):
|
||||||
|
plaintext, hashed = mfa.generate_recovery_codes()
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.get(Superadmin, sa_id)
|
||||||
|
sa.mfa_secret = secret
|
||||||
|
sa.mfa_enabled = True
|
||||||
|
sa.mfa_recovery_codes = hashed
|
||||||
|
session.pop('sa_setup_secret', None)
|
||||||
|
logger.info('PANEL | mfa_enabled | superadmin_id=%s', sa_id)
|
||||||
|
_log_audit('MFA_ENABLE', superadmin_id=sa_id, details='enabled 2FA')
|
||||||
|
return render_template('panel/mfa_recovery.html', codes=plaintext,
|
||||||
|
sa_username=session.get('sa_username'))
|
||||||
|
flash('That code did not match. Check your device clock and try again.', 'danger')
|
||||||
|
|
||||||
|
secret = session.get('sa_setup_secret') or mfa.new_secret()
|
||||||
|
session['sa_setup_secret'] = secret
|
||||||
|
uri = mfa.provisioning_uri(secret, account_name)
|
||||||
|
return render_template('panel/mfa_setup.html', secret=secret, qr_svg=mfa.qr_svg(uri),
|
||||||
|
sa_username=session.get('sa_username'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/mfa/disable', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def mfa_disable():
|
||||||
|
"""Turn off two-factor. Requires a current TOTP code or the password so a
|
||||||
|
hijacked session cannot silently strip 2FA."""
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
code = request.form.get('code', '')
|
||||||
|
pw = request.form.get('password', '')
|
||||||
|
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.get(Superadmin, sa_id)
|
||||||
|
if not sa or not sa.mfa_enabled:
|
||||||
|
return redirect(url_for('auth.security'))
|
||||||
|
if not (mfa.verify_totp(sa.mfa_secret, code)
|
||||||
|
or (pw and sa.check_password(pw))):
|
||||||
|
flash('Enter a valid authenticator code or your password to disable 2FA.', 'danger')
|
||||||
|
return redirect(url_for('auth.security'))
|
||||||
|
sa.mfa_enabled = False
|
||||||
|
sa.mfa_secret = None
|
||||||
|
sa.mfa_recovery_codes = None
|
||||||
|
|
||||||
|
logger.info('PANEL | mfa_disabled | superadmin_id=%s', sa_id)
|
||||||
|
_log_audit('MFA_DISABLE', superadmin_id=sa_id, details='disabled 2FA')
|
||||||
|
flash('Two-factor authentication has been disabled.', 'success')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
|||||||
@@ -84,6 +84,10 @@
|
|||||||
class="{{ 'active' if request.endpoint == 'health.dashboard' else '' }}">
|
class="{{ 'active' if request.endpoint == 'health.dashboard' else '' }}">
|
||||||
<i class="bi bi-heart-pulse"></i> Health
|
<i class="bi bi-heart-pulse"></i> Health
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('auth.security') }}"
|
||||||
|
class="{{ 'active' if request.endpoint == 'auth.security' else '' }}">
|
||||||
|
<i class="bi bi-shield-lock"></i> Security
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sa-footer">
|
<div class="sa-footer">
|
||||||
{% if sa_username %}
|
{% if sa_username %}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Two-Factor Verification — JQC Control</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-card { background: #1e293b; border-radius: 14px; padding: 2.5rem 2.25rem;
|
||||||
|
width: 100%; max-width: 380px; box-shadow: 0 20px 60px rgba(0,0,0,.4); }
|
||||||
|
.login-card .logo { font-size: 2rem; color: #3b82f6; }
|
||||||
|
.login-card h1 { color: #f1f5f9; font-size: 1.3rem; font-weight: 700; margin-top: .5rem; }
|
||||||
|
.login-card .sub { color: #64748b; font-size: .85rem; margin-bottom: 1.5rem; }
|
||||||
|
.login-card label { color: #94a3b8; font-size: .85rem; }
|
||||||
|
.login-card .form-control { background: #0f172a; border-color: #334155; color: #f1f5f9; text-align: center; letter-spacing: .35em; }
|
||||||
|
.login-card .form-control:focus { background: #0f172a; border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25); color: #f1f5f9; }
|
||||||
|
.form-check-label { color: #94a3b8; font-size: .8rem; }
|
||||||
|
.btn-panel { background: #3b82f6; border-color: #3b82f6; color: #fff; font-weight: 600; }
|
||||||
|
.btn-panel:hover { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||||
|
.error-box { background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5;
|
||||||
|
border-radius: 8px; padding: .65rem 1rem; font-size: .85rem; margin-bottom: 1rem; }
|
||||||
|
.footer-note { color: #334155; font-size: .72rem; text-align: center; margin-top: 1.5rem; }
|
||||||
|
.footer-note a { color: #475569; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo"><i class="bi bi-shield-lock-fill"></i></div>
|
||||||
|
<h1>Two-factor verification</h1>
|
||||||
|
<p class="sub">Enter the 6-digit code from your authenticator app.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-box"><i class="bi bi-exclamation-triangle me-1"></i>{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.mfa_challenge') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="code" class="form-control form-control-lg"
|
||||||
|
inputmode="numeric" autocomplete="one-time-code" autofocus
|
||||||
|
placeholder="123456" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="recovery" id="useRecovery" value="1">
|
||||||
|
<label class="form-check-label" for="useRecovery">Use a recovery code instead</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-panel w-100">
|
||||||
|
<i class="bi bi-check2-circle me-1"></i> Verify
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="footer-note"><a href="{{ url_for('auth.login') }}">Back to login</a></p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var chk = document.getElementById('useRecovery');
|
||||||
|
var box = document.querySelector('input[name="code"]');
|
||||||
|
if (chk && box) chk.addEventListener('change', function () {
|
||||||
|
box.style.letterSpacing = chk.checked ? '.15em' : '.35em';
|
||||||
|
box.setAttribute('placeholder', chk.checked ? 'xxxx-xxxx' : '123456');
|
||||||
|
box.setAttribute('inputmode', chk.checked ? 'text' : 'numeric');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "panel/base.html" %}
|
||||||
|
{% block title %}Recovery Codes — JQC Control{% endblock %}
|
||||||
|
{% block page_title %}Two-Factor Authentication{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="bi bi-shield-check"></i> <strong>Two-factor authentication is now enabled.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="mb-2"><i class="bi bi-key"></i> Save your recovery codes</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
Each code works <strong>once</strong> if you lose access to your authenticator.
|
||||||
|
Store them somewhere safe — <strong>they will not be shown again.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-light border rounded p-3 mb-3">
|
||||||
|
<div class="row row-cols-2 g-2 font-monospace text-center" id="codeList">
|
||||||
|
{% for code in codes %}
|
||||||
|
<div class="col"><span class="badge bg-white text-dark border fs-6 w-100 py-2">{{ code }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="copyCodes()">
|
||||||
|
<i class="bi bi-clipboard"></i> Copy codes
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('tenants.list_tenants') }}" class="btn btn-primary ms-auto">
|
||||||
|
<i class="bi bi-check-lg"></i> I've saved them — Done
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function copyCodes() {
|
||||||
|
var codes = Array.from(document.querySelectorAll('#codeList .badge'))
|
||||||
|
.map(function (b) { return b.textContent.trim(); }).join('\n');
|
||||||
|
navigator.clipboard.writeText(codes);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "panel/base.html" %}
|
||||||
|
{% block title %}Enable Two-Factor — JQC Control{% endblock %}
|
||||||
|
{% block page_title %}Two-Factor Authentication{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<h4 class="mb-3"><i class="bi bi-shield-lock"></i> Enable two-factor authentication</h4>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<ol class="mb-3 ps-3">
|
||||||
|
<li class="mb-1">Install an authenticator app (Google Authenticator, Authy, 1Password…).</li>
|
||||||
|
<li class="mb-1">Scan the QR code, or enter the setup key manually.</li>
|
||||||
|
<li>Enter the 6-digit code to confirm and finish.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<div class="d-inline-block p-2 bg-white border rounded" style="width:220px;height:220px;">
|
||||||
|
{{ qr_svg | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted mb-1">Manual setup key</label>
|
||||||
|
<input type="text" class="form-control font-monospace" value="{{ secret }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.mfa_setup') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="form-label fw-semibold">Enter the 6-digit code to confirm</label>
|
||||||
|
<div class="input-group input-group-lg mb-2">
|
||||||
|
<input type="text" name="code" class="form-control text-center" inputmode="numeric"
|
||||||
|
autocomplete="one-time-code" autofocus placeholder="123456" style="letter-spacing:.3em;">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check2-circle"></i> Enable</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('tenants.list_tenants') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-lg"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "panel/base.html" %}
|
||||||
|
{% block title %}Security — JQC Control{% endblock %}
|
||||||
|
{% block page_title %}Security{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>Two-Factor Authentication</h6>
|
||||||
|
{% if mfa_enabled %}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if mfa_enabled %}
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Your superadmin account requires a 6-digit code at every sign-in.
|
||||||
|
Recovery codes remaining: <strong>{{ recovery_remaining }}</strong>.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ url_for('auth.mfa_disable') }}"
|
||||||
|
onsubmit="return confirm('Disable two-factor on the control panel? This lowers security for ALL tenants.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="form-label small">Enter a current code or your password to disable:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="code" class="form-control" placeholder="6-digit code"
|
||||||
|
inputmode="numeric" autocomplete="off">
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="…or password"
|
||||||
|
autocomplete="off">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">Disable 2FA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Two-factor is strongly recommended — the control panel manages every
|
||||||
|
tenant. After your password you'll confirm a one-time code from an
|
||||||
|
authenticator app.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('auth.mfa_setup') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-shield-plus me-1"></i>Enable Two-Factor
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""phase35 — user two-factor (TOTP) columns
|
||||||
|
|
||||||
|
Adds opt-in TOTP MFA to user accounts (enforced for admin/director at login
|
||||||
|
when enabled). Additive + nullable; existing users default to mfa_enabled=0
|
||||||
|
so nothing changes until a user enrolls.
|
||||||
|
|
||||||
|
Guarded with INFORMATION_SCHEMA column-existence checks — safe to re-run
|
||||||
|
across every tenant DB (CLAUDE.md rule 14).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = 'phase35_user_mfa'
|
||||||
|
down_revision = 'phase34_inspection_schedules'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _col_exists(bind, table: str, column: str) -> bool:
|
||||||
|
result = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c"
|
||||||
|
), {'t': table, 'c': column})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _col_exists(bind, 'users', 'mfa_enabled'):
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE users ADD COLUMN mfa_enabled TINYINT(1) NOT NULL DEFAULT 0"
|
||||||
|
))
|
||||||
|
if not _col_exists(bind, 'users', 'mfa_secret'):
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE users ADD COLUMN mfa_secret VARCHAR(64) NULL"
|
||||||
|
))
|
||||||
|
if not _col_exists(bind, 'users', 'mfa_recovery_codes'):
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE users ADD COLUMN mfa_recovery_codes JSON NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for col in ('mfa_recovery_codes', 'mfa_secret', 'mfa_enabled'):
|
||||||
|
if _col_exists(bind, 'users', col):
|
||||||
|
op.execute(sa.text(f"ALTER TABLE users DROP COLUMN {col}"))
|
||||||
@@ -23,3 +23,5 @@ pyJWT
|
|||||||
openpyxl
|
openpyxl
|
||||||
groq
|
groq
|
||||||
stripe
|
stripe
|
||||||
|
pyotp
|
||||||
|
qrcode
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
tests/test_mfa.py
|
||||||
|
-----------------
|
||||||
|
End-to-end tests for phase35 two-factor authentication (main app).
|
||||||
|
|
||||||
|
Drives the real login → challenge flow through the test client and asserts:
|
||||||
|
|
||||||
|
* an MFA-enabled account is NOT authenticated until the code step passes
|
||||||
|
* a correct TOTP completes login; a wrong code does not
|
||||||
|
* a recovery code works once and is then consumed
|
||||||
|
* a non-MFA account logs in directly (no challenge, no regression)
|
||||||
|
* the mfa utility verifies/rejects codes correctly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Fresh schema + client, rate limiter disabled for deterministic runs."""
|
||||||
|
from app import db, limiter
|
||||||
|
limiter.enabled = False
|
||||||
|
with app.app_context():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
yield app.test_client()
|
||||||
|
db.session.remove()
|
||||||
|
limiter.enabled = True
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(username='admin1', role='admin', mfa=False):
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.utils import mfa as mfa_util
|
||||||
|
|
||||||
|
u = User(username=username, full_name='Ada Admin',
|
||||||
|
email=f'{username}@example.com', role=role, active=True,
|
||||||
|
password_set=True)
|
||||||
|
u.set_password('pw-correct')
|
||||||
|
secret = None
|
||||||
|
if mfa:
|
||||||
|
secret = mfa_util.new_secret()
|
||||||
|
u.mfa_secret = secret
|
||||||
|
u.mfa_enabled = True
|
||||||
|
# store two known recovery codes (hashed)
|
||||||
|
u.mfa_recovery_codes = [
|
||||||
|
__import__('werkzeug.security', fromlist=['generate_password_hash'])
|
||||||
|
.generate_password_hash(c) for c in ('aaaa-bbbb', 'cccc-dddd')
|
||||||
|
]
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
return u.id, secret
|
||||||
|
|
||||||
|
|
||||||
|
def _is_authenticated(client):
|
||||||
|
"""Profile page is login-gated: 200 == authenticated, 302 == not."""
|
||||||
|
return client.get('/auth/profile').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_mfa_user_must_pass_second_factor(client):
|
||||||
|
uid, secret = _make_user(mfa=True)
|
||||||
|
|
||||||
|
# Correct password → redirected to the challenge, NOT yet authenticated.
|
||||||
|
resp = client.post('/auth/login',
|
||||||
|
data={'username': 'admin1', 'password': 'pw-correct'})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/mfa' in resp.headers['Location']
|
||||||
|
assert not _is_authenticated(client)
|
||||||
|
|
||||||
|
# Wrong code keeps us out.
|
||||||
|
bad = client.post('/auth/mfa', data={'code': '000000'})
|
||||||
|
assert not _is_authenticated(client)
|
||||||
|
|
||||||
|
# Correct TOTP completes login.
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
ok = client.post('/auth/mfa', data={'code': code})
|
||||||
|
assert ok.status_code == 302
|
||||||
|
assert _is_authenticated(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_code_logs_in_and_is_consumed(client):
|
||||||
|
uid, secret = _make_user(mfa=True)
|
||||||
|
|
||||||
|
client.post('/auth/login', data={'username': 'admin1', 'password': 'pw-correct'})
|
||||||
|
# Use a recovery code.
|
||||||
|
r = client.post('/auth/mfa', data={'code': 'aaaa-bbbb', 'recovery': '1'})
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert _is_authenticated(client)
|
||||||
|
|
||||||
|
# The consumed code no longer works on a fresh login.
|
||||||
|
client.get('/auth/logout')
|
||||||
|
client.post('/auth/login', data={'username': 'admin1', 'password': 'pw-correct'})
|
||||||
|
reuse = client.post('/auth/mfa', data={'code': 'aaaa-bbbb', 'recovery': '1'})
|
||||||
|
assert not _is_authenticated(client)
|
||||||
|
# The other, unused code still works.
|
||||||
|
good = client.post('/auth/mfa', data={'code': 'cccc-dddd', 'recovery': '1'})
|
||||||
|
assert _is_authenticated(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_mfa_user_logs_in_directly(client):
|
||||||
|
_make_user(username='pm1', role='project_manager', mfa=False)
|
||||||
|
resp = client.post('/auth/login',
|
||||||
|
data={'username': 'pm1', 'password': 'pw-correct'})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/mfa' not in resp.headers['Location']
|
||||||
|
assert _is_authenticated(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_never_reaches_challenge(client):
|
||||||
|
_make_user(mfa=True)
|
||||||
|
client.post('/auth/login', data={'username': 'admin1', 'password': 'WRONG'})
|
||||||
|
# No pending challenge, not authenticated.
|
||||||
|
assert client.get('/auth/mfa').status_code == 302 # bounced back to login
|
||||||
|
assert not _is_authenticated(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mfa_util_verifies_and_rejects():
|
||||||
|
from app.utils import mfa
|
||||||
|
s = mfa.new_secret()
|
||||||
|
assert mfa.verify_totp(s, pyotp.TOTP(s).now()) is True
|
||||||
|
assert mfa.verify_totp(s, '000000') is False
|
||||||
|
assert mfa.verify_totp(s, 'not-a-code') is False
|
||||||
|
assert mfa.verify_totp('', '123456') is False
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
tests/test_panel_mfa.py
|
||||||
|
-----------------------
|
||||||
|
Behaviour tests for the superadmin-panel two-factor helper (control/mfa.py).
|
||||||
|
|
||||||
|
The panel routes are thin wrappers over this module (and mirror app/utils/mfa.py,
|
||||||
|
which is covered by tests/test_mfa.py). These tests lock the panel helper's
|
||||||
|
crypto behaviour independently so the control-plane mirror can't silently drift.
|
||||||
|
|
||||||
|
Pure logic — no control DB, no MySQL, no Flask app required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
|
||||||
|
def test_totp_round_trip_and_rejection():
|
||||||
|
from control import mfa
|
||||||
|
s = mfa.new_secret()
|
||||||
|
assert mfa.verify_totp(s, pyotp.TOTP(s).now()) is True
|
||||||
|
assert mfa.verify_totp(s, '000000') is False
|
||||||
|
assert mfa.verify_totp(s, 'abc') is False
|
||||||
|
assert mfa.verify_totp('', '123456') is False
|
||||||
|
assert mfa.verify_totp(s, None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_codes_are_hashed_single_use():
|
||||||
|
from control import mfa
|
||||||
|
plaintext, hashed = mfa.generate_recovery_codes()
|
||||||
|
assert len(plaintext) == len(hashed) == 10
|
||||||
|
# Never stored in plaintext.
|
||||||
|
assert all(p not in hashed for p in plaintext)
|
||||||
|
|
||||||
|
matched, remaining = mfa.check_and_consume_recovery(hashed, plaintext[3])
|
||||||
|
assert matched is True
|
||||||
|
assert len(remaining) == 9
|
||||||
|
|
||||||
|
# Consumed code cannot be reused.
|
||||||
|
reused, _ = mfa.check_and_consume_recovery(remaining, plaintext[3])
|
||||||
|
assert reused is False
|
||||||
|
|
||||||
|
# A different, unused code still works.
|
||||||
|
ok, remaining2 = mfa.check_and_consume_recovery(remaining, plaintext[0])
|
||||||
|
assert ok is True
|
||||||
|
assert len(remaining2) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_provisioning_uri_and_qr_are_well_formed():
|
||||||
|
from control import mfa
|
||||||
|
s = mfa.new_secret()
|
||||||
|
uri = mfa.provisioning_uri(s, 'admin@example.com')
|
||||||
|
assert uri.startswith('otpauth://totp/')
|
||||||
|
assert 'JQC%20Admin' in uri or 'JQC Admin' in uri
|
||||||
|
svg = mfa.qr_svg(uri)
|
||||||
|
assert svg.lstrip().startswith('<?xml')
|
||||||
|
assert '<svg' in svg
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
tests/test_tenant_isolation.py
|
||||||
|
------------------------------
|
||||||
|
Cross-tenant data-isolation guard (MT-1 core mechanism).
|
||||||
|
|
||||||
|
The entire database-per-tenant isolation guarantee rests on ONE thing:
|
||||||
|
`RoutingSession.get_bind()` binding every query to `g.tenant_engine` for the
|
||||||
|
current request. If a future refactor breaks that — returns a stale engine, or
|
||||||
|
falls back to the default when a tenant is active — tenant A would silently read
|
||||||
|
or write tenant B's database. That is the single worst, hardest-to-detect
|
||||||
|
failure mode for the SaaS, so it gets a dedicated test.
|
||||||
|
|
||||||
|
These tests construct two independent in-memory SQLite databases (standing in
|
||||||
|
for two tenant DBs), seed each with distinct data, then flip `g.tenant_engine`
|
||||||
|
and assert `db.session` reads and writes land in — and only in — the selected
|
||||||
|
tenant's database. No MySQL required; the routing logic is engine-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import g
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlalchemy.orm import Session as SASession
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tenant_engine():
|
||||||
|
"""A standalone in-memory SQLite engine that persists across connections.
|
||||||
|
|
||||||
|
StaticPool keeps a single underlying connection so the in-memory schema and
|
||||||
|
rows survive between checkouts (a plain sqlite:// memory DB is per-connection
|
||||||
|
and would appear empty on the next query).
|
||||||
|
"""
|
||||||
|
return create_engine(
|
||||||
|
'sqlite://',
|
||||||
|
connect_args={'check_same_thread': False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_tenants(app):
|
||||||
|
"""Two isolated tenant DBs: A has facility 'ACME-HQ', B has 'Globex-Plant'."""
|
||||||
|
from app import db
|
||||||
|
from app.models.facility import Facility
|
||||||
|
|
||||||
|
eng_a = _make_tenant_engine()
|
||||||
|
eng_b = _make_tenant_engine()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.metadata.create_all(eng_a)
|
||||||
|
db.metadata.create_all(eng_b)
|
||||||
|
|
||||||
|
sa = SASession(eng_a)
|
||||||
|
sa.add(Facility(name='ACME-HQ', active=True))
|
||||||
|
sa.commit(); sa.close()
|
||||||
|
|
||||||
|
sb = SASession(eng_b)
|
||||||
|
sb.add(Facility(name='Globex-Plant', active=True))
|
||||||
|
sb.commit(); sb.close()
|
||||||
|
|
||||||
|
yield eng_a, eng_b
|
||||||
|
|
||||||
|
eng_a.dispose()
|
||||||
|
eng_b.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _facility_names():
|
||||||
|
from app import db
|
||||||
|
from app.models.facility import Facility
|
||||||
|
return {f.name for f in db.session.query(Facility).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_are_routed_to_the_active_tenant(app, two_tenants):
|
||||||
|
"""db.session reads only ever see the tenant bound via g.tenant_engine."""
|
||||||
|
from app import db
|
||||||
|
eng_a, eng_b = two_tenants
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Bind tenant A.
|
||||||
|
g.tenant_engine = eng_a
|
||||||
|
db.session.remove() # force a fresh bind on next query
|
||||||
|
names = _facility_names()
|
||||||
|
assert names == {'ACME-HQ'}
|
||||||
|
assert 'Globex-Plant' not in names # B's data must never leak into A
|
||||||
|
|
||||||
|
# Switch to tenant B — same session machinery, different engine.
|
||||||
|
g.tenant_engine = eng_b
|
||||||
|
db.session.remove()
|
||||||
|
names = _facility_names()
|
||||||
|
assert names == {'Globex-Plant'}
|
||||||
|
assert 'ACME-HQ' not in names # A's data must never leak into B
|
||||||
|
|
||||||
|
|
||||||
|
def test_writes_do_not_leak_across_tenants(app, two_tenants):
|
||||||
|
"""A write performed while tenant B is active must not touch tenant A."""
|
||||||
|
from app import db
|
||||||
|
from app.models.facility import Facility
|
||||||
|
eng_a, eng_b = two_tenants
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Insert into B.
|
||||||
|
g.tenant_engine = eng_b
|
||||||
|
db.session.remove()
|
||||||
|
db.session.add(Facility(name='Globex-NewSite', active=True))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# A must be completely unaffected by B's write.
|
||||||
|
g.tenant_engine = eng_a
|
||||||
|
db.session.remove()
|
||||||
|
names_a = _facility_names()
|
||||||
|
assert names_a == {'ACME-HQ'}
|
||||||
|
assert 'Globex-NewSite' not in names_a
|
||||||
|
|
||||||
|
# And B genuinely received the new row.
|
||||||
|
g.tenant_engine = eng_b
|
||||||
|
db.session.remove()
|
||||||
|
names_b = _facility_names()
|
||||||
|
assert 'Globex-NewSite' in names_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_routing_reads_g_dynamically_per_request(app, two_tenants):
|
||||||
|
"""Re-binding g.tenant_engine within the app context re-routes subsequent
|
||||||
|
queries — proving get_bind() reads g live, not a cached value."""
|
||||||
|
from app import db
|
||||||
|
eng_a, eng_b = two_tenants
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
for engine, expected in ((eng_a, 'ACME-HQ'), (eng_b, 'Globex-Plant'),
|
||||||
|
(eng_a, 'ACME-HQ')):
|
||||||
|
g.tenant_engine = engine
|
||||||
|
db.session.remove()
|
||||||
|
assert _facility_names() == {expected}
|
||||||
Reference in New Issue
Block a user