diff --git a/CLAUDE.md b/CLAUDE.md index 960c6c9..f24dff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`. diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index dddad18..4b68e8e 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -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 diff --git a/app/models/user.py b/app/models/user.py index ead3542..f427d1e 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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') diff --git a/app/routes/auth.py b/app/routes/auth.py index 6d6872d..dc5b22c 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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(): diff --git a/app/templates/auth/mfa_challenge.html b/app/templates/auth/mfa_challenge.html new file mode 100644 index 0000000..aa35798 --- /dev/null +++ b/app/templates/auth/mfa_challenge.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Two-Factor Verification{% endblock %} +{% block content %} +
+
+
+
+
+ +

Two-factor verification

+

+ Enter the 6-digit code from your authenticator app. +

+
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ + +
+
+
+
+ + +{% endblock %} diff --git a/app/templates/auth/mfa_recovery.html b/app/templates/auth/mfa_recovery.html new file mode 100644 index 0000000..f845fbe --- /dev/null +++ b/app/templates/auth/mfa_recovery.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Recovery Codes{% endblock %} +{% block content %} +
+
+
+ + Two-factor authentication is now enabled. +
+ +
+
+

Save your recovery codes

+

+ Each code works once if you lose access to your authenticator + app. Store them somewhere safe — they will not be shown again. +

+ +
+
+ {% for code in codes %} +
{{ code }}
+ {% endfor %} +
+
+ +
+ + + I've saved them — Done + +
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/auth/mfa_setup.html b/app/templates/auth/mfa_setup.html new file mode 100644 index 0000000..8909b56 --- /dev/null +++ b/app/templates/auth/mfa_setup.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Enable Two-Factor{% endblock %} +{% block content %} +
+
+

Enable two-factor authentication

+ +
+
+
    +
  1. Install an authenticator app (Google Authenticator, Authy, 1Password, …).
  2. +
  3. Scan the QR code below, or enter the setup key manually.
  4. +
  5. Enter the 6-digit code the app shows to confirm and finish.
  6. +
+ +
+
+ {{ qr_svg | safe }} +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+
+
+ + + Cancel + +
+
+ + +{% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index ae96513..fc0a964 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -157,6 +157,48 @@ + {% if current_user.role in ['admin', 'director'] %} + +
+
+
Two-Factor Authentication
+ {% if current_user.mfa_enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
+
+ {% if current_user.mfa_enabled %} +

+ Your account is protected with an authenticator app. You'll be asked + for a 6-digit code each time you sign in. +

+
+ + +
+ + + +
+
+ {% else %} +

+ Add a second layer of security. After entering your password you'll + confirm a one-time code from an authenticator app. +

+ + Enable Two-Factor + + {% endif %} +
+
+ {% endif %} +
diff --git a/app/utils/mfa.py b/app/utils/mfa.py new file mode 100644 index 0000000..502a534 --- /dev/null +++ b/app/utils/mfa.py @@ -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 diff --git a/control/mfa.py b/control/mfa.py new file mode 100644 index 0000000..5826c67 --- /dev/null +++ b/control/mfa.py @@ -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 diff --git a/control/migrations/versions/control0005_superadmin_mfa.py b/control/migrations/versions/control0005_superadmin_mfa.py new file mode 100644 index 0000000..e644826 --- /dev/null +++ b/control/migrations/versions/control0005_superadmin_mfa.py @@ -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) diff --git a/control/models.py b/control/models.py index 3965d47..45b6fe3 100644 --- a/control/models.py +++ b/control/models.py @@ -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) diff --git a/control/panel/auth.py b/control/panel/auth.py index ff96b28..ffea262 100644 --- a/control/panel/auth.py +++ b/control/panel/auth.py @@ -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')) diff --git a/control/panel/templates/panel/base.html b/control/panel/templates/panel/base.html index f7e3f5d..88ad8b3 100644 --- a/control/panel/templates/panel/base.html +++ b/control/panel/templates/panel/base.html @@ -84,6 +84,10 @@ class="{{ 'active' if request.endpoint == 'health.dashboard' else '' }}"> Health + + Security +