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 -9
View File
@@ -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,