Security Update 1 #59

Merged
Lars merged 6 commits from develop into main 2026-10-13 17:38:10 +02:00
31 changed files with 1146 additions and 101 deletions
Showing only changes of commit 32b28a93f3 - Show all commits

View File

@ -53,7 +53,12 @@ SMTP_FROM=noreply@jinkendo.de
SMTP_SSL= SMTP_SSL=
SMTP_STARTTLS= 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= ADMIN_BOOTSTRAP_EMAILS=
APP_URL=https://shinkan.jinkendo.de APP_URL=https://shinkan.jinkendo.de

View File

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

View File

@ -12,6 +12,11 @@ from fastapi import Header, Query, HTTPException
import bcrypt import bcrypt
from db import get_db, get_cursor 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: 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() 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: def make_token() -> str:
"""Generate a secure random token for sessions.""" """Generate a secure random token for sessions."""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
@ -51,12 +84,39 @@ def get_session(token: str):
cur.execute( cur.execute(
"SELECT s.*, p.role, p.name " "SELECT s.*, p.role, p.name "
"FROM sessions s JOIN profiles p ON s.profile_id=p.id " "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,) (token,)
) )
return cur.fetchone() 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)): def require_auth(x_auth_token: Optional[str] = Header(default=None)):
""" """
FastAPI dependency - requires valid authentication. 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. FastAPI dependency - auth via header OR query parameter.
Used for endpoints accessed by <img> tags and SSE connections that can't send headers. Dateiabruf für <img>/<video>: Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken).
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters. Ein Sitzungstoken in der Query-Zeichenfolge wird nicht akzeptiert.
Usage: Usage:
@app.get("/api/photos/{id}") @app.get("/api/photos/{id}")
@ -93,7 +153,13 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
Raises: Raises:
HTTPException 401 if not authenticated 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: if not session:
raise HTTPException(401, "Nicht eingeloggt") raise HTTPException(401, "Nicht eingeloggt")
return session return session

View File

@ -282,7 +282,7 @@ app.include_router(ai_skill_retrieval_admin.router)
app.include_router(exercise_enrichment_admin.router) app.include_router(exercise_enrichment_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad # 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/ # Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")) _media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True) 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): class LoginRequest(BaseModel):
email: EmailStr 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): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str = Field(min_length=1, max_length=128)
name: Optional[str] = None name: Optional[str] = None
requested_club_id: Optional[int] = Field(default=None, ge=1) 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) expires = datetime.now() + timedelta(hours=hours_valid)
cur.execute( cur.execute(
""" """
INSERT INTO sessions (token, profile_id, expires_at, created_at) INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP) VALUES (%s, %s, %s, CURRENT_TIMESTAMP, 'password_reset')
""", """,
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()), (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 fastapi import APIRouter, HTTPException, Header, Depends
from starlette.requests import Request from starlette.requests import Request
import ipaddress
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address
from db import get_db, get_cursor from db import get_db, get_cursor
from auth import hash_pin, verify_pin, make_token, require_auth from auth import (
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest 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 ( from password_reset_mail import (
insert_password_reset_session, insert_password_reset_session,
password_reset_email_body, password_reset_email_body,
@ -27,47 +55,161 @@ from password_reset_mail import (
) )
router = APIRouter(prefix="/api/auth", tags=["auth"]) 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") def _client_ip(request: Request) -> str:
@limiter.limit("30/minute") """Echte Client-IP nur, wenn der direkte Peer ein vertrauenswürdiger Proxy ist."""
async def login(req: LoginRequest, request: Request): peer = request.client.host if request.client and request.client.host else ""
"""Login with email + password.""" if _is_trusted_proxy(peer):
with get_db() as conn: real = (request.headers.get("x-real-ip") or "").split(",")[0].strip()
cur = get_cursor(conn) if real:
cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) return real[:64]
prof = cur.fetchone() return peer or "unknown"
if not prof:
raise HTTPException(401, "Ungültige Zugangsdaten")
# Verify password
if not verify_pin(req.password, prof['pin_hash']):
raise HTTPException(401, "Ungültige Zugangsdaten")
# Auto-upgrade from SHA256 to bcrypt def _is_trusted_proxy(host: str) -> bool:
if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'): if not host:
new_hash = hash_pin(req.password) return False
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id'])) 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 { return {
"token": token, "token": token,
"profile_id": prof['id'], "profile_id": prof["id"],
"email": prof.get("email"), "email": prof.get("email"),
"name": prof.get("name"), "name": prof.get("name"),
"role": prof.get("role"), "role": prof.get("role"),
"tier": prof.get("tier"), "tier": prof.get("tier"),
"expires_at": expires.isoformat(), "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") @router.post("/logout")
def logout(x_auth_token: Optional[str]=Header(default=None)): def logout(x_auth_token: Optional[str]=Header(default=None)):
"""Logout (delete session).""" """Logout (delete session)."""
@ -94,21 +236,223 @@ def auth_status():
@router.put("/pin") @router.put("/pin")
def change_pin(req: dict, session: dict=Depends(require_auth)): def change_pin(req: ChangePasswordRequest, session: dict = Depends(require_auth)):
"""Change PIN/password for current user.""" """Passwort wechseln. Das aktuelle Passwort ist Pflicht; alle Sitzungen werden ersetzt."""
pid = session['profile_id'] 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)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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} 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") @router.post("/forgot-password")
@limiter.limit("3/minute") @limiter.limit("3/minute")
async def password_reset_request(req: PasswordResetRequest, request: Request): async def password_reset_request(req: PasswordResetRequest, request: Request):
@ -146,10 +490,17 @@ def password_reset_confirm(req: PasswordResetConfirm):
if not sess: if not sess:
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
pid = sess['profile_id'] pid = sess["profile_id"]
new_hash = hash_pin(req.new_password) new_hash = hash_pin(req.new_password)
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) cur.execute(
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) """
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"} 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 INNER JOIN profiles p ON p.id = s.profile_id
WHERE s.token = %s WHERE s.token = %s
AND (s.expires_at IS NULL OR s.expires_at > NOW()) AND (s.expires_at IS NULL OR s.expires_at > NOW())
AND COALESCE(s.purpose, 'full') = 'full'
""", """,
(x_auth_token,), (x_auth_token,),
) )

View File

@ -3224,7 +3224,7 @@ def download_exercise_media_file(
tenant: TenantContext = Depends(get_tenant_context_flexible), 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). Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment).
""" """
with get_db() as conn: with get_db() as conn:

View File

@ -1225,7 +1225,7 @@ def download_media_asset_file(
asset_id: int, asset_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible), 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 from routers.exercises import _binary_media_response
with get_db() as conn: with get_db() as conn:

View File

@ -14,7 +14,7 @@ from psycopg2.extras import Json
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from db import get_db, get_cursor, r2d 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 ( from club_tenancy import (
assert_club_member, assert_club_member,
club_ids_for_profile_with_roles, club_ids_for_profile_with_roles,
@ -30,6 +30,19 @@ router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) _ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"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): class ManagementPasswordResetBody(BaseModel):
@ -103,8 +116,7 @@ def get_current_profile(
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Profil nicht gefunden") raise HTTPException(404, "Profil nicht gefunden")
data = r2d(row) data = _public_profile_row(row)
data.pop("pin_hash", None)
clubs = memberships_with_roles(cur, profile_id, active_only=False) clubs = memberships_with_roles(cur, profile_id, active_only=False)
data["clubs"] = clubs data["clubs"] = clubs
ac_raw = data.get("active_club_id") ac_raw = data.get("active_club_id")
@ -140,9 +152,7 @@ def list_profiles(session=Depends(require_auth)):
rows = cur.fetchall() rows = cur.fetchall()
out = [] out = []
for r in rows: for r in rows:
d = r2d(r) out.append(_public_profile_row(r))
d.pop("pin_hash", None)
out.append(d)
return out return out
@ -203,9 +213,7 @@ def profile_document(pid: str) -> dict:
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Profil nicht gefunden") raise HTTPException(404, "Profil nicht gefunden")
d = r2d(row) return _public_profile_row(row)
d.pop("pin_hash", None)
return d
@router.post("/profiles/{pid}/management-password-reset") @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) _assert_can_management_password_help(cur, tenant, target, via_email=False)
new_hash = hash_pin(body.new_password) new_hash = hash_pin(body.new_password)
cur.execute( 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), (new_hash, target),
) )
revoke_profile_sessions(cur, target)
return {"ok": True, "mode": "direct"} return {"ok": True, "mode": "direct"}
_assert_can_management_password_help(cur, tenant, target, via_email=True) _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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.238" APP_VERSION = "0.8.239"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-10-12"
DB_SCHEMA_VERSION = "20260607095" DB_SCHEMA_VERSION = "20261012096"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "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 "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte "capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
@ -53,6 +53,16 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.238",
"date": "2026-05-22", "date": "2026-05-22",

View File

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

View File

@ -38,7 +38,8 @@ services:
SMTP_SSL: ${SMTP_SSL:-} SMTP_SSL: ${SMTP_SSL:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-} SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@… # 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:-}" ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML # Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}" APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
@ -61,7 +62,7 @@ services:
volumes: volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media} - ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
ports: ports:
- "8003:8000" - "127.0.0.1:8003:8000"
depends_on: depends_on:
- postgres - postgres
restart: unless-stopped restart: unless-stopped

View File

@ -21,6 +21,7 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute' import PlatformAdminRoute from './components/PlatformAdminRoute'
import ActiveClubSwitcher from './components/ActiveClubSwitcher' import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner' import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import TotpSetupCard from './components/TotpSetupCard'
import './app.css' import './app.css'
const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage')) 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() { function ProtectedLayout() {
const { isAuthenticated, loading, user, logout } = useAuth() const { isAuthenticated, loading, user, logout } = useAuth()
@ -155,6 +179,10 @@ function ProtectedLayout() {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
if (user?.totpEnrollmentRequired) {
return <TotpEnrollGate />
}
const location = useLocation() const location = useLocation()
const onboardingOnly = isOnboardingRestricted(user) const onboardingOnly = isOnboardingRestricted(user)
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) { if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {

View File

@ -6,7 +6,7 @@ import ExerciseMediaEmbed from './ExerciseMediaEmbed'
import ExerciseMediaThumbTile from './ExerciseMediaThumbTile' import ExerciseMediaThumbTile from './ExerciseMediaThumbTile'
import MediaPreviewModal from './MediaPreviewModal' import MediaPreviewModal from './MediaPreviewModal'
import ReportContentModal from './ReportContentModal' import ReportContentModal from './ReportContentModal'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs' import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs'
function isTrashHidden(m) { function isTrashHidden(m) {
@ -14,6 +14,7 @@ function isTrashHidden(m) {
} }
export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) { export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
const mediaToken = useMediaAccessToken()
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null) const [reportTarget, setReportTarget] = useState(null)
const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise]) const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise])
@ -66,7 +67,7 @@ export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
<MediaPreviewModal <MediaPreviewModal
title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`} title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`}
media={preview} media={preview}
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview)} fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview, mediaToken)}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
onReport={ onReport={
(preview.asset_lifecycle_state || 'active') === 'active' && !preview.asset_legal_hold_active (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 React, { useEffect, useState, useCallback, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import { import {
INLINE_MEDIA_SIZES, INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE, DEFAULT_INLINE_MEDIA_SIZE,
@ -12,8 +12,9 @@ import {
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
function RtePickerAssetThumb({ asset }) { function RtePickerAssetThumb({ asset }) {
const mediaToken = useMediaAccessToken()
const id = asset.id const id = asset.id
const src = resolveMediaAssetFileUrl(id) const src = resolveMediaAssetFileUrl(id, mediaToken)
const mt = (asset.mime_type || '').toLowerCase() const mt = (asset.mime_type || '').toLowerCase()
if (mt.startsWith('image/') && src) { if (mt.startsWith('image/') && src) {
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" /> return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import {
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api' import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub' import { activeClubMemberships } from '../utils/activeClub'
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils' import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
import { clearMediaGrant, refreshMediaGrant } from '../utils/exerciseMediaUrl'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@ -63,9 +64,25 @@ export function AuthProvider({ children }) {
const profile = await api.getCurrentProfile() const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile) syncStoredActiveClub(profile)
setUser(profile) setUser(profile)
refreshMediaGrant().catch(() => {})
} catch (err) { } 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) console.error('Auth check failed:', err)
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
clearMediaGrant()
setUser(null)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -132,6 +149,7 @@ export function AuthProvider({ children }) {
setUser(null) setUser(null)
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY) localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
clearMediaGrant()
clearCoachSessionStorage() clearCoachSessionStorage()
}, []) }, [])

View File

@ -6,6 +6,7 @@ import {
resolvePlanningModuleDisplayMode, resolvePlanningModuleDisplayMode,
} from '../config/planningModuleUx' } from '../config/planningModuleUx'
import api from '../utils/api' import api from '../utils/api'
import TotpSetupCard from '../components/TotpSetupCard'
/** /**
* Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort). * Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort).
@ -27,6 +28,10 @@ function AccountSettingsPage() {
PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND 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 [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('') const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false) const [savingPw, setSavingPw] = useState(false)
@ -144,8 +149,8 @@ function AccountSettingsPage() {
const handleChangePassword = async (e) => { const handleChangePassword = async (e) => {
e.preventDefault() e.preventDefault()
if (newPw1.length < 4) { if (newPw1.length < 8) {
showErr('Neues Passwort: mindestens 4 Zeichen.') showErr('Neues Passwort: mindestens 8 Zeichen.')
return return
} }
if (newPw1 !== newPw2) { if (newPw1 !== newPw2) {
@ -154,10 +159,12 @@ function AccountSettingsPage() {
} }
setSavingPw(true) setSavingPw(true)
try { try {
await api.changePassword(newPw1) const result = await api.changePassword(currentPw, newPw1)
if (result?.token) localStorage.setItem('authToken', result.token)
setCurrentPw('')
setNewPw1('') setNewPw1('')
setNewPw2('') setNewPw2('')
showOk('Passwort aktualisiert.') showOk('Passwort aktualisiert. Andere Geräte sind abgemeldet.')
} catch (err) { } catch (err) {
showErr(err.message || 'Passwort konnte nicht geändert werden.') showErr(err.message || 'Passwort konnte nicht geändert werden.')
} finally { } finally {
@ -461,12 +468,72 @@ function AccountSettingsPage() {
</form> </form>
</div> </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"> <div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2> <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 }}> <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> </p>
<form onSubmit={handleChangePassword}> <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' }}> <div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="settings-pw1"> <label className="form-label" htmlFor="settings-pw1">
Neues Passwort Neues Passwort

View File

@ -15,6 +15,8 @@ function LoginPage() {
const [publicClubs, setPublicClubs] = useState([]) const [publicClubs, setPublicClubs] = useState([])
const [requestedClubId, setRequestedClubId] = useState('') const [requestedClubId, setRequestedClubId] = useState('')
const [resending, setResending] = useState(false) const [resending, setResending] = useState(false)
const [challengeToken, setChallengeToken] = useState('')
const [totpCode, setTotpCode] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
const { checkAuth } = useAuth() const { checkAuth } = useAuth()
@ -39,6 +41,17 @@ function LoginPage() {
try { try {
if (mode === 'login') { if (mode === 'login') {
const response = await api.login(email, password) 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) localStorage.setItem('authToken', response.token)
await checkAuth() await checkAuth()
navigate('/') 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 () => { const handleResendVerification = async () => {
if (!email.trim()) { if (!email.trim()) {
setError('Zuerst die E-Mail-Adresse eintragen.') setError('Zuerst die E-Mail-Adresse eintragen.')
@ -122,6 +152,49 @@ function LoginPage() {
</button> </button>
</div> </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}> <form onSubmit={handleSubmit}>
{mode === 'register' && ( {mode === 'register' && (
<> <>
@ -215,8 +288,9 @@ function LoginPage() {
{loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'} {loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button> </button>
</form> </form>
) : null}
{mode === 'login' && ( {mode === 'login' && !challengeToken && (
<div <div
style={{ style={{
marginTop: '1.25rem', marginTop: '1.25rem',

View File

@ -25,7 +25,7 @@ import {
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
import RightsDeclarationDialog from '../components/RightsDeclarationDialog' import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
import ReportContentModal from '../components/ReportContentModal' import ReportContentModal from '../components/ReportContentModal'
import MediaPreviewModal from '../components/MediaPreviewModal' import MediaPreviewModal from '../components/MediaPreviewModal'
@ -159,7 +159,8 @@ function uploaderLabel(it, viewer) {
} }
function MediaThumb({ mediaId, mimeType }) { function MediaThumb({ mediaId, mimeType }) {
const url = resolveMediaAssetFileUrl(mediaId) const mediaToken = useMediaAccessToken()
const url = resolveMediaAssetFileUrl(mediaId, mediaToken)
const mime = (mimeType || '').toLowerCase() const mime = (mimeType || '').toLowerCase()
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */ /* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
if (mime.includes('heic') || mime.includes('heif')) { if (mime.includes('heic') || mime.includes('heif')) {
@ -296,6 +297,7 @@ function JournalBoolField({ label, val, context }) {
} }
export default function MediaLibraryPage() { export default function MediaLibraryPage() {
const mediaToken = useMediaAccessToken()
const { user } = useAuth() const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
@ -1122,7 +1124,7 @@ export default function MediaLibraryPage() {
<MediaPreviewModal <MediaPreviewModal
title={preview.original_filename || `Medium #${preview.id}`} title={preview.original_filename || `Medium #${preview.id}`}
media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }} media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }}
fileUrl={resolveMediaAssetFileUrl(preview.id)} fileUrl={resolveMediaAssetFileUrl(preview.id, mediaToken)}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
onReport={ onReport={
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active (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 = {}) { export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', { return request('/api/auth/register', {
method: 'POST', 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', { return request('/api/auth/pin', {
method: 'PUT', 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 = { export const api = {
// Auth // Auth
login, login,
loginTotp,
getTotpStatus,
setupTotp,
confirmTotp,
disableTotp,
issueMediaGrant,
register, register,
logout, logout,
getCurrentProfile, 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(/\/$/, '') 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 {number|string} exerciseId
* @param {object} media — exercise_media Zeile mit id, file_path * @param {object} media — exercise_media Zeile mit id, file_path
* @param {string} [accessToken]
* @returns {string|null} * @returns {string|null}
*/ */
export function resolveExerciseMediaFileUrl(exerciseId, media) { export function resolveExerciseMediaFileUrl(exerciseId, media, accessToken) {
if (!media?.file_path) return null if (!media?.file_path) return null
const fp = String(media.file_path) const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp 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 const id = media.id
if (id == null || exerciseId == null) return null 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). * Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId — media_assets.id * @param {number|string} assetId — media_assets.id
* @param {string} [accessToken]
* @returns {string|null} * @returns {string|null}
*/ */
export function resolveMediaAssetFileUrl(assetId) { export function resolveMediaAssetFileUrl(assetId, accessToken) {
if (assetId == null) return null if (assetId == null) return null
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : '' return withGrant(`/api/media-assets/${assetId}/file`, accessToken)
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
} }