feat: enhance email configuration and registration logic
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m54s

- Added SMTP_SSL and SMTP_STARTTLS options to .env.example and docker-compose.yml for improved email sending configuration.
- Updated password reset email logic to utilize a new send_email helper function with SSL and STARTTLS support.
- Implemented role assignment for the first registered user or based on ADMIN_BOOTSTRAP_EMAILS during registration, enhancing user management capabilities.
This commit is contained in:
Lars 2026-04-29 11:28:07 +02:00
parent fae673670a
commit c6569abe1a
4 changed files with 109 additions and 65 deletions

View File

@ -15,6 +15,13 @@ SMTP_PORT=587
SMTP_USER=noreply@jinkendo.de SMTP_USER=noreply@jinkendo.de
SMTP_PASS=your_smtp_password SMTP_PASS=your_smtp_password
SMTP_FROM=noreply@jinkendo.de SMTP_FROM=noreply@jinkendo.de
# Port 465 oft SSL; dann z. B. SMTP_SSL=true und SMTP_STARTTLS=false
SMTP_SSL=
SMTP_STARTTLS=
# Bootstrap: erste Registrierung (leere Nutzerliste) erhält Rolle admin; oder feste Admin-Mails:
AUTO_ADMIN_FIRST_USER=true
ADMIN_BOOTSTRAP_EMAILS=
# App # App
APP_URL=https://shinkan.jinkendo.de APP_URL=https://shinkan.jinkendo.de

View File

@ -117,7 +117,7 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
session = get_session(x_auth_token) session = get_session(x_auth_token)
if not session: if not session:
raise HTTPException(401, "Nicht eingeloggt") raise HTTPException(401, "Nicht eingeloggt")
if session['role'] != 'admin': if session.get("role") not in ("admin", "superadmin"):
raise HTTPException(403, "Nur für Admins") raise HTTPException(403, "Nur für Admins")
return session return session

View File

