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), 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/`.
+3 -2
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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():
+64
View File
@@ -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 %}
+47
View File
@@ -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 %}
+62
View File
@@ -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 %}
+42
View File
@@ -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">
+96
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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
View File
@@ -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'))
+4
View File
@@ -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 %}
+48
View File
@@ -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}"))
+2
View File
@@ -23,3 +23,5 @@ pyJWT
openpyxl openpyxl
groq groq
stripe stripe
pyotp
qrcode
+124
View File
@@ -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
+55
View File
@@ -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
+133
View File
@@ -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}