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
A superadmin can also turn off another account''s two-factor when the authenticator is lost. Co-authored-by: Cursor <cursoragent@cursor.com>
837 lines
30 KiB
Python
837 lines
30 KiB
Python
"""
|
||
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
|