Disable password login once a passkey is linked
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 6s

- /token: reject password auth with a clear message if pocketid_sub is
  set on the account — passkey-linked users must sign in via PocketID
- Link callback + auto-link-by-email: null out hashed_password when the
  passkey is attached so the old hash can't be used even if the check
  above were bypassed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:34:23 +01:00
parent d350e9caea
commit 01a8fe135c
+7
View File
@@ -142,6 +142,11 @@ async def login(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not user.hashed_password: if not user or not user.hashed_password:
raise HTTPException(status_code=400, detail="Invalid credentials") raise HTTPException(status_code=400, detail="Invalid credentials")
if user.pocketid_sub is not None:
raise HTTPException(
status_code=400,
detail="Password login is disabled for this account — use your passkey to sign in.",
)
if not verify_password(form_data.password, user.hashed_password): if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials") raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)}) token = create_access_token({"sub": str(user.id)})
@@ -262,6 +267,7 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
if target is None: if target is None:
return RedirectResponse(url="/login?auth_error=link_failed") return RedirectResponse(url="/login?auth_error=link_failed")
target.pocketid_sub = sub target.pocketid_sub = sub
target.hashed_password = None # disable password login once passkey is linked
if not target.email and email: if not target.email and email:
dup = await db.execute( dup = await db.execute(
select(User).where(User.email == email, User.id != target.id) select(User).where(User.email == email, User.id != target.id)
@@ -293,6 +299,7 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing and existing.pocketid_sub is None: if existing and existing.pocketid_sub is None:
existing.pocketid_sub = sub existing.pocketid_sub = sub
existing.hashed_password = None # disable password login once passkey is linked
user = existing user = existing
# 3) Otherwise provision a new account with a collision-safe username. # 3) Otherwise provision a new account with a collision-safe username.