fix(security): consume magic-link tokens atomically

Validation and marking-used were two separate steps, so two concurrent
requests for the same registration token could both pass validation before
either marked it used — a replay window.

Add an atomic consume() at the repository (conditional UPDATE ... WHERE
used = 0 AND not expired, gated on rowcount) and service layers, and switch
the /register handler to consume() instead of validate()+mark_used().

Refs: porchlight-ur7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-04 10:46:38 +02:00
parent 91a2277664
commit e4eb539e3f
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
6 changed files with 80 additions and 3 deletions

View file

@ -63,6 +63,34 @@ async def test_mark_used_not_found(magic_link_repo: SQLiteMagicLinkRepository) -
assert marked is False
async def test_consume_marks_used_and_returns_link(magic_link_repo: SQLiteMagicLinkRepository) -> None:
await magic_link_repo.create(_make_link())
consumed = await magic_link_repo.consume("abc123")
assert consumed is not None
assert consumed.used is True
assert consumed.username == "alice"
async def test_consume_is_single_use(magic_link_repo: SQLiteMagicLinkRepository) -> None:
await magic_link_repo.create(_make_link())
first = await magic_link_repo.consume("abc123")
assert first is not None
# A second consume of the same token must fail (atomic single-use).
second = await magic_link_repo.consume("abc123")
assert second is None
async def test_consume_expired_returns_none(magic_link_repo: SQLiteMagicLinkRepository) -> None:
await magic_link_repo.create(_make_link(token="exp", expires_at=datetime.now(UTC) - timedelta(hours=1)))
assert await magic_link_repo.consume("exp") is None
async def test_consume_nonexistent_returns_none(magic_link_repo: SQLiteMagicLinkRepository) -> None:
assert await magic_link_repo.consume("nope") is None
async def test_delete_expired(magic_link_repo: SQLiteMagicLinkRepository) -> None:
expired = _make_link(token="expired", expires_at=datetime.now(UTC) - timedelta(hours=1))
await magic_link_repo.create(expired)