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_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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user