Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Failing after 5m43s
Limit brute-force attempts, require admin TOTP, and keep session tokens out of media URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
69 lines
2.4 KiB
Python
69 lines
2.4 KiB
Python
"""Gemeinsame Passwort-Link-Erzeugung (Sessions) + Mailtext — wie /auth/forgot-password."""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import secrets
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
RESET_TOKEN_PREFIX = "reset_"
|
||
|
||
|
||
def public_reset_link(token: str) -> str:
|
||
base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
|
||
return f"{base}/reset-password?token={token}"
|
||
|
||
|
||
def revoke_pending_password_resets_for_profile(cur: Any, profile_id: int) -> None:
|
||
"""Entfernt alte Reset-Sessions eines Profils, damit nur der neueste Link aktiv ist."""
|
||
cur.execute(
|
||
"""
|
||
DELETE FROM sessions
|
||
WHERE profile_id = %s AND token LIKE %s
|
||
""",
|
||
(profile_id, f"{RESET_TOKEN_PREFIX}%"),
|
||
)
|
||
|
||
|
||
def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int = 1) -> str:
|
||
"""
|
||
Legt reset_<token>-Session an. Gibt den Klartext-Token zurück (wie bei forgot-password).
|
||
"""
|
||
raw = secrets.token_urlsafe(32)
|
||
expires = datetime.now() + timedelta(hours=hours_valid)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
|
||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, 'password_reset')
|
||
""",
|
||
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
|
||
)
|
||
return raw
|
||
|
||
|
||
def password_reset_email_body(*, recipient_name: str | None, token: str, intro: str) -> str:
|
||
name = (recipient_name or "").strip() or "Kollege/Kollegin"
|
||
link = public_reset_link(token)
|
||
return f"""Hallo {name},
|
||
|
||
{intro}
|
||
|
||
Neues Passwort setzen:
|
||
{link}
|
||
|
||
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
|
||
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
|
||
|
||
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
|
||
|
||
Dein Shinkan Jinkendo Team
|
||
"""
|
||
|
||
|
||
def issue_password_reset_via_email(cur: Any, send_email_fn, *, profile_id: int, email: str, name: str | None, intro: str) -> bool:
|
||
"""Session anlegen und Mail schicken (send_email_fn wie routers.auth.send_email)."""
|
||
revoke_pending_password_resets_for_profile(cur, profile_id)
|
||
raw_token = insert_password_reset_session(cur, profile_id)
|
||
body = password_reset_email_body(recipient_name=name, token=raw_token, intro=intro)
|
||
return send_email_fn(email, "Passwort-Link – Shinkan Jinkendo", body)
|