CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
340 lines
13 KiB
Python
340 lines
13 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import uuid
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from functools import wraps
|
|
|
|
import jwt
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from flask import current_app, request, g, jsonify
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
|
|
|
|
|
def hash_auth_token(auth_hash: str) -> str:
|
|
"""Hash the client-derived PBKDF2 auth_hash with Argon2id before storing."""
|
|
ph = PasswordHasher(
|
|
time_cost=current_app.config['ARGON2_TIME_COST'],
|
|
memory_cost=current_app.config['ARGON2_MEMORY_COST'],
|
|
parallelism=current_app.config['ARGON2_PARALLELISM'],
|
|
)
|
|
return ph.hash(auth_hash)
|
|
|
|
|
|
def verify_auth_token(auth_hash: str, stored_hash: str, user=None) -> bool:
|
|
"""
|
|
Verify auth_hash against stored Argon2id hash.
|
|
If user is provided and the stored hash uses outdated parameters,
|
|
the hash is transparently upgraded on successful verification.
|
|
Caller must commit the session after this returns True.
|
|
"""
|
|
ph = PasswordHasher()
|
|
try:
|
|
result = ph.verify(stored_hash, auth_hash)
|
|
if result and user is not None and ph.check_needs_rehash(stored_hash):
|
|
ph_fresh = PasswordHasher(
|
|
time_cost=current_app.config['ARGON2_TIME_COST'],
|
|
memory_cost=current_app.config['ARGON2_MEMORY_COST'],
|
|
parallelism=current_app.config['ARGON2_PARALLELISM'],
|
|
)
|
|
user.master_hash = ph_fresh.hash(auth_hash)
|
|
return result
|
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
|
return False
|
|
|
|
|
|
def _get_totp_key() -> bytes:
|
|
"""
|
|
Return the 32-byte AES key used for server-side TOTP secret encryption.
|
|
The key is stored as a 64-char hex string in TOTP_ENCRYPTION_KEY config.
|
|
"""
|
|
hex_key = current_app.config.get('TOTP_ENCRYPTION_KEY', '')
|
|
if not hex_key or len(hex_key) != 64:
|
|
raise RuntimeError(
|
|
'TOTP_ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes). '
|
|
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
|
)
|
|
return bytes.fromhex(hex_key)
|
|
|
|
|
|
def encrypt_totp_secret(plaintext_secret: str) -> tuple[str, str]:
|
|
"""
|
|
Encrypt a plaintext TOTP base32 secret with AES-256-GCM.
|
|
Returns (ciphertext_b64, iv_b64).
|
|
"""
|
|
key = _get_totp_key()
|
|
iv = os.urandom(12)
|
|
aesgcm = AESGCM(key)
|
|
ciphertext = aesgcm.encrypt(iv, plaintext_secret.encode(), None)
|
|
return base64.b64encode(ciphertext).decode(), base64.b64encode(iv).decode()
|
|
|
|
|
|
def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str:
|
|
"""
|
|
Decrypt a base64-encoded AES-256-GCM TOTP secret ciphertext.
|
|
Returns the plaintext base32 secret string.
|
|
"""
|
|
key = _get_totp_key()
|
|
iv = base64.b64decode(iv_b64)
|
|
ciphertext = base64.b64decode(ciphertext_b64)
|
|
aesgcm = AESGCM(key)
|
|
return aesgcm.decrypt(iv, ciphertext, None).decode()
|
|
|
|
|
|
def generate_tokens(user_id: int, token_epoch: int = 0) -> dict:
|
|
"""
|
|
Return access_token and refresh_token JWTs, each with a unique jti.
|
|
|
|
token_epoch stamps the user's current session generation into both tokens.
|
|
require_jwt and /refresh compare it against users.token_epoch and reject on
|
|
mismatch, so incrementing that column revokes every outstanding token.
|
|
Always pass user.token_epoch — the 0 default exists only so old call sites
|
|
fail visibly in tests rather than silently minting unrevokable tokens.
|
|
"""
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
secret = current_app.config['JWT_SECRET_KEY']
|
|
access_payload = {
|
|
'sub': str(user_id),
|
|
'type': 'access',
|
|
'jti': str(uuid.uuid4()),
|
|
'epoch': int(token_epoch or 0),
|
|
'iat': now,
|
|
'exp': now + current_app.config['JWT_ACCESS_TOKEN_EXPIRES'],
|
|
}
|
|
refresh_payload = {
|
|
'sub': str(user_id),
|
|
'type': 'refresh',
|
|
'jti': str(uuid.uuid4()),
|
|
'epoch': int(token_epoch or 0),
|
|
'iat': now,
|
|
'exp': now + current_app.config['JWT_REFRESH_TOKEN_EXPIRES'],
|
|
}
|
|
return {
|
|
'access_token': jwt.encode(access_payload, secret, algorithm='HS256'),
|
|
'refresh_token': jwt.encode(refresh_payload, secret, algorithm='HS256'),
|
|
}
|
|
|
|
|
|
def generate_mfa_token(user_id: int) -> str:
|
|
"""Short-lived (5-min) single-use token issued after password but before TOTP."""
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
payload = {
|
|
'sub': str(user_id),
|
|
'type': 'mfa',
|
|
'jti': str(uuid.uuid4()),
|
|
'iat': now,
|
|
'exp': now + timedelta(minutes=5),
|
|
}
|
|
return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
|
|
|
|
|
def decode_token(token: str, expected_type: str = 'access', check_blacklist: bool = True) -> dict:
|
|
"""Decode and validate a JWT. Raises jwt.PyJWTError on any failure."""
|
|
secret = current_app.config['JWT_SECRET_KEY']
|
|
payload = jwt.decode(token, secret, algorithms=['HS256'])
|
|
if payload.get('type') != expected_type:
|
|
raise jwt.InvalidTokenError('Wrong token type')
|
|
if check_blacklist:
|
|
from app.models.token_blacklist import TokenBlacklist
|
|
jti = payload.get('jti')
|
|
if jti and TokenBlacklist.is_blacklisted(jti):
|
|
raise jwt.InvalidTokenError('Token has been revoked')
|
|
return payload
|
|
|
|
|
|
def blacklist_token(token: str, token_type: str) -> None:
|
|
"""Add a JWT's jti to the blacklist. Silently ignores invalid tokens."""
|
|
try:
|
|
payload = decode_token(token, expected_type=token_type, check_blacklist=False)
|
|
jti = payload.get('jti')
|
|
if not jti:
|
|
return
|
|
exp = payload.get('exp')
|
|
expires_at = (
|
|
datetime.fromtimestamp(exp, tz=timezone.utc).replace(tzinfo=None)
|
|
if exp
|
|
else datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=7)
|
|
)
|
|
from app.models.token_blacklist import TokenBlacklist
|
|
from app import db
|
|
from sqlalchemy.exc import IntegrityError
|
|
# INSERT directly — no SELECT-before-INSERT race.
|
|
# Two concurrent logouts of the same token would both try to insert,
|
|
# but the UNIQUE constraint on jti makes exactly one succeed.
|
|
# We catch IntegrityError and roll back gracefully; the token is
|
|
# already blacklisted so the outcome is correct either way.
|
|
entry = TokenBlacklist(
|
|
jti=jti,
|
|
user_id=int(payload.get('sub', 0)),
|
|
expires_at=expires_at,
|
|
)
|
|
db.session.add(entry)
|
|
try:
|
|
db.session.commit()
|
|
except IntegrityError:
|
|
db.session.rollback() # already blacklisted — safe to ignore
|
|
# Cleanup is handled by the APScheduler background job in create_app(),
|
|
# not here — keeps the logout/refresh hot path free of extra DB writes.
|
|
except Exception:
|
|
pass # Never let blacklisting errors break the logout flow
|
|
|
|
|
|
def load_user_for_token(payload) -> 'object | None':
|
|
"""
|
|
Resolve the User a validated token refers to, or None if the token must be
|
|
rejected.
|
|
|
|
Two checks beyond signature validity:
|
|
|
|
1. The user still exists. Routes immediately dereference the result of
|
|
db.session.get(User, ...); without this a valid token for a deleted
|
|
account produced an AttributeError on None and a 500.
|
|
2. The token's epoch claim still matches users.token_epoch. A master-password
|
|
change increments that column, which revokes every token minted before it.
|
|
Tokens issued before the claim existed decode as 0 and match the column
|
|
default, so an upgrade does not sign existing sessions out.
|
|
"""
|
|
from app.models.user import User
|
|
from app import db
|
|
|
|
try:
|
|
user_id = int(payload['sub'])
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
|
|
user = db.session.get(User, user_id)
|
|
if user is None:
|
|
return None
|
|
if int(payload.get('epoch', 0) or 0) != int(user.token_epoch or 0):
|
|
return None
|
|
return user
|
|
|
|
|
|
def require_jwt(f):
|
|
"""
|
|
Decorator: validates the Bearer token and sets g.current_user_id.
|
|
|
|
Also sets g.current_user to the resolved User so handlers can reuse it
|
|
instead of issuing a second lookup (SQLAlchemy's identity map makes the
|
|
repeat cheap, but reusing it is clearer).
|
|
"""
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
auth_header = request.headers.get('Authorization', '')
|
|
if not auth_header.startswith('Bearer '):
|
|
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
|
token = auth_header[7:]
|
|
try:
|
|
payload = decode_token(token, expected_type='access')
|
|
except jwt.ExpiredSignatureError:
|
|
return jsonify({'error': 'Token expired'}), 401
|
|
except jwt.PyJWTError:
|
|
return jsonify({'error': 'Invalid token'}), 401
|
|
|
|
user = load_user_for_token(payload)
|
|
if user is None:
|
|
# Deleted account, or a token predating a credential change.
|
|
return jsonify({'error': 'Session is no longer valid. Please log in again.'}), 401
|
|
|
|
g.current_user = user
|
|
g.current_user_id = user.id
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
# ── TOTP replay prevention ────────────────────────────────────────────────────
|
|
|
|
def is_totp_code_used(user_id: int, code: str) -> bool:
|
|
"""Return True if this TOTP code was already consumed for this user."""
|
|
from app.models.totp_used_code import TotpUsedCode
|
|
return TotpUsedCode.is_used(user_id, code)
|
|
|
|
|
|
def mark_totp_code_used(user_id: int, code: str) -> None:
|
|
"""
|
|
Record that this TOTP code was consumed so it cannot be replayed.
|
|
Caller must commit the session — the surrounding request handler does this.
|
|
"""
|
|
from app.models.totp_used_code import TotpUsedCode
|
|
TotpUsedCode.mark_used(user_id, code)
|
|
|
|
|
|
|
|
def generate_recovery_nonce() -> str:
|
|
"""
|
|
Return a fresh 32-byte random nonce (hex) for use in the recovery proof
|
|
challenge-response. Must be stored in the server-side flask.session and
|
|
consumed (deleted) exactly once.
|
|
"""
|
|
return os.urandom(32).hex()
|
|
|
|
|
|
# NOTE: compute_recovery_proof() is intentionally absent.
|
|
# The server cannot decrypt the recovery blob (it was encrypted client-side with
|
|
# the user's recovery key). Instead, the expected HMAC is computed inline in the
|
|
# /recovery/data route using the key returned by _recovery_proof_key(user) —
|
|
# user.recovery_verifier, or user.enc_key_salt for legacy codes — persisted in
|
|
# the recovery_challenges table, and compared on submission via
|
|
# verify_recovery_proof() below.
|
|
|
|
|
|
def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool:
|
|
"""Constant-time comparison of the server-computed proof vs the client-submitted one."""
|
|
return hmac.compare_digest(expected_hmac, client_hmac)
|
|
|
|
|
|
# ── MFA backup codes ──────────────────────────────────────────────────────────
|
|
|
|
BACKUP_CODE_COUNT = 10 # codes generated per enrollment
|
|
BACKUP_CODE_LENGTH = 10 # characters per code (alphanumeric, ~50 bits entropy)
|
|
_BACKUP_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789' # omit l/o/0/1 to avoid confusion
|
|
|
|
|
|
def generate_backup_codes() -> tuple[list[str], list[str]]:
|
|
"""
|
|
Generate BACKUP_CODE_COUNT plaintext backup codes and their Argon2id hashes.
|
|
|
|
Returns (plaintext_codes, hashed_codes).
|
|
The plaintext list is shown to the user ONCE and never stored.
|
|
Only the hashed list is persisted in user.mfa_backup_codes (JSON array).
|
|
"""
|
|
import secrets
|
|
ph = PasswordHasher(
|
|
time_cost=1, # backup codes can afford lighter params than master password
|
|
memory_cost=16384,
|
|
parallelism=2,
|
|
)
|
|
plaintext = [
|
|
''.join(secrets.choice(_BACKUP_ALPHABET) for _ in range(BACKUP_CODE_LENGTH))
|
|
for _ in range(BACKUP_CODE_COUNT)
|
|
]
|
|
hashed = [ph.hash(code) for code in plaintext]
|
|
return plaintext, hashed
|
|
|
|
|
|
def verify_and_consume_backup_code(hashed_codes: list[str], candidate: str) -> tuple[bool, list[str]]:
|
|
"""
|
|
Check `candidate` against the stored hashed backup codes.
|
|
|
|
Returns (matched, remaining_hashes).
|
|
If matched, the consumed code is removed from remaining_hashes.
|
|
Performs constant-time-safe iteration (always checks all codes).
|
|
"""
|
|
ph = PasswordHasher()
|
|
matched_index = -1
|
|
for i, h in enumerate(hashed_codes):
|
|
try:
|
|
if ph.verify(h, candidate):
|
|
matched_index = i
|
|
# Do not break — continue iterating to avoid timing leaks.
|
|
except Exception:
|
|
pass
|
|
|
|
if matched_index == -1:
|
|
return False, hashed_codes
|
|
|
|
remaining = [h for i, h in enumerate(hashed_codes) if i != matched_index]
|
|
return True, remaining |