teecup/app/email.py
Erol Haagenrud 89a803140e Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt

Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)

app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere

app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering

app/email.py: 2FA-kode og invitasjons-maler

app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti

Frontend: login-form passord-modus + 2FA-skjermer

Frontend: kontoinnstillinger + org-medlemsstyring-skjerm

Ekte typesjekket frontend-build

Scratch-verifisere hele auth-løpet grundig (backend)

Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.

Klar til utrulling mot ekte systemer:

Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00

147 lines
5.4 KiB
Python

"""
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)
_TEMPLATES = {
"nb": {
"subject": "Logg inn på TeeCup",
"body": (
"Hei,\n\n"
"Åpne denne lenken for å logge inn på TeeCup innen {minutes} minutter:\n\n"
"{link}\n\n"
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
"{token}\n\n"
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
),
},
"en": {
"subject": "Log in to TeeCup",
"body": (
"Hi,\n\n"
"Open this link to log in to TeeCup within {minutes} minutes:\n\n"
"{link}\n\n"
"Link not working? Paste this code in manually instead:\n"
"{token}\n\n"
"If you didn't request this, you can ignore this email.\n"
),
},
}
async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb") -> None:
"""Sender en klikkbar innloggingslenke (pluss selve koden som fallback).
`locale` valideres allerede av Pydantic (Literal["nb","en"]) på vei inn i
routeren, men faller trygt tilbake til "nb" her også hvis den skulle
komme fra et annet kall uten den garantien.
"""
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
subject = template["subject"]
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
body = template["body"].format(
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link
)
await to_thread(_send_sync, to_email, subject, body)
_TWO_FACTOR_TEMPLATES = {
"nb": {
"subject": "Din engangskode for TeeCup",
"body": (
"Hei,\n\n"
"Engangskoden din er: {code}\n\n"
"Den er gyldig i 5 minutter. Ba du ikke om dette, kan du se bort fra e-posten.\n"
),
},
"en": {
"subject": "Your TeeCup one-time code",
"body": (
"Hi,\n\n"
"Your one-time code is: {code}\n\n"
"It is valid for 5 minutes. If you didn't request this, you can ignore this email.\n"
),
},
}
async def send_two_factor_code_email(to_email: str, code: str, locale: str = "nb") -> None:
"""ADR-021 Beslutning C: e-post-2FA-engangskode. Samme leveringsmønster
som magic-link (smtplib i egen tråd)."""
template = _TWO_FACTOR_TEMPLATES.get(locale, _TWO_FACTOR_TEMPLATES["nb"])
body = template["body"].format(code=code)
await to_thread(_send_sync, to_email, template["subject"], body)
_INVITATION_TEMPLATES = {
"nb": {
"subject": "Du er invitert til {org} på TeeCup",
"body": (
"Hei,\n\n"
"{inviter} har invitert deg til å bli {role} i {org} på TeeCup.\n\n"
"Logg inn med denne e-postadressen på {link} for å godta invitasjonen "
"-- den kobles automatisk til kontoen din ved innlogging.\n"
),
},
"en": {
"subject": "You're invited to {org} on TeeCup",
"body": (
"Hi,\n\n"
"{inviter} has invited you to become {role} of {org} on TeeCup.\n\n"
"Log in with this email address at {link} to accept the invitation "
"-- it's linked to your account automatically on login.\n"
),
},
}
_ROLE_LABELS_NB = {"owner": "eier", "admin": "administrator", "member": "medlem"}
_ROLE_LABELS_EN = {"owner": "an owner", "admin": "an administrator", "member": "a member"}
async def send_organization_invitation_email(
to_email: str, org_name: str, role: str, inviter_name: str, locale: str = "nb"
) -> None:
"""ADR-022 Beslutning B: ingen klikkbar aksept-lenke -- invitasjonen
godtas automatisk ved neste innlogging med matchende e-post (samme
mønster som spiller-e-post-kobling, ADR-017)."""
template = _INVITATION_TEMPLATES.get(locale, _INVITATION_TEMPLATES["nb"])
role_label = (_ROLE_LABELS_NB if locale == "nb" else _ROLE_LABELS_EN).get(role, role)
subject = template["subject"].format(org=org_name)
body = template["body"].format(
inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL
)
await to_thread(_send_sync, to_email, subject, body)