refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter
This commit is contained in:
parent
06ab25fb47
commit
03af41e837
74 changed files with 6089 additions and 5183 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
42
backend/app/auth/templates/magic_link.html
Normal file
42
backend/app/auth/templates/magic_link.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue