refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter

This commit is contained in:
Jonas Miederer 2026-09-28 18:03:07 +02:00
parent 06ab25fb47
commit 03af41e837
74 changed files with 6089 additions and 5183 deletions

View file

@ -126,17 +126,14 @@ def consume_magic(
session: Annotated[Session, Depends(get_db)],
):
try:
user_session = service.consume_magic_token(session, data.token)
user, session_token = service.consume_magic_token(session, data.token)
except (
service.InvalidTokenError,
service.ExpiredTokenError,
service.UsedTokenError,
) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = service.get_user(session, user_session.user_id)
if user is None: # pragma: no cover - consume already validated the user
raise HTTPException(status_code=400, detail="unknown token")
_set_session_cookie(response, user_session.id)
_set_session_cookie(response, session_token)
return _read_me(user)
@ -151,11 +148,8 @@ def logout(
response: Response,
session: Annotated[Session, Depends(get_db)],
):
# Revoke the server-side row when the cookie maps to a live session;
# always clear the client cookie.
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
service.revoke_session(session, session_id)
# Stateless sessions: nothing server-side to revoke — always clear
# the client cookie.
response.delete_cookie(SESSION_COOKIE, path="/")
return MessageRead(message="logged out")

View file

@ -19,13 +19,10 @@ SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
def get_optional_user(
request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE)
if not session_id:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
record = service.get_valid_session(session, session_id)
if record is None:
return None
return service.get_user(session, record.user_id)
return service.get_token_user(session, token)
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:

View file

