teecup/app/auth.py
Erol Haagenrud 89a803140e Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt

Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)

app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere

app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering

app/email.py: 2FA-kode og invitasjons-maler

app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti

Frontend: login-form passord-modus + 2FA-skjermer

Frontend: kontoinnstillinger + org-medlemsstyring-skjerm

Ekte typesjekket frontend-build

Scratch-verifisere hele auth-løpet grundig (backend)

Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.

Klar til utrulling mot ekte systemer:

Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00

294 lines
11 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
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