Harden login, sessions, and media access before the dev deploy.
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Failing after 5m43s

Limit brute-force attempts, require admin TOTP, and keep session tokens out of media URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-10-13 07:45:53 +02:00
parent 6c7c24e887
commit 32b28a93f3
31 changed files with 1146 additions and 101 deletions

View File

@ -53,7 +53,12 @@ SMTP_FROM=noreply@jinkendo.de
SMTP_SSL=
SMTP_STARTTLS=
AUTO_ADMIN_FIRST_USER=true
# Produktion: false. true nur für eine bewusst leere Erstinstallation.
AUTO_ADMIN_FIRST_USER=false
# Plattform-Admins müssen TOTP einrichten. 0 schaltet die Pflicht ab.
REQUIRE_ADMIN_TOTP=true
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
# MEDIA_TOKEN_SECRET=
ADMIN_BOOTSTRAP_EMAILS=
APP_URL=https://shinkan.jinkendo.de

View File

@ -33,6 +33,8 @@ AUTH_INFRA_PREFIXES = (
"/api/auth/status",
"/api/auth/pin",
"/api/auth/resend-verification",
"/api/auth/totp",
"/api/auth/media-grant",
"/api/profiles/me",
"/api/me/entitlements",
)

View File

@ -12,6 +12,11 @@ from fastapi import Header, Query, HTTPException
import bcrypt
from db import get_db, get_cursor
from media_grant import profile_id_from_media_grant
# Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer.
_LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i"
MAX_SESSION_DAYS = 30
def hash_pin(pin: str) -> str:
@ -33,6 +38,34 @@ def verify_pin(pin: str, stored_hash: str) -> bool:
return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool:
"""Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel)."""
if stored_hash and str(stored_hash).startswith("$2"):
return verify_pin(pin, stored_hash)
verify_pin(pin or "x", _LOGIN_TIMING_HASH)
if not stored_hash:
return False
return verify_pin(pin, stored_hash)
def session_lifetime_days(profile: Optional[dict]) -> int:
raw = profile.get("session_days") if profile else None
try:
days = int(raw) if raw is not None else MAX_SESSION_DAYS
except (TypeError, ValueError):
days = MAX_SESSION_DAYS
if days < 1:
days = 1
if days > MAX_SESSION_DAYS:
days = MAX_SESSION_DAYS
return days
def revoke_profile_sessions(cur, profile_id: int) -> None:
"""Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens."""
cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,))
def make_token() -> str:
"""Generate a secure random token for sessions."""
return secrets.token_urlsafe(32)
@ -51,12 +84,39 @@ def get_session(token: str):
cur.execute(
"SELECT s.*, p.role, p.name "
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP",
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
"AND COALESCE(s.purpose, 'full') = 'full'",
(token,)
)
return cur.fetchone()
def get_limited_session(token: str, purpose: str):
"""Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff."""
if not token or purpose not in ("totp_enroll", "totp_challenge"):
return None
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 = %s",
(token, purpose),
)
return cur.fetchone()
def _profile_session_from_id(profile_id: int) -> Optional[dict]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id AS profile_id, role, name FROM profiles WHERE id = %s",
(profile_id,),
)
return cur.fetchone()
def require_auth(x_auth_token: Optional[str] = Header(default=None)):
"""
FastAPI dependency - requires valid authentication.
@ -80,8 +140,8 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
"""
FastAPI dependency - auth via header OR query parameter.
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
Dateiabruf für <img>/<video>: Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken).
Ein Sitzungstoken in der Query-Zeichenfolge wird nicht akzeptiert.
Usage:
@app.get("/api/photos/{id}")
@ -93,7 +153,13 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
Raises:
HTTPException 401 if not authenticated
"""
session = get_session(x_auth_token or ssetoken)
session = get_session(x_auth_token) if x_auth_token else None
if session:
return session
profile_id = profile_id_from_media_grant(ssetoken)
if profile_id is None:
raise HTTPException(401, "Nicht eingeloggt")
session = _profile_session_from_id(profile_id)
if not session:
raise HTTPException(401, "Nicht eingeloggt")
return session

View File

@ -282,7 +282,7 @@ app.include_router(ai_skill_retrieval_admin.router)
app.include_router(exercise_enrichment_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken = kurzlebiges Medien-Token, kein Sitzungstoken).
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True)

49
backend/media_grant.py Normal file
View File

@ -0,0 +1,49 @@
"""Kurzlebige Medien-Tokens. Kein Sitzungstoken — nur für Dateiabruf per Query."""
from __future__ import annotations
import hashlib
import hmac
import os
import time
from typing import Optional
MEDIA_GRANT_TTL_SECONDS = 600
_PREFIX = "mg1"
def _key() -> bytes:
dedicated = (os.getenv("MEDIA_TOKEN_SECRET") or "").strip()
if dedicated:
return hashlib.sha256(dedicated.encode("utf-8")).digest()
material = (os.getenv("DB_PASSWORD") or "shinkan-dev-media").encode("utf-8")
return hashlib.sha256(b"shinkan-media-grant-v1|" + material).digest()
def issue_media_grant(profile_id: int, *, ttl_seconds: int = MEDIA_GRANT_TTL_SECONDS) -> tuple[str, int]:
exp = int(time.time()) + int(ttl_seconds)
payload = f"{int(profile_id)}.{exp}"
sig = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
return f"{_PREFIX}.{payload}.{sig}", exp
def profile_id_from_media_grant(token: Optional[str], *, now: Optional[int] = None) -> Optional[int]:
if not token or not token.startswith(f"{_PREFIX}."):
return None
parts = token.split(".")
if len(parts) != 4:
return None
_, pid_s, exp_s, sig = parts
if not pid_s.isdigit() or not exp_s.isdigit():
return None
payload = f"{pid_s}.{exp_s}"
expected = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
return None
exp = int(exp_s)
moment = int(time.time() if now is None else now)
if exp < moment:
return None
pid = int(pid_s)
if pid < 1:
return None
return pid

View File

@ -0,0 +1,29 @@
-- Login-Sperre, Sitzungszweck (Vollzugriff / 2FA-Einrichtung / 2FA-Challenge), TOTP.
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP,
ADD COLUMN IF NOT EXISTS totp_secret TEXT,
ADD COLUMN IF NOT EXISTS totp_pending_secret TEXT,
ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) NOT NULL DEFAULT 'full',
ADD COLUMN IF NOT EXISTS failed_attempts INTEGER NOT NULL DEFAULT 0;
UPDATE sessions
SET purpose = 'password_reset'
WHERE token LIKE 'reset_%' AND purpose = 'full';
CREATE INDEX IF NOT EXISTS idx_sessions_profile_purpose ON sessions (profile_id, purpose);
CREATE TABLE IF NOT EXISTS profile_recovery_codes (
id SERIAL PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
code_hash VARCHAR(64) NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_profile_recovery_codes_profile
ON profile_recovery_codes (profile_id);

View File

@ -13,11 +13,30 @@ from datetime import date, time, datetime
class LoginRequest(BaseModel):
email: EmailStr
password: str
password: str = Field(min_length=1, max_length=128)
class LoginTotpRequest(BaseModel):
challenge_token: str = Field(min_length=8, max_length=200)
code: str = Field(min_length=6, max_length=32)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
class TotpCodeRequest(BaseModel):
code: str = Field(min_length=6, max_length=12)
class TotpDisableRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
code: str = Field(min_length=6, max_length=32)
class RegisterRequest(BaseModel):
email: EmailStr
password: str
password: str = Field(min_length=1, max_length=128)
name: Optional[str] = None
requested_club_id: Optional[int] = Field(default=None, ge=1)

View File

@ -33,8 +33,8 @@ def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int
expires = datetime.now() + timedelta(hours=hours_valid)
cur.execute(
"""
INSERT INTO sessions (token, profile_id, expires_at, created_at)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, 'password_reset')
""",
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
)

View File

@ -14,12 +14,40 @@ 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 slowapi.util import get_remote_address
from db import get_db, get_cursor
from auth import hash_pin, verify_pin, make_token, require_auth
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest
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,
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,
@ -27,47 +55,161 @@ from password_reset_mail import (
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address)
LOGIN_LOCK_AFTER = 8
LOGIN_LOCK_MINUTES = 15
_ADMIN_ROLES = ("admin", "superadmin")
@router.post("/login")
@limiter.limit("30/minute")
async def login(req: LoginRequest, request: Request):
"""Login with email + password."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
prof = cur.fetchone()
if not prof:
raise HTTPException(401, "Ungültige Zugangsdaten")
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"
# Verify password
if not verify_pin(req.password, prof['pin_hash']):
raise HTTPException(401, "Ungültige Zugangsdaten")
# Auto-upgrade from SHA256 to bcrypt
if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
new_hash = hash_pin(req.password)
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
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
# Create session
token = make_token()
session_days = prof.get('session_days', 30)
expires = datetime.now() + timedelta(days=session_days)
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
(token, prof['id'], expires.isoformat()))
limiter = Limiter(key_func=_client_ip)
def _admin_totp_required() -> bool:
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
if raw in ("1", "true", "yes"):
return True
if raw in ("0", "false", "no"):
return False
return (os.getenv("ENVIRONMENT") or "development").strip().lower() in ("production", "prod")
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'],
"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)."""
@ -94,21 +236,223 @@ def auth_status():
@router.put("/pin")
def change_pin(req: dict, session: dict=Depends(require_auth)):
"""Change PIN/password for current user."""
pid = session['profile_id']
new_pin = req.get('pin', '')
if len(new_pin) < 8:
raise HTTPException(400, "Passwort muss mind. 8 Zeichen haben")
new_hash = hash_pin(new_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("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
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),
)
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,))
plain_codes = generate_recovery_codes()
for code in plain_codes:
cur.execute(
"""
INSERT INTO profile_recovery_codes (profile_id, code_hash)
VALUES (%s, %s)
""",
(pid, hash_recovery_code(code)),
)
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
@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):
@ -146,10 +490,17 @@ def password_reset_confirm(req: PasswordResetConfirm):
if not sess:
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
pid = sess['profile_id']
pid = sess["profile_id"]
new_hash = hash_pin(req.new_password)
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
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"}

