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.
108 lines
3.8 KiB
Python
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
|