"""Password recovery: the emailed link, and what consuming it does.
Two things are worth pinning here beyond the happy path. A reset must not become
a way to learn whether an address has an account, and it must clear enrolled
authenticators - which is a deliberate trade, so a test that fails when somebody
reverses it is the point.
"""
import pytest
from app.core import auth as auth_core
from app.core.context import Forbidden
from db.db_declarations import PasswordResetToken, User, WebauthnCredential
PASSWORD = "correcthorsebatterystaple"
NEW_PASSWORD = "a-completely-different-one"
def _user(session, email="dev@example.com"):
return auth_core.create_user(session, email, PASSWORD, "Dev")
# --- who gets a link --------------------------------------------------------
def test_no_link_for_an_address_without_an_account(session):
assert auth_core.request_password_reset(session, "nobody@example.com") is None
def test_no_link_for_a_provider_only_account(session):
"""No password to reset. Returning None rather than a link keeps the endpoint
answering identically for it."""
auth_core.create_user(session, "google@example.com", None)
assert auth_core.request_password_reset(session, "google@example.com") is None
def test_no_link_for_a_disabled_account(session):
user = _user(session)
user.is_active = False
session.commit()
assert auth_core.request_password_reset(session, "dev@example.com") is None
def test_a_link_for_a_real_one(session):
_user(session)
result = auth_core.request_password_reset(session, "dev@example.com")
assert result is not None
user, token = result
assert user.email == "dev@example.com"
assert token
def test_only_the_digest_is_stored(session):
_user(session)
_, token = auth_core.request_password_reset(session, "dev@example.com")
row = session.query(PasswordResetToken).one()
assert token not in row.token_hash
assert len(row.token_hash) == 64
# --- consuming one ----------------------------------------------------------
def test_reset_sets_the_password(session):
user = _user(session)
_, token = auth_core.request_password_reset(session, "dev@example.com")
auth_core.reset_password(session, token, NEW_PASSWORD)
auth_core.authenticate_password(session, "dev@example.com", NEW_PASSWORD)
with pytest.raises(Forbidden):
auth_core.authenticate_password(session, "dev@example.com", PASSWORD)
def test_reset_clears_a_lockout(session):
"""Somebody who locked themselves out guessing is exactly who reaches for
this, so the reset has to be a way back in and not half of one."""
user = _user(session)
for _ in range(auth_core.MAX_FAILED_LOGINS):
with pytest.raises(Forbidden):
auth_core.authenticate_password(session, "dev@example.com", "wrong")
session.refresh(user)
assert user.locked_until is not None
_, token = auth_core.request_password_reset(session, "dev@example.com")
auth_core.reset_password(session, token, NEW_PASSWORD)
session.refresh(user)
assert user.locked_until is None
assert user.failed_logins == 0
def test_a_reset_token_works_once(session):
_user(session)
_, token = auth_core.request_password_reset(session, "dev@example.com")
auth_core.reset_password(session, token, NEW_PASSWORD)
with pytest.raises(Forbidden):
auth_core.reset_password(session, token, "yet-another-password")
def test_requesting_a_new_link_burns_the_old(session):
_user(session)
_, first = auth_core.request_password_reset(session, "dev@example.com")
_, second = auth_core.request_password_reset(session, "dev@example.com")
with pytest.raises(Forbidden):
auth_core.reset_password(session, first, NEW_PASSWORD)
auth_core.reset_password(session, second, NEW_PASSWORD)
def test_an_expired_token_is_refused(session):
from datetime import datetime, timedelta
_user(session)
_, token = auth_core.request_password_reset(session, "dev@example.com")
row = session.query(PasswordResetToken).one()
row.expires_at = datetime.utcnow() - timedelta(minutes=1)
session.commit()
with pytest.raises(Forbidden):
auth_core.reset_password(session, token, NEW_PASSWORD)
def test_a_short_password_is_refused_before_anything_is_written(session):
from app.core.context import Invalid
user = _user(session)
original = user.password_hash
_, token = auth_core.request_password_reset(session, "dev@example.com")
with pytest.raises(Invalid):
auth_core.reset_password(session, token, "short")
session.rollback()
session.refresh(user)
assert user.password_hash == original
# The token was not spent by the attempt, so the person can try again.
assert session.query(PasswordResetToken).one().used_at is None
def test_reset_clears_enrolled_authenticators(session):
"""A deliberate trade, written out in auth.reset_password: a passkey on a
lost device would otherwise lock the owner out for good, and this is the path
they arrive by when it already has.
If you change that decision, change this test and the PasswordResetToken
docstring in the same edit.
"""
user = _user(session)
session.add(WebauthnCredential(user_id=user.id, credential_id="abc",
public_key="key", sign_count=0, name="Phone"))
session.commit()
assert session.query(WebauthnCredential).count() == 1
_, token = auth_core.request_password_reset(session, "dev@example.com")
auth_core.reset_password(session, token, NEW_PASSWORD)
assert session.query(WebauthnCredential).count() == 0
# --- the façade -------------------------------------------------------------
def test_the_request_endpoint_answers_identically(app_client):
"""Whether an address has an account is exactly what this form must not
reveal, so both answers have to be the same bytes."""
real = app_client.post("/api/password/reset/request",
json={"email": "nobody@example.com"})
unknown = app_client.post("/api/password/reset/request",
json={"email": "someone-else@example.com"})
assert real.status_code == unknown.status_code == 200
assert real.get_data() == unknown.get_data()
def test_reset_does_not_start_a_session(app_client):
"""Signing in afterwards re-runs every gate. An endpoint that handed back a
session would be a way around them."""
from utils.db_wrapper import db_session
auth_core.create_user(db_session, "dev@example.com", PASSWORD, "Dev")
_, token = auth_core.request_password_reset(db_session, "dev@example.com")
r = app_client.post("/api/password/reset",
json={"token": token, "password": NEW_PASSWORD})
assert r.status_code == 200
assert app_client.get("/").headers["Location"].endswith("/login")
def test_the_recovery_pages_are_reachable_signed_out(app_client):
"""They exist for people who cannot sign in; requiring a session would make
them unreachable by exactly the people they are for."""
for path in ("/forgot", "/reset", "/login", "/signup", "/awaiting-approval"):
assert app_client.get(path).status_code == 200, path