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
|
magic_link_service = request.app.state.magic_link_service
|
||||||
user_repo = request.app.state.user_repo
|
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:
|
if link is None:
|
||||||
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
|
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"])
|
user = User(userid=userid, username=link.username, groups=["users"])
|
||||||
await user_repo.create(user)
|
await user_repo.create(user)
|
||||||
|
|
||||||
await magic_link_service.mark_used(token)
|
|
||||||
|
|
||||||
request.session["userid"] = user.userid
|
request.session["userid"] = user.userid
|
||||||
request.session["username"] = user.username
|
request.session["username"] = user.username
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,19 @@ class MagicLinkService:
|
||||||
"""Mark a magic link as used. Returns True if found and marked."""
|
"""Mark a magic link as used. Returns True if found and marked."""
|
||||||
return await self._repo.mark_used(self._hash_token(token))
|
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:
|
async def cleanup_expired(self) -> int:
|
||||||
"""Delete expired unused links. Returns count deleted."""
|
"""Delete expired unused links. Returns count deleted."""
|
||||||
return await self._repo.delete_expired()
|
return await self._repo.delete_expired()
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ class MagicLinkRepository(Protocol):
|
||||||
|
|
||||||
async def mark_used(self, token: str) -> bool: ...
|
async def mark_used(self, token: str) -> bool: ...
|
||||||
|
|
||||||
|
async def consume(self, token: str) -> MagicLink | None: ...
|
||||||
|
|
||||||
async def delete_expired(self) -> int: ...
|
async def delete_expired(self) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,25 @@ class SQLiteMagicLinkRepository:
|
||||||
await self._db.commit()
|
await self._db.commit()
|
||||||
return cursor.rowcount > 0
|
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:
|
async def delete_expired(self) -> int:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
cursor = await self._db.execute("DELETE FROM magic_links WHERE expires_at < ? AND used = 0", (now,))
|
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
|
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:
|
async def test_mark_used_returns_true(service: MagicLinkService) -> None:
|
||||||
link = await service.create(username="alice")
|
link = await service.create(username="alice")
|
||||||
result = await service.mark_used(link.token)
|
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
|
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:
|
async def test_delete_expired(magic_link_repo: SQLiteMagicLinkRepository) -> None:
|
||||||
expired = _make_link(token="expired", expires_at=datetime.now(UTC) - timedelta(hours=1))
|
expired = _make_link(token="expired", expires_at=datetime.now(UTC) - timedelta(hours=1))
|
||||||
await magic_link_repo.create(expired)
|
await magic_link_repo.create(expired)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue