shinkan-jinkendo/backend/routers/auth.py
Lars f10c28e5cd
Some checks failed
Deploy Development / deploy (push) Successful in 59s
Test Suite / pytest-backend (push) Failing after 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 17s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 7m48s
Close medium exposure on reports, registration, uploads, and exercise HTML.
A superadmin can also turn off another account''s two-factor when the authenticator is lost.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 16:47:57 +02:00

837 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Authentication Endpoints for Mitai Jinkendo
Handles login, logout, password reset, and profile authentication.
"""
import os
import secrets
import smtplib
import ssl
from urllib.parse import quote
from typing import Optional
from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText
from fastapi import APIRouter, HTTPException, Header, Depends
from starlette.requests import Request
import ipaddress
from slowapi import Limiter
from db import get_db, get_cursor
from auth import (
get_limited_session,
get_session,
hash_pin,
make_token,
require_auth,
revoke_profile_sessions,
session_lifetime_days,
verify_pin,
verify_pin_for_login,
)
from media_grant import MEDIA_GRANT_TTL_SECONDS, issue_media_grant
from models import (
ChangePasswordRequest,
LoginRequest,
LoginTotpRequest,
PasswordResetRequest,
PasswordResetConfirm,
RegisterRequest,
ResendVerificationRequest,
TotpCodeRequest,
TotpDisableRequest,
)
from totp_auth import (
generate_recovery_codes,
generate_totp_secret,
hash_recovery_code,
otpauth_uri,
verify_totp,
)
from password_reset_mail import (
insert_password_reset_session,
password_reset_email_body,
revoke_pending_password_resets_for_profile,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
LOGIN_LOCK_AFTER = 8
LOGIN_LOCK_MINUTES = 15
_ADMIN_ROLES = ("admin", "superadmin")
def _client_ip(request: Request) -> str:
"""Echte Client-IP nur, wenn der direkte Peer ein vertrauenswürdiger Proxy ist."""
peer = request.client.host if request.client and request.client.host else ""
if _is_trusted_proxy(peer):
real = (request.headers.get("x-real-ip") or "").split(",")[0].strip()
if real:
return real[:64]
return peer or "unknown"
def _is_trusted_proxy(host: str) -> bool:
if not host:
return False
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip.is_private or ip.is_loopback or ip.is_link_local
limiter = Limiter(key_func=_client_ip)
def _admin_totp_required() -> bool:
"""Nur wenn REQUIRE_ADMIN_TOTP ausdrücklich an ist. Standard: der Nutzer entscheidet selbst."""
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
return raw in ("1", "true", "yes")
def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]:
token = make_token()
expires = datetime.now(timezone.utc) + timedelta(days=days if purpose == "full" else 0, minutes=0)
if purpose == "totp_challenge":
expires = datetime.now(timezone.utc) + timedelta(minutes=5)
elif purpose == "totp_enroll":
expires = datetime.now(timezone.utc) + timedelta(minutes=30)
cur.execute(
"""
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, %s)
""",
(token, profile_id, expires, purpose),
)
return token, expires
def _login_payload(prof: dict, token: str, expires: datetime) -> dict:
return {
"token": token,
"profile_id": prof["id"],
"email": prof.get("email"),
"name": prof.get("name"),
"role": prof.get("role"),
"tier": prof.get("tier"),
"expires_at": expires.isoformat(),
"totp_enabled": bool(prof.get("totp_enabled")),
}
def _record_failed_login(cur, profile_id: int) -> None:
cur.execute(
"""
UPDATE profiles
SET failed_login_count = CASE
WHEN failed_login_count + 1 >= %s THEN 0
ELSE failed_login_count + 1
END,
locked_until = CASE
WHEN failed_login_count + 1 >= %s
THEN CURRENT_TIMESTAMP + (%s * INTERVAL '1 minute')
ELSE locked_until
END
WHERE id = %s
""",
(LOGIN_LOCK_AFTER, LOGIN_LOCK_AFTER, LOGIN_LOCK_MINUTES, profile_id),
)
def _clear_login_lock(cur, profile_id: int) -> None:
cur.execute(
"UPDATE profiles SET failed_login_count = 0, locked_until = NULL WHERE id = %s",
(profile_id,),
)
@router.post("/login")
@limiter.limit("10/minute")
async def login(req: LoginRequest, request: Request):
"""Login with email + password. Bei aktivem TOTP folgt ein zweiter Schritt."""
email = req.email.lower().strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT *,
(locked_until IS NOT NULL AND locked_until > CURRENT_TIMESTAMP) AS is_locked
FROM profiles WHERE email=%s
""",
(email,),
)
prof = cur.fetchone()
password_ok = verify_pin_for_login(req.password, prof["pin_hash"] if prof else None)
if not prof or not password_ok:
if prof and not prof.get("is_locked"):
_record_failed_login(cur, prof["id"])
outcome = "invalid"
elif prof.get("is_locked"):
outcome = "locked"
else:
outcome = "ok"
if prof["pin_hash"] and not str(prof["pin_hash"]).startswith("$2"):
cur.execute(
"UPDATE profiles SET pin_hash=%s WHERE id=%s",
(hash_pin(req.password), prof["id"]),
)
_clear_login_lock(cur, prof["id"])
if outcome == "invalid":
raise HTTPException(401, "Ungültige Zugangsdaten")
if outcome == "locked":
raise HTTPException(429, "Zu viele Anmeldeversuche. Bitte später erneut versuchen.")
with get_db() as conn:
cur = get_cursor(conn)
role = (prof.get("role") or "").strip().lower()
if prof.get("totp_enabled"):
token, expires = _issue_session(cur, prof["id"], days=1, purpose="totp_challenge")
return {
"totp_required": True,
"challenge_token": token,
"expires_at": expires.isoformat(),
}
if role in _ADMIN_ROLES and _admin_totp_required() and not prof.get("totp_enabled"):
token, expires = _issue_session(cur, prof["id"], days=1, purpose="totp_enroll")
return {
"totp_enrollment_required": True,
"token": token,
"email": prof.get("email"),
"expires_at": expires.isoformat(),
}
token, expires = _issue_session(cur, prof["id"], days=session_lifetime_days(prof))
return _login_payload(prof, token, expires)
@router.post("/logout")
def logout(x_auth_token: Optional[str]=Header(default=None)):
"""Logout (delete session)."""
if x_auth_token:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,))
return {"ok": True}
@router.get("/me")
def get_me(session: dict=Depends(require_auth)):
"""Get current user info."""
pid = session['profile_id']
# Import here to avoid circular dependency
from routers.profiles import profile_document
return profile_document(pid)
@router.get("/status")
def auth_status():
"""Health check endpoint."""
return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
@router.put("/pin")
def change_pin(req: ChangePasswordRequest, session: dict = Depends(require_auth)):
"""Passwort wechseln. Das aktuelle Passwort ist Pflicht; alle Sitzungen werden ersetzt."""
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
cur.execute(
"UPDATE profiles SET pin_hash=%s WHERE id=%s",
(hash_pin(req.new_password), pid),
)
revoke_profile_sessions(cur, int(pid))
token, expires = _issue_session(cur, int(pid), days=session_lifetime_days(prof))
return {"ok": True, "token": token, "expires_at": expires.isoformat()}
@router.post("/login/totp")
@limiter.limit("10/minute")
async def login_totp(req: LoginTotpRequest, request: Request):
"""Zweiter Login-Schritt: Authenticator-Code oder Wiederherstellungscode."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT s.*, p.role, p.name, p.email
FROM sessions s JOIN profiles p ON s.profile_id = p.id
WHERE s.token = %s AND s.expires_at > CURRENT_TIMESTAMP AND s.purpose = 'totp_challenge'
""",
(req.challenge_token,),
)
challenge = cur.fetchone()
error = None
payload = None
if not challenge:
error = "Anmeldung abgelaufen. Bitte erneut anmelden."
else:
pid = int(challenge["profile_id"])
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_enabled"):
error = "Anmeldung abgelaufen. Bitte erneut anmelden."
else:
code_ok = verify_totp(prof.get("totp_secret"), req.code) or _consume_recovery_code(
cur, pid, req.code
)
if not code_ok:
attempts = int(challenge.get("failed_attempts") or 0) + 1
if attempts >= 5:
cur.execute("DELETE FROM sessions WHERE token=%s", (req.challenge_token,))
else:
cur.execute(
"UPDATE sessions SET failed_attempts=%s WHERE token=%s",
(attempts, req.challenge_token),
)
error = "Ungültiger Code"
else:
revoke_profile_sessions(cur, pid)
token, expires = _issue_session(cur, pid, days=session_lifetime_days(prof))
payload = _login_payload(prof, token, expires)
if error:
raise HTTPException(401, error)
return payload
def _consume_recovery_code(cur, profile_id: int, code: str) -> bool:
digest = hash_recovery_code(code)
cur.execute(
"""
UPDATE profile_recovery_codes
SET used_at = CURRENT_TIMESTAMP
WHERE profile_id = %s AND code_hash = %s AND used_at IS NULL
RETURNING id
""",
(profile_id, digest),
)
return cur.fetchone() is not None
def _setup_session_or_401(token: str) -> dict:
full = None
if token:
full = get_session(token)
if full:
return full
enroll = get_limited_session(token, "totp_enroll") if token else None
if not enroll:
raise HTTPException(401, "Nicht eingeloggt")
return enroll
@router.get("/totp/status")
def totp_status(x_auth_token: Optional[str] = Header(default=None)):
session = _setup_session_or_401(x_auth_token or "")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT email, role, totp_enabled FROM profiles WHERE id=%s",
(session["profile_id"],),
)
prof = cur.fetchone()
if not prof:
raise HTTPException(401, "Nicht eingeloggt")
role = (prof.get("role") or session.get("role") or "").strip().lower()
enrollment = (session.get("purpose") or "full") == "totp_enroll"
return {
"totp_enabled": bool(prof.get("totp_enabled")),
"enrollment_required": enrollment or (
role in _ADMIN_ROLES and _admin_totp_required() and not prof.get("totp_enabled")
),
"email": prof.get("email"),
"role": role,
}
@router.post("/totp/setup")
def totp_setup(x_auth_token: Optional[str] = Header(default=None)):
"""Neues Geheimnis erzeugen. Wirksam erst nach /totp/confirm."""
session = _setup_session_or_401(x_auth_token or "")
secret = generate_totp_secret()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT email FROM profiles WHERE id=%s", (session["profile_id"],))
prof = cur.fetchone()
if not prof:
raise HTTPException(404, "Profil nicht gefunden")
cur.execute(
"UPDATE profiles SET totp_pending_secret=%s WHERE id=%s",
(secret, session["profile_id"]),
)
email = prof.get("email") or "konto"
return {
"secret": secret,
"otpauth_uri": otpauth_uri(secret=secret, account_name=email),
}
@router.post("/totp/confirm")
def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(default=None)):
session = _setup_session_or_401(x_auth_token or "")
pid = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_pending_secret"):
raise HTTPException(400, "Zuerst ein neues Geheimnis erzeugen")
if not verify_totp(prof.get("totp_pending_secret"), req.code):
raise HTTPException(400, "Code stimmt nicht. Uhrzeit des Geräts prüfen.")
cur.execute(
"""
UPDATE profiles
SET totp_secret=%s, totp_pending_secret=NULL, totp_enabled=TRUE
WHERE id=%s
""",
(prof["totp_pending_secret"], pid),
)
plain_codes = _replace_recovery_codes(cur, pid)
prof = dict(prof)
prof["totp_enabled"] = True
prof["totp_secret"] = None
revoke_profile_sessions(cur, pid)
token, expires = _issue_session(cur, pid, days=session_lifetime_days(prof))
payload = _login_payload(prof, token, expires)
payload["recovery_codes"] = plain_codes
return payload
def _replace_recovery_codes(cur, profile_id: int) -> list[str]:
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (profile_id,))
plain_codes = generate_recovery_codes()
for code in plain_codes:
cur.execute(
"""
INSERT INTO profile_recovery_codes (profile_id, code_hash)
VALUES (%s, %s)
""",
(profile_id, hash_recovery_code(code)),
)
return plain_codes
@router.post("/totp/recovery-codes")
def regenerate_recovery_codes(req: TotpDisableRequest, session: dict = Depends(require_auth)):
"""Neue Codes ausgeben. Die bisherigen werden ungültig. Passwort und aktueller App-Code sind Pflicht."""
pid = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_enabled"):
raise HTTPException(400, "Zwei-Faktor ist nicht aktiv")
if not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
if not verify_totp(prof.get("totp_secret"), req.code):
raise HTTPException(400, "Code aus der App stimmt nicht")
plain_codes = _replace_recovery_codes(cur, pid)
return {"recovery_codes": plain_codes}
@router.post("/totp/disable")
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
pid = int(session["profile_id"])
role = (session.get("role") or "").strip().lower()
if role in _ADMIN_ROLES and _admin_totp_required():
raise HTTPException(400, "Für Plattform-Admins bleibt die Zwei-Faktor-Anmeldung aktiv.")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
if not (
verify_totp(prof.get("totp_secret"), req.code)
or _consume_recovery_code(cur, pid, req.code)
):
raise HTTPException(400, "Code stimmt nicht")
cur.execute(
"""
UPDATE profiles
SET totp_enabled=FALSE, totp_secret=NULL, totp_pending_secret=NULL
WHERE id=%s
""",
(pid,),
)
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,))
return {"ok": True}
@router.post("/media-grant")
def media_grant(session: dict = Depends(require_auth)):
"""Kurzlebiges Token nur für Medien-Dateien. Ersetzt das Sitzungstoken in Bild-URLs."""
token, exp = issue_media_grant(int(session["profile_id"]), ttl_seconds=MEDIA_GRANT_TTL_SECONDS)
return {
"token": token,
"expires_at": datetime.fromtimestamp(exp, tz=timezone.utc).isoformat(),
}
@router.post("/forgot-password")
@limiter.limit("3/minute")
async def password_reset_request(req: PasswordResetRequest, request: Request):
"""Request password reset email."""
email = req.email.lower().strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
prof = cur.fetchone()
if not prof:
# Don't reveal if email exists
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
revoke_pending_password_resets_for_profile(cur, prof["id"])
raw_token = insert_password_reset_session(cur, prof["id"])
reset_body = password_reset_email_body(
recipient_name=prof.get("name"),
token=raw_token,
intro="Du hast einen Passwort-Reset angefordert.",
)
if not send_email(email, "Passwort zurücksetzen – Shinkan Jinkendo", reset_body):
print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
@router.post("/reset-password")
def password_reset_confirm(req: PasswordResetConfirm):
"""Confirm password reset with token."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP",
(f"reset_{req.token}",))
sess = cur.fetchone()
if not sess:
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
pid = sess["profile_id"]
new_hash = hash_pin(req.new_password)
cur.execute(
"""
UPDATE profiles
SET pin_hash=%s, failed_login_count=0, locked_until=NULL
WHERE id=%s
""",
(new_hash, pid),
)
revoke_profile_sessions(cur, int(pid))
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
# ── Helpers (Registrierung / E-Mail-Links) ─────────────────────────────────────
def _public_app_base() -> str:
return (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
def _verification_link_expired(expires_at) -> bool:
"""
True, wenn Ablaufzeit in der Vergangenheit liegt.
PG liefert TIMESTAMP oft als naive datetime — Vergleich mit timezone-aware UTC
würde sonst TypeError → 500.
"""
if expires_at is None:
return False
deadline = expires_at.replace(tzinfo=timezone.utc) if expires_at.tzinfo is None else expires_at.astimezone(timezone.utc)
return datetime.now(timezone.utc) > deadline
def verification_link(token: str) -> str:
"""Link zur Web-App (`/verify?token=`); die SPA ruft wie bei Mitai die API auf."""
return f"{_public_app_base()}/verify?token={quote(token, safe='')}"
def registration_role(cur, email_lower: str) -> str:
"""
bootstrap: erste Registrierung in leerer DB → superadmin,
oder E-Mail ∈ ADMIN_BOOTSTRAP_EMAILS (kommasepariert, Groß/Klein egal).
superadmin deckt alle Portal-Rechte ab (inkl. löschen / andere Super-Admins).
Um alle Self-Regs als Trainer zu haben: AUTO_ADMIN_FIRST_USER=false und keine ADMIN_BOOTSTRAP_EMAILS.
"""
bootstrap = {
x.strip().lower()
for x in os.getenv("ADMIN_BOOTSTRAP_EMAILS", "").split(",")
if x.strip()
}
if email_lower in bootstrap:
return "superadmin"
if os.getenv("AUTO_ADMIN_FIRST_USER", "true").strip().lower() not in ("1", "true", "yes"):
return "trainer"
cur.execute("SELECT COUNT(*) AS n FROM profiles")
row = cur.fetchone()
try:
n = int(row["n"]) if row is not None else 0
except (KeyError, TypeError, ValueError):
n = 0
return "superadmin" if n == 0 else "trainer"
# ── Helper: Send Email ────────────────────────────────────────────────────────
def send_email(to_email: str, subject: str, body: str):
"""Send mail via SMTP. Port 465 → SSL; sonst SMTP + optional STARTTLS (587)."""
try:
smtp_host = (os.getenv("SMTP_HOST") or "").strip()
smtp_port_raw = os.getenv("SMTP_PORT") or "587"
try:
smtp_port = int(str(smtp_port_raw).strip())
except ValueError:
smtp_port = 587
smtp_user = (os.getenv("SMTP_USER") or "").strip()
smtp_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
if not smtp_host or not smtp_user or smtp_pass is None or smtp_pass == "":
print("[SMTP] Nicht konfiguriert — setze SMTP_HOST, SMTP_USER, SMTP_PASS (und ggf. SMTP_PORT)")
return False
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = to_email
use_tls_env = (os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no"))
force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465
ctx = ssl.create_default_context()
if force_ssl:
print(f"[SMTP] Versand (SSL) an …@{to_email.split('@')[-1]} über {smtp_host}:{smtp_port}")
with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as server:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
else:
print(f"[SMTP] Versand (STARTTLS={use_tls_env}) an …@{to_email.split('@')[-1]} über {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
server.ehlo()
if use_tls_env:
server.starttls(context=ctx)
server.ehlo()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
print("[SMTP] Nachricht erfolgreich akzeptiert (Server-Queue)")
return True
except Exception as e:
print(f"[SMTP] Fehler: {e}")
return False
# ── Registration Endpoints ────────────────────────────────────────────────────
@router.post("/register")
@limiter.limit("3/hour")
async def register(req: RegisterRequest, request: Request):
"""Self-registration with email verification."""
email = req.email.lower().strip()
name = req.name.strip()
password = req.password
# Validation
if not email or '@' not in email:
raise HTTPException(400, "Ungültige E-Mail-Adresse")
if len(password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein")
if not name or len(name) < 2:
raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
existing = cur.fetchone()
if existing:
existing_name = existing.get("name")
else:
existing_name = None
if existing is None:
# Generate verification token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
role = registration_role(cur, email)
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
cur.execute("""
INSERT INTO profiles (
name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
new_profile_id = cur.fetchone()["id"]
req_club = req.requested_club_id
if req_club is not None:
cur.execute(
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
(int(req_club),),
)
if cur.fetchone():
cur.execute(
"""
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(new_profile_id, int(req_club)),
)
if not cur.fetchone():
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', NULL)
""",
(new_profile_id, int(req_club)),
)
generic = {
"ok": True,
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse.",
}
if existing is not None:
send_email(
email,
"Shinkan Jinkendo – Registrierung",
f"""Hallo {existing_name or name},
für diese E-Mail-Adresse gibt es bereits ein Konto. Es wurde kein neues Passwort gesetzt.
Wenn du den Zugang brauchst, melde dich an oder fordere ein neues Passwort an.
Dein Shinkan Jinkendo Team
""",
)
return generic
verify_url = verification_link(verification_token)
email_body = f"""Hallo {name},
willkommen bei Shinkan Jinkendo!
Bitte bestätige deine E-Mail-Adresse, um die Registrierung abzuschließen:
{verify_url}
Der Link ist 24 Stunden gültig.
Dein Shinkan Jinkendo Team
"""
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
return generic
@router.get("/verify/{token}")
async def verify_email(token: str):
"""Verify email address and activate account."""
with get_db() as conn:
cur = get_cursor(conn)
# Find profile with this verification token
cur.execute("""
SELECT id, name, email, email_verified, verification_expires
FROM profiles
WHERE verification_token=%s
""", (token,))
prof = cur.fetchone()
if not prof:
# Token not found - might be already used/verified
# Check if there's a verified profile (token was deleted after verification)
raise HTTPException(400, "Verifikations-Link ungültig oder bereits verwendet. Falls du bereits verifiziert bist, melde dich einfach an.")
if prof['email_verified']:
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
# Check if token expired
if _verification_link_expired(prof["verification_expires"]):
raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.")
# Mark as verified and clear token
cur.execute("""
UPDATE profiles
SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL
WHERE id=%s
""", (prof['id'],))
# Create session (auto-login after verification)
session_token = make_token()
expires = datetime.now(timezone.utc) + timedelta(days=30)
cur.execute("""
INSERT INTO sessions (token, profile_id, expires_at, created_at)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
""", (session_token, prof['id'], expires))
return {
"ok": True,
"message": "E-Mail-Adresse erfolgreich bestätigt!",
"token": session_token,
"profile": {
"id": prof['id'],
"name": prof['name'],
"email": prof['email']
}
}
@router.post("/resend-verification")
@limiter.limit("3/hour")
async def resend_verification(req: ResendVerificationRequest, request: Request):
"""Bestätigung erneut senden. Bekannte und unbekannte Adressen antworten gleich."""
email = str(req.email).lower().strip()
generic = {
"ok": True,
"message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet.",
}
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, name, email, email_verified, verification_token, verification_expires
FROM profiles
WHERE email=%s
""", (email,))
prof = cur.fetchone()
if not prof or prof["email_verified"]:
return generic
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
cur.execute("""
UPDATE profiles
SET verification_token=%s, verification_expires=%s
WHERE id=%s
""", (verification_token, verification_expires, prof["id"]))
verify_url = verification_link(verification_token)
email_body = f"""Hallo {prof['name']},
du hast eine neue Bestätigungs-E-Mail angefordert.
Bitte bestätige deine E-Mail-Adresse mit diesem Link:
{verify_url}
Dieser Link ist 24 Stunden gültig.
Dein Shinkan Jinkendo Team
"""
send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body)
return generic