05/18 Enhanced codes and functionalities

This commit is contained in:
2026-05-18 11:38:28 -04:00
parent 22fff0660e
commit fc4145a78e
8 changed files with 197 additions and 13 deletions
+26 -8
View File
@@ -19,6 +19,8 @@ from app.services.auth_service import (
verify_recovery_proof,
generate_backup_codes,
verify_and_consume_backup_code,
is_totp_code_used,
mark_totp_code_used,
)
auth_bp = Blueprint('auth', __name__)
@@ -280,7 +282,11 @@ def mfa_enable():
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
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
@@ -324,7 +330,11 @@ def mfa_disable():
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)
@@ -380,7 +390,11 @@ def mfa_verify():
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 '[]')
@@ -454,8 +468,11 @@ def mfa_backup_codes_regenerate():
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)
@@ -886,7 +903,6 @@ def recovery_data():
db.session.commit()
return jsonify({
'enc_key_salt': user.enc_key_salt,
'recovery_enc_salt': user.recovery_enc_salt,
'recovery_iv': user.recovery_iv,
'nonce': nonce,
@@ -902,11 +918,12 @@ def recovery_items():
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
The server validates the proof against the value stored in the DB
during /recovery/data — enc_key_salt is never sent in plaintext.
The challenge row is NOT consumed here; it is consumed by the final
POST /recover call so the client can call this endpoint to preview
items before committing the full re-encryption.
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.
The challenge row is NOT consumed here — it is consumed by the final
POST /recover call so that endpoint can also validate the proof.
Items are returned as encrypted ciphertext blobs only.
"""
from app.models.recovery_challenge import RecoveryChallenge
@@ -921,7 +938,8 @@ def recovery_items():
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404
# Validate against the DB-stored challenge — safe across all workers.
# Validate against the DB-stored challenge without consuming it —
# POST /recover will consume it atomically on commit.
challenge = RecoveryChallenge.query.filter_by(user_id=user.id).first()
from datetime import datetime, timezone
if not challenge or challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):