teecup/app/routers/auth.py

201 lines
7 KiB
Python
Raw Normal View History

Ekte autentisering er bygget og verifisert. X-Debug-User-Id-stubben er helt fjernet, ingen fallback beholdt. Ny flyt: magic-link (POST /auth/request-link → POST /auth/verify-link) + JWT-sesjon i HttpOnly/SameSite=Lax/dynamisk-Secure-cookie (30 dager), pluss /auth/logout og /auth/me. Ny migrasjon 004_auth.sql (unik e-post-indeks + magic_link_token-tabell). Sikkerhetsdesignet fra Plan-agent-gjennomgangen holdt gjennom testing: Token: secrets.token_urlsafe(32), kun SHA-256-hash lagres Atomisk forbruk (UPDATE...RETURNING, ikke les-sjekk-skriv) — hindrer replay Generisk respons uansett om e-posten finnes — hindrer enumerering app_user opprettes først ved vellykket verifisering, ikke ved forespørsel — hindrer massopprettelse Gamle uforbrukte lenker ugyldiggjøres når en ny utstedes PyJWT (byttet fra python-jose pga. bredere sårbarhetsflate) med eksplisitt algorithms=["HS256"] Ekte eksistens-sjekk mot app_user på hvert kall — en slettet bruker mister tilgang umiddelbart, ikke etter 30 dager Alle 12 planlagte tester bestått, inkludert cooldown, token-ugyldiggjøring, utløp, tuklet JWT, slettet bruker, og at debug-headeren nå er helt uten effekt. To ting funnet og fikset/dokumentert underveis: ON CONFLICT (email) matchet ikke den nye partielle unike indeksen uten eksplisitt WHERE-klausul — fikset. En reell, dypere RLS-bug (dokumentert i FEATURE_BACKLOG.md, ikke fikset her): organization-tabellens RLS-policy kaster en 500 i stedet for "se ingenting" når app.current_org leses tilbake som tomstreng (ikke NULL) på en gjenbrukt pool-tilkobling. Berører trolig alle 15 RLS-policyer i skjemaet — for stort og sensitivt (ADR-003-grunnmuren) til å hastefikse her, så jeg mitigerte det lokalt i /auth/me og satte det som punkt 1 i neste-steg-listen.
2026-07-16 15:16:53 +02:00
"""
Autentisering: magic-link-forespørsel/-verifisering, utlogging, "hvem er jeg".
Sikkerhetsmønster (se plan/ADR-009):
- request-link svarer ALLTID identisk, uansett om e-posten finnes eller
nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke
hvilke e-poster som har konto (enumerering).
- Kun SHA-256-hashen av token lagres, aldri klarteksten.
- app_user opprettes FØRST når en gyldig, uforbrukt token løses inn i
verify-link -- IKKE når lenken bare forespørres (hindrer massopprettelse
av kontoer for e-poster man ikke eier).
- Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv)
for å hindre at to samtidige forsøk med samme lenke begge lykkes.
- Dev-modus: siden ingen ekte SMTP-oppsett finnes for TeeCup ennå, logges
token server-side i stedet for å sendes e-post -- KUN når
settings.DEV_LOG_MAGIC_LINKS er eksplisitt satt (aldri som standard).
# TODO: erstatt med ekte SMTP-utsending før produksjon.
"""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, EmailStr
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies
from ..config import settings
from ..db import plain_connection
router = APIRouter(prefix="/auth", tags=["auth"])
_GENERIC_RESPONSE = {
"status": "ok",
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
}
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class MagicLinkRequest(BaseModel):
email: EmailStr
@router.post("/request-link")
async def request_magic_link(body: MagicLinkRequest) -> dict:
email = body.email.lower()
now = datetime.now(timezone.utc)
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
async with plain_connection() as conn:
recent = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM magic_link_token
WHERE email = $1 AND consumed_at IS NULL AND created_at > $2
)
""",
email,
cooldown_cutoff,
)
if not recent:
# Ugyldiggjør tidligere uforbrukte lenker for denne e-posten --
# begrenser vinduet en eldre, lekket lenke kan misbrukes i.
await conn.execute(
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
email,
)
raw_token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at) VALUES ($1, $2, $3)",
email,
_hash_token(raw_token),
expires_at,
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Magic link for {email}: {raw_token}", flush=True)
return _GENERIC_RESPONSE
class MagicLinkVerify(BaseModel):
token: str
display_name: str | None = None
class SessionUser(BaseModel):
id: str
email: str
display_name: str
@router.post("/verify-link", response_model=SessionUser)
async def verify_magic_link(
body: MagicLinkVerify, response: Response, request: Request
) -> SessionUser:
token_hash = _hash_token(body.token)
async with plain_connection() as conn:
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
# samtidige forsøk med samme lenke kan da ikke begge lykkes.
email = await conn.fetchval(
"""
UPDATE magic_link_token
SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING email
""",
token_hash,
)
if email is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail="Lenken er ugyldig, brukt eller utløpt."
)
placeholder_name = email.split("@")[0]
user_id = await conn.fetchval(
"""
INSERT INTO app_user (email, display_name)
VALUES ($1, $2)
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
RETURNING id
""",
email,
body.display_name or placeholder_name,
)
if user_id is None:
# En annen samtidig verifisering for samme e-post vant innsettingen.
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
user_row = await conn.fetchrow(
"SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1",
user_id,
)
token = create_session_token(user_row["id"])
response.set_cookie(
SESSION_COOKIE_NAME,
token,
max_age=settings.SESSION_MAX_AGE_SECONDS,
httponly=True,
samesite="lax",
secure=should_use_secure_cookies(request),
path="/",
)
return SessionUser(**dict(user_row))
@router.post("/logout")
async def logout(response: Response) -> dict:
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
class MyOrg(BaseModel):
organization_id: str
role: str
class Me(BaseModel):
id: str
email: str
display_name: str
organizations: list[MyOrg]
@router.get("/me", response_model=Me)
async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
# MERK: henter bevisst IKKE organisasjonens navn her. Det ville krevd å
# spørre `organization`-tabellen (som HAR RLS, org_self-policyen) via
# plain_connection() -- altså UTEN org-kontekst satt. Under scratch-
# testing avdekket dette en reell, dypere bug: org_self sin
# `current_setting('app.current_org', true)::uuid` håndterer NULL trygt,
# men IKKE tomstreng (som en custom GUC kan lese tilbake som på en
# gjenbrukt pool-tilkobling der en annen forespørsel tidligere satte den
# via SET LOCAL) -- det gir en 500 i stedet for skjemaets lovede "trygg
# standard: se ingenting". Dette er en tverrgående RLS-sak (berører alle
# 15 policyer i skjemaet, ikke noe auth-spesifikt) som fortjener sin egen
# fokuserte rettingsrunde, ikke en hastefiks her. Se FEATURE_BACKLOG.md.
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1",
user.user_id,
)
org_rows = await conn.fetch(
"""
SELECT organization_id::text AS organization_id, role
FROM organization_membership
WHERE user_id = $1
""",
user.user_id,
)
return Me(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
organizations=[MyOrg(**dict(r)) for r in org_rows],
)