diff --git a/.env.example b/.env.example index a5b4153..26b5726 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/auth.py b/backend/auth.py index e4acbea..a68cf09 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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 diff --git a/backend/routers/auth.py b/backend/routers/auth.py index da49987..605e82a 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -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."} diff --git a/docker-compose.yml b/docker-compose.yml index 859a61f..f20b602 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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