July 4 - Implement TOTP 2FA
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
control/mfa.py
|
||||
--------------
|
||||
TOTP two-factor helpers for the standalone superadmin panel.
|
||||
|
||||
Mirrors app/utils/mfa.py (the panel must not import from app/, per the MT-4
|
||||
standalone-app boundary — same rationale as control/time_utils.py). Pure
|
||||
functions; no Flask-app or model dependency.
|
||||
"""
|
||||
|
||||
import io
|
||||
import secrets
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
ISSUER = 'JQC Admin'
|
||||
_RECOVERY_CODE_COUNT = 10
|
||||
|
||||
|
||||
def new_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def provisioning_uri(secret: str, account_name: str, issuer: str = ISSUER) -> str:
|
||||
return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer)
|
||||
|
||||
|
||||
def verify_totp(secret: str, code: str) -> bool:
|
||||
if not secret or not code:
|
||||
return False
|
||||
code = code.strip().replace(' ', '')
|
||||
if not code.isdigit():
|
||||
return False
|
||||
try:
|
||||
return pyotp.totp.TOTP(secret).verify(code, valid_window=1)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def qr_svg(uri: str) -> str:
|
||||
buf = io.BytesIO()
|
||||
qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage).save(buf)
|
||||
return buf.getvalue().decode('utf-8')
|
||||
|
||||
|
||||
def generate_recovery_codes(n: int = _RECOVERY_CODE_COUNT):
|
||||
plaintext, hashed = [], []
|
||||
for _ in range(n):
|
||||
raw = secrets.token_hex(4)
|
||||
code = f'{raw[:4]}-{raw[4:]}'
|
||||
plaintext.append(code)
|
||||
hashed.append(generate_password_hash(code))
|
||||
return plaintext, hashed
|
||||
|
||||
|
||||
def _normalise(code: str) -> str:
|
||||
return (code or '').strip().lower().replace(' ', '')
|
||||
|
||||
|
||||
def check_and_consume_recovery(hashed_codes, code):
|
||||
remaining = list(hashed_codes or [])
|
||||
candidate = _normalise(code)
|
||||
if not candidate:
|
||||
return False, remaining
|
||||
for h in list(remaining):
|
||||
if check_password_hash(h, candidate):
|
||||
remaining.remove(h)
|
||||
return True, remaining
|
||||
return False, remaining
|
||||
@@ -0,0 +1,51 @@
|
||||
"""control0005_superadmin_mfa
|
||||
|
||||
Adds opt-in TOTP two-factor columns to the `superadmins` table:
|
||||
- mfa_enabled TINYINT(1) NOT NULL DEFAULT 0
|
||||
- mfa_secret VARCHAR(64) NULL — base32 TOTP shared secret
|
||||
- mfa_recovery_codes JSON NULL — hashed one-time backup codes
|
||||
|
||||
All columns are guarded by INFORMATION_SCHEMA existence checks so this
|
||||
migration is safe to re-run. Existing superadmins default to disabled, so
|
||||
nothing changes until an operator enrolls.
|
||||
|
||||
Revision ID: control0005_superadmin_mfa
|
||||
Revises: control0004_dunning_tracking
|
||||
Create Date: 2026-07-04
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'control0005_superadmin_mfa'
|
||||
down_revision = 'control0004_dunning_tracking'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
result = op.get_bind().execute(sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
" AND TABLE_NAME = :tbl "
|
||||
" AND COLUMN_NAME = :col"
|
||||
), {'tbl': table, 'col': column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _column_exists('superadmins', 'mfa_enabled'):
|
||||
op.add_column('superadmins', sa.Column('mfa_enabled', sa.Boolean(),
|
||||
nullable=False, server_default='0'))
|
||||
if not _column_exists('superadmins', 'mfa_secret'):
|
||||
op.add_column('superadmins', sa.Column('mfa_secret', sa.String(64),
|
||||
nullable=True))
|
||||
if not _column_exists('superadmins', 'mfa_recovery_codes'):
|
||||
op.add_column('superadmins', sa.Column('mfa_recovery_codes', sa.JSON(),
|
||||
nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
for col in ('mfa_recovery_codes', 'mfa_secret', 'mfa_enabled'):
|
||||
if _column_exists('superadmins', col):
|
||||
op.drop_column('superadmins', col)
|
||||
+6
-1
@@ -9,7 +9,7 @@ data-plane house style (db.Enum('a','b',...)).
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from 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
@@ -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'))
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user