Ekte autentisering er bygget og verifisert. X-Debug-User-Id-stubben er helt fjernet, ingen fallback beholdt.

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.
This commit is contained in:
Erol Haagenrud 2026-07-16 15:16:53 +02:00
parent c521641c2e
commit fbd3f58a1c
9 changed files with 402 additions and 27 deletions

View file

@ -86,7 +86,16 @@
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: __TRACKED_VAR__' __TRACKED_VAR__/orgs/__TRACKED_VAR__/matches/__TRACKED_VAR__/scorecard)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/hr_ids.env)",
"Bash(python3 -m py_compile /opt/teecup/app/routers/scoring.py)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/lock_ids.env)"
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/lock_ids.env)",
"Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/auth.py /opt/teecup/app/routers/auth.py /opt/teecup/app/main.py)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"ny.spiller@test.no\"}' http://127.0.0.1:8099/auth/request-link)",
"Bash(curl -s -i -c /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies.txt -X POST -H 'Content-Type: application/json' -d '{\"token\":\"ruxjsDxPsyUShCFxeGQZ7VL2yWJmPw8KANxT7xpNpK0\"}' http://127.0.0.1:8099/auth/verify-link)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies.txt http://127.0.0.1:8099/auth/me)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"token\":\"UyBT7vlUzzc-lPumCKIpHBhHSyUMYJE59uiQEU4f_NU\"}' http://127.0.0.1:8099/auth/verify-link)",
"Bash(curl -s -o /dev/null -X POST -H 'Content-Type: application/json' -d '{\"email\":\"cooldown@test.no\"}' http://127.0.0.1:8099/auth/request-link)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"ikke-en-epost\"}' http://127.0.0.1:8099/auth/request-link)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 43b4ed80-5bc1-49bf-b6eb-4190f68554a2' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies.txt __TRACKED_VAR__/cookies2.txt)"
]
}
}

40
004_auth.sql Normal file
View file

