teecup/app/auth.py
Erol Haagenrud 7d28727cb7 Kontosammenslåing, selvbetjent (ADR-080, "Del 2" av flere e-postadresser)
Selvbetjent sammenslåing av to TeeCup-kontoer: keeper (initiativtaker)
ber om sammenslåing, bekreftelseslenke sendt til taperens e-post beviser
eierskap, taperens data (org-medlemskap/spillerkoblinger/runder/venner)
flyttes over og taperens konto slettes. Ny migrasjon 077 (account_merge_
token, ikke kjørt mot ekte teecup_db ennå), ny app/account_merge.py
(N+1-transaksjoner per RLS-grensen, fullt konfliktkart), nye endepunkter
i app/routers/account_merge.py, ny frontend-seksjon i kontoinnstillinger
+ egen bekreftelsesside.

Fant og fikset en reell RLS-relatert bug via testsuiten før produksjon:
seks RLS-beskyttede tabeller var feilaktig plassert i den globale
"trygt å re-peke"-løkken, forårsaket et krasj pga. en Postgres GUC-kvirk
på pooled forbindelser. Se ADR-080 for full begrunnelse.

12 nye tester (95/95 backend totalt), full scratch-verifisert
ende-til-ende inkl. lys/mørk, ekte teecup_db urørt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:56:41 +02:00

340 lines
14 KiB
Python

"""
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, WebSocket
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 resolve_user_id_by_email(conn, email: str) -> str | None:
"""Løser en e-postadresse til eierens app_user.id -- sjekker PRIMÆR
adresse (app_user.email) først, deretter SEKUNDÆR (user_secondary_
email, FEATURE_BACKLOG.md "flere e-postadresser"/ADR-032). Returnerer
None hvis ingen konto eier adressen i det hele tatt -- kalleren
avgjør selv hva det betyr (opprett ny konto ved innlogging, avvis
passord-innlogging, eller "finnes ikke, kan ikke slås sammen med" ved
kontosammenslåing). Faktorert ut 2026-08-16 (kontosammenslåing) fra to
tidligere identiske kopier i verify_magic_link/login_with_password."""
user_id = await conn.fetchval("SELECT id::text AS id FROM app_user WHERE email = $1", email)
if user_id is not None:
return user_id
return await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
)
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_current_user_from_websocket(websocket: WebSocket) -> CurrentUser | None:
"""Som get_current_user_optional, men leser sesjonscookien fra en
WebSocket-tilkobling (ADR-025) i stedet for en HTTP Request -- FastAPI
sitt Depends()-system kan ikke gjenbruke en Request-typet avhengighet
direkte i en WS-rute (ingen ekte Request finnes i en WS-scope), derav
denne separate, bevisst minimale kopien av samme dekode-/oppslagslogikk.
Returnerer None i stedet for å kaste -- ruten selv avgjør om anonym
tilgang er greit (offentlig feed) eller ikke (lag-chat, som lukker
tilkoblingen selv ved None)."""
token = websocket.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