@ -6,6 +6,7 @@ Handles login, logout, password reset, and profile authentication.
import os import os
import secrets import secrets
import smtplib import smtplib
import ssl
from typing import Optional from typing import Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText from email.mime.text import MIMEText
@ -123,38 +124,21 @@ async def password_reset_request(req: PasswordResetRequest, request: Request):
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
(f"reset_{token}", prof['id'], expires.isoformat())) (f"reset_{token}", prof['id'], expires.isoformat()))
# Send email app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
try: reset_body = f"""Hallo {prof['name']},
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM")
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
if smtp_host and smtp_user and smtp_pass:
msg = MIMEText(f"""Hallo {prof['name']},
Du hast einen Passwort-Reset angefordert. Du hast einen Passwort-Reset angefordert.
Reset-Link: {app_url}/reset-password?token={token} Reset-Link: {app_base}/reset-password?token={token}
Der Link ist 1 Stunde gültig. Der Link ist 1 Stunde gültig.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Mitai Jinkendo Team Dein Shinkan Jinkendo Team
""") """
msg['Subject'] = "Passwort zurücksetzen Mitai Jinkendo" if not send_email(email, "Passwort zurücksetzen Shinkan Jinkendo", reset_body):
msg['From'] = smtp_from print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
msg['To'] = email
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
except Exception as e:
print(f"Email error: {e}")
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
@ -177,34 +161,89 @@ def password_reset_confirm(req: PasswordResetConfirm):
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} 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(token: str) -> str:
"""Öffentlicher Link zur Bestätigung (FastAPI unter /api/auth)."""
return f"{_public_app_base()}/api/auth/verify/{token}"
def registration_role(cur, email_lower: str) -> str:
"""
bootstrap: erste Registrierung in leerer DB admin,
oder E-Mail ADMIN_BOOTSTRAP_EMAILS (kommasepariert, Groß/Klein egal).
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 "admin"
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 "admin" if n == 0 else "trainer"
# ── Helper: Send Email ──────────────────────────────────────────────────────── # ── Helper: Send Email ────────────────────────────────────────────────────────
def send_email(to_email: str, subject: str, body: str): def send_email(to_email: str, subject: str, body: str):
"""Send email via SMTP (reusable helper).""" """Send mail via SMTP. Port 465 → SSL; sonst SMTP + optional STARTTLS (587)."""
try: try:
smtp_host = os.getenv("SMTP_HOST") smtp_host = (os.getenv("SMTP_HOST") or "").strip()
smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_port_raw = os.getenv("SMTP_PORT") or "587"
smtp_user = os.getenv("SMTP_USER") 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_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
if not smtp_host or not smtp_user or not smtp_pass: if not smtp_host or not smtp_user or smtp_pass is None or smtp_pass == "":
print("SMTP not configured, skipping email") print("[SMTP] Nicht konfiguriert — setze SMTP_HOST, SMTP_USER, SMTP_PASS (und ggf. SMTP_PORT)")
return False return False
msg = MIMEText(body) msg = MIMEText(body)
msg['Subject'] = subject msg["Subject"] = subject
msg['From'] = smtp_from msg["From"] = smtp_from
msg['To'] = to_email msg["To"] = to_email
with smtplib.SMTP(smtp_host, smtp_port) as server: use_tls_env = (os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no"))
server.starttls() force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465
server.login(smtp_user, smtp_pass)
server.send_message(msg)
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 return True
except Exception as e: except Exception as e:
print(f"Email error: {e}") print(f"[SMTP] Fehler: {e}")
return False return False
@ -237,6 +276,9 @@ async def register(req: RegisterRequest, request: Request):
verification_token = secrets.token_urlsafe(32) verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → admin
role = registration_role(cur, email)
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen. # Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password) pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
@ -246,27 +288,28 @@ async def register(req: RegisterRequest, request: Request):
name, email, pin_hash, auth_type, role, tier, name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires, email_verified, verification_token, verification_expires,
trial_ends_at, created_at trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) ) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (name, email, pin_hash, verification_token, verification_expires, trial_ends)) """, (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
# Send verification email verify_url = verification_link(verification_token)
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
verify_url = f"{app_url}/verify?token={verification_token}"
email_body = f"""Hallo {name}, email_body = f"""Hallo {name},
willkommen bei Mitai Jinkendo! willkommen bei Shinkan Jinkendo!
Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen: Bitte bestätige deine E-Mail-Adresse, um die Registrierung abzuschließen:
{verify_url} {verify_url}
Der Link ist 24 Stunden gültig. Der Link ist 24 Stunden gültig.
Dein Mitai Jinkendo Team Nach dem Aufruf des Links bist du bestätigt (Browser zeigt eine kurze JSON-Antwort das ist in Ordnung).
Dein Shinkan Jinkendo Team
""" """
send_email(email, "Willkommen bei Mitai Jinkendo E-Mail bestätigen", email_body) 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 { return {
"ok": True, "ok": True,
@ -366,34 +409,22 @@ async def resend_verification(req: dict, request: Request):
WHERE id=%s WHERE id=%s
""", (verification_token, verification_expires, prof['id'])) """, (verification_token, verification_expires, prof['id']))
# Send verification email verify_url = verification_link(verification_token)
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
verify_url = f"{app_url}/verify?token={verification_token}"
email_body = f"""Hallo {prof['name']}, email_body = f"""Hallo {prof['name']},
du hast eine neue Bestätigungs-E-Mail angefordert. du hast eine neue Bestätigungs-E-Mail angefordert.
Bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst: Bitte bestätige deine E-Mail-Adresse mit diesem Link:
{verify_url} {verify_url}
Dieser Link ist 24 Stunden gültig. Dieser Link ist 24 Stunden gültig.
Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren. Dein Shinkan Jinkendo Team
Viele Grüße
Dein Mitai Jinkendo Team
""" """
try: if not send_email(email, "Shinkan Jinkendo E-Mail bestätigen", email_body):
send_email( raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
to=email,
subject="Neue Bestätigungs-E-Mail - Mitai Jinkendo",
body=email_body
)
except Exception as e:
print(f"Failed to send verification email: {e}")
raise HTTPException(500, "E-Mail konnte nicht versendet werden")
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."} return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}

View File

@ -34,6 +34,12 @@ services:
SMTP_USER: ${SMTP_USER} SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS} SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: ${SMTP_FROM} SMTP_FROM: ${SMTP_FROM}
# SMTP_STARTTLS=false | SMTP_SSL=true nach Anbieter (z. B. Port 465)
SMTP_SSL: ${SMTP_SSL:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-true}"
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
APP_URL: https://shinkan.jinkendo.de APP_URL: https://shinkan.jinkendo.de
ALLOWED_ORIGINS: https://shinkan.jinkendo.de ALLOWED_ORIGINS: https://shinkan.jinkendo.de
ENVIRONMENT: production ENVIRONMENT: production