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:
parent
c521641c2e
commit
fbd3f58a1c
9 changed files with 402 additions and 27 deletions
|
|
@ -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(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(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(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
40
004_auth.sql
Normal 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;
|
||||||
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -79,12 +79,52 @@ Ferdig og verifisert:
|
||||||
upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser.
|
upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser.
|
||||||
Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar
|
Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar
|
||||||
er «rostret på laget».
|
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:
|
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_db` med `teecup_app`, rute via eksisterende Caddy til
|
||||||
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
|
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
|
||||||
monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
|
monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
|
||||||
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
|
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
|
||||||
med samme relative plassering.)
|
med samme relative plassering.) Ekte SMTP-utsending av magic-link må også
|
||||||
2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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». |
|
| 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. |
|
| 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` på
|
||||||
|
`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
79
app/auth.py
79
app/auth.py
|
|
@ -1,46 +1,83 @@
|
||||||
"""
|
"""
|
||||||
Auth-lag for TeeCup.
|
Auth-lag for TeeCup.
|
||||||
|
|
||||||
Selve innloggingen er en STUB her (erstattes med ekte sesjon/JWT senere). Det som
|
Ekte autentisering: magic-link (se app/routers/auth.py for utstedelse) +
|
||||||
IKKE er en stub, og som er sikkerhetskritisk, er `get_authorized_org`: den
|
JWT-sesjon i en HttpOnly-cookie. `get_current_user` er IKKE lenger en stub —
|
||||||
verifiserer at den innloggede brukeren faktisk er medlem av organisasjonen før
|
den dekoder og verifiserer den innsendte sesjonscookien, og bekrefter at
|
||||||
org-konteksten settes.
|
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
|
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
|
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
|
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 (som
|
FØR org_connection kalles — aldri sett konteksten fra en uverifisert kilde.
|
||||||
en klient-oppgitt header).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
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
|
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)
|
@dataclass(frozen=True)
|
||||||
class CurrentUser:
|
class CurrentUser:
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
def create_session_token(user_id: str) -> str:
|
||||||
x_debug_user_id: str | None = Header(default=None),
|
now = int(time.time())
|
||||||
) -> CurrentUser:
|
payload = {"sub": user_id, "iat": now, "exp": now + settings.SESSION_MAX_AGE_SECONDS}
|
||||||
"""STUB: hent brukeren fra en verifisert sesjon.
|
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
|
def should_use_secure_cookies(request: Request) -> bool:
|
||||||
settings.SESSION_SECRET) før noe settes i produksjon. En header er IKKE en
|
"""Secure-flagget skal kun være sant over https.
|
||||||
autentisert kilde.
|
|
||||||
|
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 not x_debug_user_id:
|
if request.url.scheme == "https":
|
||||||
raise HTTPException(
|
return True
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
return request.headers.get("x-forwarded-proto", "").strip().lower() == "https"
|
||||||
detail="Ikke autentisert (stub forventer X-Debug-User-Id).",
|
|
||||||
)
|
|
||||||
return CurrentUser(user_id=x_debug_user_id)
|
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.
|
||||||
|
"""
|
||||||
|
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(
|
async def get_authorized_org(
|
||||||
|
|
|
||||||
|
|
@ -30,5 +30,23 @@ class Settings:
|
||||||
DB_POOL_MIN: int = int(os.getenv("TEECUP_DB_POOL_MIN", "2"))
|
DB_POOL_MIN: int = int(os.getenv("TEECUP_DB_POOL_MIN", "2"))
|
||||||
DB_POOL_MAX: int = int(os.getenv("TEECUP_DB_POOL_MAX", "10"))
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import init_pool, close_pool
|
from .db import init_pool, close_pool
|
||||||
from .routers import matches, players, scoring, tournaments
|
from .routers import auth, matches, players, scoring, tournaments
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -29,6 +29,7 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
app.include_router(players.router)
|
app.include_router(players.router)
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
fastapi>=0.111
|
fastapi>=0.111
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
asyncpg>=0.29
|
asyncpg>=0.29
|
||||||
pydantic>=2.7
|
pydantic[email]>=2.7
|
||||||
|
pyjwt>=2.8
|
||||||
|
|
|
||||||
200
app/routers/auth.py
Normal file
200
app/routers/auth.py
Normal 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
|
||||||
|
rå token server-side i stedet for å sendes på e-post -- KUN når
|
||||||
|
settings.DEV_LOG_MAGIC_LINKS er eksplisitt satt (aldri på 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],
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue