1. Migrasjon — kjør 013_messaging.sql mot ekte teecup_db (ny message-tabell, RLS, ingen endring av eksisterende data). 2. Backend + frontend — docker compose up -d --build teecup_api teecup_frontend. 3. Caddy — legger til /ws/*-ruten i /opt/teeoff/deploy/Caddyfile (allerede skrevet og syntaks-validert). Som i alle tidligere runder som har rørt denne filen: en graceful reload plukker historisk IKKE opp endringen (stale bind-mount-inode), så det trengs en full docker restart teeoff_caddy — det gir noen sekunders nedetid for teeoff.no også, ikke bare teecup.teeoff.no. Etter alt dette: sjekke /health + /dashboard → 200, en reell WebSocket-tilkobling fungerer over wss://teecup.teeoff.no/ws/..., og teeoff.no er tilbake på 200.
323 lines
13 KiB
Python
323 lines
13 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 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
|