Files
PassKeeper/app/routes/auth.py
T
2026-05-19 11:19:20 -04:00

982 lines
36 KiB
Python

import re
import time
from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip
from app.models.user import User
from app.models.audit_log import AuditLog
from app.services.auth_service import (
hash_auth_token,
verify_auth_token,
generate_tokens,
generate_mfa_token,
decode_token,
blacklist_token,
require_jwt,
encrypt_totp_secret,
decrypt_totp_secret,
generate_recovery_nonce,
verify_recovery_proof,
generate_backup_codes,
verify_and_consume_backup_code,
is_totp_code_used,
mark_totp_code_used,
)
auth_bp = Blueprint('auth', __name__)
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
@auth_bp.route('/register', methods=['POST'])
@limiter.limit('10 per minute')
def register():
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
auth_hash = data.get('auth_hash', '')
enc_key_salt = data.get('enc_key_salt', '')
if not email or not EMAIL_RE.match(email):
return jsonify({'error': 'Invalid email address'}), 400
if len(email) > 254:
return jsonify({'error': 'Email address is too long'}), 400
if not auth_hash:
return jsonify({'error': 'auth_hash is required'}), 400
if not enc_key_salt:
return jsonify({'error': 'enc_key_salt is required'}), 400
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email already registered'}), 409
master_hash = hash_auth_token(auth_hash)
user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt)
db.session.add(user)
db.session.flush() # populate user.id before logging
AuditLog.log(
user_id=user.id,
action='auth.register',
resource_type='user',
resource_id=user.id,
detail=f'New account registered: {email}',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'Account created successfully'}), 201
@auth_bp.route('/login', methods=['POST'])
@limiter.limit('10 per minute')
def login():
from datetime import datetime, timezone, timedelta
from sqlalchemy.exc import OperationalError
# Number of consecutive failures before a temporary lockout is applied.
MAX_FAILED_LOGINS = 5
LOCKOUT_MINUTES = 15
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
auth_hash = data.get('auth_hash', '')
time.sleep(0.1) # mitigate timing-based user enumeration
if not email or not auth_hash:
return jsonify({'error': 'Email and auth_hash are required'}), 400
user = User.query.filter_by(email=email).first()
# Per-account lockout check.
# Guarded with try/except so that a deployment where the migration has not
# yet been run (columns missing) degrades gracefully instead of returning
# an HTML 500 page that breaks JSON parsing in the extension.
try:
if user and user.locked_until:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() // 60) + 1
AuditLog.log(
user_id=user.id,
action='auth.login_blocked',
resource_type='user',
resource_id=user.id,
detail=f'Login blocked — account locked for {remaining} more minute(s)',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': f'Account temporarily locked. Try again in {remaining} minute(s).'
}), 429
else:
# Lockout has expired — reset the counter.
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
# Columns do not exist yet — migration pending. Skip lockout check.
db.session.rollback()
if not user or not verify_auth_token(auth_hash, user.master_hash, user=user):
if user:
try:
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_FAILED_LOGINS:
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
AuditLog.log(
user_id=user.id,
action='auth.account_locked',
resource_type='user',
resource_id=user.id,
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
ip_address=client_ip(),
)
else:
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
ip_address=client_ip(),
)
db.session.commit()
except OperationalError:
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail='Failed login attempt — invalid password',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401
# Successful authentication — reset lockout state.
try:
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
db.session.rollback()
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
AuditLog.log(
user_id=user.id,
action='auth.login',
resource_type='user',
resource_id=user.id,
detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}',
ip_address=client_ip(),
)
db.session.commit()
# MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens
if user.totp_enabled:
mfa_token = generate_mfa_token(user.id)
return jsonify({
'mfa_required': True,
'mfa_token': mfa_token,
'enc_key_salt': user.enc_key_salt,
}), 200
tokens = generate_tokens(user.id)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'enc_key_salt': user.enc_key_salt,
}), 200
@auth_bp.route('/logout', methods=['POST'])
@limiter.limit('60 per minute')
def logout():
"""Blacklist both the access token (from header) and refresh token (from body)."""
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
blacklist_token(auth_header[7:], 'access')
data = request.get_json(silent=True) or {}
refresh_token = data.get('refresh_token', '')
if refresh_token:
blacklist_token(refresh_token, 'refresh')
return jsonify({'message': 'Logged out'}), 200
@auth_bp.route('/refresh', methods=['POST'])
@limiter.limit('30 per minute')
def refresh():
data = request.get_json(silent=True) or {}
refresh_token = data.get('refresh_token', '')
if not refresh_token:
return jsonify({'error': 'refresh_token is required'}), 400
try:
payload = decode_token(refresh_token, expected_type='refresh')
except Exception:
return jsonify({'error': 'Invalid or expired refresh token'}), 401
# Rotate: blacklist old refresh token and issue fresh pair
blacklist_token(refresh_token, 'refresh')
tokens = generate_tokens(int(payload['sub']))
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
}), 200
# ── MFA / TOTP endpoints ─────────────────────────────────────────────────────
@auth_bp.route('/mfa/setup', methods=['GET'])
@limiter.limit('10 per minute')
@require_jwt
def mfa_setup():
"""Generate a new TOTP secret and return QR code (as base64 PNG data URI)."""
user = db.session.get(User, g.current_user_id)
if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400
import pyotp
import qrcode
import io
import base64
secret = pyotp.random_base32()
uri = pyotp.TOTP(secret).provisioning_uri(
name=user.email,
issuer_name='PassKeeper',
)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format='PNG')
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return jsonify({
'secret': secret,
'qr_code': f'data:image/png;base64,{qr_b64}',
'uri': uri,
}), 200
@auth_bp.route('/mfa/enable', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def mfa_enable():
"""Enable MFA after verifying the first TOTP code. Returns one-time backup codes."""
import json
user = db.session.get(User, g.current_user_id)
if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400
data = request.get_json(silent=True) or {}
secret = (data.get('secret') or '').strip()
totp_code = (data.get('totp_code') or '').strip()
if not secret or not totp_code:
return jsonify({'error': 'secret and totp_code are required'}), 400
import pyotp
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
# Encrypt the secret before replay check so totp_secret_enc/totp_iv are defined.
totp_secret_enc, totp_iv = encrypt_totp_secret(secret)
# Prevent replay: reject a code that was already consumed within the valid window.
# user.id is not yet persisted (MFA not enabled), so use g.current_user_id directly.
if is_totp_code_used(g.current_user_id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
mark_totp_code_used(g.current_user_id, totp_code)
user.totp_secret = totp_secret_enc
user.totp_iv = totp_iv
user.totp_enabled = True
# Generate one-time backup codes — plaintext shown once, only hashes stored.
plaintext_codes, hashed_codes = generate_backup_codes()
user.mfa_backup_codes = json.dumps(hashed_codes)
AuditLog.log(
user_id=user.id,
action='auth.mfa_enable',
resource_type='user',
resource_id=user.id,
detail='TOTP two-factor authentication enabled; backup codes generated',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'message': 'MFA enabled successfully',
'backup_codes': plaintext_codes,
}), 200
@auth_bp.route('/mfa/disable', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def mfa_disable():
"""Disable MFA after verifying the current TOTP code or a backup code."""
import json
user = db.session.get(User, g.current_user_id)
if not user.totp_enabled:
return jsonify({'error': 'MFA is not enabled'}), 400
data = request.get_json(silent=True) or {}
totp_code = (data.get('totp_code') or '').strip()
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
verified = False
if totp_code:
if is_totp_code_used(user.id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
if verified:
mark_totp_code_used(user.id, totp_code)
elif backup_code:
stored = json.loads(user.mfa_backup_codes or '[]')
matched, remaining = verify_and_consume_backup_code(stored, backup_code)
if matched:
user.mfa_backup_codes = json.dumps(remaining)
verified = True
if not verified:
return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = None
user.totp_iv = None
user.totp_enabled = False
user.mfa_backup_codes = None
AuditLog.log(
user_id=user.id,
action='auth.mfa_disable',
resource_type='user',
resource_id=user.id,
detail='TOTP two-factor authentication disabled; backup codes cleared',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'MFA disabled'}), 200
@auth_bp.route('/mfa/verify', methods=['POST'])
@limiter.limit('10 per minute')
def mfa_verify():
"""Complete MFA login: verify TOTP code (or backup code) and exchange mfa_token for real tokens."""
import json
data = request.get_json(silent=True) or {}
mfa_token = data.get('mfa_token', '')
totp_code = (data.get('totp_code') or '').strip()
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
if not mfa_token or (not totp_code and not backup_code):
return jsonify({'error': 'mfa_token and either totp_code or backup_code are required'}), 400
try:
payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True)
except Exception:
return jsonify({'error': 'Invalid or expired MFA token'}), 401
user = db.session.get(User, int(payload['sub']))
if not user or not user.totp_enabled:
return jsonify({'error': 'MFA not configured for this account'}), 400
import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
verified = False
if totp_code:
if is_totp_code_used(user.id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
if verified:
mark_totp_code_used(user.id, totp_code)
if not verified and backup_code:
stored = json.loads(user.mfa_backup_codes or '[]')
matched, remaining = verify_and_consume_backup_code(stored, backup_code)
if matched:
user.mfa_backup_codes = json.dumps(remaining)
verified = True
AuditLog.log(
user_id=user.id,
action='auth.mfa_backup_code_used',
resource_type='user',
resource_id=user.id,
detail=f'MFA backup code used; {len(remaining)} code(s) remaining',
ip_address=client_ip(),
)
if not verified:
return jsonify({'error': 'Invalid verification code'}), 400
# One-time use: blacklist the mfa_token
blacklist_token(mfa_token, 'mfa')
AuditLog.log(
user_id=user.id,
action='auth.mfa_verify',
resource_type='user',
resource_id=user.id,
detail='MFA verification successful — session tokens issued',
ip_address=client_ip(),
)
db.session.commit()
tokens = generate_tokens(user.id)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
}), 200
@auth_bp.route('/mfa/status', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def mfa_status():
user = db.session.get(User, g.current_user_id)
import json
stored = json.loads(user.mfa_backup_codes or '[]')
return jsonify({
'totp_enabled': user.totp_enabled,
'backup_codes_remaining': len(stored),
}), 200
@auth_bp.route('/mfa/backup-codes/regenerate', methods=['POST'])
@limiter.limit('5 per minute')
@require_jwt
def mfa_backup_codes_regenerate():
"""
Regenerate MFA backup codes. Requires a valid TOTP code to authorise.
All existing backup codes are invalidated and replaced.
Returns the new plaintext codes — shown once, never stored.
"""
import json
user = db.session.get(User, g.current_user_id)
if not user.totp_enabled:
return jsonify({'error': 'MFA is not enabled'}), 400
data = request.get_json(silent=True) or {}
totp_code = (data.get('totp_code') or '').strip()
if not totp_code:
return jsonify({'error': 'totp_code is required'}), 400
import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
if is_totp_code_used(user.id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
mark_totp_code_used(user.id, totp_code)
plaintext_codes, hashed_codes = generate_backup_codes()
user.mfa_backup_codes = json.dumps(hashed_codes)
AuditLog.log(
user_id=user.id,
action='auth.mfa_backup_codes_regenerated',
resource_type='user',
resource_id=user.id,
detail='MFA backup codes regenerated — previous codes invalidated',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'message': 'Backup codes regenerated. Save these — they will not be shown again.',
'backup_codes': plaintext_codes,
}), 200
@auth_bp.route('/me', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def me():
"""Return basic profile info for the authenticated user."""
import json
user = db.session.get(User, g.current_user_id)
stored_codes = json.loads(user.mfa_backup_codes or '[]')
return jsonify({
'id': user.id,
'email': user.email,
'created_at': user.created_at.isoformat() if user.created_at else None,
'last_login': user.last_login.isoformat() if user.last_login else None,
'totp_enabled': user.totp_enabled,
'backup_codes_remaining': len(stored_codes),
'recovery_configured': bool(user.recovery_enc_salt),
}), 200
@auth_bp.route('/audit-log', methods=['GET'])
@require_jwt
@limiter.limit('30 per minute')
def audit_log():
"""
Return the authenticated user's recent audit log entries.
Query params:
limit — max entries to return (default 50, max 200)
offset — pagination offset (default 0)
Sensitive field values are never logged — entries contain only action
types, resource IDs, timestamps, and IP addresses.
"""
try:
limit = min(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
except (ValueError, TypeError):
return jsonify({'error': 'limit and offset must be integers'}), 400
entries = (
AuditLog.query
.filter_by(user_id=g.current_user_id)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
.all()
)
total = AuditLog.query.filter_by(user_id=g.current_user_id).count()
return jsonify({
'total': total,
'limit': limit,
'offset': offset,
'entries': [e.to_dict() for e in entries],
}), 200
# ── Account management ────────────────────────────────────────────────────────
@auth_bp.route('/change-password', methods=['POST'])
@limiter.limit('5 per minute')
@require_jwt
def change_password():
"""
Change master password — zero-knowledge atomic re-encryption.
The client must:
1. Derive current auth_hash and verify it locally against what it knows.
2. Re-encrypt every vault item with the new vault key client-side.
3. POST the new credentials + all re-encrypted item blobs in one request.
The server verifies the current password, updates master_hash + enc_key_salt,
and bulk-replaces all vault item ciphertexts atomically. If any step fails,
the entire transaction is rolled back — the vault is never left in a split state.
"""
data = request.get_json(silent=True) or {}
current_auth_hash = data.get('current_auth_hash', '')
new_auth_hash = data.get('new_auth_hash', '')
new_enc_key_salt = data.get('new_enc_key_salt', '')
items = data.get('items', []) # [{id, enc_data, iv}, ...]
if not current_auth_hash or not new_auth_hash or not new_enc_key_salt:
return jsonify({'error': 'current_auth_hash, new_auth_hash, and new_enc_key_salt are required'}), 400
user = db.session.get(User, g.current_user_id)
if not verify_auth_token(current_auth_hash, user.master_hash):
AuditLog.log(
user_id=user.id,
action='auth.change_password_failed',
resource_type='user',
resource_id=user.id,
detail='Password change rejected — current password incorrect',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Current password is incorrect'}), 401
try:
from app.models.vault_item import VaultItem
# Bulk-update all vault item ciphertexts with new vault key encryption
item_ids = [i.get('id') for i in items if i.get('id')]
existing = {
v.id: v
for v in VaultItem.query.filter(
VaultItem.user_id == user.id,
VaultItem.id.in_(item_ids),
).all()
} if item_ids else {}
for item_data in items:
item_id = item_data.get('id')
enc_data = item_data.get('enc_data', '')
iv = item_data.get('iv', '')
if not item_id or not enc_data or not iv:
continue
vault_item = existing.get(item_id)
if vault_item:
vault_item.enc_data = enc_data
vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
# Update credentials
user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt
# Clear recovery data — it was encrypted with the old vault key and is now invalid
user.recovery_enc_salt = None
user.recovery_iv = None
AuditLog.log(
user_id=user.id,
action='auth.change_password',
resource_type='user',
resource_id=user.id,
detail=f'Master password changed; {len(existing)} vault item(s) re-encrypted; recovery code cleared',
ip_address=client_ip(),
)
db.session.commit()
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Password change failed: {str(e)}'}), 500
return jsonify({'message': 'Password changed successfully. Please log in again.'}), 200
@auth_bp.route('/account', methods=['DELETE'])
@limiter.limit('3 per minute')
@require_jwt
def delete_account():
"""
Permanently delete the authenticated user's account and all associated data.
Requires the current auth_hash for confirmation.
Cascading deletes handle vault_items, folders, shared_items, emergency_access.
"""
data = request.get_json(silent=True) or {}
auth_hash = data.get('auth_hash', '')
if not auth_hash:
return jsonify({'error': 'auth_hash is required for account deletion'}), 400
user = db.session.get(User, g.current_user_id)
if not verify_auth_token(auth_hash, user.master_hash):
AuditLog.log(
user_id=user.id,
action='auth.delete_account_failed',
resource_type='user',
resource_id=user.id,
detail='Account deletion rejected — password incorrect',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Incorrect password'}), 401
user_id = user.id
user_email = user.email
try:
# Log before delete (user row will be gone after commit)
AuditLog.log(
user_id=user_id,
action='auth.delete_account',
resource_type='user',
resource_id=user_id,
detail=f'Account permanently deleted: {user_email}',
ip_address=client_ip(),
)
db.session.delete(user)
db.session.commit()
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Account deletion failed: {str(e)}'}), 500
return jsonify({'message': 'Account deleted'}), 200
# ── Account Recovery ──────────────────────────────────────────────────────────
@auth_bp.route('/recovery/setup', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def recovery_setup():
"""
Store a recovery-key-encrypted copy of enc_key_salt.
The client generates a random 128-bit recovery code, derives a recovery key
from it (PBKDF2), encrypts enc_key_salt with that key (AES-256-GCM), and
sends the ciphertext + iv. The server stores these blobs — it never sees the
recovery code or enc_key_salt plaintext.
The recovery code is displayed to the user once and never stored server-side.
"""
data = request.get_json(silent=True) or {}
recovery_enc_salt = data.get('recovery_enc_salt', '').strip()
recovery_iv = data.get('recovery_iv', '').strip()
if not recovery_enc_salt or not recovery_iv:
return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400
user = db.session.get(User, g.current_user_id)
user.recovery_enc_salt = recovery_enc_salt
user.recovery_iv = recovery_iv
AuditLog.log(
user_id=user.id,
action='auth.recovery_setup',
resource_type='user',
resource_id=user.id,
detail='Account recovery code configured',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'Recovery code saved'}), 200
@auth_bp.route('/recovery/status', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def recovery_status():
"""Return whether the user has a recovery code configured."""
user = db.session.get(User, g.current_user_id)
return jsonify({'recovery_configured': bool(user.recovery_enc_salt)}), 200
@auth_bp.route('/recover', methods=['POST'])
@limiter.limit('5 per minute')
def recover_account():
"""
Recover account access using a recovery code.
Flow:
1. Client calls /recovery/data → receives enc_key_salt, recovery blobs, nonce.
2. Client decrypts recovery_enc_salt using the recovery key → gets enc_key_salt.
3. Client computes: recovery_proof = HMAC-SHA256(enc_key_salt_bytes, nonce).
4. Client derives new credentials and re-encrypts all vault items.
5. Client POSTs everything here in one atomic payload.
The server validates recovery_proof against the value stored in the DB
during /recovery/data — enc_key_salt is never sent in plaintext.
The challenge row is consumed (deleted) on first use to prevent replay.
Challenge state is stored in the database, not the Flask session, so the
flow works correctly across all Gunicorn workers.
"""
from app.models.recovery_challenge import RecoveryChallenge
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
new_auth_hash = data.get('new_auth_hash', '')
new_enc_key_salt = data.get('new_enc_key_salt', '')
client_proof = data.get('recovery_proof', '')
items = data.get('items', [])
if not all([email, new_auth_hash, new_enc_key_salt, client_proof]):
return jsonify({'error': 'email, new_auth_hash, new_enc_key_salt, and recovery_proof are required'}), 400
time.sleep(0.1) # timing mitigation
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery code found for this account'}), 404
# Consume the challenge — atomic read-and-delete from the DB.
# consume() returns None if the challenge is missing or expired.
challenge = RecoveryChallenge.consume(user.id)
if not challenge:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof):
AuditLog.log(
user_id=user.id,
action='auth.recovery_failed',
resource_type='user',
resource_id=user.id,
detail='Recovery attempt failed — incorrect recovery proof',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid recovery code'}), 401
try:
from app.models.vault_item import VaultItem
item_ids = [i.get('id') for i in items if i.get('id')]
existing = {
v.id: v
for v in VaultItem.query.filter(
VaultItem.user_id == user.id,
VaultItem.id.in_(item_ids),
).all()
} if item_ids else {}
for item_data in items:
item_id = item_data.get('id')
enc_data = item_data.get('enc_data', '')
iv = item_data.get('iv', '')
if not item_id or not enc_data or not iv:
continue
vault_item = existing.get(item_id)
if vault_item:
vault_item.enc_data = enc_data
vault_item.iv = iv
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt
# Recovery code is consumed — clear it so it cannot be reused.
user.recovery_enc_salt = None
user.recovery_iv = None
AuditLog.log(
user_id=user.id,
action='auth.recovery_success',
resource_type='user',
resource_id=user.id,
detail=f'Account recovered; {len(existing)} vault item(s) re-encrypted; recovery code consumed',
ip_address=client_ip(),
)
db.session.commit()
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Recovery failed: {str(e)}'}), 500
tokens = generate_tokens(user.id)
return jsonify({
'message': 'Account recovered successfully',
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'enc_key_salt': user.enc_key_salt,
}), 200
@auth_bp.route('/recovery/data', methods=['GET'])
@limiter.limit('10 per minute')
def recovery_data():
"""
Return the data the client needs to attempt recovery (unauthenticated).
Exposes: enc_key_salt, recovery_enc_salt, recovery_iv, and a one-time nonce.
The nonce is used for the HMAC-SHA256 challenge-response proof:
- Client decrypts recovery_enc_salt → gets enc_key_salt bytes.
- Client computes: proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce)
- Server stores expected proof in the DB (recovery_challenges table),
verifying it on /recover and /recovery/items without ever receiving
enc_key_salt in plaintext.
Returns 404 if no recovery code is configured (prevents user enumeration).
The challenge is stored in the database (not the Flask session cookie) so
it works correctly across all Gunicorn workers.
"""
import hashlib, hmac as _hmac
from app.models.recovery_challenge import RecoveryChallenge
email = (request.args.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'email is required'}), 400
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404
# Generate a fresh nonce and precompute the expected HMAC using the stored
# enc_key_salt. The client must return HMAC-SHA256(enc_key_salt, nonce).
# This proves it decrypted the recovery blob correctly without sending
# enc_key_salt in plaintext.
nonce = generate_recovery_nonce()
expected_proof = _hmac.new(
user.enc_key_salt.encode(),
nonce.encode(),
hashlib.sha256,
).hexdigest()
# Persist challenge in the DB — safe across all Gunicorn workers.
# RecoveryChallenge.create() deletes any previous challenge for this user
# before inserting, so a re-issued challenge always starts fresh.
RecoveryChallenge.create(
user_id=user.id,
nonce=nonce,
expected_proof=expected_proof,
)
db.session.commit()
return jsonify({
'recovery_enc_salt': user.recovery_enc_salt,
'recovery_iv': user.recovery_iv,
'nonce': nonce,
}), 200
@auth_bp.route('/recovery/items', methods=['GET'])
@limiter.limit('10 per minute')
def recovery_items():
"""
Return encrypted vault items for recovery re-encryption (unauthenticated).
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
The enc_key_salt used as the HMAC key is NOT returned by /recovery/data;
the client must derive it by decrypting the recovery blob with the recovery
code. This ensures only the holder of the recovery code can compute the proof.
Replay prevention: the challenge is consumed (deleted) on success, then
immediately re-issued with the same expected_proof but a new nonce and a
fresh TTL. This means each call to /recovery/items rotates the challenge,
so a captured X-Recovery-Proof header cannot be replayed by a third party.
POST /recover will consume the rotated challenge on final commit.
Items are returned as encrypted ciphertext blobs only.
"""
from app.models.recovery_challenge import RecoveryChallenge
email = (request.args.get('email') or '').strip().lower()
client_proof = request.headers.get('X-Recovery-Proof', '').strip()
if not email or not client_proof:
return jsonify({'error': 'email and X-Recovery-Proof header are required'}), 400
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404
# Consume the current challenge atomically.
challenge = RecoveryChallenge.consume(user.id)
if not challenge:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof):
AuditLog.log(
user_id=user.id,
action='auth.recovery_items_denied',
resource_type='user',
resource_id=user.id,
detail='Recovery items request denied — incorrect recovery proof',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid recovery proof'}), 401
# Re-issue a fresh challenge with the same expected_proof but a new nonce
# and TTL. POST /recover will consume this rotated challenge on final commit.
# The client continues to send the same proof value — no client change needed.
new_nonce = generate_recovery_nonce()
RecoveryChallenge.create(
user_id=user.id,
nonce=new_nonce,
expected_proof=challenge.expected_proof, # same proof, new nonce
)
db.session.commit()
from app.models.vault_item import VaultItem
items = VaultItem.query.filter_by(user_id=user.id).all()
return jsonify({
'items': [
{'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv}
for item in items
]
}), 200