8000 fix(auth): make only one first admin when setup requests race by adeelahmad · Pull Request #159 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions fastapi_backend/app/api/v1/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,13 @@ def setup(body: SetupRequest, request: Request, db: Db, cfg: Cfg) -> TokenPair:
passwords on (auth.passwords). The web app makes the first admin with a passkey (POST /auth/passkey/setup/options),
or with the code alone where browsers won't make passkeys (POST /auth/setup/no-passkey)."""
archive = request.app.state.archive
code = archive.setup_code
if not code or auth.account_count(db) or not secrets.compare_digest(body.code, code):
raise HTTPException(403, "setup is closed or the code is wrong")
with domain_errors():
uid = auth.create_account(db, body.email, body.password, body.name, admin=True)
archive.setup_code = None
with auth.SETUP_LOCK:
code = archive.setup_code
if not code or auth.account_count(db) or not secrets.compare_digest(body.code, code):
raise HTTPException(403, "setup is closed or the code is wrong")
with domain_errors():
uid = auth.create_account(db, body.email, body.password, body.name, admin=True)
archive.setup_code = None
if not auth.passwords_on(cfg):
from app.domain import settings as app_settings

Expand Down
30 changes: 16 additions & 14 deletions fastapi_backend/app/api/v1/routes/passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,14 @@ def passkey_setup_options(body: PasskeySetupStart, request: Request, db: Db, cfg
def passkey_setup(body: PasskeyAnswer, request: Request, db: Db) -> LoginTicket:
"""Make the first admin with the passkey the browser just created. Answers a ticket for signing in."""
archive = request.app.state.archive
if not archive.setup_code:
raise HTTPException(403, "setup is closed")
try:
uid = passkeys.setup_finish(db, body.flow, body.credential, body.name)
except ValueError as e:
raise HTTPException(400, str(e)) from None
archive.setup_code = None
with auth.SETUP_LOCK:
if not archive.setup_code:
raise HTTPException(403, "setup is closed")
try:
uid = passkeys.setup_finish(db, body.flow, body.credential, body.name)
except ValueError as e:
raise HTTPException(400, str(e)) from None
archive.setup_code = None
u = auth.get_account(db, uid)
auth.audit(db, {"id": uid, "email": u["email"]}, "setup", detail=["passkey"])
return LoginTicket(ticket=passkeys.issue_ticket(db, uid, "passkey"))
Expand All @@ -105,13 +106,14 @@ def setup_without_passkey(body: PasskeySetupStart, request: Request, db: Db) ->
other than localhost). No password: they sign in later with a passkey (at an https:// address) or a sign-in link."""
key = _throttle(request, "setup", shared=True)
archive = request.app.state.archive
code = archive.setup_code
if not code or auth.account_count(db) or not secrets.compare_digest(body.code.strip(), code):
_hit(key)
raise HTTPException(403, "setup is closed or the code is wrong")
with domain_errors():
uid = auth.create_account(db, body.email, None, body.name, admin=True)
archive.setup_code = None
with auth.SETUP_LOCK:
code = archive.setup_code
if not code or auth.account_count(db) or not secrets.compare_digest(body.code.strip(), code):
_hit(key)
raise HTTPException(403, "setup is closed or the code is wrong")
with domain_errors():
uid = auth.create_account(db, body.email, None, body.name, admin=True)
archive.setup_code = None
auth.audit(db, {"id": uid, "email": body.email.strip().lower()}, "setup", detail=["setup-code"])
return LoginTicket(ticket=passkeys.issue_ticket(db, uid, "setup"))

Expand Down
5 changes: 5 additions & 0 deletions fastapi_backend/app/domain/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def create_account(db, email, password, name=None, admin=False):
return uid


# The setup routes check "no accounts yet" and make the first admin while holding this, so two setup requests at once
# can't both pass the check. The setup code lives in the one API process, so a process lock is enough.
SETUP_LOCK = threading.Lock()


def passwords_on(cfg):
"""Whether passwords sign in at all (auth.passwords). Fresh installs use passkeys only; installs that already had
passwords keep them until an admin turns them off (domain/settings.keep_passwords)."""
Expand Down
31 changes: 31 additions & 0 deletions fastapi_backend/tests/api/test_passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

from __future__ import annotations

import threading
from unittest import mock

import pytest
from fastapi.testclient import TestClient

from app.domain import auth, passkeys, settings, store
from tests.fake_authenticator import Authenticator
Expand Down Expand Up @@ -303,3 +307,30 @@ def test_the_last_passkey_for_this_site_stays(app, client, db):
r = client.delete(f"/api/v1/auth/passkeys/{keys['Laptop']}", headers={**h, **WEB})
assert r.status_code == 400 and "last passkey for localhost" in r.json()["detail"]
assert client.delete(f"/api/v1/auth/passkeys/{keys['Other']}", headers={**h, **WEB}).status_code == 200


def test_two_setup_requests_at_once_make_one_admin(app, db):
"""Setup checks "no accounts yet" and makes the account as one step: two requests racing with the code make one admin."""
code, real, inside = app.state.archive.setup_code, auth.create_account, threading.Barrier(2, timeout=2)

def slow_create(*a, **k): # hold the first request between its check and its account until the second gets there
try:
inside.wait()
except threading.BrokenBarrierError:
pass # the second request is held off before its check: what setup should do
return real(*a, **k)

results: list[int] = []

def attempt(email):
c = TestClient(app)
results.append(c.post("/api/v1/auth/setup/no-passkey", json={"code": code, "email": email}).status_code)

with mock.patch.object(auth, "create_account", slow_create):
threads = [threading.Thread(target=attempt, args=(e,)) for e in ("ada@x.io", "eve@x.io")]
for t in threads:
t.start()
for t in threads:
t.join()
assert sorted(results) == [200, 403]
assert db.values("SELECT VALUE email FROM account WHERE admin = true") in (["ada@x.io"], ["eve@x.io"])
Loading
0