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.
200 lines
7 KiB
Python
200 lines
7 KiB
Python
"""
|
|
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
|
|
rå token server-side i stedet for å sendes på e-post -- KUN når
|
|
settings.DEV_LOG_MAGIC_LINKS er eksplisitt satt (aldri på 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],
|
|
)
|