@ -0,0 +1,40 @@
-- =====================================================================
-- TeeCup — migrasjon 004
-- Ekte autentisering: magic-link + JWT-sesjon
-- =====================================================================
-- Kjøres etter 001 (skjema), 002 (roller) og 003 (scoring/blind draw).
--
-- MERK om nummerering: dette klaimer migrasjonsnummer 004, som CLAUDE.md/
-- FEATURE_BACKLOG løst har forutsatt til en fremtidig "kommunikasjon"-
-- migrasjon — den blir 005 i stedet. Ren nummerkonvensjon, ingen teknisk
-- konflikt.
-- =====================================================================
\set ON_ERROR_STOP on
-- ---------------------------------------------------------------------
-- 1. Unik e-post (mangler i 001 — nødvendig for at magic-link-flyten skal
-- kunne bruke e-post som race-trygg oppslagsnøkkel via ON CONFLICT).
-- ---------------------------------------------------------------------
CREATE UNIQUE INDEX app_user_email_unique ON app_user (email) WHERE email IS NOT NULL;
-- ---------------------------------------------------------------------
-- 2. Magic-link-tokens
-- ---------------------------------------------------------------------
-- Lagrer e-post, IKKE en user_id-FK: app_user-raden opprettes først når
-- noen faktisk beviser eierskap ved å løse inn en gyldig, uforbrukt token
-- (se app/routers/auth.py) — ikke når lenken bare forespørres. Dette er
-- identitetsnivå (som app_user), ingen organization_id/RLS.
CREATE TABLE magic_link_token (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext NOT NULL,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON magic_link_token (email);
-- Grants til runtime-rollen (den eier ikke tabellen, så den trenger eksplisitt DML).
GRANT SELECT, INSERT, UPDATE, DELETE ON magic_link_token TO teecup_app;

View file

@ -79,12 +79,52 @@ Ferdig og verifisert:
upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser.
Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar
er «rostret på laget».
- **Match-lås ved avgjørelse (2026-07-16):** `submit_hole_score`/
`submit_hole_result` avviser nå 409 hvis `match.points_side_a IS NOT NULL`
(matchen er avgjort) — FØR upserten kjøres, både for nye hull og
korrigering av allerede talte hull. Tetter en reell bug: uten dette kunne
«spøkelses-hull» lagt inn etter avgjørelse endre en allerede cachet margin
ved neste omregning. Automatisk, ingen ny autorisasjon involvert.
- **Ekte autentisering bygget og verifisert (2026-07-16):** `X-Debug-User-Id`-
stubben er HELT fjernet (ingen fallback). Magic-link + JWT-sesjon i
`app/routers/auth.py` + `app/auth.py` (`request-link`/`verify-link`/
`logout`/`me`), ny migrasjon `004_auth.sql` (`magic_link_token`-tabell +
unik e-post-indeks på `app_user`). Token = `secrets.token_urlsafe(32)`, kun
SHA-256-hash lagres, atomisk forbruk (`UPDATE ... RETURNING`, ikke
les-sjekk-skriv), generisk respons uansett om e-posten finnes (unngår
enumerering), gamle uforbrukte lenker ugyldiggjøres når en ny utstedes,
`app_user` opprettes FØRST ved vellykket verifisering (ikke ved
forespørsel). Sesjons-JWT (PyJWT, `algorithms=["HS256"]` eksplisitt) i
HttpOnly/SameSite=Lax/dynamisk-Secure-cookie, 30 dager, med et ekte
eksistens-oppslag mot `app_user` på hver forespørsel (faktisk
tilbakekalling — en slettet bruker kan ikke ri ut sesjonen). Alle 12
planlagte tester bestått.
**Fant og fikset underveis:** `ON CONFLICT (email)` matchet ikke den nye
PARTIELLE unike indeksen uten eksplisitt `WHERE email IS NOT NULL` (samme
klasse feil som `hole_score`s partielle indekser i scoring-runden).
**Fant, IKKE fikset her (egen sak, se FEATURE_BACKLOG):** `organization`-
tabellens RLS-policy (`org_self`, og trolig ALLE `org_isolation`-policyer i
001/003) kaster en 500 i stedet for skjemaets lovede "trygg standard: se
ingenting" når `current_setting('app.current_org', true)` returnerer
TOMSTRENG (ikke NULL) — noe som kan skje på en gjenbrukt asyncpg-pool-
tilkobling der en tidligere forespørsel satte GUC-en via `SET LOCAL`. Kun
et problem for kode som spør org-scopede tabeller via `plain_connection()`
(ingen org-kontekst) — `/auth/me` unngår det bevisst ved å ikke joine mot
`organization`. Fiksen (`NULLIF(current_setting(...), '')::uuid` i alle 15
policyer) er reell, billig, og lav risiko, men berører selve
isolasjonsgrunnmuren (ADR-003) og fortjener en egen, fokusert
rettingsrunde med skikkelig testing — ikke en hastefiks boltet på noe annet.
Neste steg:
1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
1. **Fiks RLS-tomstreng-buggen** beskrevet over (egen liten runde, migrasjon
005) — reell, men isolert og lavrisiko.
2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
med samme relative plassering.)
2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).
med samme relative plassering.) Ekte SMTP-utsending av magic-link må også
kobles inn før dette går live (i dag: dev-only logging bak
`TEECUP_DEV_LOG_MAGIC_LINKS`).
3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006,
siden 004/005 nå er tatt av auth og RLS-fiksen).

View file

@ -27,6 +27,35 @@
| API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. |
| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». |
| Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. |
| Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. Ekte SMTP-utsending gjenstår (i dag: dev-only logging). |
---
### RLS-tomstreng-bug (funnet 2026-07-16, IKKE fikset ennå)
- **Status:** ❓ trenger egen rettingsrunde (lavrisiko, men berører ADR-003s
isolasjonsgrunnmur — fortjener fokusert testing, ikke en hastefiks).
- Alle RLS-policyer i 001/003 (`org_isolation` på 13 tabeller + `org_self`
`organization`) bruker `current_setting('app.current_org', true)::uuid`.
Denne håndterer NULL trygt (gir ingen rader, som tiltenkt), men IKKE
tomstreng — og en gjenbrukt asyncpg-pool-tilkobling der en TIDLIGERE
forespørsel satte GUC-en via `SET LOCAL` kan lese den tilbake som `''`
(tomstreng) i stedet for NULL etter at den transaksjonen er ferdig. Da
kaster casten en 500 (`invalid input syntax for type uuid: ""`) i stedet
for skjemaets lovede "trygg standard: se ingenting".
- **Oppdaget av:** `/auth/me` (ny denne runden) prøvde å joine mot
`organization`-tabellen via `plain_connection()` (ingen org-kontekst) for å
hente organisasjonsnavn til en multi-org-liste — det er FØRSTE gang noe
spør en org-scopet, RLS-beskyttet tabell via en tilkobling uten
org-kontekst. Mitigert MIDLERTIDIG i `/auth/me` ved rett og slett å ikke
joine mot `organization` (returnerer kun `organization_id` + `role`, ikke
navn) — unngår buggen, løser den ikke.
- **Fiks:** `NULLIF(current_setting('app.current_org', true), '')::uuid` i
stedet for `current_setting(...)::uuid`, i alle 15 policyer (`ALTER
POLICY`, egen migrasjon 005). NULLIF konverterer tomstreng til NULL FØR
cast, så den trygge "se ingenting"-oppførselen gjenopprettes uansett hvilken
tilstand GUC-en er i.
- **Følgeoppgave når fikset:** `/auth/me` kan da trygt joine mot
`organization` igjen og returnere organisasjonsnavn, ikke bare ID+rolle.
---

