July 4 - Implement TOTP 2FA

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