05/02/2026 updated code for security
This commit is contained in:
+227
-53
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
import time
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, session
|
||||
from app import db, limiter
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
@@ -15,6 +15,10 @@ from app.services.auth_service import (
|
||||
require_jwt,
|
||||
encrypt_totp_secret,
|
||||
decrypt_totp_secret,
|
||||
generate_recovery_nonce,
|
||||
verify_recovery_proof,
|
||||
generate_backup_codes,
|
||||
verify_and_consume_backup_code,
|
||||
)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
@@ -66,6 +70,12 @@ def register():
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
@limiter.limit('10 per minute')
|
||||
def login():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# 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', '')
|
||||
@@ -76,20 +86,59 @@ def login():
|
||||
return jsonify({'error': 'Email and auth_hash are required'}), 400
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if not user or not verify_auth_token(auth_hash, user.master_hash):
|
||||
if user:
|
||||
|
||||
# Per-account lockout check — evaluated before password verification so the
|
||||
# check itself doesn't leak whether the account exists via timing.
|
||||
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_failed',
|
||||
action='auth.login_blocked',
|
||||
resource_type='user',
|
||||
resource_id=user.id,
|
||||
detail='Failed login attempt — invalid password',
|
||||
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
|
||||
|
||||
if not user or not verify_auth_token(auth_hash, user.master_hash):
|
||||
if user:
|
||||
user.failed_login_count = (user.failed_login_count or 0) + 1
|
||||
if user.failed_login_count >= MAX_FAILED_LOGINS:
|
||||
from datetime import timedelta
|
||||
user.locked_until = datetime.utcnow() + 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()
|
||||
return jsonify({'error': 'Invalid email or password'}), 401
|
||||
|
||||
from datetime import datetime
|
||||
# Successful authentication — reset lockout state.
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login = datetime.utcnow()
|
||||
|
||||
AuditLog.log(
|
||||
@@ -192,7 +241,8 @@ def mfa_setup():
|
||||
@auth_bp.route('/mfa/enable', methods=['POST'])
|
||||
@require_jwt
|
||||
def mfa_enable():
|
||||
"""Enable MFA after verifying the first TOTP code."""
|
||||
"""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
|
||||
@@ -213,45 +263,66 @@ def mfa_enable():
|
||||
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',
|
||||
detail='TOTP two-factor authentication enabled; backup codes generated',
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'MFA enabled successfully'}), 200
|
||||
return jsonify({
|
||||
'message': 'MFA enabled successfully',
|
||||
'backup_codes': plaintext_codes,
|
||||
}), 200
|
||||
|
||||
|
||||
@auth_bp.route('/mfa/disable', methods=['POST'])
|
||||
@require_jwt
|
||||
def mfa_disable():
|
||||
"""Disable MFA after verifying the current TOTP code."""
|
||||
"""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)
|
||||
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
|
||||
verified = False
|
||||
|
||||
if totp_code:
|
||||
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
|
||||
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',
|
||||
detail='TOTP two-factor authentication disabled; backup codes cleared',
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
db.session.commit()
|
||||
@@ -262,13 +333,15 @@ def mfa_disable():
|
||||
@auth_bp.route('/mfa/verify', methods=['POST'])
|
||||
@limiter.limit('10 per minute')
|
||||
def mfa_verify():
|
||||
"""Complete MFA login: verify TOTP code and exchange mfa_token for real tokens."""
|
||||
"""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:
|
||||
return jsonify({'error': 'mfa_token and totp_code are required'}), 400
|
||||
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)
|
||||
@@ -281,7 +354,27 @@ def mfa_verify():
|
||||
|
||||
import pyotp
|
||||
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
|
||||
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
|
||||
verified = False
|
||||
|
||||
if totp_code:
|
||||
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
|
||||
|
||||
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
|
||||
@@ -308,7 +401,54 @@ def mfa_verify():
|
||||
@require_jwt
|
||||
def mfa_status():
|
||||
user = db.session.get(User, g.current_user_id)
|
||||
return jsonify({'totp_enabled': user.totp_enabled}), 200
|
||||
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'])
|
||||
@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 not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
|
||||
return jsonify({'error': 'Invalid verification code'}), 400
|
||||
|
||||
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'])
|
||||
@@ -521,52 +661,56 @@ def recover_account():
|
||||
Recover account access using a recovery code.
|
||||
|
||||
Flow:
|
||||
1. Client looks up enc_key_salt and recovery blobs by email.
|
||||
2. Client decrypts enc_key_salt using the recovery key (derived from the recovery code).
|
||||
3. Client derives new auth_hash and new vault key with a new master password.
|
||||
4. Client re-encrypts all vault items with the new vault key.
|
||||
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.
|
||||
|
||||
This endpoint is unauthenticated — the recovery code is the credential.
|
||||
The server validates recovery_proof against the value precomputed during
|
||||
/recovery/data — enc_key_salt is never sent in plaintext.
|
||||
The nonce is consumed on first use to prevent replay.
|
||||
"""
|
||||
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', '')
|
||||
# Proof that the client successfully decrypted enc_key_salt:
|
||||
# the client re-derives auth_hash from the *original* enc_key_salt path
|
||||
# and sends it alongside the new credentials for server-side verification.
|
||||
recovery_proof = data.get('recovery_proof', '')
|
||||
client_proof = data.get('recovery_proof', '')
|
||||
items = data.get('items', [])
|
||||
|
||||
if not all([email, new_auth_hash, new_enc_key_salt, recovery_proof]):
|
||||
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
|
||||
|
||||
# Validate session binding.
|
||||
expected_proof = session.get('recovery_expected_proof', '')
|
||||
session_user_id = session.get('recovery_user_id')
|
||||
|
||||
if not expected_proof or not session_user_id:
|
||||
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if not user or not user.recovery_enc_salt:
|
||||
if not user or not user.recovery_enc_salt or user.id != session_user_id:
|
||||
return jsonify({'error': 'No recovery code found for this account'}), 404
|
||||
|
||||
# recovery_proof is the enc_key_salt re-encrypted by the client using the
|
||||
# recovery key — we return it as a blob for the client to verify, then
|
||||
# the client sends back the decrypted enc_key_salt as recovery_proof.
|
||||
# Simpler: recovery_proof = HMAC or simply the decrypted enc_key_salt itself,
|
||||
# which the client proves by sending it back plaintext. The server checks it
|
||||
# matches user.enc_key_salt — if the recovery code was wrong, decryption
|
||||
# would produce garbage that won't match.
|
||||
if recovery_proof != user.enc_key_salt:
|
||||
if not verify_recovery_proof(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 code',
|
||||
detail='Recovery attempt failed — incorrect recovery proof',
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
db.session.commit()
|
||||
return jsonify({'error': 'Invalid recovery code'}), 401
|
||||
|
||||
# Consume the nonce — one-time use only.
|
||||
session.pop('recovery_nonce', None)
|
||||
session.pop('recovery_expected_proof', None)
|
||||
session.pop('recovery_user_id', None)
|
||||
|
||||
try:
|
||||
from app.models.vault_item import VaultItem
|
||||
|
||||
@@ -589,7 +733,6 @@ def recover_account():
|
||||
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'):
|
||||
@@ -597,7 +740,7 @@ def recover_account():
|
||||
|
||||
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
|
||||
# Recovery code is consumed — clear it so it cannot be reused.
|
||||
user.recovery_enc_salt = None
|
||||
user.recovery_iv = None
|
||||
|
||||
@@ -628,9 +771,16 @@ def recover_account():
|
||||
def recovery_data():
|
||||
"""
|
||||
Return the data the client needs to attempt recovery (unauthenticated).
|
||||
Exposes only: enc_key_salt, recovery_enc_salt, recovery_iv.
|
||||
Returns 404 if no recovery code is configured (prevents user enumeration
|
||||
of which accounts have recovery set up — same response for unknown email).
|
||||
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 server-side session at challenge time,
|
||||
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).
|
||||
"""
|
||||
email = (request.args.get('email') or '').strip().lower()
|
||||
if not email:
|
||||
@@ -640,10 +790,28 @@ def recovery_data():
|
||||
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.
|
||||
import hashlib, hmac as _hmac
|
||||
nonce = generate_recovery_nonce()
|
||||
expected_proof = _hmac.new(
|
||||
user.enc_key_salt.encode(),
|
||||
nonce.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
# Store expected proof and bind it to the user — consumed on first use.
|
||||
session['recovery_nonce'] = nonce
|
||||
session['recovery_expected_proof'] = expected_proof
|
||||
session['recovery_user_id'] = user.id
|
||||
|
||||
return jsonify({
|
||||
'enc_key_salt': user.enc_key_salt,
|
||||
'recovery_enc_salt': user.recovery_enc_salt,
|
||||
'recovery_iv': user.recovery_iv,
|
||||
'nonce': nonce,
|
||||
}), 200
|
||||
|
||||
|
||||
@@ -653,24 +821,31 @@ def recovery_items():
|
||||
"""
|
||||
Return encrypted vault items for recovery re-encryption (unauthenticated).
|
||||
|
||||
Requires X-Recovery-Proof header containing the plaintext enc_key_salt.
|
||||
The server verifies it matches user.enc_key_salt — proof that the client
|
||||
correctly decrypted the recovery blob (i.e. has the correct recovery code).
|
||||
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
|
||||
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
|
||||
|
||||
Items are returned as encrypted ciphertext blobs only — no sensitive
|
||||
plaintext is exposed. The client re-encrypts them locally.
|
||||
The server validates the proof against the value precomputed and stored in
|
||||
flask.session during /recovery/data — enc_key_salt is never sent in plaintext.
|
||||
Items are returned as encrypted ciphertext blobs only.
|
||||
"""
|
||||
email = (request.args.get('email') or '').strip().lower()
|
||||
recovery_proof = request.headers.get('X-Recovery-Proof', '').strip()
|
||||
client_proof = request.headers.get('X-Recovery-Proof', '').strip()
|
||||
|
||||
if not email or not recovery_proof:
|
||||
if not email or not client_proof:
|
||||
return jsonify({'error': 'email and X-Recovery-Proof header are required'}), 400
|
||||
|
||||
# Validate session binding: proof must match what was issued to this session.
|
||||
expected_proof = session.get('recovery_expected_proof', '')
|
||||
session_user_id = session.get('recovery_user_id')
|
||||
|
||||
if not expected_proof or not session_user_id:
|
||||
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if not user or not user.recovery_enc_salt:
|
||||
if not user or not user.recovery_enc_salt or user.id != session_user_id:
|
||||
return jsonify({'error': 'No recovery data found'}), 404
|
||||
|
||||
if recovery_proof != user.enc_key_salt:
|
||||
if not verify_recovery_proof(expected_proof, client_proof):
|
||||
AuditLog.log(
|
||||
user_id=user.id,
|
||||
action='auth.recovery_items_denied',
|
||||
@@ -689,5 +864,4 @@ def recovery_items():
|
||||
{'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv}
|
||||
for item in items
|
||||
]
|
||||
}), 200
|
||||
|
||||
}), 200
|
||||
Reference in New Issue
Block a user