"""The second factor: what it gates, and what the half-finished state can reach.
A real assertion needs an authenticator, so the cryptography is the library's to
test. What is ours - and what a mistake here would quietly break - is the
*state machine*: a correct password must stop short of a session when a passkey
is enrolled, and the pending state it leaves behind must grant nothing.
"""
import pytest
from app.core import auth as auth_core
from app.core import webauthn as webauthn_core
from app.core.context import Forbidden, Invalid, NotFound
from db.db_declarations import Organization, WebauthnCredential
from utils.db_wrapper import db_session
from .conftest import PASSWORD
def _enrol(session, user, credential_id="cred-1", name="Phone"):
cred = WebauthnCredential(user_id=user.id, credential_id=credential_id,
public_key="not-a-real-key", sign_count=0, name=name)
session.add(cred)
session.commit()
return cred
# --- core -------------------------------------------------------------------
def test_has_credentials_is_the_question_sign_in_asks(session):
user = auth_core.create_user(session, "dev@example.com", PASSWORD)
assert webauthn_core.has_credentials(session, user.id) is False
_enrol(session, user)
assert webauthn_core.has_credentials(session, user.id) is True
def test_options_refuse_an_account_with_no_authenticator(session):
user = auth_core.create_user(session, "dev@example.com", PASSWORD)
rp = webauthn_core.RelyingParty(rp_id="localhost", origin="http://localhost", name="t")
with pytest.raises(Invalid):
webauthn_core.authentication_options(session, user, rp)
def test_a_credential_belongs_to_one_account(session):
"""A valid assertion from somebody else's authenticator must not complete
this user's sign-in, so the lookup is scoped to user_id."""
org = Organization(name="Acme", slug="acme")
session.add(org)
session.commit()
mine = auth_core.create_user(session, "mine@example.com", PASSWORD)
theirs = auth_core.create_user(session, "theirs@example.com", PASSWORD)
auth_core.add_membership(session, mine, org, "owner")
_enrol(session, theirs, credential_id="theirs-1")
rp = webauthn_core.RelyingParty(rp_id="localhost", origin="http://localhost", name="t")
with pytest.raises(Forbidden):
webauthn_core.verify_authentication(
session, mine, rp, {"id": "theirs-1"}, "a-challenge")
def test_delete_is_scoped_to_the_caller(session):
"""Nobody can strip another account's second factor."""
org = Organization(name="Acme", slug="acme")
session.add(org)
session.commit()
mine = auth_core.create_user(session, "mine@example.com", PASSWORD)
theirs = auth_core.create_user(session, "theirs@example.com", PASSWORD)
auth_core.add_membership(session, mine, org, "owner")
cred = _enrol(session, theirs, credential_id="theirs-1")
principal = auth_core.principal_for_user(session, mine)
with pytest.raises(NotFound):
webauthn_core.delete_credential(session, principal, cred.id)
assert session.query(WebauthnCredential).count() == 1
def test_a_missing_challenge_is_refused(session):
"""Empty rather than wrong: without this the library would be handed b"" as
the expected challenge, and the error would name the wrong thing."""
user = auth_core.create_user(session, "dev@example.com", PASSWORD)
_enrol(session, user)
rp = webauthn_core.RelyingParty(rp_id="localhost", origin="http://localhost", name="t")
with pytest.raises(Forbidden):
webauthn_core.verify_authentication(session, user, rp, {"id": "cred-1"}, "")
# --- the façade -------------------------------------------------------------
def test_a_password_alone_stops_short_when_a_passkey_is_enrolled(account):
client, user, _ = account
client.get("/logout")
_enrol(db_session, user)
r = client.post("/api/login",
json={"email": user.email, "password": PASSWORD})
assert r.status_code == 200
assert r.get_json() == {"mfa_required": True}
# The pending state is not a session: "/" still bounces to the sign-in page.
assert client.get("/").headers["Location"].endswith("/login")
# And it reaches no API either.
assert client.get("/api/me").status_code == 401
def test_the_challenge_endpoint_refuses_a_stranger(app_client):
"""Not an oracle: without the pending state a passer-by cannot ask whether an
address has a passkey."""
r = app_client.post("/api/login/webauthn/options")
assert r.status_code == 401
def test_the_verify_endpoint_refuses_without_a_challenge(account):
client, user, _ = account
client.get("/logout")
_enrol(db_session, user)
client.post("/api/login", json={"email": user.email, "password": PASSWORD})
# No options call, so no challenge was ever stashed.
r = client.post("/api/login/webauthn/verify", json={"credential": {"id": "cred-1"}})
assert r.status_code == 401
def test_approval_is_rechecked_at_the_second_leg(account):
"""The password leg already refused a pending account, so this can only fire
if approval was withdrawn between the two calls. It is checked because this
is the call that mints the session."""
client, user, _ = account
client.get("/logout")
_enrol(db_session, user)
client.post("/api/login", json={"email": user.email, "password": PASSWORD})
user.approved_at = None
db_session.commit()
r = client.post("/api/login/webauthn/verify", json={"credential": {"id": "cred-1"}})
# 401 for the missing challenge or 403 for the withdrawn approval - either
# way no session is minted, which is the property under test.
assert r.status_code in (401, 403)
assert client.get("/api/me").status_code == 401