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
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:
parent
6c7c24e887
commit
32b28a93f3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
49
backend/media_grant.py
Normal 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
|
||||
29
backend/migrations/096_login_lockout_and_totp.sql
Normal file
29
backend/migrations/096_login_lockout_and_totp.sql
Normal 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);
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
51
backend/tests/test_auth_hardening.py
Normal file
51
backend/tests/test_auth_hardening.py
Normal 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
63
backend/totp_auth.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
|
|
|
|||
101
frontend/src/components/TotpSetupCard.jsx
Normal file
101
frontend/src/components/TotpSetupCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user