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
|
|
@ -77,7 +77,8 @@ async def register_magic_link(request: Request, token: str) -> Response:
|
|||
magic_link_service = request.app.state.magic_link_service
|
||||
user_repo = request.app.state.user_repo
|
||||
|
||||
link = await magic_link_service.validate(token)
|
||||
# Atomically validate and consume the token (single-use, no replay race).
|
||||
link = await magic_link_service.consume(token)
|
||||
if link is None:
|
||||
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
|
||||
|
||||
|
|
@ -91,8 +92,6 @@ async def register_magic_link(request: Request, token: str) -> Response:
|
|||
user = User(userid=userid, username=link.username, groups=["users"])
|
||||
await user_repo.create(user)
|
||||
|
||||
await magic_link_service.mark_used(token)
|
||||
|
||||
request.session["userid"] = user.userid
|
||||
request.session["username"] = user.username
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,19 @@ class MagicLinkService:
|
|||
"""Mark a magic link as used. Returns True if found and marked."""
|
||||
return await self._repo.mark_used(self._hash_token(token))
|
||||
|
||||
async def consume(self, token: str) -> MagicLink | None:
|
||||
"""Atomically validate and consume a token in one step.
|
||||
|
||||
Prefer this over validate()+mark_used(): it closes the replay race
|
||||
where two concurrent requests could both pass validation before either
|
||||
marks the token used. Returns the link (raw token re-attached) on
|
||||
success, else None.
|
||||
"""
|
||||
link = await self._repo.consume(self._hash_token(token))
|
||||
if link is None:
|
||||
return None
|
||||
return link.model_copy(update={"token": token})
|
||||
|
||||
async def cleanup_expired(self) -> int:
|
||||
"""Delete expired unused links. Returns count deleted."""
|
||||
return await self._repo.delete_expired()
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ class MagicLinkRepository(Protocol):
|
|||
|
||||
async def mark_used(self, token: str) -> bool: ...
|
||||
|
||||
async def consume(self, token: str) -> MagicLink | None: ...
|
||||
|
||||
async def delete_expired(self) -> int: ...
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -311,6 +311,25 @@ class SQLiteMagicLinkRepository:
|
|||
await self._db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def consume(self, token: str) -> MagicLink | None:
|
||||
"""Atomically validate-and-mark a token as used.
|
||||
|
||||
The conditional UPDATE is the single point of decision, so two
|
||||
concurrent requests cannot both consume the same token. Returns the
|
||||
link if this call won the race (unused and unexpired), else None.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
cursor = await self._db.execute(
|
||||
"UPDATE magic_links SET used = 1 WHERE token = ? AND used = 0 AND expires_at > ?",
|
||||
(token, now),
|
||||
)
|
||||
await self._db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
async with self._db.execute("SELECT * FROM magic_links WHERE token = ?", (token,)) as c:
|
||||
row = await c.fetchone()
|
||||
return self._row_to_magic_link(row) if row is not None else None
|
||||
|
||||
async def delete_expired(self) -> int:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
cursor = await self._db.execute("DELETE FROM magic_links WHERE expires_at < ? AND used = 0", (now,))
|
||||
|
|
|
|||
|
|
@ -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