Adds phishing-resistant 2nd factor via FIDO2 hardware keys (YubiKey etc.) and device biometrics (Touch ID, Windows Hello, etc.). Reuses the existing B-2.5 TOTP gate so a passkey is a 3rd valid option on /2fa/verify, alongside TOTP code and recovery code. Post-login enrolment lives at /2fa/passkey/setup. Wraps python-webauthn==2.5.2 in a thin service layer (src/auth/webauthn.py) that persists credentials in the existing User.webauthn_credentials JSON column (added in B-2.1 — no schema change). Each credential dict carries id, public_key, sign_count, transports, name, and created_at. sign_count is updated after every successful authentication for WebAuthn anti-cloning (§6.1.1). Backend: 6 new auth routes (passkey_setup, register/begin, register/finish, delete, auth/begin, auth/finish). The 4 JSON endpoints are CSRF-exempt at Flask-WTF level because CSRFProtect cannot read tokens from a JSON body without app-wide config; the X-CSRFToken header is still sent as defence-in-depth. The form-POST delete route DOES enforce CSRF. The @csrf_exempt decorator was previously a no-op label; init_auth_extensions now walks module-level functions and applies real csrf.exempt() to any flagged with _csrf_exempt=True. Login gate now fires when the user has TOTP enabled OR at least one passkey, and totp_verify_login passes has_passkeys + has_totp flags so the template can show only the relevant sections. Frontend: templates/auth/totp_verify.html updated IN PLACE with a passkey button section (above TOTP) and an "ou" divider. New templates/auth/passkey_setup.html for managing/enrolling passkeys. New static/js/webauthn-client.js (no external deps, ES2020) wraps navigator.credentials and exchanges base64url payloads with the backend. Tailwind CSS rebuilt. Tests: 22 new tests in tests/test_webauthn_passkey.py covering the service layer (b64url helpers, RP config, list/has, begin/finish for both registration and authentication, delete) and the route flow (CSRF-exempt JSON endpoints, login gate redirection, sign_count anti-cloning persistence). Mocks python-webauthn's verify_* functions so tests run without a real authenticator. Windows manual driver follows the existing no-conftest pattern. Self-review: 22/22 new tests pass; 21/21 prior TOTP, 16/16 email, 21/21 OAuth tests still pass (no regression). Env: config/env.oauth.example documents WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGIN with full deployment notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
682 lines
28 KiB
Python
682 lines
28 KiB
Python
"""Tests for B-2.6 — WebAuthn / Passkey support.
|
|
|
|
Covers:
|
|
- Service layer helpers: b64url encode/decode, RP getters, list/has_passkeys.
|
|
- begin_registration returns json-safe options + challenge.
|
|
- finish_registration persists the credential dict in user.webauthn_credentials.
|
|
- begin_authentication raises if user has no credentials.
|
|
- finish_authentication increments sign_count (anti-cloning) + rejects unknown id.
|
|
- delete_credential removes by id.
|
|
- Routes: register/begin, register/finish (success + missing challenge + invalid),
|
|
auth/begin (with/without pending session), auth/finish (success + invalid),
|
|
delete (form POST), and the login gate that defers when has_passkeys.
|
|
|
|
Mocks the python-webauthn library functions so the tests don't need a real
|
|
authenticator. The library is installed (webauthn==2.5.2) so imports work,
|
|
but the verify_* functions are patched.
|
|
|
|
Note: pytest cannot collect this file on Windows native because src/init_db.py
|
|
imports `fcntl` (POSIX-only). Use tests/_run_webauthn_passkey_windows.py.
|
|
"""
|
|
import os
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key-webauthn')
|
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
|
|
|
|
from src.app import app, db, bcrypt # noqa: E402
|
|
from src.models.user import User # noqa: E402
|
|
from src.auth import webauthn as wa # noqa: E402
|
|
|
|
|
|
def _disable_csrf():
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
|
|
|
|
def _make_user(email='passkey@example.qc.ca', password='Password!123',
|
|
username=None, webauthn_credentials=None, totp_enabled=False,
|
|
totp_secret_encrypted=None, totp_recovery_codes=None):
|
|
"""Create a User row with bcrypt-hashed password. Returns the User."""
|
|
hashed = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
u = User(
|
|
username=username or email.split('@', 1)[0][:20],
|
|
email=email,
|
|
password=hashed,
|
|
email_verified=True,
|
|
webauthn_credentials=webauthn_credentials,
|
|
totp_enabled=totp_enabled,
|
|
totp_secret_encrypted=totp_secret_encrypted,
|
|
totp_recovery_codes=totp_recovery_codes,
|
|
)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
return u
|
|
|
|
|
|
def _login_session(client, user):
|
|
"""Mark a Flask-Login session as logged in for `user`."""
|
|
with client.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
sess['_fresh'] = True
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 1-3. Helpers + RP config
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_b64url_encode_decode_round_trips():
|
|
"""bytes → str → bytes round-trips for various lengths (incl. padding edge)."""
|
|
samples = [b'', b'A', b'AB', b'ABC', b'ABCD', b'\x00\xff\x10' * 20]
|
|
for raw in samples:
|
|
encoded = wa._b64url_encode(raw)
|
|
assert isinstance(encoded, str)
|
|
assert '=' not in encoded # no padding
|
|
assert wa._b64url_decode(encoded) == raw
|
|
|
|
|
|
def test_get_rp_id_default():
|
|
"""get_rp_id returns 'localhost' when WEBAUTHN_RP_ID not set."""
|
|
saved = os.environ.pop('WEBAUTHN_RP_ID', None)
|
|
try:
|
|
assert wa.get_rp_id() == 'localhost'
|
|
finally:
|
|
if saved is not None:
|
|
os.environ['WEBAUTHN_RP_ID'] = saved
|
|
|
|
|
|
def test_get_rp_id_from_env():
|
|
"""get_rp_id returns the env var value when set."""
|
|
saved = os.environ.get('WEBAUTHN_RP_ID')
|
|
try:
|
|
os.environ['WEBAUTHN_RP_ID'] = 'dictia.ca'
|
|
assert wa.get_rp_id() == 'dictia.ca'
|
|
finally:
|
|
if saved is None:
|
|
os.environ.pop('WEBAUTHN_RP_ID', None)
|
|
else:
|
|
os.environ['WEBAUTHN_RP_ID'] = saved
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 4-5. list_user_credentials / has_passkeys
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_list_user_credentials_empty():
|
|
"""Returns [] when webauthn_credentials is None."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='listempty@example.qc.ca')
|
|
assert wa.list_user_credentials(user) == []
|
|
assert wa.has_passkeys(user) is False
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_has_passkeys_true_after_credential_added():
|
|
"""has_passkeys returns True after a credential dict is appended."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(
|
|
email='hasone@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': 'AQID', 'public_key': 'oLDA',
|
|
'sign_count': 0, 'transports': ['usb'],
|
|
'name': 'Test', 'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
assert wa.has_passkeys(user) is True
|
|
assert len(wa.list_user_credentials(user)) == 1
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 6. begin_registration
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_begin_registration_returns_options_and_challenge():
|
|
"""Mocks generate_registration_options; asserts json-safe dict + challenge str."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='regbegin@example.qc.ca')
|
|
fake_challenge = b'\x01\x02\x03\x04challenge-bytes\xff'
|
|
with patch('src.auth.webauthn.generate_registration_options') as gen, \
|
|
patch('src.auth.webauthn.options_to_json') as to_json:
|
|
gen.return_value = SimpleNamespace(challenge=fake_challenge)
|
|
to_json.return_value = (
|
|
'{"challenge": "AQIDBGNoYWxsZW5nZS1ieXRlc_8", '
|
|
'"rp": {"name": "DictIA", "id": "localhost"}, '
|
|
'"user": {"id": "MQ", "name": "x", "displayName": "x"}}'
|
|
)
|
|
opts, challenge_b64 = wa.begin_registration(user)
|
|
assert isinstance(opts, dict)
|
|
assert 'challenge' in opts
|
|
assert 'rp' in opts
|
|
assert isinstance(challenge_b64, str)
|
|
assert wa._b64url_decode(challenge_b64) == fake_challenge
|
|
gen.assert_called_once()
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 7. finish_registration
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_finish_registration_persists_credential():
|
|
"""Mocks verify_registration_response; asserts credential dict appended."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='regfin@example.qc.ca')
|
|
challenge = b'challenge-bytes-xyz'
|
|
challenge_b64 = wa._b64url_encode(challenge)
|
|
mock_verification = SimpleNamespace(
|
|
credential_id=b'\xaa\xbb\xcc',
|
|
credential_public_key=b'\x01\x02\x03',
|
|
sign_count=0,
|
|
)
|
|
response_json = {
|
|
'id': wa._b64url_encode(b'\xaa\xbb\xcc'),
|
|
'rawId': wa._b64url_encode(b'\xaa\xbb\xcc'),
|
|
'type': 'public-key',
|
|
'response': {
|
|
'clientDataJSON': 'aaa',
|
|
'attestationObject': 'bbb',
|
|
'transports': ['usb', 'nfc'],
|
|
},
|
|
}
|
|
with patch('src.auth.webauthn.verify_registration_response',
|
|
return_value=mock_verification) as ver:
|
|
cred = wa.finish_registration(
|
|
user, response_json, challenge_b64, label='YubiKey 5C'
|
|
)
|
|
ver.assert_called_once()
|
|
assert cred['id'] == wa._b64url_encode(b'\xaa\xbb\xcc')
|
|
assert cred['public_key'] == wa._b64url_encode(b'\x01\x02\x03')
|
|
assert cred['sign_count'] == 0
|
|
assert cred['transports'] == ['usb', 'nfc']
|
|
assert cred['name'] == 'YubiKey 5C'
|
|
assert 'created_at' in cred
|
|
db.session.refresh(user)
|
|
assert len(user.webauthn_credentials) == 1
|
|
assert user.webauthn_credentials[0]['id'] == cred['id']
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 8. begin_authentication
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_begin_authentication_raises_when_no_credentials():
|
|
"""ValueError when user has no registered passkeys."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='noauthcreds@example.qc.ca')
|
|
try:
|
|
wa.begin_authentication(user)
|
|
raise AssertionError('Expected ValueError')
|
|
except ValueError:
|
|
pass
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 9-10. finish_authentication
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_finish_authentication_increments_sign_count():
|
|
"""Pre-set 1 cred sign_count=5; mock verify returns 6; assert persisted."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
cred_id = wa._b64url_encode(b'\xde\xad\xbe\xef')
|
|
user = _make_user(
|
|
email='signinc@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': cred_id,
|
|
'public_key': wa._b64url_encode(b'pub'),
|
|
'sign_count': 5,
|
|
'transports': ['internal'],
|
|
'name': 'Touch ID',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
challenge_b64 = wa._b64url_encode(b'auth-challenge')
|
|
mock_ver = SimpleNamespace(new_sign_count=6)
|
|
response_json = {'id': cred_id, 'type': 'public-key', 'response': {}}
|
|
with patch('src.auth.webauthn.verify_authentication_response',
|
|
return_value=mock_ver):
|
|
matched = wa.finish_authentication(
|
|
user, response_json, challenge_b64
|
|
)
|
|
assert matched['id'] == cred_id
|
|
assert matched['sign_count'] == 6
|
|
db.session.refresh(user)
|
|
assert user.webauthn_credentials[0]['sign_count'] == 6
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_finish_authentication_rejects_unknown_credential_id():
|
|
"""Response.id not in user's credentials → ValueError."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(
|
|
email='unknownid@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': 'KNOWN', 'public_key': 'pub', 'sign_count': 0,
|
|
'transports': [], 'name': 'A',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
response_json = {'id': 'NOT-REGISTERED', 'response': {}}
|
|
try:
|
|
wa.finish_authentication(
|
|
user, response_json, wa._b64url_encode(b'c')
|
|
)
|
|
raise AssertionError('Expected ValueError')
|
|
except ValueError:
|
|
pass
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 11-12. delete_credential
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_delete_credential_removes_by_id():
|
|
"""Pre-set 2 creds; delete one; remaining list shrinks."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(
|
|
email='delok@example.qc.ca',
|
|
webauthn_credentials=[
|
|
{'id': 'A', 'public_key': 'p1', 'sign_count': 0,
|
|
'transports': [], 'name': 'A',
|
|
'created_at': '2026-04-27T00:00:00+00:00'},
|
|
{'id': 'B', 'public_key': 'p2', 'sign_count': 0,
|
|
'transports': [], 'name': 'B',
|
|
'created_at': '2026-04-27T00:00:00+00:00'},
|
|
],
|
|
)
|
|
assert wa.delete_credential(user, 'A') is True
|
|
db.session.refresh(user)
|
|
assert len(user.webauthn_credentials) == 1
|
|
assert user.webauthn_credentials[0]['id'] == 'B'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_delete_credential_returns_false_for_unknown_id():
|
|
"""Bogus id → False, list unchanged."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(
|
|
email='delno@example.qc.ca',
|
|
webauthn_credentials=[
|
|
{'id': 'X', 'public_key': 'p', 'sign_count': 0,
|
|
'transports': [], 'name': 'X',
|
|
'created_at': '2026-04-27T00:00:00+00:00'},
|
|
],
|
|
)
|
|
assert wa.delete_credential(user, 'NOPE') is False
|
|
db.session.refresh(user)
|
|
assert len(user.webauthn_credentials) == 1
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 13-16. /2fa/passkey/register routes
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_passkey_register_begin_returns_options_for_logged_in_user():
|
|
"""POST /2fa/passkey/register/begin → JSON options + session challenge set."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='regbeginrt@example.qc.ca')
|
|
fake_challenge = b'route-begin-challenge'
|
|
with app.test_client() as client:
|
|
_login_session(client, user)
|
|
with patch('src.auth.webauthn.generate_registration_options') as gen, \
|
|
patch('src.auth.webauthn.options_to_json') as to_json:
|
|
gen.return_value = SimpleNamespace(challenge=fake_challenge)
|
|
to_json.return_value = '{"challenge": "x", "rp": {"id": "localhost"}}'
|
|
resp = client.post('/2fa/passkey/register/begin', json={})
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert 'challenge' in data
|
|
assert 'rp' in data
|
|
with client.session_transaction() as sess:
|
|
assert sess.get('webauthn_register_challenge') == \
|
|
wa._b64url_encode(fake_challenge)
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_passkey_register_finish_persists_credential():
|
|
"""POST /2fa/passkey/register/finish with challenge in session + mocked verify."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='regfinrt@example.qc.ca')
|
|
challenge = b'finish-route-challenge'
|
|
challenge_b64 = wa._b64url_encode(challenge)
|
|
mock_ver = SimpleNamespace(
|
|
credential_id=b'\x10\x20\x30',
|
|
credential_public_key=b'\x40\x50',
|
|
sign_count=0,
|
|
)
|
|
with app.test_client() as client:
|
|
_login_session(client, user)
|
|
with client.session_transaction() as sess:
|
|
sess['webauthn_register_challenge'] = challenge_b64
|
|
with patch('src.auth.webauthn.verify_registration_response',
|
|
return_value=mock_ver):
|
|
resp = client.post('/2fa/passkey/register/finish', json={
|
|
'label': 'My Key',
|
|
'response': {
|
|
'id': wa._b64url_encode(b'\x10\x20\x30'),
|
|
'rawId': wa._b64url_encode(b'\x10\x20\x30'),
|
|
'type': 'public-key',
|
|
'response': {
|
|
'clientDataJSON': 'aa', 'attestationObject': 'bb',
|
|
'transports': ['usb'],
|
|
},
|
|
},
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data['ok'] is True
|
|
assert data['credential']['name'] == 'My Key'
|
|
db.session.refresh(user)
|
|
assert len(user.webauthn_credentials) == 1
|
|
# Challenge consumed
|
|
with client.session_transaction() as sess:
|
|
assert 'webauthn_register_challenge' not in sess
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_passkey_register_finish_rejects_without_challenge():
|
|
"""POST /2fa/passkey/register/finish without session challenge → 400 FR."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='regnoch@example.qc.ca')
|
|
with app.test_client() as client:
|
|
_login_session(client, user)
|
|
resp = client.post('/2fa/passkey/register/finish', json={
|
|
'response': {'id': 'x', 'response': {}},
|
|
})
|
|
assert resp.status_code == 400
|
|
data = resp.get_json()
|
|
assert data['error'] == 'no_challenge'
|
|
# French message
|
|
assert 'défi' in data['message'].lower() or 'attente' in data['message'].lower()
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_passkey_register_finish_rejects_invalid_response():
|
|
"""verify_registration_response raises → 400 with FR message."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='regbad@example.qc.ca')
|
|
challenge_b64 = wa._b64url_encode(b'c')
|
|
with app.test_client() as client:
|
|
_login_session(client, user)
|
|
with client.session_transaction() as sess:
|
|
sess['webauthn_register_challenge'] = challenge_b64
|
|
with patch('src.auth.webauthn.verify_registration_response',
|
|
side_effect=Exception('bad')):
|
|
resp = client.post('/2fa/passkey/register/finish', json={
|
|
'response': {'id': 'x', 'response': {}},
|
|
})
|
|
assert resp.status_code == 400
|
|
data = resp.get_json()
|
|
assert data['error'] == 'verification_failed'
|
|
assert 'vérification' in data['message'].lower() or \
|
|
'échec' in data['message'].lower()
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 17-20. /2fa/passkey/auth routes
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_passkey_auth_begin_uses_pending_session_user():
|
|
"""POST /2fa/passkey/auth/begin with pending_totp_user_id → options."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
cred_id = wa._b64url_encode(b'authcred')
|
|
user = _make_user(
|
|
email='authbegin@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': cred_id, 'public_key': wa._b64url_encode(b'pub'),
|
|
'sign_count': 0, 'transports': [], 'name': 'A',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
fake_challenge = b'auth-route-challenge'
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess['pending_totp_user_id'] = user.id
|
|
with patch('src.auth.webauthn.generate_authentication_options') as gen, \
|
|
patch('src.auth.webauthn.options_to_json') as to_json:
|
|
gen.return_value = SimpleNamespace(challenge=fake_challenge)
|
|
to_json.return_value = '{"challenge": "x", "rpId": "localhost"}'
|
|
resp = client.post('/2fa/passkey/auth/begin', json={})
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert 'challenge' in data
|
|
with client.session_transaction() as sess:
|
|
assert sess.get('webauthn_auth_challenge') == \
|
|
wa._b64url_encode(fake_challenge)
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_passkey_auth_begin_returns_400_without_pending_session():
|
|
"""No pending_totp_user_id → 400 with FR error."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
with app.test_client() as client:
|
|
resp = client.post('/2fa/passkey/auth/begin', json={})
|
|
assert resp.status_code == 400
|
|
data = resp.get_json()
|
|
assert data['error'] == 'no_session'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_passkey_auth_finish_logs_in_user_and_returns_redirect():
|
|
"""Successful auth → ok+redirect, user logged in via session."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
cred_id = wa._b64url_encode(b'\xaa\xbb')
|
|
user = _make_user(
|
|
email='authfin@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': cred_id, 'public_key': wa._b64url_encode(b'p'),
|
|
'sign_count': 3, 'transports': [], 'name': 'X',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
challenge_b64 = wa._b64url_encode(b'authch')
|
|
mock_ver = SimpleNamespace(new_sign_count=4)
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess['pending_totp_user_id'] = user.id
|
|
sess['webauthn_auth_challenge'] = challenge_b64
|
|
sess['pending_totp_remember'] = True
|
|
with patch('src.auth.webauthn.verify_authentication_response',
|
|
return_value=mock_ver):
|
|
resp = client.post('/2fa/passkey/auth/finish', json={
|
|
'response': {'id': cred_id, 'response': {}},
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data['ok'] is True
|
|
assert 'redirect' in data
|
|
with client.session_transaction() as sess:
|
|
assert sess.get('_user_id') == str(user.id)
|
|
assert 'pending_totp_user_id' not in sess
|
|
assert 'webauthn_auth_challenge' not in sess
|
|
# sign_count incremented in DB
|
|
db.session.refresh(user)
|
|
assert user.webauthn_credentials[0]['sign_count'] == 4
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_passkey_auth_finish_rejects_invalid():
|
|
"""verify raises → 400 verification_failed; user NOT logged in."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
cred_id = wa._b64url_encode(b'\x99')
|
|
user = _make_user(
|
|
email='authbad@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': cred_id, 'public_key': wa._b64url_encode(b'p'),
|
|
'sign_count': 0, 'transports': [], 'name': 'X',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
challenge_b64 = wa._b64url_encode(b'c')
|
|
with app.test_client() as client:
|
|
with client.session_transaction() as sess:
|
|
sess['pending_totp_user_id'] = user.id
|
|
sess['webauthn_auth_challenge'] = challenge_b64
|
|
with patch('src.auth.webauthn.verify_authentication_response',
|
|
side_effect=Exception('nope')):
|
|
resp = client.post('/2fa/passkey/auth/finish', json={
|
|
'response': {'id': cred_id, 'response': {}},
|
|
})
|
|
assert resp.status_code == 400
|
|
data = resp.get_json()
|
|
assert data['error'] == 'verification_failed'
|
|
with client.session_transaction() as sess:
|
|
assert '_user_id' not in sess
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 21. Delete route (form POST)
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_passkey_delete_route_removes_credential():
|
|
"""POST /2fa/passkey/credentials/<id>/delete removes it."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(
|
|
email='delroute@example.qc.ca',
|
|
webauthn_credentials=[{
|
|
'id': 'TARGET', 'public_key': 'p', 'sign_count': 0,
|
|
'transports': [], 'name': 'Target',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
with app.test_client() as client:
|
|
_login_session(client, user)
|
|
resp = client.post(
|
|
'/2fa/passkey/credentials/TARGET/delete',
|
|
follow_redirects=False,
|
|
)
|
|
assert resp.status_code == 302
|
|
db.session.refresh(user)
|
|
assert (user.webauthn_credentials or []) == []
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 22. Login gate extension
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_login_gate_redirects_to_2fa_when_user_has_passkey():
|
|
"""Login with valid password + has_passkeys → 302 to /2fa/verify."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
password = 'CorrectHorseBattery!42'
|
|
user = _make_user(
|
|
email='gatepasskey@example.qc.ca', password=password,
|
|
totp_enabled=False,
|
|
webauthn_credentials=[{
|
|
'id': 'GATE', 'public_key': 'p', 'sign_count': 0,
|
|
'transports': [], 'name': 'Gate',
|
|
'created_at': '2026-04-27T00:00:00+00:00',
|
|
}],
|
|
)
|
|
with app.test_client() as client:
|
|
resp = client.post('/login', data={
|
|
'email': 'gatepasskey@example.qc.ca',
|
|
'password': password,
|
|
'remember': 'y',
|
|
})
|
|
assert resp.status_code == 302
|
|
assert '/2fa/verify' in resp.headers['Location']
|
|
with client.session_transaction() as sess:
|
|
assert sess.get('pending_totp_user_id') == user.id
|
|
assert sess.get('pending_totp_remember') is True
|
|
# NOT logged in yet
|
|
assert '_user_id' not in sess
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|