Aug 26 - Enhance security 2
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

This commit is contained in:
2026-08-26 12:54:17 -04:00
parent 82dd7c5aef
commit 6c1bef73c8
20 changed files with 1193 additions and 79 deletions
+32 -14
View File
@@ -4,7 +4,6 @@ import time
from flask import Blueprint, request, jsonify, g
_log = logging.getLogger(__name__)
from app import db, limiter, client_ip
from app.models.user import User
from app.models.audit_log import AuditLog
@@ -12,6 +11,7 @@ from app.services.auth_service import (
hash_auth_token,
verify_auth_token,
generate_tokens,
load_user_for_token,
generate_mfa_token,
decode_token,
blacklist_token,
@@ -26,6 +26,8 @@ from app.services.auth_service import (
mark_totp_code_used,
)
_log = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
@@ -285,7 +287,7 @@ def login():
'mfa_token': mfa_token,
}), 200
tokens = generate_tokens(user.id)
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
@@ -322,9 +324,17 @@ def refresh():
except Exception:
return jsonify({'error': 'Invalid or expired refresh token'}), 401
# Same gate as require_jwt: the account must still exist and the token's
# epoch must still match. Without this a refresh token captured before a
# password change could keep minting fresh access tokens for its full
# 7-day lifetime, defeating the revocation entirely.
user = load_user_for_token(payload)
if user is None:
return jsonify({'error': 'Session is no longer valid. Please log in again.'}), 401
# Rotate: blacklist old refresh token and issue fresh pair
blacklist_token(refresh_token, 'refresh')
tokens = generate_tokens(int(payload['sub']))
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
@@ -534,7 +544,7 @@ def mfa_verify():
)
db.session.commit()
tokens = generate_tokens(user.id)
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
@@ -684,9 +694,12 @@ def change_password():
items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...]
sharing_private_key_enc = data.get('sharing_private_key_enc', '')
sharing_private_key_iv = data.get('sharing_private_key_iv', '')
# Explicit opt-in to rotating the key while some items go un-re-encrypted.
# The client must have confirmed the resulting data loss with the user.
allow_partial = bool(data.get('allow_partial'))
# NOTE: there is deliberately no allow_partial opt-in here.
#
# Recovery needs one, because refusing outright leaves a locked-out user with
# no way into their account. Changing the password has no such pressure — the
# current password keeps working — so accepting data loss is never the right
# answer, and the server refuses regardless of what the client asks for.
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
@@ -708,9 +721,7 @@ def change_password():
try:
# Refuse the rotation outright unless every item was re-encrypted —
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
updated, total = _apply_reencrypted_items(
user.id, items, allow_partial=allow_partial
)
updated, total = _apply_reencrypted_items(user.id, items)
# Update credentials
user.master_hash = hash_auth_token(new_auth_hash)
@@ -719,6 +730,10 @@ def change_password():
user.recovery_enc_salt = None
user.recovery_iv = None
user.recovery_verifier = None
# Revoke every token issued under the old password. Without this the
# "Please log in again" message below is advisory only — outstanding
# refresh tokens would stay valid for their full 7-day lifetime.
user.token_epoch = (user.token_epoch or 0) + 1
# Re-encrypt sharing private key with new vault key if the client sent it.
# Without this update, the old ciphertext would be undecryptable after key rotation.
if sharing_private_key_enc and sharing_private_key_iv:
@@ -731,9 +746,8 @@ def change_password():
resource_type='user',
resource_id=user.id,
detail=(
f'Master password changed; {updated}/{total} vault item(s) re-encrypted'
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
'recovery code cleared'
f'Master password changed; {updated}/{total} vault item(s) '
're-encrypted; recovery code cleared'
),
ip_address=client_ip(),
)
@@ -962,6 +976,10 @@ def recover_account():
user.recovery_enc_salt = None
user.recovery_iv = None
user.recovery_verifier = None
# Recovery resets the master password, so revoke prior sessions too —
# an attacker holding a stolen token must not survive the victim
# recovering their account.
user.token_epoch = (user.token_epoch or 0) + 1
AuditLog.log(
user_id=user.id,
@@ -1008,7 +1026,7 @@ def recover_account():
_log.exception('recover_account failed for user %s', user.id)
return jsonify({'error': 'Account recovery failed. Please try again.'}), 500
tokens = generate_tokens(user.id)
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'message': 'Account recovered successfully',
'access_token': tokens['access_token'],