fix(security): invite links must not log in accounts with credentials

A registration/re-invite link auto-established a session for any existing
active user, so re-inviting a fully set-up user acted as a passwordless
login. Invite links are for account setup only.

After consuming the token, refuse to establish a session when the target
account already has a password or WebAuthn credential. Credential-less
accounts (e.g. freshly created by initial-admin) can still complete setup.
Account recovery for set-up accounts must use a separate, authenticated flow.

Refs: porchlight-a3a

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

View file

@ -86,6 +86,18 @@ async def register_magic_link(request: Request, token: str) -> Response:
if existing_user is not None: if existing_user is not None:
if not existing_user.active: if not existing_user.active:
return HTMLResponse("<p>This account has been deactivated.</p>", status_code=400) return HTMLResponse("<p>This account has been deactivated.</p>", status_code=400)
# An invite link is for account setup, not authentication. If the
# account already has credentials, refuse to establish a session —
# otherwise a re-invite would be a passwordless login. Account
# recovery must go through a separate, explicitly authenticated flow.
cred_repo = request.app.state.credential_repo
has_password = await cred_repo.get_password_by_user(existing_user.userid) is not None
has_webauthn = bool(await cred_repo.get_webauthn_by_user(existing_user.userid))
if has_password or has_webauthn:
return HTMLResponse(
"<p>This account is already set up. Please sign in instead.</p>",
status_code=400,
)
user = existing_user user = existing_user
else: else:
userid = await generate_unique_userid(user_repo) userid = await generate_unique_userid(user_repo)

View file

@ -1,7 +1,9 @@
from argon2 import PasswordHasher
from httpx import AsyncClient from httpx import AsyncClient
from porchlight.authn.password import PasswordService
from porchlight.invite.service import MagicLinkService from porchlight.invite.service import MagicLinkService
from porchlight.models import User from porchlight.models import PasswordCredential, User
async def test_register_invalid_token_returns_error_page(client: AsyncClient) -> None: async def test_register_invalid_token_returns_error_page(client: AsyncClient) -> None:
@ -78,3 +80,24 @@ async def test_register_existing_user_logs_in_and_redirects(client: AsyncClient)
assert existing is not None assert existing is not None
assert existing.userid == "lusab-bansen" assert existing.userid == "lusab-bansen"
assert "admin" in existing.groups assert "admin" in existing.groups
async def test_register_rejects_user_that_already_has_credentials(client: AsyncClient) -> None:
"""An invite/re-invite link must not act as a passwordless login for an
account that already has credentials. Recovery is a separate flow."""
app = client._transport.app # type: ignore[union-attr]
magic_link_service = app.state.magic_link_service
user_repo = app.state.user_repo
cred_repo = app.state.credential_repo
user = User(userid="lusab-hascreds", username="hascreds", groups=["users"])
await user_repo.create(user)
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("existing-pass")))
link = await magic_link_service.create(username="hascreds")
res = await client.get(f"/register/{link.token}", follow_redirects=False)
# No passwordless login: rejected, not redirected to setup.
assert res.status_code == 400
assert "setup=1" not in res.headers.get("location", "")