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
+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 %}