teecup/app/routers/auth.py
Erol Haagenrud c314d4866e 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.
2026-07-16 20:59:59 +02:00

223 lines
8.2 KiB
Python

"""
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.
- 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
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"])
_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)
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
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
name: 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: `organization` (org_self-policyen) kan IKKE joines direkte her via
# plain_connection() -- migrasjon 005 fikset kun tomstreng-krasjen, den
# endret ikke at org_self krever en MATCHENDE app.current_org for å vise
# en rad i det hele tatt (riktig RLS-oppførsel, ikke en bug). En bruker
# kan tilhøre flere organisasjoner samtidig, så det finnes ingen ÉN
# kontekst å sette for en tverr-org-spørring som denne. Løsningen er
# derfor å slå opp hvert org-navn ETT OM GANGEN gjennom org_connection()
# (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer,
# men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
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,
)
membership_rows = await conn.fetch(
"SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
user.user_id,
)
organizations = []
for m in membership_rows:
async with org_connection(m["organization_id"]) as org_conn:
name = await org_conn.fetchval(
"SELECT name FROM organization WHERE id = $1", m["organization_id"]
)
organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"]))
return Me(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
organizations=organizations,
)