feat: enhance email configuration and registration logic
- 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:
parent
fae673670a
commit
c6569abe1a
|
|
@ -15,6 +15,13 @@ SMTP_PORT=587
|
|||
SMTP_USER=noreply@jinkendo.de
|
||||
SMTP_PASS=your_smtp_password
|
||||
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_URL=https://shinkan.jinkendo.de
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
|
|||
session = get_session(x_auth_token)
|
||||
if not session:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
if session['role'] != 'admin':
|
||||
if session.get("role") not in ("admin", "superadmin"):
|
||||
raise HTTPException(403, "Nur für Admins")
|
||||
return session
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Handles login, logout, password reset, and profile authentication.
|
|||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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)",
|
||||
(f"reset_{token}", prof['id'], expires.isoformat()))
|
||||
|
||||
# Send email
|
||||
try:
|
||||
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']},
|
||||
app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
|
||||
reset_body = f"""Hallo {prof['name']},
|
||||
|
||||
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.
|
||||
|
||||
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
|
||||
|
||||
Dein Mitai Jinkendo Team
|
||||
""")
|
||||
msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo"
|
||||
msg['From'] = smtp_from
|
||||
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}")
|
||||
Dein Shinkan Jinkendo Team
|
||||
"""
|
||||
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."}
|
||||
|
||||
|
|
@ -177,34 +161,89 @@ def password_reset_confirm(req: PasswordResetConfirm):
|
|||
|
||||
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 ────────────────────────────────────────────────────────
|
||||
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:
|
||||
smtp_host = os.getenv("SMTP_HOST")
|
||||
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
||||
smtp_user = os.getenv("SMTP_USER")
|
||||
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 not smtp_pass:
|
||||
print("SMTP not configured, skipping email")
|
||||
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
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_from
|
||||
msg["To"] = to_email
|
||||
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_pass)
|
||||
server.send_message(msg)
|
||||
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"Email error: {e}")
|
||||
print(f"[SMTP] Fehler: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -237,6 +276,9 @@ async def register(req: RegisterRequest, request: Request):
|
|||
verification_token = secrets.token_urlsafe(32)
|
||||
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.
|
||||
pin_hash = hash_pin(password)
|
||||
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,
|
||||
email_verified, verification_token, verification_expires,
|
||||
trial_ends_at, created_at
|
||||
) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
""", (name, email, pin_hash, verification_token, verification_expires, trial_ends))
|
||||
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
|
||||
|
||||
# Send verification email
|
||||
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
||||
verify_url = f"{app_url}/verify?token={verification_token}"
|
||||
verify_url = verification_link(verification_token)
|
||||
|
||||
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}
|
||||
|
||||
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 {
|
||||
"ok": True,
|
||||
|
|
@ -366,34 +409,22 @@ async def resend_verification(req: dict, request: Request):
|
|||
WHERE id=%s
|
||||
""", (verification_token, verification_expires, prof['id']))
|
||||
|
||||
# Send verification email
|
||||
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
||||
verify_url = f"{app_url}/verify?token={verification_token}"
|
||||
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, indem du auf folgenden Link klickst:
|
||||
Bitte bestätige deine E-Mail-Adresse mit diesem Link:
|
||||
|
||||
{verify_url}
|
||||
|
||||
Dieser Link ist 24 Stunden gültig.
|
||||
|
||||
Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren.
|
||||
|
||||
Viele Grüße
|
||||
Dein Mitai Jinkendo Team
|
||||
Dein Shinkan Jinkendo Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_email(
|
||||
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")
|
||||
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
|
||||
raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
|
||||
|
||||
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ services:
|
|||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
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
|
||||
ALLOWED_ORIGINS: https://shinkan.jinkendo.de
|
||||
ENVIRONMENT: production
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user