teecup/app/auth.py
Erol Haagenrud fbd3f58a1c 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

108 lines
3.8 KiB
Python

"""
Auth-lag for TeeCup.
Ekte autentisering: magic-link (se app/routers/auth.py for utstedelse) +
JWT-sesjon i en HttpOnly-cookie. `get_current_user` er IKKE lenger en stub —
den dekoder og verifiserer den innsendte sesjonscookien, og bekrefter at
brukeren fortsatt finnes.
`get_authorized_org` er fortsatt sikkerhetskritisk uendret: den verifiserer at
den innloggede brukeren faktisk er medlem av organisasjonen før org-konteksten
settes.
Hvorfor det er kritisk: RLS stoler blindt på `app.current_org`. Setter appen den
til en organisasjon brukeren ikke tilhører, gir RLS lydig tilgang til den
organisasjonens data. Isolasjonen står og faller altså på at denne sjekken skjer
FØR org_connection kalles — aldri sett konteksten fra en uverifisert kilde.
"""
import time
from dataclasses import dataclass
import jwt
from fastapi import Depends, HTTPException, Request, status
from .config import settings
from .db import plain_connection
SESSION_COOKIE_NAME = "teecup_session"
_JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer
# alg-forvirring/"alg:none"-angrep.
@dataclass(frozen=True)
class CurrentUser:
user_id: str
def create_session_token(user_id: str) -> str:
now = int(time.time())
payload = {"sub": user_id, "iat": now, "exp": now + settings.SESSION_MAX_AGE_SECONDS}
return jwt.encode(payload, settings.SESSION_SECRET, algorithm=_JWT_ALGORITHM)
def should_use_secure_cookies(request: Request) -> bool:
"""Secure-flagget skal kun være sant over https.
Stoler på X-Forwarded-Proto -- dette er KUN trygt fordi appen ikke skal
være nåbar unntatt gjennom Caddy, som terminerer TLS og proxyer videre
over vanlig http internt.
"""
if request.url.scheme == "https":
return True
return request.headers.get("x-forwarded-proto", "").strip().lower() == "https"
async def get_current_user(request: Request) -> CurrentUser:
"""Dekoder sesjonscookien og bekrefter at brukeren fortsatt finnes.
Det siste steget (et ekte DB-oppslag, ikke bare å stole på JWT-en) gir
faktisk tilbakekalling: en slettet/deaktivert bruker kan ikke ri ut
resten av sesjonens levetid på en ellers gyldig token.
"""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ikke innlogget.")
try:
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
except jwt.PyJWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig eller utløpt sesjon.")
user_id = claims.get("sub")
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig sesjon.")
async with plain_connection() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
if not exists:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Brukeren finnes ikke lenger.")
return CurrentUser(user_id=user_id)
async def get_authorized_org(
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> str:
"""Returnerer organization_id KUN hvis brukeren er medlem — ellers 403.
Medlemskapstabellen er ikke organisasjonsavgrenset, så oppslaget gjøres på en
tilkobling uten org-kontekst.
"""
async with plain_connection() as conn:
is_member = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM organization_membership
WHERE user_id = $1 AND organization_id = $2
)
""",
user.user_id,
organization_id,
)
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Brukeren er ikke medlem av denne organisasjonen.",
)
return organization_id