View File

@ -324,6 +324,7 @@ def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[i
INNER JOIN profiles p ON p.id = s.profile_id
WHERE s.token = %s
AND (s.expires_at IS NULL OR s.expires_at > NOW())
AND COALESCE(s.purpose, 'full') = 'full'
""",
(x_auth_token,),
)

View File

@ -3224,7 +3224,7 @@ def download_exercise_media_file(
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""
Dateiauslieferung mit Governance wie GET Übung — Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
Dateiauslieferung mit Governance wie GET Übung — Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken).
Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment).
"""
with get_db() as conn:

View File

@ -1225,7 +1225,7 @@ def download_media_asset_file(
asset_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken)."""
"""Direktzugriff auf Archiv-Datei. Auth per Header oder kurzlebigem Medien-Token (?ssetoken)."""
from routers.exercises import _binary_media_response
with get_db() as conn:

View File

@ -14,7 +14,7 @@ from psycopg2.extras import Json
from pydantic import BaseModel, Field, field_validator
from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin
from auth import require_auth, hash_pin, revoke_profile_sessions
from club_tenancy import (
assert_club_member,
club_ids_for_profile_with_roles,
@ -30,6 +30,19 @@ router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"})
_PROFILE_SECRET_FIELDS = (
"pin_hash",
"totp_secret",
"totp_pending_secret",
"verification_token",
)
def _public_profile_row(row) -> dict:
data = r2d(row)
for key in _PROFILE_SECRET_FIELDS:
data.pop(key, None)
return data
class ManagementPasswordResetBody(BaseModel):
@ -103,8 +116,7 @@ def get_current_profile(
row = cur.fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden")
data = r2d(row)
data.pop("pin_hash", None)
data = _public_profile_row(row)
clubs = memberships_with_roles(cur, profile_id, active_only=False)
data["clubs"] = clubs
ac_raw = data.get("active_club_id")
@ -140,9 +152,7 @@ def list_profiles(session=Depends(require_auth)):
rows = cur.fetchall()
out = []
for r in rows:
d = r2d(r)
d.pop("pin_hash", None)
out.append(d)
out.append(_public_profile_row(r))
return out
@ -203,9 +213,7 @@ def profile_document(pid: str) -> dict:
row = cur.fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden")
d = r2d(row)
d.pop("pin_hash", None)
return d
return _public_profile_row(row)
@router.post("/profiles/{pid}/management-password-reset")
@ -245,9 +253,14 @@ def management_password_reset(
_assert_can_management_password_help(cur, tenant, target, via_email=False)
new_hash = hash_pin(body.new_password)
cur.execute(
"UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s",
"""
UPDATE profiles
SET pin_hash = %s, failed_login_count = 0, locked_until = NULL, updated_at = NOW()
WHERE id = %s
""",
(new_hash, target),
)
revoke_profile_sessions(cur, target)
return {"ok": True, "mode": "direct"}
_assert_can_management_password_help(cur, tenant, target, via_email=True)

View File

@ -0,0 +1,51 @@
"""Login-Sperre, TOTP und Medien-Grant ohne Datenbank."""
from __future__ import annotations
import os
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
os.environ.setdefault("REQUIRE_ADMIN_TOTP", "0")
import pytest
from fastapi.testclient import TestClient
from main import app
from media_grant import issue_media_grant, profile_id_from_media_grant
from totp_auth import generate_totp_secret, totp_code, verify_totp
import time
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_totp_accepts_current_code() -> None:
secret = generate_totp_secret()
code = totp_code(secret, int(time.time()) // 30)
assert verify_totp(secret, code)
assert not verify_totp(secret, "000000") or code == "000000"
def test_media_grant_rejects_session_shaped_token() -> None:
assert profile_id_from_media_grant("not-a-media-grant") is None
token, _exp = issue_media_grant(42, ttl_seconds=60)
assert profile_id_from_media_grant(token) == 42
assert profile_id_from_media_grant(token + "x") is None
def test_change_password_requires_current_password() -> None:
from pydantic import ValidationError
from models import ChangePasswordRequest
with pytest.raises(ValidationError):
ChangePasswordRequest.model_validate({"pin": "neuespasswort"})
def test_login_rejects_oversized_password(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
json={"email": "trainer@example.com", "password": "x" * 200},
)
assert response.status_code == 422

63
backend/totp_auth.py Normal file
View File

@ -0,0 +1,63 @@
"""TOTP (RFC 6238) und einmalige Wiederherstellungscodes — nur Standardbibliothek."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import struct
import time
from typing import Optional
from urllib.parse import quote
def generate_totp_secret() -> str:
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
def _decode_secret(secret: str) -> bytes:
raw = "".join(ch for ch in (secret or "").upper() if ch.isalnum())
pad = "=" * ((8 - len(raw) % 8) % 8)
return base64.b32decode(raw + pad, casefold=True)
def totp_code(secret: str, counter: int) -> str:
digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
return f"{binary % 1_000_000:06d}"
def verify_totp(secret: Optional[str], code: str, *, window: int = 1, now: Optional[int] = None) -> bool:
if not secret:
return False
digits = "".join(ch for ch in (code or "") if ch.isdigit())
if len(digits) != 6:
return False
moment = int(time.time() if now is None else now)
counter = moment // 30
for delta in range(-window, window + 1):
if hmac.compare_digest(totp_code(secret, counter + delta), digits):
return True
return False
def otpauth_uri(*, secret: str, account_name: str, issuer: str = "Shinkan Jinkendo") -> str:
label = quote(f"{issuer}:{account_name}")
return (
f"otpauth://totp/{label}?secret={quote(secret)}"
f"&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30"
)
def generate_recovery_codes(count: int = 8) -> list[str]:
codes = []
for _ in range(count):
raw = secrets.token_hex(4)
codes.append(f"{raw[:4]}-{raw[4:]}")
return codes
def hash_recovery_code(code: str) -> str:
normalized = "".join(ch for ch in (code or "").lower() if ch.isalnum())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.238"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607095"
APP_VERSION = "0.8.239"
BUILD_DATE = "2026-10-12"
DB_SCHEMA_VERSION = "20261012096"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"auth": "1.3.0", # Login-Sperre, TOTP für Plattform-Admins, Medien-Grant statt Sitzungstoken in URLs
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
@ -53,6 +53,16 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.239",
"date": "2026-10-12",
"changes": [
"Login: 8 Fehlversuche sperren das Konto 15 Minuten, gleiche bcrypt-Laufzeit bei unbekannter E-Mail, Limit 10/Minute an der echten Client-IP hinter dem Proxy.",
"Passwortwechsel verlangt das aktuelle Passwort und beendet alle Sitzungen. Reset-Tokens sind keine Login-Sitzung.",
"Zwei-Faktor (TOTP) für Plattform-Admins Pflicht, für andere Konten in den Einstellungen aktivierbar. Migration 096.",
"Medienabruf nutzt ein 10-Minuten-Token (?ssetoken), nicht mehr das Sitzungstoken. API- und Dev-DB-Ports nur an 127.0.0.1. AUTO_ADMIN_FIRST_USER standardmäßig aus.",
],
},
{
"version": "0.8.238",
"date": "2026-05-22",

View File

@ -12,7 +12,7 @@ services:
volumes:
- dev-shinkan-db-data:/var/lib/postgresql/data
ports:
- "5435:5432"
- "127.0.0.1:5435:5432"
restart: unless-stopped
networks:
- dev-shinkan-network
@ -38,6 +38,8 @@ services:
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
ENVIRONMENT: "${ENVIRONMENT:-development}"
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}"
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
@ -51,7 +53,7 @@ services:
volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media/dev}:${MEDIA_ROOT:-/app/media}
ports:
- "8098:8000"
- "127.0.0.1:8098:8000"
depends_on:
- postgres
restart: unless-stopped

