Adds TOTP-based two-factor authentication (RFC 6238) with 10 single-use recovery codes. Secret is encrypted at rest with a Fernet key derived deterministically from app SECRET_KEY (SHA-256 -> urlsafe-base64); the raw base32 secret never lives in the database. Recovery codes are bcrypt-hashed and consumed atomically (single-use, removed from the JSON list on match). Routes: - GET /2fa/setup: generate fresh secret + QR + 10 recovery codes; cache pending state in session, render auth/totp_setup.html with inline QR data URL and the 10 codes shown ONCE. - POST /2fa/setup: verify the user-submitted 6-digit code against the pending secret; on success persist encrypted secret + hashes and flip totp_enabled=True. On invalid code re-render same QR (don't rotate), preserving the user's authenticator scan. - GET /2fa/verify: second factor during login; reads pending_totp_user_id from session and renders auth/totp_verify.html (TOTP code input + collapsed recovery code form, with X codes restants notice). - POST /2fa/verify: accepts EITHER a 6-digit TOTP code OR a recovery code; on success finalises login_user (preserving remember-me intent + next URL captured at the password step), audits success/failure. - POST /2fa/disable: requires password re-auth; nullifies the 3 TOTP fields. Login gate (src/api/auth.py /login): after password+email-verification checks but BEFORE login_user, if user.totp_enabled set session['pending_totp_user_id'] / pending_totp_remember / pending_totp_next and 302 -> /2fa/verify. OAuth/SSO/magic-link paths are intentionally NOT gated in B-2.5 (deferred — IdP handles its own MFA). Schema: - New JSON column User.totp_recovery_codes (nullable) added via add_column_if_not_exists in src/init_db.py (no Alembic, follows existing pattern). - Re-uses B-2.1 columns totp_secret_encrypted (VARCHAR 255) and totp_enabled (BOOLEAN); both already migrated. Compatibility audit overrides honoured: - Service layer at src/auth/totp.py (NOT a new src/auth_extended/ pkg). - Templates at templates/auth/totp_setup.html and templates/auth/totp_verify.html extending marketing/base.html with brand tokens + WCAG patterns (focus-visible, role=alert, aria-required, autocomplete=one-time-code, inputmode=numeric). - account.html integration deferred to a polish task — admins access /2fa/setup directly for now. Tests (21, all green via Windows manual driver): - Service layer: encrypt/decrypt round-trip, key-mismatch rejection, secret validity, code verification (current/wrong/non-digit), recovery codes (10 pairs, 1:1 bcrypt mapping, single-use consumption, unknown rejection), set/disable user TOTP fields. - Routes: login redirect-to-/2fa/verify when totp_enabled, direct login when disabled, /2fa/verify with correct/wrong TOTP, recovery code consume, redirect-to-login when no pending session, /2fa/setup GET creates pending, POST with valid code enables MFA, POST with invalid code keeps pending + returns 400, /2fa/disable wrong/correct password. Regression check: prior 21 OAuth+magic-link, 16 email-service, and 9 signup-Loi-25 tests all still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
2.4 KiB
Python
78 lines
2.4 KiB
Python
"""Windows manual driver for tests/test_totp_mfa.py.
|
|
|
|
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
|
|
before src.app gets imported, then run each test_* function and report.
|
|
|
|
Run from the repo root:
|
|
py -3 tests/_run_totp_mfa_windows.py
|
|
|
|
This script is local-dev only (not picked up by pytest collection).
|
|
"""
|
|
import os
|
|
import sys
|
|
import types
|
|
import traceback
|
|
|
|
# 1) Stub fcntl BEFORE any import of src.* happens.
|
|
if 'fcntl' not in sys.modules:
|
|
fcntl_stub = types.ModuleType('fcntl')
|
|
fcntl_stub.LOCK_EX = 2
|
|
fcntl_stub.LOCK_NB = 4
|
|
fcntl_stub.LOCK_UN = 8
|
|
fcntl_stub.LOCK_SH = 1
|
|
fcntl_stub.flock = lambda *_args, **_kw: None
|
|
fcntl_stub.fcntl = lambda *_args, **_kw: 0
|
|
sys.modules['fcntl'] = fcntl_stub
|
|
|
|
# 2) Make repo root importable
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
REPO = os.path.dirname(HERE)
|
|
sys.path.insert(0, REPO)
|
|
|
|
# 3) Set test config
|
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key-totp')
|
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
|
|
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
|
|
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
|
|
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
|
|
try:
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
except Exception:
|
|
pass
|
|
|
|
# 4) Import the test module and run every test_* function it defines
|
|
import importlib.util # noqa: E402
|
|
spec = importlib.util.spec_from_file_location(
|
|
'test_totp_mfa',
|
|
os.path.join(HERE, 'test_totp_mfa.py'),
|
|
)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
|
|
tests = [(name, fn) for name, fn in vars(mod).items()
|
|
if name.startswith('test_') and callable(fn)]
|
|
|
|
passed = 0
|
|
failed = []
|
|
for name, fn in tests:
|
|
try:
|
|
fn()
|
|
print(f' PASS {name}')
|
|
passed += 1
|
|
except Exception as e: # noqa: BLE001
|
|
print(f' FAIL {name}: {type(e).__name__}: {e}')
|
|
failed.append((name, traceback.format_exc()))
|
|
|
|
total = len(tests)
|
|
print()
|
|
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
|
|
if failed:
|
|
print('\n--- Failures ---\n')
|
|
for name, tb in failed:
|
|
print(f'### {name}\n{tb}\n')
|
|
sys.exit(0 if not failed else 1)
|