July 4 - Implement TOTP 2FA
This commit is contained in:
+130
-1
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user