Files
Budget_Tool_Martinhal/backend/app/mfa.py
T

122 lines
4.3 KiB
Python
Raw Normal View History

2026-10-29 20:40:02 +00:00
"""MFA with an authenticator app (TOTP, RFC 6238: 6 digits, 30 seconds, SHA-1).
Works with Microsoft Authenticator, Google Authenticator, Authy, 1Password and similar apps.
"""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import segno
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
from .models import User
ISSUER = "Budget & Capex"
STEP = 30
WINDOW = 1 # also accept the code just before and just after, for clocks that are slightly off
MAX_FAILED = 5
LOCK_MINUTES = 5
RECOVERY_CODES = 8
def _fernet() -> Fernet:
key = hashlib.sha256(("mfa-secret:" + settings.secret_key).encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def new_secret() -> str:
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
def seal(secret: str) -> str:
return _fernet().encrypt(secret.encode()).decode()
def unseal(token: str | None) -> str | None:
try:
return _fernet().decrypt(token.encode()).decode() if token else None
except InvalidToken: # SECRET_KEY changed: an administrator has to reset this user's MFA
return None
def code_at(secret: str, step: int) -> str:
key = base64.b32decode(secret + "=" * (-len(secret) % 8))
digest = hmac.new(key, struct.pack(">Q", step), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return f"{value % 1_000_000:06d}"
def matching_step(secret: str, code: str, last_step: int | None = None, at: float | None = None) -> int | None:
"""The time step the code belongs to, or None. A step already used is refused (no replay)."""
code = "".join(ch for ch in code if ch.isdigit())
if len(code) != 6:
return None
now = int((at or time.time()) // STEP)
for step in range(now - WINDOW, now + WINDOW + 1):
if hmac.compare_digest(code_at(secret, step), code) and (last_step is None or step > last_step):
return step
return None
def otpauth_uri(secret: str, email: str) -> str:
label = quote(f"{ISSUER}:{email}")
return f"otpauth://totp/{label}?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period={STEP}"
def qr_svg(uri: str) -> str:
return segno.make(uri, error="m").svg_inline(scale=5, dark="#000", light="#fff", border=2)
def _hash(code: str) -> str:
return hashlib.sha256(code.replace("-", "").strip().lower().encode()).hexdigest()
def new_recovery_codes() -> tuple[list[str], list[str]]:
"""Codes to show once, and their hashes to keep."""
alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
codes = ["".join(secrets.choice(alphabet) for _ in range(4)) + "-" + "".join(secrets.choice(alphabet) for _ in range(4))
for _ in range(RECOVERY_CODES)]
return codes, [_hash(c) for c in codes]
def locked(user: User) -> bool:
until = user.mfa_locked_until
if until and until.tzinfo is None:
until = until.replace(tzinfo=timezone.utc)
return bool(until and until > datetime.now(timezone.utc))
def check(user: User, code: str) -> str | None:
"""Checks an authenticator code or a recovery code. Returns 'totp', 'recovery' or None, and updates
the user's counters (a recovery code is used up; too many wrong codes lock MFA for a few minutes)."""
if locked(user):
return None
code = (code or "").strip()
secret = unseal(user.mfa_secret)
kind = None
if secret and (step := matching_step(secret, code, user.mfa_last_step)) is not None:
user.mfa_last_step, kind = step, "totp"
elif any(ch.isalpha() for ch in code) and _hash(code) in (user.mfa_recovery or []):
user.mfa_recovery = [h for h in user.mfa_recovery if h != _hash(code)]
kind = "recovery"
if kind:
user.mfa_failed, user.mfa_locked_until = 0, None
else:
user.mfa_failed = (user.mfa_failed or 0) + 1
if user.mfa_failed >= MAX_FAILED:
user.mfa_failed, user.mfa_locked_until = 0, datetime.now(timezone.utc) + timedelta(minutes=LOCK_MINUTES)
return kind
def disable(user: User) -> None:
user.mfa_enabled, user.mfa_secret, user.mfa_pending_secret = False, None, None
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = None, None, None
user.mfa_failed, user.mfa_locked_until = 0, None