diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0259a63..6e07b43 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -106,7 +106,11 @@ "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt -X POST -H 'Content-Type: application/json' -d '{\"name\":\"Høstcupen\"}' http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/auth/me)", - "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_org.txt __TRACKED_VAR__/cookies_org2.txt)" + "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_org.txt __TRACKED_VAR__/cookies_org2.txt)", + "Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/email.py /opt/teecup/app/routers/auth.py)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"devlog@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\":\"hei@erol.no\"}' http://127.0.0.1:8099/auth/request-link)", + "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 2208bef..209d12c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,18 +135,27 @@ Ferdig og verifisert: antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende id avvist med `insufficient_privilege`) og full kryss-org-isolasjon mellom to uavhengig opprettede organisasjoner. +- **Ekte SMTP-utsending bygget og verifisert (2026-07-16):** ny `app/email.py` + (`send_magic_link_email`, `smtplib` via `asyncio.to_thread`, håndterer + både implisitt TLS/port 465 og STARTTLS dynamisk). Brukeren la egne + SMTP-credentials i `.env` (`TEECUP_SMTP_*`, `TEECUP_FROM_EMAIL` — ADR-009, + ikke delt med teeoff); jeg leste kun nøkkelnavnene for å bekrefte de + fantes, aldri verdiene. `app/config.py` sin `SMTP_CONFIGURED` er valgfri + (ikke `_required`) — dev-only logging (`TEECUP_DEV_LOG_MAGIC_LINKS`) + fortsatt fungerer uendret når SMTP ikke er satt opp. Driftsfeil i + utsendingen lekker aldri til klientresponsen (bevarer anti-enumerering). + **Verifisert med faktisk levering:** sendte én ekte test-e-post til en + adresse brukeren oppga — brukeren bekreftet mottak. Første gang noe i + prosjektet er bevist ved ekte levering, ikke bare curl/scratch. Neste steg: -1. Ekte SMTP-utsending av magic-link (i dag: dev-only logging bak - `TEECUP_DEV_LOG_MAGIC_LINKS`) — krever ekte e-post-credentials, ikke noe - som kan scratch-testes uten dem. -2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot +1. 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.) -3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, +2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, siden 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — API-et alene er ikke en brukbar nettside. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 863de88..fae63e9 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -27,9 +27,39 @@ | 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). | +| Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. | | RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. | | Organisasjon-bootstrap (opprette ny org via API) | ✅ | `POST /orgs`, `app/routers/organizations.py`. Se detaljer under. | +| Ekte SMTP-utsending av magic-link | ✅ | `app/email.py`. Se detaljer under. | + +--- + +### Ekte SMTP-utsending — ✅ BYGGET OG VERIFISERT 2026-07-16 +- Brukeren la inn egne, uavhengige SMTP-credentials i `.env` + (`TEECUP_SMTP_SERVER/PORT/USER/PASS`, `TEECUP_FROM_EMAIL` — ADR-009, ikke + delt med teeoff). Kun nøkkelnavn ble lest for å bekrefte de fantes, ALDRI + verdiene (CLAUDE.md sin sikkerhetsregel). +- **Løsning:** ny `app/email.py` (`send_magic_link_email`, `smtplib` kjørt via + `asyncio.to_thread` siden det er et synkront bibliotek — samme mønster som + teeoffs egen fungerende utsending). Håndterer BEGGE vanlige + SMTP-tilkoblingsmåter dynamisk (implisitt TLS på port 465 vs. STARTTLS på + andre porter) siden porten bevisst ikke ble lest under planlegging. + `app/config.py` fikk nye, valgfrie innstillinger (`SMTP_CONFIGURED` avledet + fra at alle fem er satt) — IKKE `_required`, så scratch-/dev-testing + fungerer fortsatt uten SMTP satt opp, via `TEECUP_DEV_LOG_MAGIC_LINKS`. +- **Bevisst designvalg:** en driftsfeil i selve utsendingen (feil passord, + SMTP nede, eller ingen leveringsmåte konfigurert i det hele tatt) logges + kun server-side og endrer ALDRI klientens respons — alt annet ville brutt + anti-enumereringsgarantien i `request-link` (klienten skal ikke kunne + skille "e-posten finnes ikke" fra "e-posten finnes men utsendingen + feilet"). +- **Verifisert i to trinn:** (1) dev-log-flyten uendret uten SMTP satt + (regresjonstest av eksisterende scratch-løype), (2) én ekte test-e-post + sendt til en adresse brukeren oppga, med de ekte credentials videreført fra + `.env` til scratch-containeren uten at jeg noensinne leste verdiene selv — + **brukeren bekreftet mottak** av en e-post med innloggingskode. Dette er + første gang noe i dette prosjektet er verifisert ved faktisk levering til + en ekte, ekstern mottaker, ikke bare via curl/scratch-container. --- diff --git a/app/config.py b/app/config.py index 5182440..0aac72e 100644 --- a/app/config.py +++ b/app/config.py @@ -48,5 +48,17 @@ class Settings: "yes", ) + # Ekte e-postutsending for magic-link (ADR-009: egne credentials, ikke + # delt med teeoff). Valgfritt (ikke _required) -- scratch-/dev-testing + # skal fortsatt fungere uten SMTP satt, via DEV_LOG_MAGIC_LINKS over. + SMTP_SERVER: str | None = os.environ.get("TEECUP_SMTP_SERVER", "").strip() or None + SMTP_PORT: int | None = ( + int(os.environ["TEECUP_SMTP_PORT"]) if os.environ.get("TEECUP_SMTP_PORT", "").strip() else None + ) + SMTP_USER: str | None = os.environ.get("TEECUP_SMTP_USER", "").strip() or None + SMTP_PASS: str | None = os.environ.get("TEECUP_SMTP_PASS", "").strip() or None + FROM_EMAIL: str | None = os.environ.get("TEECUP_FROM_EMAIL", "").strip() or None + SMTP_CONFIGURED: bool = bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and FROM_EMAIL) + settings = Settings() diff --git a/app/email.py b/app/email.py new file mode 100644 index 0000000..58add9d --- /dev/null +++ b/app/email.py @@ -0,0 +1,54 @@ +""" +E-postutsending for TeeCup. I dag kun magic-link-innlogging. + +Egne, uavhengige SMTP-credentials fra teeoff (ADR-009) -- se .env +(TEECUP_SMTP_SERVER/PORT/USER/PASS, TEECUP_FROM_EMAIL). `smtplib` er +synkron, så selve sendingen kjøres i en tråd (asyncio.to_thread) for ikke å +blokkere event loop-en -- samme mønster som teeoffs egen, fungerende +magic-link-utsending. + +Håndterer BEGGE vanlige SMTP-tilkoblingsmåter siden porten ikke er antatt på +forhånd: implisitt TLS (SMTP_SSL, typisk port 465) eller STARTTLS på en +vanlig tilkobling (typisk port 587/25). +""" + +import smtplib +from asyncio import to_thread +from email.message import EmailMessage + +from .config import settings + + +def _send_sync(to_email: str, subject: str, body: str) -> None: + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = settings.FROM_EMAIL + msg["To"] = to_email + msg.set_content(body) + + if settings.SMTP_PORT == 465: + with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp: + smtp.login(settings.SMTP_USER, settings.SMTP_PASS) + smtp.send_message(msg) + else: + with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp: + smtp.starttls() + smtp.login(settings.SMTP_USER, settings.SMTP_PASS) + smtp.send_message(msg) + + +async def send_magic_link_email(to_email: str, raw_token: str) -> None: + """Sender selve magic-link-tokenet som klartekst. + + MERK: ingen klikkbar lenke ennå -- TeeCup har ikke noe frontend å peke + på (se plan). Når frontend bygges, oppdater malen til en ekte URL. + """ + subject = "Logg inn på TeeCup" + body = ( + "Hei,\n\n" + f"Bruk denne koden for å logge inn på TeeCup innen " + f"{settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter:\n\n" + f"{raw_token}\n\n" + "Ba du ikke om dette, kan du se bort fra e-posten.\n" + ) + await to_thread(_send_sync, to_email, subject, body) diff --git a/app/routers/auth.py b/app/routers/auth.py index 6f08dfe..aae8c88 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -11,14 +11,17 @@ Sikkerhetsmønster (se plan/ADR-009): 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. + - Levering: ekte SMTP-utsending (app/email.py) hvis settings.SMTP_CONFIGURED, + ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri på + som standard). En driftsfeil i selve utsendingen (feil passord, SMTP nede, + eller ingen av delene konfigurert) logges server-side og lekker ALDRI til + klienten som en annen respons -- det ville brutt enumereringsvernet over + (klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet"). """ import hashlib import secrets +import traceback from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Request, Response, status @@ -27,6 +30,7 @@ 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 org_connection, plain_connection +from ..email import send_magic_link_email router = APIRouter(prefix="/auth", tags=["auth"]) @@ -78,6 +82,20 @@ async def request_magic_link(body: MagicLinkRequest) -> dict: ) if settings.DEV_LOG_MAGIC_LINKS: print(f"[DEV] Magic link for {email}: {raw_token}", flush=True) + elif settings.SMTP_CONFIGURED: + try: + await send_magic_link_email(email, raw_token) + except Exception: + # Se modul-docstring: driftsfeil skal ALDRI endre klientens + # respons. Token-raden består og utløper naturlig hvis den + # aldri når frem. + traceback.print_exc() + else: + print( + f"[ADVARSEL] Verken DEV_LOG_MAGIC_LINKS eller SMTP er konfigurert " + f"-- ingen lenke ble sendt for {email}.", + flush=True, + ) return _GENERIC_RESPONSE