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 %} +
+ Enter the 6-digit code from your authenticator app. +
++ Each code works once if you lose access to your authenticator + app. Store them somewhere safe — they will not be shown again. +
+ ++ 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 %} +