View File

@ -38,7 +38,8 @@ services:
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}"
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}"
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
@ -61,7 +62,7 @@ services:
volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
ports:
- "8003:8000"
- "127.0.0.1:8003:8000"
depends_on:
- postgres
restart: unless-stopped

View File

@ -21,6 +21,7 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import TotpSetupCard from './components/TotpSetupCard'
import './app.css'
const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage'))
@ -126,6 +127,29 @@ function Nav({ showAdminNav, onboardingOnly }) {
)
}
function TotpEnrollGate() {
const { checkAuth, logout } = useAuth()
return (
<div className="page-padding" style={{ maxWidth: 520, margin: '2rem auto' }}>
<h1 style={{ fontSize: '1.4rem' }}>Zwei-Faktor einrichten</h1>
<p style={{ color: 'var(--text2)' }}>
Plattform-Admins melden sich erst an, wenn ein Authenticator eingerichtet ist.
</p>
<TotpSetupCard onEnabled={() => checkAuth()} />
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
await logout()
window.location.href = '/login'
}}
>
Abmelden
</button>
</div>
)
}
function ProtectedLayout() {
const { isAuthenticated, loading, user, logout } = useAuth()
@ -155,6 +179,10 @@ function ProtectedLayout() {
return <Navigate to="/login" replace />
}
if (user?.totpEnrollmentRequired) {
return <TotpEnrollGate />
}
const location = useLocation()
const onboardingOnly = isOnboardingRestricted(user)
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {

View File

@ -6,7 +6,7 @@ import ExerciseMediaEmbed from './ExerciseMediaEmbed'
import ExerciseMediaThumbTile from './ExerciseMediaThumbTile'
import MediaPreviewModal from './MediaPreviewModal'
import ReportContentModal from './ReportContentModal'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs'
function isTrashHidden(m) {
@ -14,6 +14,7 @@ function isTrashHidden(m) {
}
export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
const mediaToken = useMediaAccessToken()
const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise])
@ -66,7 +67,7 @@ export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
<MediaPreviewModal
title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`}
media={preview}
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview)}
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview, mediaToken)}
onClose={() => setPreview(null)}
onReport={
(preview.asset_lifecycle_state || 'active') === 'active' && !preview.asset_legal_hold_active

View File

@ -3,7 +3,7 @@
*/
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import api from '../utils/api'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import {
INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE,
@ -12,8 +12,9 @@ import {
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
function RtePickerAssetThumb({ asset }) {
const mediaToken = useMediaAccessToken()
const id = asset.id
const src = resolveMediaAssetFileUrl(id)
const src = resolveMediaAssetFileUrl(id, mediaToken)
const mt = (asset.mime_type || '').toLowerCase()
if (mt.startsWith('image/') && src) {
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />

View File

@ -1,5 +1,5 @@
import React from 'react'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
/**
@ -7,6 +7,7 @@ import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
* @param {{ media: object, exerciseId: number, layoutSize?: string }} props
*/
export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'medium' }) {
const mediaToken = useMediaAccessToken()
const sz = sanitizeInlineMediaSize(layoutSize)
const box =
sz === 'small'
@ -46,7 +47,7 @@ export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'me
</div>
)
}
const src = resolveExerciseMediaFileUrl(exerciseId, media)
const src = resolveExerciseMediaFileUrl(exerciseId, media, mediaToken)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (

View File

@ -2,10 +2,11 @@
* Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img, Embed = Label.
*/
import React from 'react'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview, size = 72 }) {
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
const mediaToken = useMediaAccessToken()
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media, mediaToken) : null
const commonStyle = {
width: '100%',
height: '100%',

View File

@ -0,0 +1,101 @@
import { useState } from 'react'
import api from '../utils/api'
/**
* TOTP einrichten: Geheimnis anzeigen, Code bestätigen, Wiederherstellungscodes einmal zeigen.
*/
export default function TotpSetupCard({ onEnabled }) {
const [secret, setSecret] = useState('')
const [otpauthUri, setOtpauthUri] = useState('')
const [code, setCode] = useState('')
const [recoveryCodes, setRecoveryCodes] = useState([])
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const start = async () => {
setError('')
setBusy(true)
try {
const data = await api.setupTotp()
setSecret(data.secret || '')
setOtpauthUri(data.otpauth_uri || '')
setRecoveryCodes([])
} catch (err) {
setError(err.message || 'Einrichtung fehlgeschlagen')
} finally {
setBusy(false)
}
}
const confirm = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const data = await api.confirmTotp(code.trim())
if (data?.token) localStorage.setItem('authToken', data.token)
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setSecret('')
setCode('')
if (onEnabled) await onEnabled(data)
} catch (err) {
setError(err.message || 'Code nicht akzeptiert')
} finally {
setBusy(false)
}
}
return (
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor-Anmeldung</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', lineHeight: 1.5 }}>
Authenticator-App (zum Beispiel Aegis, 1Password oder Microsoft Authenticator) öffnen und das
Geheimnis manuell eintragen. Den otpauth-Link nicht an Dritte geben.
</p>
{error ? (
<p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>
) : null}
{!secret && recoveryCodes.length === 0 ? (
<button type="button" className="btn btn-primary" onClick={start} disabled={busy}>
{busy ? 'Wird erzeugt…' : 'Geheimnis erzeugen'}
</button>
) : null}
{secret ? (
<form onSubmit={confirm}>
<p style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '0.04em' }}>{secret}</p>
{otpauthUri ? (
<p style={{ fontSize: '0.75rem', wordBreak: 'break-all', color: 'var(--text3)' }}>{otpauthUri}</p>
) : null}
<label className="form-label" htmlFor="totp-confirm-code">
Code aus der App
</label>
<input
id="totp-confirm-code"
className="form-input"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: '0.75rem' }}>
{busy ? 'Prüft…' : 'Aktivieren'}
</button>
</form>
) : null}
{recoveryCodes.length > 0 ? (
<div>
<p style={{ fontSize: '0.875rem' }}>
Diese Wiederherstellungscodes jetzt sichern. Sie werden nur dieses eine Mal angezeigt.
</p>
<ul>
{recoveryCodes.map((item) => (
<li key={item} style={{ fontFamily: 'ui-monospace, monospace' }}>
{item}
</li>
))}
</ul>
</div>
) : null}
</div>
)
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useNavigate, useParams, Link, useLocation } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl, useMediaAccessToken } from '../../utils/exerciseMediaUrl'
import RichTextEditor from '../RichTextEditor'
import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel'
import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile'
@ -573,6 +573,7 @@ function detailToForm(exercise) {
}
function ExerciseFormPageRoot() {
const mediaToken = useMediaAccessToken()
const { id: routeId } = useParams()
const navigate = useNavigate()
const location = useLocation()
@ -2832,7 +2833,7 @@ function ExerciseFormPageRoot() {
{a.mime_type?.startsWith('image/') ? (
<img
alt=""
src={resolveMediaAssetFileUrl(a.id)}
src={resolveMediaAssetFileUrl(a.id, mediaToken)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : a.mime_type?.startsWith('video/') ? (
@ -2880,7 +2881,7 @@ function ExerciseFormPageRoot() {
<MediaPreviewModal
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
media={mediaPreview}
fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview, mediaToken)}
onClose={() => setMediaPreview(null)}
onReport={
!mediaPreview.asset_legal_hold_active

View File

@ -10,6 +10,7 @@ import {
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
import { clearMediaGrant, refreshMediaGrant } from '../utils/exerciseMediaUrl'
const AuthContext = createContext(null)
@ -63,9 +64,25 @@ export function AuthProvider({ children }) {
const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile)
setUser(profile)
refreshMediaGrant().catch(() => {})
} catch (err) {
try {
const status = await api.getTotpStatus()
if (status?.enrollment_required && !status?.totp_enabled) {
setUser({
totpEnrollmentRequired: true,
email: status.email || null,
role: status.role || null,
})
return
}
} catch {
/* kein Einrichtungs-Token */
}
console.error('Auth check failed:', err)
localStorage.removeItem('authToken')
clearMediaGrant()
setUser(null)
} finally {
setLoading(false)
}
@ -132,6 +149,7 @@ export function AuthProvider({ children }) {
setUser(null)
localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
clearMediaGrant()
clearCoachSessionStorage()
}, [])

View File

@ -6,6 +6,7 @@ import {
resolvePlanningModuleDisplayMode,
} from '../config/planningModuleUx'
import api from '../utils/api'
import TotpSetupCard from '../components/TotpSetupCard'
/**
* Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort).
@ -27,6 +28,10 @@ function AccountSettingsPage() {
PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
)
const [currentPw, setCurrentPw] = useState('')
const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('')
const [disablingTotp, setDisablingTotp] = useState(false)
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@ -144,8 +149,8 @@ function AccountSettingsPage() {
const handleChangePassword = async (e) => {
e.preventDefault()
if (newPw1.length < 4) {
showErr('Neues Passwort: mindestens 4 Zeichen.')
if (newPw1.length < 8) {
showErr('Neues Passwort: mindestens 8 Zeichen.')
return
}
if (newPw1 !== newPw2) {
@ -154,10 +159,12 @@ function AccountSettingsPage() {
}
setSavingPw(true)
try {
await api.changePassword(newPw1)
const result = await api.changePassword(currentPw, newPw1)
if (result?.token) localStorage.setItem('authToken', result.token)
setCurrentPw('')
setNewPw1('')
setNewPw2('')
showOk('Passwort aktualisiert.')
showOk('Passwort aktualisiert. Andere Geräte sind abgemeldet.')
} catch (err) {
showErr(err.message || 'Passwort konnte nicht geändert werden.')
} finally {
@ -461,12 +468,72 @@ function AccountSettingsPage() {
</form>
</div>
{user?.totp_enabled ? (
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
</p>
{user?.role === 'admin' || user?.role === 'superadmin' ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Für Plattform-Admins bleibt das so.
</p>
) : (
<form
onSubmit={async (e) => {
e.preventDefault()
setDisablingTotp(true)
try {
await api.disableTotp(disablePw, disableCode.trim())
setDisablePw('')
setDisableCode('')
showOk('Zwei-Faktor ist aus.')
await checkAuth()
} catch (err) {
showErr(err.message || 'Abschalten fehlgeschlagen')
} finally {
setDisablingTotp(false)
}
}}
>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-code">Code oder Wiederherstellungscode</label>
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
</div>
<button type="submit" className="btn btn-secondary" disabled={disablingTotp}>
{disablingTotp ? 'Wird gespeichert…' : 'Zwei-Faktor ausschalten'}
</button>
</form>
)}
</div>
) : (
<TotpSetupCard onEnabled={() => checkAuth()} />
)}
<div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein neues Passwort (mindestens 4 Zeichen, wie beim Login gewohnt empfehlen wir längere Passwörter).
Das aktuelle Passwort wird geprüft. Danach sind andere Sitzungen ungültig. Mindestens 8 Zeichen.
</p>
<form onSubmit={handleChangePassword}>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="settings-pw-current">
Aktuelles Passwort
</label>
<input
id="settings-pw-current"
type="password"
className="form-input"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="settings-pw1">
Neues Passwort

View File

@ -15,6 +15,8 @@ function LoginPage() {
const [publicClubs, setPublicClubs] = useState([])
const [requestedClubId, setRequestedClubId] = useState('')
const [resending, setResending] = useState(false)
const [challengeToken, setChallengeToken] = useState('')
const [totpCode, setTotpCode] = useState('')
const navigate = useNavigate()
const { checkAuth } = useAuth()
@ -39,6 +41,17 @@ function LoginPage() {
try {
if (mode === 'login') {
const response = await api.login(email, password)
if (response?.totp_required && response.challenge_token) {
setChallengeToken(response.challenge_token)
setTotpCode('')
return
}
if (response?.totp_enrollment_required && response.token) {
localStorage.setItem('authToken', response.token)
await checkAuth()
navigate('/')
return
}
localStorage.setItem('authToken', response.token)
await checkAuth()
navigate('/')
@ -60,6 +73,23 @@ function LoginPage() {
}
}
const handleTotp = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await api.loginTotp(challengeToken, totpCode.trim())
localStorage.setItem('authToken', response.token)
setChallengeToken('')
await checkAuth()
navigate('/')
} catch (err) {
setError(err.message || 'Code nicht akzeptiert')
} finally {
setLoading(false)
}
}
const handleResendVerification = async () => {
if (!email.trim()) {
setError('Zuerst die E-Mail-Adresse eintragen.')
@ -122,6 +152,49 @@ function LoginPage() {
</button>
</div>
{challengeToken ? (
<form onSubmit={handleTotp}>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.45 }}>
Code aus der Authenticator-App oder ein Wiederherstellungscode.
</p>
<div className="form-row">
<label className="form-label" htmlFor="login-totp">
Einmalcode
</label>
<input
id="login-totp"
className="form-input"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
required
/>
</div>
{error ? (
<div style={{ padding: '0.75rem', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '1rem' }}>
{error}
</div>
) : null}
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? 'Laden...' : 'Bestätigen'}
</button>
<button
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: '0.5rem' }}
onClick={() => {
setChallengeToken('')
setTotpCode('')
setError('')
}}
>
Zurück
</button>
</form>
) : null}
{!challengeToken ? (
<form onSubmit={handleSubmit}>
{mode === 'register' && (
<>
@ -215,8 +288,9 @@ function LoginPage() {
{loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
</form>
) : null}
{mode === 'login' && (
{mode === 'login' && !challengeToken && (
<div
style={{
marginTop: '1.25rem',

View File

@ -25,7 +25,7 @@ import {
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
import ReportContentModal from '../components/ReportContentModal'
import MediaPreviewModal from '../components/MediaPreviewModal'
@ -159,7 +159,8 @@ function uploaderLabel(it, viewer) {
}
function MediaThumb({ mediaId, mimeType }) {
const url = resolveMediaAssetFileUrl(mediaId)
const mediaToken = useMediaAccessToken()
const url = resolveMediaAssetFileUrl(mediaId, mediaToken)
const mime = (mimeType || '').toLowerCase()
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
if (mime.includes('heic') || mime.includes('heif')) {
@ -296,6 +297,7 @@ function JournalBoolField({ label, val, context }) {
}
export default function MediaLibraryPage() {
const mediaToken = useMediaAccessToken()
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
@ -1122,7 +1124,7 @@ export default function MediaLibraryPage() {
<MediaPreviewModal
title={preview.original_filename || `Medium #${preview.id}`}
media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }}
fileUrl={resolveMediaAssetFileUrl(preview.id)}
fileUrl={resolveMediaAssetFileUrl(preview.id, mediaToken)}
onClose={() => setPreview(null)}
onReport={
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active

View File

@ -25,6 +25,39 @@ export async function login(email, password) {
})
}
export async function loginTotp(challengeToken, code) {
return request('/api/auth/login/totp', {
method: 'POST',
body: JSON.stringify({ challenge_token: challengeToken, code }),
})
}
export async function getTotpStatus() {
return request('/api/auth/totp/status')
}
export async function setupTotp() {
return request('/api/auth/totp/setup', { method: 'POST', body: '{}' })
}
export async function confirmTotp(code) {
return request('/api/auth/totp/confirm', {
method: 'POST',
body: JSON.stringify({ code }),
})
}
export async function disableTotp(currentPassword, code) {
return request('/api/auth/totp/disable', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, code }),
})
}
export async function issueMediaGrant() {
return request('/api/auth/media-grant', { method: 'POST', body: '{}' })
}
export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', {
method: 'POST',
@ -126,10 +159,13 @@ export async function managementPasswordReset(profileId, newPassword = null) {
})
}
export async function changePassword(newPassword) {
export async function changePassword(currentPassword, newPassword) {
return request('/api/auth/pin', {
method: 'PUT',
body: JSON.stringify({ pin: newPassword }),
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
})
}
@ -992,6 +1028,12 @@ export async function healthCheck() {
export const api = {
// Auth
login,
loginTotp,
getTotpStatus,
setupTotp,
confirmTotp,
disableTotp,
issueMediaGrant,
register,
logout,
getCurrentProfile,

View File

@ -1,31 +1,77 @@
/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
/** URL für Übungs-Mediendateien. Query-Token ist ein kurzlebiges Medien-Grant, kein Sitzungstoken. */
import { useEffect, useState } from 'react'
import api from './api'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
let grant = { token: '', expiresAt: 0 }
const listeners = new Set()
function notify() {
listeners.forEach((fn) => fn())
}
export function peekMediaAccessToken() {
if (grant.expiresAt > Date.now() + 15_000) return grant.token
return ''
}
export function clearMediaGrant() {
grant = { token: '', expiresAt: 0 }
notify()
}
export async function refreshMediaGrant() {
const data = await api.issueMediaGrant()
const expiresAt = data?.expires_at ? new Date(data.expires_at).getTime() : Date.now() + 9 * 60 * 1000
grant = { token: data?.token || '', expiresAt }
notify()
return grant.token
}
export function useMediaAccessToken() {
const [token, setToken] = useState(peekMediaAccessToken)
useEffect(() => {
const pull = () => setToken(peekMediaAccessToken())
listeners.add(pull)
pull()
if (!peekMediaAccessToken()) {
refreshMediaGrant().catch(() => {})
}
return () => listeners.delete(pull)
}, [])
return token
}
function withGrant(path, accessToken) {
const token = accessToken || peekMediaAccessToken()
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}${path}${q}`
}
/**
* @param {number|string} exerciseId
* @param {object} media — exercise_media Zeile mit id, file_path
* @param {string} [accessToken]
* @returns {string|null}
*/
export function resolveExerciseMediaFileUrl(exerciseId, media) {
export function resolveExerciseMediaFileUrl(exerciseId, media, accessToken) {
if (!media?.file_path) return null
const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
const id = media.id
if (id == null || exerciseId == null) return null
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
return withGrant(`/api/exercises/${exerciseId}/media/${id}/file`, accessToken)
}
/**
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId — media_assets.id
* @param {string} [accessToken]
* @returns {string|null}
*/
export function resolveMediaAssetFileUrl(assetId) {
export function resolveMediaAssetFileUrl(assetId, accessToken) {
if (assetId == null) return null
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
return withGrant(`/api/media-assets/${assetId}/file`, accessToken)
}