04/18 Enhance app functionalities: master password reset, account deletion/recovery
This commit is contained in:
@@ -309,3 +309,375 @@ def mfa_verify():
|
||||
def mfa_status():
|
||||
user = db.session.get(User, g.current_user_id)
|
||||
return jsonify({'totp_enabled': user.totp_enabled}), 200
|
||||
|
||||
|
||||
@auth_bp.route('/me', methods=['GET'])
|
||||
@require_jwt
|
||||
def me():
|
||||
"""Return basic profile info for the authenticated user."""
|
||||
user = db.session.get(User, g.current_user_id)
|
||||
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,
|
||||
'recovery_configured': bool(user.recovery_enc_salt),
|
||||
}), 200
|
||||
|
||||
|
||||
# ── Account management ────────────────────────────────────────────────────────
|
||||
|
||||
@auth_bp.route('/change-password', methods=['POST'])
|
||||
@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
|
||||
|
||||
# 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'])
|
||||
@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'])
|
||||
@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'])
|
||||
@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 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.
|
||||
5. Client POSTs everything here in one atomic payload.
|
||||
|
||||
This endpoint is unauthenticated — the recovery code is the credential.
|
||||
"""
|
||||
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', '')
|
||||
items = data.get('items', [])
|
||||
|
||||
if not all([email, new_auth_hash, new_enc_key_salt, recovery_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
|
||||
|
||||
# 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:
|
||||
AuditLog.log(
|
||||
user_id=user.id,
|
||||
action='auth.recovery_failed',
|
||||
resource_type='user',
|
||||
resource_id=user.id,
|
||||
detail='Recovery attempt failed — incorrect recovery code',
|
||||
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
|
||||
|
||||
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 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).
|
||||
"""
|
||||
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
|
||||
|
||||
return jsonify({
|
||||
'enc_key_salt': user.enc_key_salt,
|
||||
'recovery_enc_salt': user.recovery_enc_salt,
|
||||
'recovery_iv': user.recovery_iv,
|
||||
}), 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 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).
|
||||
|
||||
Items are returned as encrypted ciphertext blobs only — no sensitive
|
||||
plaintext is exposed. The client re-encrypts them locally.
|
||||
"""
|
||||
email = (request.args.get('email') or '').strip().lower()
|
||||
recovery_proof = request.headers.get('X-Recovery-Proof', '').strip()
|
||||
|
||||
if not email or not recovery_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
|
||||
|
||||
if recovery_proof != user.enc_key_salt:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user