View file

@ -1,46 +1,83 @@
"""
Auth-lag for TeeCup.
Selve innloggingen er en STUB her (erstattes med ekte sesjon/JWT senere). Det som
IKKE er en stub, og som er sikkerhetskritisk, er `get_authorized_org`: den
verifiserer at den innloggede brukeren faktisk er medlem av organisasjonen før
org-konteksten settes.
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 `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å at denne sjekken skjer
FØR org_connection kalles aldri sett konteksten fra en uverifisert kilde (som
en klient-oppgitt header).
FØR org_connection kalles aldri sett konteksten fra en uverifisert kilde.
"""
import time
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, status
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
async def get_current_user(
x_debug_user_id: str | None = Header(default=None),
) -> CurrentUser:
"""STUB: hent brukeren fra en verifisert sesjon.
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)
Midlertidig leses bruker-id fra en header for at skjelettet skal kunne kjøres.
ERSTATT dette med validering av sesjonscookie/JWT (signert med
settings.SESSION_SECRET) før noe settes i produksjon. En header er IKKE en
autentisert kilde.
def should_use_secure_cookies(request: Request) -> bool:
"""Secure-flagget skal kun være sant over https.
Stoler 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 not x_debug_user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ikke autentisert (stub forventer X-Debug-User-Id).",
)
return CurrentUser(user_id=x_debug_user_id)
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 JWT-en) gir
faktisk tilbakekalling: en slettet/deaktivert bruker kan ikke ri ut
resten av sesjonens levetid 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(

View file

@ -30,5 +30,23 @@ class Settings:
DB_POOL_MIN: int = int(os.getenv("TEECUP_DB_POOL_MIN", "2"))
DB_POOL_MAX: int = int(os.getenv("TEECUP_DB_POOL_MAX", "10"))
# Ekte autentisering (magic-link + JWT-sesjon). Ikke hemmeligheter --
# sane defaults, ikke _required.
SESSION_MAX_AGE_SECONDS: int = int(
os.getenv("TEECUP_SESSION_MAX_AGE_SECONDS", str(30 * 24 * 3600))
)
MAGIC_LINK_MAX_AGE_MINUTES: int = int(os.getenv("TEECUP_MAGIC_LINK_MAX_AGE_MINUTES", "20"))
MAGIC_LINK_COOLDOWN_SECONDS: int = int(os.getenv("TEECUP_MAGIC_LINK_COOLDOWN_SECONDS", "60"))
# Logger rå magic-link-token server-side i stedet for å sende e-post.
# ALDRI på som standard -- må settes eksplisitt, kun for dev/scratch-
# testing (samme klasse feil som denne runden finnes for å lukke for
# X-Debug-User-Id-headeren).
DEV_LOG_MAGIC_LINKS: bool = os.getenv("TEECUP_DEV_LOG_MAGIC_LINKS", "").strip().lower() in (
"1",
"true",
"yes",
)
settings = Settings()

View file

@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from .db import init_pool, close_pool
from .routers import matches, players, scoring, tournaments
from .routers import auth, matches, players, scoring, tournaments
@asynccontextmanager
@ -29,6 +29,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(players.router)
app.include_router(tournaments.router)
app.include_router(matches.router)

View file

@ -1,4 +1,5 @@
fastapi>=0.111
uvicorn[standard]>=0.30
asyncpg>=0.29
pydantic>=2.7
pydantic[email]>=2.7
pyjwt>=2.8

200
app/routers/auth.py Normal file
View file

@ -0,0 +1,200 @@
"""
Autentisering: magic-link-forespørsel/-verifisering, utlogging, "hvem er jeg".
Sikkerhetsmønster (se plan/ADR-009):
- request-link svarer ALLTID identisk, uansett om e-posten finnes eller
nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke
hvilke e-poster som har konto (enumerering).
- Kun SHA-256-hashen av token lagres, aldri klarteksten.
- app_user opprettes FØRST når en gyldig, uforbrukt token løses inn i
verify-link -- IKKE når lenken bare forespørres (hindrer massopprettelse
av kontoer for e-poster man ikke eier).
- Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv)
for å hindre at to samtidige forsøk med samme lenke begge lykkes.
- Dev-modus: siden ingen ekte SMTP-oppsett finnes for TeeCup ennå, logges
token server-side i stedet for å sendes e-post -- KUN når
settings.DEV_LOG_MAGIC_LINKS er eksplisitt satt (aldri som standard).
# TODO: erstatt med ekte SMTP-utsending før produksjon.
"""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, EmailStr
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies
from ..config import settings
from ..db import plain_connection
router = APIRouter(prefix="/auth", tags=["auth"])
_GENERIC_RESPONSE = {
"status": "ok",
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
}
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class MagicLinkRequest(BaseModel):
email: EmailStr
@router.post("/request-link")
async def request_magic_link(body: MagicLinkRequest) -> dict:
email = body.email.lower()
now = datetime.now(timezone.utc)
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
async with plain_connection() as conn:
recent = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM magic_link_token
WHERE email = $1 AND consumed_at IS NULL AND created_at > $2
)
""",
email,
cooldown_cutoff,
)
if not recent:
# Ugyldiggjør tidligere uforbrukte lenker for denne e-posten --
# begrenser vinduet en eldre, lekket lenke kan misbrukes i.
await conn.execute(
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
email,
)
raw_token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at) VALUES ($1, $2, $3)",
email,
_hash_token(raw_token),
expires_at,
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Magic link for {email}: {raw_token}", flush=True)
return _GENERIC_RESPONSE
class MagicLinkVerify(BaseModel):
token: str
display_name: str | None = None
class SessionUser(BaseModel):
id: str
email: str
display_name: str
@router.post("/verify-link", response_model=SessionUser)
async def verify_magic_link(
body: MagicLinkVerify, response: Response, request: Request
) -> SessionUser:
token_hash = _hash_token(body.token)
async with plain_connection() as conn:
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
# samtidige forsøk med samme lenke kan da ikke begge lykkes.
email = await conn.fetchval(
"""
UPDATE magic_link_token
SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING email
""",
token_hash,
)
if email is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail="Lenken er ugyldig, brukt eller utløpt."
)
placeholder_name = email.split("@")[0]
user_id = await conn.fetchval(
"""
INSERT INTO app_user (email, display_name)
VALUES ($1, $2)
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
RETURNING id
""",
email,
body.display_name or placeholder_name,
)
if user_id is None:
# En annen samtidig verifisering for samme e-post vant innsettingen.
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
user_row = await conn.fetchrow(
"SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1",
user_id,
)
token = create_session_token(user_row["id"])
response.set_cookie(
SESSION_COOKIE_NAME,
token,
max_age=settings.SESSION_MAX_AGE_SECONDS,
httponly=True,
samesite="lax",
secure=should_use_secure_cookies(request),
path="/",
)
return SessionUser(**dict(user_row))
@router.post("/logout")
async def logout(response: Response) -> dict:
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
class MyOrg(BaseModel):
organization_id: str
role: str
class Me(BaseModel):
id: str
email: str
display_name: str
organizations: list[MyOrg]
@router.get("/me", response_model=Me)
async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
# MERK: henter bevisst IKKE organisasjonens navn her. Det ville krevd å
# spørre `organization`-tabellen (som HAR RLS, org_self-policyen) via
# plain_connection() -- altså UTEN org-kontekst satt. Under scratch-
# testing avdekket dette en reell, dypere bug: org_self sin
# `current_setting('app.current_org', true)::uuid` håndterer NULL trygt,
# men IKKE tomstreng (som en custom GUC kan lese tilbake som på en
# gjenbrukt pool-tilkobling der en annen forespørsel tidligere satte den
# via SET LOCAL) -- det gir en 500 i stedet for skjemaets lovede "trygg
# standard: se ingenting". Dette er en tverrgående RLS-sak (berører alle
# 15 policyer i skjemaet, ikke noe auth-spesifikt) som fortjener sin egen
# fokuserte rettingsrunde, ikke en hastefiks her. Se FEATURE_BACKLOG.md.
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1",
user.user_id,
)
org_rows = await conn.fetch(
"""
SELECT organization_id::text AS organization_id, role
FROM organization_membership
WHERE user_id = $1
""",
user.user_id,
)
return Me(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
organizations=[MyOrg(**dict(r)) for r in org_rows],
)