|
24 | 24 | import string |
25 | 25 | import time |
26 | 26 | import json |
27 | | -import time |
28 | 27 | from flask import Blueprint, flash |
29 | 28 | from flask import redirect |
30 | 29 | from flask import render_template |
@@ -436,66 +435,6 @@ def _register_mfa_failure(user, reason): |
436 | 435 | session.pop("username", None) |
437 | 436 |
|
438 | 437 |
|
439 | | -# MFA hardening constants. Values are deliberately conservative — a legitimate |
440 | | -# user mistypes their token occasionally; an attacker brute-forcing 10^6 TOTP |
441 | | -# codes should not be able to linearly grind them. |
442 | | -_MFA_MAX_ATTEMPTS = 5 |
443 | | -_MFA_LOCKOUT_SECONDS = 15 * 60 |
444 | | - |
445 | | - |
446 | | -def _get_pre_mfa_user(): |
447 | | - """Return the user this session just passed password auth for, or None. |
448 | | -
|
449 | | - The pre_mfa_user_id marker is set exclusively by wrap_login_user after a |
450 | | - successful password (or LDAP) check. Any MFA handler that runs without it |
451 | | - is being hit directly by an attacker and must be refused. |
452 | | - """ |
453 | | - pre_mfa_user_id = session.get('pre_mfa_user_id') |
454 | | - if pre_mfa_user_id is None: |
455 | | - return None |
456 | | - return get_user(pre_mfa_user_id, id_key='id') |
457 | | - |
458 | | - |
459 | | -def _clear_pre_mfa_state(preserve_lockout=False): |
460 | | - """Clear the markers that prove this session just passed password auth. |
461 | | -
|
462 | | - preserve_lockout: when True, keep the lockout timestamp and fail counter |
463 | | - so that an attacker cannot wipe a brute-force lockout simply by hitting |
464 | | - /login again. |
465 | | - """ |
466 | | - session.pop('pre_mfa_user_id', None) |
467 | | - session.pop('pending_mfa_secret', None) |
468 | | - if not preserve_lockout: |
469 | | - session.pop('mfa_fail_count', None) |
470 | | - session.pop('mfa_lockout_until', None) |
471 | | - |
472 | | - |
473 | | -def _mfa_is_locked_out(): |
474 | | - locked_until = session.get('mfa_lockout_until') |
475 | | - if locked_until and locked_until > time.time(): |
476 | | - return True |
477 | | - if locked_until and locked_until <= time.time(): |
478 | | - # Lockout expired — reset the counter so the user gets a fresh window. |
479 | | - session.pop('mfa_lockout_until', None) |
480 | | - session['mfa_fail_count'] = 0 |
481 | | - return False |
482 | | - |
483 | | - |
484 | | -def _register_mfa_failure(user, reason): |
485 | | - session['mfa_fail_count'] = session.get('mfa_fail_count', 0) + 1 |
486 | | - track_activity( |
487 | | - f'Failed MFA {reason} for user {user.user} (attempt {session["mfa_fail_count"]}/{_MFA_MAX_ATTEMPTS})', |
488 | | - ctx_less=True, display_in_ui=False |
489 | | - ) |
490 | | - if session['mfa_fail_count'] >= _MFA_MAX_ATTEMPTS: |
491 | | - session['mfa_lockout_until'] = time.time() + _MFA_LOCKOUT_SECONDS |
492 | | - # Drop the pending-MFA marker so the attacker has to go back through |
493 | | - # password auth before they get another burst of attempts. Preserve |
494 | | - # the lockout timestamp so a fresh /login cannot wipe it. |
495 | | - _clear_pre_mfa_state(preserve_lockout=True) |
496 | | - session.pop('username', None) |
497 | | - |
498 | | - |
499 | 438 | @app.route("/auth/mfa-setup", methods=["GET", "POST"]) |
500 | 439 | def mfa_setup(): |
501 | 440 | user = _get_pre_mfa_user() |
|
0 commit comments