""" Auth-lag for TeeCup. Ekte autentisering: magic-link ELLER e-post+passord (ADR-021, begge fører til samme sesjons-cookie), pluss valgfri/påkrevd 2FA (TOTP eller e-post- engangskode). `get_current_user` 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. Sesjons-STADIER (ADR-021 Beslutning E): en sesjonscookie er ikke nødvendigvis en FULL sesjon. Når 2FA kreves, utstedes et kortlevd JWT med `stage: "pending_2fa"` eller `"must_enroll_2fa"` i stedet for en full 30-dagers sesjon -- `get_current_user` (brukt av ALLE vanlige endepunkter) avviser eksplisitt alt annet enn `stage: "full"` (eller en ELDRE token uten noe stage-felt i det hele tatt, utstedt før denne runden -- behandles som "full" for bakoverkompatibilitet, ingen eksisterende bruker logges brått ut). De egne 2FA-endepunktene bruker i stedet `get_pending_user`, som KUN godtar en mellomtilstand. """ import time from dataclasses import dataclass from typing import Literal import jwt import pyotp from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, InvalidHash from fastapi import Depends, Request from .config import settings from .db import plain_connection from .errors import app_error SESSION_COOKIE_NAME = "teecup_session" _JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer # alg-forvirring/"alg:none"-angrep. Stage = Literal["full", "pending_2fa", "must_enroll_2fa"] _PENDING_STAGE_MAX_AGE_SECONDS = 5 * 60 # kort levetid -- kun for å fullføre 2FA @dataclass(frozen=True) class CurrentUser: user_id: str @dataclass(frozen=True) class PendingUser: """En bruker som har bevist primær-identitet (magic-link/passord), men IKKE fullført 2FA ennå -- se modul-docstring.""" user_id: str stage: Stage def create_session_token(user_id: str, stage: Stage = "full") -> str: now = int(time.time()) max_age = settings.SESSION_MAX_AGE_SECONDS if stage == "full" else _PENDING_STAGE_MAX_AGE_SECONDS payload: dict = {"sub": user_id, "iat": now, "exp": now + max_age} if stage != "full": payload["stage"] = stage return jwt.encode(payload, settings.SESSION_SECRET, algorithm=_JWT_ALGORITHM) # --------------------------------------------------------------------------- # Passord (ADR-021 Beslutning B) -- Argon2id, ikke bcrypt (unngår 72-byte- # trunkering, viktig siden spesialtegn/mellomrom skal fungere korrekt). # --------------------------------------------------------------------------- _password_hasher = PasswordHasher() def hash_password(raw_password: str) -> str: return _password_hasher.hash(raw_password) def verify_password(raw_password: str, password_hash: str) -> bool: try: _password_hasher.verify(password_hash, raw_password) return True except (VerifyMismatchError, InvalidHash): return False # --------------------------------------------------------------------------- # TOTP (ADR-021 Beslutning C) -- RFC 6238, virker med enhver standard # autentisator-app (Google Authenticator/Authy/1Password osv.). # --------------------------------------------------------------------------- def generate_totp_secret() -> str: return pyotp.random_base32() def totp_provisioning_uri(secret: str, email: str) -> str: return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name="TeeCup") def verify_totp_code(secret: str, code: str) -> bool: # valid_window=1 tillater ett 30-sekunders steg klokkedrift hver vei -- # standard toleranse, unngår at en litt ute-av-synk klokke på # brukerens enhet gir falske avvisninger. return pyotp.TOTP(secret).verify(code, valid_window=1) 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. Avviser eksplisitt alt annet enn `stage: "full"` -- en `pending_2fa`- eller `must_enroll_2fa`-token gir ALDRI tilgang her, kun via `get_pending_user` på de egne 2FA-endepunktene (se modul-docstring). """ token = request.cookies.get(SESSION_COOKIE_NAME) if not token: raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.") try: claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM]) except jwt.PyJWTError: raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.") user_id = claims.get("sub") if not user_id: raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig sesjon.") if claims.get("stage", "full") != "full": raise app_error(401, "NOT_AUTHENTICATED", "Innloggingen er ikke fullført (2FA gjenstår).") 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 app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.") return CurrentUser(user_id=user_id) async def get_pending_user(request: Request) -> PendingUser: """Som get_current_user, men KUN for en mellomtilstand (2FA gjenstår). Brukt utelukkende av 2FA-verifiserings-/oppsett-endepunktene (app/routers/auth.py) -- avviser en FULL sesjon like strengt som en manglende en, siden dette endepunktet ikke gir mening for en allerede ferdig innlogget bruker (de bruker de vanlige kontoinnstillings- endepunktene for frivillig 2FA-oppsett i stedet, se get_current_or_enrolling_user).""" token = request.cookies.get(SESSION_COOKIE_NAME) if not token: raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.") try: claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM]) except jwt.PyJWTError: raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.") user_id = claims.get("sub") stage = claims.get("stage", "full") if not user_id or stage == "full": raise app_error(401, "NOT_AUTHENTICATED", "Ingen 2FA-verifisering pågår.") 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 app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.") return PendingUser(user_id=user_id, stage=stage) async def get_current_or_enrolling_user(request: Request) -> CurrentUser: """Godtar BÅDE en full sesjon (frivillig 2FA-oppsett fra kontoinnstillinger) OG `must_enroll_2fa` (tvungen oppsett rett etter innlogging, ADR-021 Beslutning D) -- samme oppsett-endepunkter dekker begge veiene inn, siden selve oppsett-logikken er identisk.""" token = request.cookies.get(SESSION_COOKIE_NAME) if not token: raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.") try: claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM]) except jwt.PyJWTError: raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.") user_id = claims.get("sub") stage = claims.get("stage", "full") if not user_id or stage not in ("full", "must_enroll_2fa"): raise app_error(401, "NOT_AUTHENTICATED", "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 app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.") return CurrentUser(user_id=user_id) async def user_requires_2fa_enrollment(conn, user_id: str) -> bool: """ADR-021 Beslutning D: org-eier/admin UTEN 2FA konfigurert må sette det opp FØR de får en full sesjon. organization_membership har ingen RLS (se 001), så dette fungerer på en plain_connection().""" return await conn.fetchval( """ SELECT (two_factor_method IS NULL) AND EXISTS ( SELECT 1 FROM organization_membership WHERE user_id = $1 AND role IN ('owner', 'admin') ) FROM app_user WHERE id = $1 """, user_id, ) async def get_superadmin_user(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: """ADR-022 Beslutning D: is_super_admin er KUN manuelt DB-tildelt, aldri settbart via noe API-endepunkt. Denne avhengigheten sjekker bare flagget -- den gir ikke selv noen vei til å endre det.""" async with plain_connection() as conn: is_super_admin = await conn.fetchval( "SELECT is_super_admin FROM app_user WHERE id = $1", user.user_id ) if not is_super_admin: raise app_error(403, "NOT_SUPER_ADMIN", "Krever superadmin-rettigheter.") return user async def get_current_user_optional(request: Request) -> CurrentUser | None: """Som get_current_user, men returnerer None i stedet for å kaste 401. Brukt av offentlige landingsside-endepunkter (ADR-018) som skal fungere for en helt anonym leser også -- bare med redusert tilgang (kun visibility='public'-turneringer), ikke en hard 401. """ token = request.cookies.get(SESSION_COOKIE_NAME) if not token: return None try: claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM]) except jwt.PyJWTError: return None user_id = claims.get("sub") if not user_id or claims.get("stage", "full") != "full": return None 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: return None 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 app_error(403, "NOT_ORG_MEMBER", "Brukeren er ikke medlem av denne organisasjonen.") return organization_id