@ -9,8 +9,10 @@ fonts, no external images (many clients block them / track opens).
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from pathlib import Path
from jinja2 import Template
from app.settings import settings
@ -22,67 +24,62 @@ BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "magic_link.html"
_template_cache: Template | None = None
def _template() -> Template:
global _template_cache
if _template_cache is None:
_template_cache = Template(_TEMPLATE_PATH.read_text(encoding="utf-8"))
return _template_cache
def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
"""Branded HTML mail body (table layout, inline styles, no images)."""
button = ""
if button_url:
button = (
'<table role="presentation" cellpadding="0" cellspacing="0" '
'style="margin:24px auto 8px auto;"><tr>'
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
"font-weight:600;color:#ffffff;text-decoration:none;"
f'border-radius:8px;">{button_label}</a></td></tr></table>'
)
return f"""<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{BLUE}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{INK};">{title}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{MUTED};">{intro}</p>
{button}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
{note}
</p>
</td></tr>
</table>
</body>
</html>"""
"""Branded HTML mail body (jinja template, byte-identical to the old f-string)."""
return _template().render(
title=title,
intro=intro,
button_label=button_label,
button_url=button_url,
note=note,
blue=BLUE,
blue_light=BLUE_LIGHT,
ink=INK,
muted=MUTED,
)
def _send(message: EmailMessage) -> None:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
smtp.starttls()
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
"""Deliver via fastapi-mail (same never-raise contract at call sites)."""
import asyncio
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
config = ConnectionConfig(
MAIL_USERNAME=settings.smtp_username,
MAIL_PASSWORD=settings.smtp_password,
MAIL_FROM=settings.mail_from or settings.contact_to or message["To"],
MAIL_PORT=settings.smtp_port,
MAIL_SERVER=settings.smtp_host,
MAIL_STARTTLS=True,
MAIL_SSL_TLS=False,
USE_CREDENTIALS=bool(settings.smtp_username),
)
html = message.get_body(preferencelist=("html",))
plain = message.get_body(preferencelist=("plain",))
outgoing = MessageSchema(
subject=str(message["Subject"]),
recipients=[str(message["To"])],
body=plain.get_content() if plain else "",
html=html.get_content() if html else "",
subtype=MessageType.html,
)
asyncio.run(FastMail(config).send_message(outgoing))
def _branded_mail(
@ -142,7 +139,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
except Exception: # noqa: BLE001 -- never-raise contract: delivery must not 500
logger.warning("magic-link delivery failed for %s", to_email)
return False
logger.info("magic link mailed to %s", to_email)
@ -167,7 +164,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
except Exception: # noqa: BLE001 -- best-effort notification, never raises
logger.warning("access-request notification failed for %s", request_id)
return False
return True

View file

@ -1,7 +1,12 @@
"""Access requests, magic-link tokens, and sessions (DB via function args).
"""Access requests, magic-link tokens, and stateless JWT sessions.
Keep API handlers thin: all persistence lives here. Tokens and session ids
are bearer secrets — log user/request ids only, never secret values.
Keep API handlers thin: all persistence lives here. Tokens and session
JWTs are bearer secrets — log user/request ids only, never secret values.
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
iat, exp=30d, jti) in the HttpOnly session cookie. No session table:
logout clears the cookie; role/tenant changes apply at next login (or
immediately for deleted users — the user row is reloaded per request).
"""
from __future__ import annotations
@ -9,12 +14,14 @@ from __future__ import annotations
import hashlib
import logging
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from uuid import UUID
import jwt
from sqlmodel import Session, func, select
from app.models import AccessRequest, MagicLinkToken, User, UserSession
from app.models import AccessRequest, MagicLinkToken, User
from app.models.base import utcnow
from app.settings import settings
@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime:
return value
def consume_magic_token(session: Session, token: str) -> UserSession:
"""Single-use consume → session row. used_at is set before creation."""
def _jwt_secret() -> str:
secret = settings.jwt_secret
if not secret:
raise RuntimeError("JWT_SECRET is empty — set it in .env")
return secret
def mint_session_token(user: User) -> str:
"""Issue a stateless session JWT for a user (30-day expiry)."""
now = utcnow()
return jwt.encode(
{
"sub": str(user.id),
"email": user.email,
"role": user.role,
"tenant_id": user.tenant_id,
"iat": int(now.timestamp()),
"exp": int((now + SESSION_TTL).timestamp()),
"jti": uuid.uuid4().hex,
},
_jwt_secret(),
algorithm="HS256",
)
def decode_session_token(token: str) -> dict:
"""Verify a session JWT → claims. Expired/forged → token errors."""
try:
return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"])
except jwt.ExpiredSignatureError as exc:
raise ExpiredTokenError("session expired") from exc
except jwt.InvalidTokenError as exc:
raise InvalidTokenError("invalid session") from exc
def consume_magic_token(session: Session, token: str) -> tuple[User, str]:
"""Single-use consume → (user, session JWT). used_at is set first."""
record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
).first()
@ -252,33 +294,23 @@ def consume_magic_token(session: Session, token: str) -> UserSession:
# Mark used first: a reused token can never mint a second session.
record.used_at = utcnow()
session.add(record)
user_session = UserSession(
id=secrets.token_urlsafe(32),
user_id=user.id,
expires_at=utcnow() + SESSION_TTL,
)
session.add(user_session)
session.commit()
session.refresh(user_session)
session.refresh(user)
logger.info("magic token consumed for user %s", user.id)
return user_session
return user, mint_session_token(user)
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
record = session.get(UserSession, session_id)
if record is None or record.revoked_at is not None:
def get_token_user(session: Session, token: str) -> User | None:
"""User for a session JWT, or None (expired/forged/deleted user)."""
try:
claims = decode_session_token(token)
except (InvalidTokenError, ExpiredTokenError):
return None
if _aware(record.expires_at) <= utcnow():
try:
user_id = UUID(str(claims.get("sub", "")))
except ValueError:
return None
return record
def revoke_session(session: Session, session_id: str) -> None:
record = session.get(UserSession, session_id)
if record is not None and record.revoked_at is None:
record.revoked_at = utcnow()
session.add(record)
session.commit()
return session.get(User, user_id)
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{{ blue_light }};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{{ blue_light }};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{{ blue }}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{{ ink }};">{{ title }}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{{ muted }};">{{ intro }}</p>
{% if button_url %}
<table role="presentation" cellpadding="0" cellspacing="0"
style="margin:24px auto 8px auto;"><tr>
<td bgcolor="{{ blue }}" style="border-radius:8px;">
<a href="{{ button_url }}"
style="display:inline-block;padding:12px 28px;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ button_label }}</a>
</td>
</tr></table>
{% endif %}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{{ muted }};margin:16px 0 0 0;max-width:520px;">
{{ note }}
</p>
</td></tr>
</table>
</body>
</html>