- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""FlowDeck — TOTP 2FA + backup codes (v7.2.0).
|
|
|
|
Secrets are Fernet-encrypted at rest (same construction as SSO secrets).
|
|
Login flow: ``POST /auth/local-login`` returns ``2fa_required`` + a short-lived
|
|
signed ``pending`` token; ``POST /auth/local-verify`` exchanges it for a
|
|
session. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import secrets
|
|
|
|
from app.db import get_conn
|
|
|
|
BACKUP_CODE_COUNT = 10
|
|
|
|
|
|
def _fernet():
|
|
import base64
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
from app.config import settings
|
|
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
|
|
return Fernet(base64.urlsafe_b64encode(key))
|
|
|
|
|
|
def is_enabled(user_id: int) -> bool:
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?",
|
|
(user_id,)).fetchone()
|
|
if not row or not row["totp_secret_enc"]:
|
|
return False
|
|
try:
|
|
return bool(_fernet().decrypt(row["totp_secret_enc"].encode()).decode())
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
|
|
def setup_secret(user_id: int) -> dict:
|
|
"""Create a new TOTP secret (not yet active until verified)."""
|
|
import pyotp
|
|
secret = pyotp.random_base32()
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT login, email FROM users WHERE id=?", (user_id,)).fetchone()
|
|
label = (row["email"] or row["login"]) if row else f"user{user_id}"
|
|
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name="FlowDeck")
|
|
return {"secret": secret, "otpauth_url": uri}
|
|
|
|
|
|
def activate_secret(user_id: int, secret: str, code: str) -> list[str]:
|
|
"""Verify ``code`` against ``secret``; on success store + return backup codes."""
|
|
import pyotp
|
|
if not pyotp.TOTP(secret).verify(code, valid_window=1):
|
|
raise ValueError("invalid code")
|
|
codes = [secrets.token_hex(4) for _ in range(BACKUP_CODE_COUNT)]
|
|
hashes = [hashlib.sha256(c.encode()).hexdigest() for c in codes]
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET totp_secret_enc=?, totp_backup_hashes=? WHERE id=?",
|
|
(_fernet().encrypt(secret.encode()).decode(),
|
|
json.dumps(hashes), user_id))
|
|
conn.commit()
|
|
return codes
|
|
|
|
|
|
def verify_code(user_id: int, code: str) -> bool:
|
|
"""Check a TOTP code or consume a backup code."""
|
|
code = (code or "").strip().replace(" ", "")
|
|
if not code:
|
|
return False
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT totp_secret_enc, totp_backup_hashes FROM users WHERE id=?",
|
|
(user_id,)).fetchone()
|
|
if not row or not row["totp_secret_enc"]:
|
|
return False
|
|
try:
|
|
secret = _fernet().decrypt(row["totp_secret_enc"].encode()).decode()
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
import pyotp
|
|
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
|
|
return True
|
|
# backup codes (single use)
|
|
try:
|
|
hashes = json.loads(row["totp_backup_hashes"] or "[]")
|
|
except (TypeError, json.JSONDecodeError):
|
|
hashes = []
|
|
digest = hashlib.sha256(code.encode()).hexdigest()
|
|
if digest in hashes:
|
|
hashes.remove(digest)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET totp_backup_hashes=? WHERE id=?",
|
|
(json.dumps(hashes), user_id))
|
|
conn.commit()
|
|
return True
|
|
return False
|
|
|
|
|
|
def disable(user_id: int) -> None:
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET totp_secret_enc='', totp_backup_hashes='[]'"
|
|
" WHERE id=?", (user_id,))
|
|
conn.commit()
|
|
|
|
|
|
def remaining_backup_codes(user_id: int) -> int:
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT totp_backup_hashes FROM users WHERE id=?",
|
|
(user_id,)).fetchone()
|
|
try:
|
|
return len(json.loads(row["totp_backup_hashes"] or "[]")) if row else 0
|
|
except (TypeError, json.JSONDecodeError):
|
|
return 0
|
|
|
|
|
|
# ── pending 2FA challenge (signed, 5 min) ──────────────────────────────────
|
|
|
|
def mint_pending(user_id: int) -> str:
|
|
from itsdangerous import URLSafeTimedSerializer
|
|
|
|
from app.config import settings
|
|
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
|
|
return ser.dumps({"user_id": user_id})
|
|
|
|
|
|
def redeem_pending(token: str, max_age: int = 300) -> int | None:
|
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
|
|
from app.config import settings
|
|
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
|
|
try:
|
|
payload = ser.loads(token, max_age=max_age)
|
|
return int(payload.get("user_id", 0)) or None
|
|
except (BadSignature, SignatureExpired, ValueError):
|
|
return None
|