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:
parent
91a2277664
commit
e4eb539e3f
6 changed files with 80 additions and 3 deletions
|
|
@ -102,6 +102,22 @@ async def test_validate_expired_token(service: MagicLinkService, repo: SQLiteMag
|
|||
assert result is None
|
||||
|
||||
|
||||
async def test_consume_validates_and_is_single_use(service: MagicLinkService) -> None:
|
||||
link = await service.create(username="alice")
|
||||
consumed = await service.consume(link.token)
|
||||
assert consumed is not None
|
||||
assert consumed.username == "alice"
|
||||
assert consumed.token == link.token # raw token re-attached
|
||||
# A second consume of the same token fails.
|
||||
assert await service.consume(link.token) is None
|
||||
|
||||
|
||||
async def test_consume_expired_returns_none(service: MagicLinkService, repo: SQLiteMagicLinkRepository) -> None:
|
||||
expired_service = MagicLinkService(repo=repo, ttl=-1)
|
||||
link = await expired_service.create(username="alice")
|
||||
assert await service.consume(link.token) is None
|
||||
|
||||
|
||||
async def test_mark_used_returns_true(service: MagicLinkService) -> None:
|
||||
link = await service.create(username="alice")
|
||||
result = await service.mark_used(link.token)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue