Ekte SMTP-utsending er bygget og verifisert — med faktisk levering, ikke bare kodegjennomgang.
Bygget: ny app/email.py (send_magic_link_email, kjører smtplib via asyncio.to_thread siden det er synkront, håndterer både implisitt TLS (port 465) og STARTTLS dynamisk siden jeg bevisst ikke leste TEECUP_SMTP_PORT-verdien). app/config.py fikk nye, valgfrie innstillinger — SMTP_CONFIGURED er IKKE _required, så scratch-/dev-testing fortsatt fungerer uendret uten SMTP satt opp. Sikkerhetsdesign: en driftsfeil i selve utsendingen (feil passord, SMTP nede, eller ingenting konfigurert) logges kun server-side og endrer aldri klientresponsen — bevarer request-link sitt anti-enumereringsvern. Verifisert i to trinn: Dev-log-flyten uendret uten SMTP satt (regresjonstest). Én ekte test-e-post sendt til hei@erol.no, med credentials videreført fra .env til scratch-containeren uten at jeg noensinne leste verdiene — du bekreftet mottak. Dette er første gang noe i prosjektet er bevist ved ekte, ekstern levering fremfor bare curl/scratch-container. CLAUDE.md/FEATURE_BACKLOG.md oppdatert.
This commit is contained in:
parent
62017a1b0d
commit
c314d4866e
6 changed files with 138 additions and 11 deletions
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
CLAUDE.md
19
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
54
app/email.py
Normal file
54
app/email.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue