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
+130 -1
View File
@@ -1,10 +1,11 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session
from urllib.parse import urlparse
from flask_login import login_user, logout_user, login_required, current_user
from app import db, limiter
from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
from app.utils import mfa
import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
from app.tenancy.gates import quota_soft_check
@@ -35,6 +36,17 @@ def login():
'warning'
)
return render_template('auth/login.html', form=form)
# ── Two-factor gate (phase35) ────────────────────────────────
# If this account has TOTP enabled, defer login_user() to the
# second-factor step. Password is verified; identity is NOT yet
# established until the code is confirmed at /auth/mfa.
if user.mfa_enabled and user.mfa_secret:
session['mfa_pending_user_id'] = user.id
session['mfa_pending_remember'] = bool(form.remember_me.data)
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
logger.info('MFA_CHALLENGE | user=%s', user.username)
return redirect(url_for('auth.mfa_challenge'))
login_user(user, remember=form.remember_me.data)
# Use validated next URL — never redirect blindly to request.args['next']
next_page = safe_redirect_url(request.args.get('next'))
@@ -59,6 +71,123 @@ def logout():
return redirect(url_for('auth.login'))
# ── Two-factor authentication (phase35) ─────────────────────────────────────
@bp.route('/mfa', methods=['GET', 'POST'])
@limiter.limit('10 per minute; 3 per second')
def mfa_challenge():
"""Second-factor step during login. Reached only after a correct password
for an MFA-enabled account (identity is held pending in the session)."""
uid = session.get('mfa_pending_user_id')
if not uid:
return redirect(url_for('auth.login'))
user = db.session.get(User, uid)
if user is None or not user.mfa_enabled or not user.active:
session.pop('mfa_pending_user_id', None)
return redirect(url_for('auth.login'))
if request.method == 'POST':
code = request.form.get('code', '')
use_recovery = bool(request.form.get('recovery'))
verified = False
via = 'totp'
if use_recovery:
matched, remaining = mfa.check_and_consume_recovery(user.mfa_recovery_codes, code)
if matched:
user.mfa_recovery_codes = remaining
db.session.commit()
verified = True
via = 'recovery'
else:
verified = mfa.verify_totp(user.mfa_secret, code)
if verified:
remember = session.pop('mfa_pending_remember', False)
next_page = session.pop('mfa_pending_next', None)
session.pop('mfa_pending_user_id', None)
login_user(user, remember=remember)
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
if via == 'recovery':
remaining_n = len(user.mfa_recovery_codes or [])
flash(f'Signed in with a recovery code. {remaining_n} recovery '
f'code(s) remaining.', 'warning')
else:
flash(f'Welcome back, {user.username}!', 'success')
return redirect(safe_redirect_url(next_page))
logger.warning('MFA_FAILED | user=%s ip=%s recovery=%s',
user.username, request.remote_addr, use_recovery)
flash('Invalid verification code. Please try again.', 'danger')
return render_template('auth/mfa_challenge.html')
@bp.route('/mfa/setup', methods=['GET', 'POST'])
@login_required
@supervisor_required # admin + director
def mfa_setup():
"""Enroll the current account in TOTP two-factor. Opt-in.
The candidate secret is held in the session until the user proves they can
generate a valid code, so a half-finished enrollment never locks anyone out.
"""
if current_user.mfa_enabled:
flash('Two-factor authentication is already enabled on your account.', 'info')
return redirect(url_for('auth.profile'))
if request.method == 'POST':
secret = session.get('mfa_setup_secret')
code = request.form.get('code', '')
if not secret:
flash('Your setup session expired. Please start again.', 'warning')
return redirect(url_for('auth.mfa_setup'))
if mfa.verify_totp(secret, code):
plaintext, hashed = mfa.generate_recovery_codes()
current_user.mfa_secret = secret
current_user.mfa_enabled = True
current_user.mfa_recovery_codes = hashed
db.session.commit()
session.pop('mfa_setup_secret', None)
log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
'enabled two-factor authentication')
logger.info('MFA_ENABLED | user=%s', current_user.username)
# Recovery codes are shown exactly once, right here.
return render_template('auth/mfa_recovery.html', codes=plaintext)
flash('That code did not match. Make sure your device clock is correct '
'and try again.', 'danger')
# GET, or a failed POST: (re)present the QR for the pending secret.
secret = session.get('mfa_setup_secret') or mfa.new_secret()
session['mfa_setup_secret'] = secret
uri = mfa.provisioning_uri(secret, current_user.email or current_user.username)
return render_template('auth/mfa_setup.html', secret=secret, qr_svg=mfa.qr_svg(uri))
@bp.route('/mfa/disable', methods=['POST'])
@login_required
def mfa_disable():
"""Turn off two-factor. Requires a current authenticator code OR the account
password, so a merely-hijacked session can't silently strip 2FA."""
if not current_user.mfa_enabled:
return redirect(url_for('auth.profile'))
code = request.form.get('code', '')
pw = request.form.get('password', '')
if not (mfa.verify_totp(current_user.mfa_secret, code)
or (pw and current_user.check_password(pw))):
flash('Enter a valid authenticator code or your password to disable 2FA.', 'danger')
return redirect(url_for('auth.profile'))
current_user.mfa_enabled = False
current_user.mfa_secret = None
current_user.mfa_recovery_codes = None
db.session.commit()
log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
'disabled two-factor authentication')
logger.info('MFA_DISABLED | user=%s', current_user.username)
flash('Two-factor authentication has been disabled.', 'success')
return redirect(url_for('auth.profile'))
@bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():