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
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:
+32
-14
@@ -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'],
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@ import logging
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db, limiter, client_ip
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
from app.models.vault_item import VaultItem, ItemType
|
||||
from app.models.folder import Folder
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.services.auth_service import require_jwt
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
vault_bp = Blueprint('vault', __name__)
|
||||
|
||||
VALID_TYPES = {t.value for t in ItemType}
|
||||
|
||||
+32
-9
@@ -27,6 +27,7 @@ Challenge storage:
|
||||
is stored client-side (signed, not encrypted — the challenge is not secret).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import webauthn
|
||||
@@ -50,6 +51,8 @@ from app.services.auth_service import require_jwt, generate_tokens
|
||||
|
||||
webauthn_bp = Blueprint('webauthn', __name__)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Session key for the pending challenge bytes.
|
||||
_REG_CHALLENGE_KEY = 'webauthn_reg_challenge'
|
||||
_AUTH_CHALLENGE_KEY = 'webauthn_auth_challenge'
|
||||
@@ -113,7 +116,12 @@ def register_begin():
|
||||
user_display_name=user.email,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
# REQUIRED, not PREFERRED. A passkey here replaces BOTH the password
|
||||
# and the TOTP second factor, so the authenticator must actually
|
||||
# verify the human (biometric or PIN) rather than merely prove it is
|
||||
# present. Under PREFERRED an authenticator is free to skip that,
|
||||
# which reduced a full login to possession of an unlocked device.
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
authenticator_attachment=authenticator_attachment,
|
||||
),
|
||||
exclude_credentials=exclude_credentials,
|
||||
@@ -156,10 +164,18 @@ def register_complete():
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=_rp_id(),
|
||||
expected_origin=_origins(),
|
||||
require_user_verification=False,
|
||||
# Reject a credential created without user verification — otherwise
|
||||
# the REQUIRED hint above is only a request, not a guarantee.
|
||||
require_user_verification=True,
|
||||
)
|
||||
except (InvalidCBORData, InvalidRegistrationResponse, Exception) as e:
|
||||
return jsonify({'error': f'Registration verification failed: {str(e)}'}), 400
|
||||
except (InvalidCBORData, InvalidRegistrationResponse) as e:
|
||||
# Never echo str(e) to the client: py-webauthn messages quote raw
|
||||
# attestation internals. Log the detail, return a generic message.
|
||||
_log.warning('[PassKeeper] passkey registration rejected: %s', e)
|
||||
return jsonify({'error': 'Could not verify this passkey. Please try again.'}), 400
|
||||
except Exception:
|
||||
_log.exception('[PassKeeper] passkey registration failed unexpectedly')
|
||||
return jsonify({'error': 'Could not verify this passkey. Please try again.'}), 400
|
||||
|
||||
# Persist the new credential.
|
||||
import base64
|
||||
@@ -231,7 +247,8 @@ def authenticate_begin():
|
||||
options = webauthn.generate_authentication_options(
|
||||
rp_id=_rp_id(),
|
||||
allow_credentials=allow_credentials,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
# See register_begin — this assertion stands in for password + MFA.
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
)
|
||||
|
||||
session[_AUTH_CHALLENGE_KEY] = webauthn.options_to_json(options)
|
||||
@@ -296,15 +313,21 @@ def authenticate_complete():
|
||||
expected_origin=_origins(),
|
||||
credential_public_key=webauthn.base64url_to_bytes(credential.public_key),
|
||||
credential_current_sign_count=credential.sign_count,
|
||||
require_user_verification=False,
|
||||
# Enforced, not merely requested: the assertion must carry the UV
|
||||
# flag or it is not sufficient to stand in for two factors.
|
||||
require_user_verification=True,
|
||||
)
|
||||
except Exception:
|
||||
_log.warning(
|
||||
'[PassKeeper] passkey assertion rejected for credential id=%s',
|
||||
credential.id, exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
AuditLog.log(
|
||||
user_id=user.id,
|
||||
action='webauthn.auth_failed',
|
||||
resource_type='webauthn_credential',
|
||||
resource_id=credential.id,
|
||||
detail=f'Passkey authentication failed: {str(e)[:200]}',
|
||||
detail='Passkey authentication failed (assertion rejected)',
|
||||
ip_address=client_ip(),
|
||||
)
|
||||
db.session.commit()
|
||||
@@ -315,7 +338,7 @@ def authenticate_complete():
|
||||
credential.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Issue tokens.
|
||||
tokens = generate_tokens(user.id)
|
||||
tokens = generate_tokens(user.id, user.token_epoch)
|
||||
|
||||
AuditLog.log(
|
||||
user_id=user.id,
|
||||
|
||||
Reference in New Issue
Block a user