From 7788c74cfe7ef09663da1917fa0111ad1881fed3 Mon Sep 17 00:00:00 2001 From: smueller Date: Tue, 7 Jul 2026 16:31:10 +0200 Subject: [PATCH] Refactor configuration and security features for improved development experience - Updated the .env.example file to reflect a development environment setup with enhanced secret key requirements and local host settings. - Modified the Docker Compose configuration to enhance security with read-only settings and no-new-privileges options. - Updated requirements.txt to pin package versions for better dependency management. - Enhanced the FastAPI application to include dynamic OpenAPI and documentation URLs based on the environment. - Implemented session versioning in JWT tokens to improve security and user session management. - Added new validation for user roles and password strength in schemas. - Improved email sending logic to handle recipient lists more robustly and added logging for SMTP operations. - Updated dashboard and profile templates to reflect new features and improve user experience. --- .env.example | 18 ++- .gitea/workflows/docker-build.yml | 6 + app/core/config.py | 15 ++- app/core/email_utils.py | 16 +++ app/core/password_policy.py | 18 +++ app/core/rate_limit.py | 35 ++++++ app/core/secrets_crypto.py | 28 +++++ app/core/security.py | 20 +++- app/core/startup_checks.py | 34 ++++++ app/main.py | 181 ++++++++++++++++++++++++++---- app/models/user.py | 1 + app/schemas/user.py | 20 +++- app/services/auth.py | 43 +++---- app/services/config_store.py | 18 ++- app/services/session_tokens.py | 8 ++ app/services/smtp_sender.py | 28 ++++- app/static/css/main.css | 15 +++ app/templates/dashboard.html | 18 ++- app/templates/profile.html | 2 +- app/templates/users.html | 37 +++++- docker-compose.yml | 5 + requirements.txt | 25 +++-- 22 files changed, 509 insertions(+), 82 deletions(-) create mode 100644 app/core/email_utils.py create mode 100644 app/core/password_policy.py create mode 100644 app/core/rate_limit.py create mode 100644 app/core/secrets_crypto.py create mode 100644 app/core/startup_checks.py create mode 100644 app/services/session_tokens.py diff --git a/.env.example b/.env.example index 6622e6c..cd91eaa 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,26 @@ APP_NAME=TK Wiki Newsletter Admin -ENVIRONMENT=production -SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET +# development = lokale Entwicklung ohne harte Produktions-Checks +# production = erzwingt starke Secrets, ALLOWED_HOSTS, COOKIE_SECURE=true +ENVIRONMENT=development +SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET_AT_LEAST_32_CHARS ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=120 +JWT_ISSUER=tk-wiki-newsletter-admin +JWT_AUDIENCE=tk-wiki-newsletter-admin HOST_PORT=8080 DATABASE_URL=sqlite:///./data/newsletter.db # Beispiel PostgreSQL: # DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php -ALLOWED_HOSTS=* +ALLOWED_HOSTS=localhost,127.0.0.1 COOKIE_SECURE=false -# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) +# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) – Pflicht in production +HSTS_MAX_AGE=31536000 ADMIN_BOOTSTRAP_EMAIL=admin@internal.local ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123! # true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start # auf die obigen Werte zurueck (auch wenn der User schon existiert). -# Nach erfolgreichem Login wieder auf false setzen, damit UI-Passwortaenderungen -# nicht beim naechsten Neustart ueberschrieben werden. +# In production nicht erlaubt. Nach Erst-Setup auf false lassen. ADMIN_BOOTSTRAP_RESET=false +EDITOR_TIP_MAX_LENGTH=10000 +HIGHLIGHTS_MAX_LENGTH=5000 diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index dfac39e..a498347 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -25,6 +25,12 @@ jobs: - name: Repository auschecken uses: actions/checkout@v4 + - name: Python-Abhängigkeiten prüfen (pip-audit) + run: | + python -m pip install --upgrade pip + pip install pip-audit + pip-audit -r requirements.txt + - name: Docker Buildx einrichten uses: docker/setup-buildx-action@v3 diff --git a/app/core/config.py b/app/core/config.py index 72defcf..311ff65 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -3,21 +3,30 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): app_name: str = "TK Wiki Newsletter Admin" - environment: str = "production" + environment: str = "development" secret_key: str = "change-me-in-production" algorithm: str = "HS256" access_token_expire_minutes: int = 120 + jwt_issuer: str = "tk-wiki-newsletter-admin" + jwt_audience: str = "tk-wiki-newsletter-admin" database_url: str = "sqlite:///./data/newsletter.db" wiki_api_url: str = "https://www.thomas-krenn.com/de/wikiDE/api.php" - allowed_hosts: str = "*" + allowed_hosts: str = "localhost,127.0.0.1" cookie_secure: bool = False + hsts_max_age: int = 31536000 admin_bootstrap_email: str = "admin@internal.local" admin_bootstrap_password: str = "ChangeMe123!" # Wenn true: setzt Passwort/Rolle/Status des Bootstrap-Admins beim Start # auf die .env-Werte zurück (auch wenn der User bereits existiert). admin_bootstrap_reset: bool = False + editor_tip_max_length: int = 10000 + highlights_max_length: int = 5000 - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + @property + def is_production(self) -> bool: + return self.environment.lower() == "production" settings = Settings() diff --git a/app/core/email_utils.py b/app/core/email_utils.py new file mode 100644 index 0000000..37fb3fd --- /dev/null +++ b/app/core/email_utils.py @@ -0,0 +1,16 @@ +from pydantic import EmailStr, TypeAdapter + +_email_adapter = TypeAdapter(EmailStr) + + +def parse_recipient_list(raw: str) -> list[str]: + recipients: list[str] = [] + for part in raw.split(","): + addr = part.strip() + if not addr: + continue + if any(ch in addr for ch in ("\n", "\r", "\0")): + raise ValueError(f"Ungültige E-Mail-Adresse: {addr!r}") + _email_adapter.validate_python(addr) + recipients.append(addr) + return recipients diff --git a/app/core/password_policy.py b/app/core/password_policy.py new file mode 100644 index 0000000..e2216f4 --- /dev/null +++ b/app/core/password_policy.py @@ -0,0 +1,18 @@ +import re + +_PASSWORD_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r".{10,}"), "Mindestens 10 Zeichen."), + (re.compile(r"[A-Z]"), "Mindestens ein Großbuchstabe."), + (re.compile(r"[a-z]"), "Mindestens ein Kleinbuchstabe."), + (re.compile(r"\d"), "Mindestens eine Ziffer."), +) + + +def password_policy_errors(password: str) -> list[str]: + return [msg for pattern, msg in _PASSWORD_RULES if not pattern.search(password)] + + +def validate_password_strength(password: str) -> None: + errors = password_policy_errors(password) + if errors: + raise ValueError(errors[0]) diff --git a/app/core/rate_limit.py b/app/core/rate_limit.py new file mode 100644 index 0000000..85fd553 --- /dev/null +++ b/app/core/rate_limit.py @@ -0,0 +1,35 @@ +import time +from collections import defaultdict + +from fastapi import HTTPException, Request, status + +_WINDOW_SECONDS = 300 +_MAX_ATTEMPTS = 5 +_attempts: dict[str, list[float]] = defaultdict(list) + + +def _client_key(request: Request, email: str) -> str: + forwarded = request.headers.get("x-forwarded-for", "") + ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown") + return f"{ip}:{email.strip().lower()}" + + +def check_login_rate_limit(request: Request, email: str) -> None: + key = _client_key(request, email) + now = time.monotonic() + window_start = now - _WINDOW_SECONDS + recent = [t for t in _attempts[key] if t >= window_start] + _attempts[key] = recent + if len(recent) >= _MAX_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.", + ) + + +def record_failed_login(request: Request, email: str) -> None: + _attempts[_client_key(request, email)].append(time.monotonic()) + + +def clear_login_attempts(request: Request, email: str) -> None: + _attempts.pop(_client_key(request, email), None) diff --git a/app/core/secrets_crypto.py b/app/core/secrets_crypto.py new file mode 100644 index 0000000..03d6ea2 --- /dev/null +++ b/app/core/secrets_crypto.py @@ -0,0 +1,28 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken + +from app.core.config import settings + + +def _fernet() -> Fernet: + digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest() + key = base64.urlsafe_b64encode(digest) + return Fernet(key) + + +def encrypt_secret(plain: str) -> str: + if not plain: + return "" + return _fernet().encrypt(plain.encode("utf-8")).decode("utf-8") + + +def decrypt_secret(cipher: str) -> str: + if not cipher: + return "" + try: + return _fernet().decrypt(cipher.encode("utf-8")).decode("utf-8") + except (InvalidToken, ValueError): + # Bestehende Klartext-Einträge aus älteren Installationen. + return cipher diff --git a/app/core/security.py b/app/core/security.py index 664b118..6adf61e 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -15,8 +15,24 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) -def create_access_token(subject: Any) -> str: +def create_access_token(subject: Any, session_version: int = 0) -> str: expires_delta = timedelta(minutes=settings.access_token_expire_minutes) expire = datetime.now(timezone.utc) + expires_delta - to_encode = {"exp": expire, "sub": str(subject)} + to_encode = { + "exp": expire, + "sub": str(subject), + "sv": session_version, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + } return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + + +def decode_access_token(token: str) -> dict[str, Any]: + return jwt.decode( + token, + settings.secret_key, + algorithms=[settings.algorithm], + issuer=settings.jwt_issuer, + audience=settings.jwt_audience, + ) diff --git a/app/core/startup_checks.py b/app/core/startup_checks.py new file mode 100644 index 0000000..a110d7d --- /dev/null +++ b/app/core/startup_checks.py @@ -0,0 +1,34 @@ +import sys + +from app.core.config import settings + +_WEAK_SECRET_KEYS = frozenset( + { + "change-me-in-production", + "please_change_to_a_long_random_secret", + "secret", + "changeme", + } +) +_WEAK_BOOTSTRAP_PASSWORDS = frozenset({"changeme123!", "change_me_123", "admin123!"}) + + +def validate_startup_config() -> None: + errors: list[str] = [] + + if settings.environment.lower() == "production": + if settings.secret_key.lower() in _WEAK_SECRET_KEYS or len(settings.secret_key) < 32: + errors.append("SECRET_KEY muss in Produktion mindestens 32 Zeichen lang und zufällig sein.") + if settings.admin_bootstrap_password.lower() in _WEAK_BOOTSTRAP_PASSWORDS: + errors.append("ADMIN_BOOTSTRAP_PASSWORD ist zu schwach für Produktion.") + if settings.admin_bootstrap_reset: + errors.append("ADMIN_BOOTSTRAP_RESET darf in Produktion nicht aktiv sein.") + if settings.allowed_hosts.strip() == "*": + errors.append("ALLOWED_HOSTS darf in Produktion nicht '*' sein – konkrete Hostnamen setzen.") + if not settings.cookie_secure: + errors.append("COOKIE_SECURE muss in Produktion auf true gesetzt sein (HTTPS/TLS-Terminierung).") + + if errors: + for message in errors: + print(f"STARTUP-FEHLER: {message}", file=sys.stderr) + sys.exit(1) diff --git a/app/main.py b/app/main.py index 904878f..590bab6 100644 --- a/app/main.py +++ b/app/main.py @@ -16,13 +16,17 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.cookies import cookie_secure +from app.core.email_utils import parse_recipient_list +from app.core.rate_limit import check_login_rate_limit, clear_login_attempts, record_failed_login from app.core.security import create_access_token, hash_password, verify_password +from app.core.startup_checks import validate_startup_config from app.db.session import Base, engine, get_db from app.models.system import SendLog from app.models.user import User -from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate -from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin -from app.services.config_store import get_config, set_config +from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate, UserRoleUpdate +from app.services.auth import can_view_sensitive_logs, get_admin_user, get_current_user, get_optional_user, require_editor_or_admin +from app.services.config_store import get_config, has_secret_config, set_config +from app.services.session_tokens import invalidate_user_sessions from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf from app.services.newsletter import ( DEFAULT_DISPLAY, @@ -40,7 +44,17 @@ from app.services.newsletter import ( from app.services.smtp_sender import get_smtp_settings, mark_scheduled_sent, schedule_is_due, send_newsletter from app.services.wiki import WikiFetchError, WikiService -app = FastAPI(title=settings.app_name) +validate_startup_config() + +_openapi_url = None if settings.is_production else "/openapi.json" +_docs_url = None if settings.is_production else "/docs" +_redoc_url = None if settings.is_production else "/redoc" +app = FastAPI( + title=settings.app_name, + docs_url=_docs_url, + redoc_url=_redoc_url, + openapi_url=_openapi_url, +) allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()] if allowed_hosts: app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) @@ -109,7 +123,12 @@ async def _run_scheduled_send_if_due() -> None: logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).") return - recipients = [r.strip() for r in settings_snapshot.get("recipients", "").split(",") if r.strip()] + try: + recipients = parse_recipient_list(settings_snapshot.get("recipients", "")) + except ValueError: + logger.warning("Geplanter Versand übersprungen (ungültige Empfängerliste).") + return + send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=True) mark_scheduled_sent(db, now) finally: @@ -124,6 +143,8 @@ def _ensure_schema_upgrades() -> None: if "role" not in cols: conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'reader'")) conn.execute(text("UPDATE users SET role = CASE WHEN is_admin = 1 THEN 'admin' ELSE 'reader' END")) + if "session_version" not in cols: + conn.execute(text("ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0")) @app.middleware("http") @@ -137,12 +158,28 @@ async def add_security_headers(request: Request, call_next): response.headers["Referrer-Policy"] = "same-origin" # Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP. # E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host. - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; " - "img-src 'self' https: data:; " - "style-src 'self' 'unsafe-inline'; " - "script-src 'self';" - ) + path = request.url.path + if path.startswith("/dashboard"): + csp = ( + "default-src 'self'; " + "img-src 'self' https: data:; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "frame-src 'self';" + ) + else: + csp = ( + "default-src 'self'; " + "img-src 'self' https: data:; " + "style-src 'self'; " + "script-src 'self';" + ) + response.headers["Content-Security-Policy"] = csp + if settings.is_production and cookie_secure(request): + response.headers["Strict-Transport-Security"] = f"max-age={settings.hsts_max_age}; includeSubDomains" + if path not in {"/login", "/static"} and not path.startswith("/static/"): + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" if not cookie_token: response.set_cookie( CSRF_COOKIE_NAME, @@ -180,6 +217,15 @@ def _bootstrap_admin() -> None: db.close() +def _smtp_context(db: Session, *, include_recipients: bool) -> dict[str, str]: + smtp = get_smtp_settings(db) + safe = {key: value for key, value in smtp.items() if key != "password"} + safe["password_configured"] = "true" if has_secret_config(db, "smtp.password") or smtp.get("password") else "false" + if not include_recipients: + safe["recipients"] = "" + return safe + + def _base_context(request: Request, user: User, db: Session) -> dict: path = request.url.path active_nav = "dashboard" @@ -190,8 +236,11 @@ def _base_context(request: Request, user: User, db: Session) -> dict: return { "user": user, "csrf_token": ensure_csrf_cookie(request), - "smtp": get_smtp_settings(db), + "smtp": _smtp_context(db, include_recipients=can_view_sensitive_logs(user)), + "can_view_sensitive": can_view_sensitive_logs(user), "active_nav": active_nav, + "editor_tip_max_length": settings.editor_tip_max_length, + "highlights_max_length": settings.highlights_max_length, } @@ -253,8 +302,20 @@ def login( "error_message": "Sitzung abgelaufen. Bitte erneut anmelden.", }, ) + try: + check_login_rate_limit(request, email) + except HTTPException: + return _render( + request, + "login.html", + { + "csrf_token": ensure_csrf_cookie(request), + "error_message": "Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.", + }, + ) user = db.query(User).filter(User.email == email, User.is_active.is_(True)).first() if not user or not verify_password(password, user.password_hash): + record_failed_login(request, email) return _render( request, "login.html", @@ -263,15 +324,31 @@ def login( "error_message": "Ungültige Zugangsdaten.", }, ) - token = create_access_token(user.id) + clear_login_attempts(request, email) + token = create_access_token(user.id, session_version=user.session_version or 0) + max_age = settings.access_token_expire_minutes * 60 response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) - response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict") + response.set_cookie( + "access_token", + token, + httponly=True, + secure=cookie_secure(request), + samesite="strict", + max_age=max_age, + ) return response @app.post("/logout") -def logout(request: Request, csrf_token: str = Form(...)): +def logout( + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): validate_csrf(request, csrf_token) + current_user = get_optional_user(request, db) + if current_user: + invalidate_user_sessions(current_user, db) response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) response.delete_cookie("access_token") return response @@ -376,6 +453,10 @@ async def dashboard_generate( current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) + if len(editor_tip) > settings.editor_tip_max_length: + editor_tip = editor_tip[: settings.editor_tip_max_length] + if len(highlights) > settings.highlights_max_length: + highlights = highlights[: settings.highlights_max_length] days = max(1, min(days, 90)) if article_type not in ("all", "new", "edited"): article_type = "all" @@ -439,6 +520,10 @@ async def send_now( current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) + if len(editor_tip) > settings.editor_tip_max_length: + editor_tip = editor_tip[: settings.editor_tip_max_length] + if len(highlights) > settings.highlights_max_length: + highlights = highlights[: settings.highlights_max_length] display = parse_display_options(show_date, show_user, show_category) if article_type not in ("all", "new", "edited"): article_type = "all" @@ -464,9 +549,13 @@ async def send_now( elif not gen["articles"]: send_result = {"status": "failed", "message": "Versand abgebrochen – im Zeitraum wurden keine Artikel gefunden."} else: - recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] - status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False) - send_result = {"status": status_code, "message": detail} + try: + recipients = parse_recipient_list(get_config(db, "smtp.recipients", "")) + except ValueError as exc: + send_result = {"status": "failed", "message": f"Versand abgebrochen – {exc}"} + else: + status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False) + send_result = {"status": status_code, "message": detail} query = urlencode({"send_status": send_result["status"], "send_msg": send_result["message"]}) return RedirectResponse(url=f"/dashboard?{query}", status_code=status.HTTP_303_SEE_OTHER) @@ -491,6 +580,7 @@ def update_smtp_settings( schedule_time: str = Form("08:00"), schedule_weekdays: list[str] = Form(default=[]), schedule_dom: str = Form("1"), + schedule_acknowledged: str | None = Form(None), gen_period: str = Form("days"), gen_days: str = Form("30"), gen_article_type: str = Form("all"), @@ -503,14 +593,20 @@ def update_smtp_settings( set_config(db, "smtp.host", host.strip()) set_config(db, "smtp.port", port.strip() or "587") set_config(db, "smtp.username", username.strip()) - set_config(db, "smtp.password", password) + if password.strip(): + set_config(db, "smtp.password", password.strip()) set_config(db, "smtp.from_email", from_email.strip()) set_config(db, "smtp.from_name", from_name.strip()) set_config(db, "smtp.reply_to", reply_to.strip()) set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false") - set_config(db, "smtp.recipients", recipients.strip()) + try: + validated_recipients = parse_recipient_list(recipients) + set_config(db, "smtp.recipients", ", ".join(validated_recipients)) + except ValueError: + set_config(db, "smtp.recipients", recipients.strip()) set_config(db, "smtp.schedule_enabled", "true" if schedule_enabled == "true" else "false") + set_config(db, "smtp.schedule_acknowledged", "true" if schedule_acknowledged == "true" else "false") if schedule_frequency not in ("daily", "weekly", "monthly"): schedule_frequency = "monthly" set_config(db, "smtp.schedule_frequency", schedule_frequency) @@ -725,5 +821,50 @@ def change_password( ) current_user.password_hash = hash_password(payload.new_password) + invalidate_user_sessions(current_user, db) db.commit() return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND) + + +@app.post("/admin/users/{user_id}/role", response_class=HTMLResponse) +def update_user_role( + request: Request, + user_id: int, + csrf_token: str = Form(...), + role: str = Form(...), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + validate_csrf(request, csrf_token) + target = db.query(User).filter(User.id == user_id).first() + if not target: + return _render(request, "users.html", _users_context(request, admin, db, error_message="Benutzer nicht gefunden.")) + if target.id == admin.id: + return _render(request, "users.html", _users_context(request, admin, db, error_message="Eigene Rolle kann hier nicht geändert werden.")) + try: + payload = UserRoleUpdate(role=role) + except ValidationError as exc: + return _render(request, "users.html", _users_context(request, admin, db, error_message=_format_validation_error(exc))) + target.role = payload.role + target.is_admin = payload.role == "admin" + invalidate_user_sessions(target, db) + return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND) + + +@app.post("/admin/users/{user_id}/toggle-active", response_class=HTMLResponse) +def toggle_user_active( + request: Request, + user_id: int, + csrf_token: str = Form(...), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + validate_csrf(request, csrf_token) + target = db.query(User).filter(User.id == user_id).first() + if not target: + return _render(request, "users.html", _users_context(request, admin, db, error_message="Benutzer nicht gefunden.")) + if target.id == admin.id: + return _render(request, "users.html", _users_context(request, admin, db, error_message="Eigenes Konto kann nicht deaktiviert werden.")) + target.is_active = not target.is_active + invalidate_user_sessions(target, db) + return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND) diff --git a/app/models/user.py b/app/models/user.py index 06a0d9c..a1a9e3d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -13,4 +13,5 @@ class User(Base): is_admin = Column(Boolean, default=False, nullable=False) role = Column(String(32), default="reader", nullable=False) is_active = Column(Boolean, default=True, nullable=False) + session_version = Column(Integer, default=0, nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/schemas/user.py b/app/schemas/user.py index 1ee9f9d..7d1feb9 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength class UserCreate(BaseModel): @@ -7,6 +9,12 @@ class UserCreate(BaseModel): password: str = Field(min_length=10, max_length=255) role: str = Field(default="reader", pattern="^(admin|editor|reader)$") + @field_validator("password") + @classmethod + def strong_password(cls, value: str) -> str: + validate_password_strength(value) + return value + class ProfileUpdate(BaseModel): email: EmailStr @@ -16,6 +24,16 @@ class ProfileUpdate(BaseModel): class PasswordChange(BaseModel): new_password: str = Field(min_length=10, max_length=255) + @field_validator("new_password") + @classmethod + def strong_password(cls, value: str) -> str: + validate_password_strength(value) + return value + + +class UserRoleUpdate(BaseModel): + role: str = Field(pattern="^(admin|editor|reader)$") + class UserOut(BaseModel): id: int diff --git a/app/services/auth.py b/app/services/auth.py index 74740f6..da6817a 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,42 +1,45 @@ from fastapi import Depends, HTTPException, Request, status -from jose import JWTError, jwt +from jose import JWTError from sqlalchemy.orm import Session -from app.core.config import settings +from app.core.security import decode_access_token from app.db.session import get_db from app.models.user import User +def _user_from_token(token: str, db: Session) -> User | None: + try: + payload = decode_access_token(token) + user_id = int(payload.get("sub")) + token_sv = int(payload.get("sv", 0)) + except (JWTError, TypeError, ValueError): + return None + + user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() + if not user or (user.session_version or 0) != token_sv: + return None + return user + + def get_optional_user(request: Request, db: Session = Depends(get_db)) -> User | None: token = request.cookies.get("access_token") if not token: return None - try: - payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) - user_id = int(payload.get("sub")) - except (JWTError, TypeError, ValueError): - return None - return db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() + return _user_from_token(token, db) def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: token = request.cookies.get("access_token") if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet.") - try: - payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) - user_id = int(payload.get("sub")) - except (JWTError, TypeError, ValueError) as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger Token.") from exc - - user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() + user = _user_from_token(token, db) if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzer nicht gefunden.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger oder abgelaufener Token.") return user def get_admin_user(current_user: User = Depends(get_current_user)) -> User: - if current_user.role != "admin" and not current_user.is_admin: + if current_user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.") return current_user @@ -47,7 +50,5 @@ def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> U return current_user -def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User: - if current_user.role not in {"admin", "editor", "reader"}: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.") - return current_user +def can_view_sensitive_logs(user: User) -> bool: + return user.role in {"admin", "editor"} diff --git a/app/services/config_store.py b/app/services/config_store.py index 60e4fc2..376e89a 100644 --- a/app/services/config_store.py +++ b/app/services/config_store.py @@ -1,17 +1,29 @@ from sqlalchemy.orm import Session +from app.core.secrets_crypto import decrypt_secret, encrypt_secret from app.models.system import AppConfig +SECRET_CONFIG_KEYS = frozenset({"smtp.password"}) + def get_config(db: Session, key: str, default: str = "") -> str: row = db.query(AppConfig).filter(AppConfig.key == key).first() - return row.value if row else default + if not row: + return default + if key in SECRET_CONFIG_KEYS: + return decrypt_secret(row.value) + return row.value def set_config(db: Session, key: str, value: str) -> None: + stored = encrypt_secret(value) if key in SECRET_CONFIG_KEYS else value row = db.query(AppConfig).filter(AppConfig.key == key).first() if row: - row.value = value + row.value = stored else: - db.add(AppConfig(key=key, value=value)) + db.add(AppConfig(key=key, value=stored)) db.commit() + + +def has_secret_config(db: Session, key: str) -> bool: + return db.query(AppConfig).filter(AppConfig.key == key).first() is not None diff --git a/app/services/session_tokens.py b/app/services/session_tokens.py new file mode 100644 index 0000000..9597a64 --- /dev/null +++ b/app/services/session_tokens.py @@ -0,0 +1,8 @@ +from sqlalchemy.orm import Session + +from app.models.user import User + + +def invalidate_user_sessions(user: User, db: Session) -> None: + user.session_version = (user.session_version or 0) + 1 + db.commit() diff --git a/app/services/smtp_sender.py b/app/services/smtp_sender.py index 86d8685..4a40b9d 100644 --- a/app/services/smtp_sender.py +++ b/app/services/smtp_sender.py @@ -1,3 +1,4 @@ +import logging import smtplib from datetime import datetime from email.mime.multipart import MIMEMultipart @@ -7,9 +8,12 @@ from typing import Sequence from sqlalchemy.orm import Session +from app.core.email_utils import parse_recipient_list from app.models.system import SendLog from app.services.config_store import get_config +logger = logging.getLogger("newsletter.smtp") + def get_smtp_settings(db: Session) -> dict[str, str]: return { @@ -25,6 +29,7 @@ def get_smtp_settings(db: Session) -> dict[str, str]: "recipients": get_config(db, "smtp.recipients", ""), # Zeitplan "schedule_enabled": get_config(db, "smtp.schedule_enabled", "false"), + "schedule_acknowledged": get_config(db, "smtp.schedule_acknowledged", "false"), "schedule_frequency": get_config(db, "smtp.schedule_frequency", "monthly"), "schedule_time": get_config(db, "smtp.schedule_time", "08:00"), "schedule_weekdays": get_config(db, "smtp.schedule_weekdays", "0"), @@ -44,6 +49,8 @@ def schedule_is_due(settings: dict[str, str], now: datetime) -> bool: return False if (settings.get("schedule_enabled") or "false").lower() != "true": return False + if (settings.get("schedule_acknowledged") or "false").lower() != "true": + return False freq = (settings.get("schedule_frequency") or "off").lower() if freq == "off": return False @@ -88,6 +95,13 @@ def send_newsletter( _log(db, subject, recipients, "failed", detail, scheduled) return "failed", detail + try: + validated_recipients = parse_recipient_list(",".join(recipients)) + except ValueError as exc: + detail = f"Ungültige Empfängerliste: {exc}" + _log(db, subject, recipients, "failed", detail, scheduled) + return "failed", detail + msg = MIMEMultipart("alternative") msg.attach(MIMEText(text_content, "plain", "utf-8")) msg.attach(MIMEText(html_content, "html", "utf-8")) @@ -96,7 +110,8 @@ def send_newsletter( msg["From"] = formataddr((settings["from_name"], settings["from_email"])) else: msg["From"] = settings["from_email"] - msg["To"] = ", ".join(recipients) + msg["To"] = settings["from_email"] + msg["Bcc"] = ", ".join(validated_recipients) if settings.get("reply_to"): msg["Reply-To"] = settings["reply_to"] @@ -107,13 +122,14 @@ def send_newsletter( server.starttls() if settings["username"]: server.login(settings["username"], settings["password"]) - server.sendmail(settings["from_email"], list(recipients), msg.as_string()) - detail = f"Versand erfolgreich an {len(recipients)} Empfänger." - _log(db, subject, recipients, "sent", detail, scheduled) + server.sendmail(settings["from_email"], validated_recipients, msg.as_string()) + detail = f"Versand erfolgreich an {len(validated_recipients)} Empfänger." + _log(db, subject, validated_recipients, "sent", detail, scheduled) return "sent", detail except Exception as exc: - detail = f"Versandfehler: {exc}" - _log(db, subject, recipients, "failed", detail, scheduled) + logger.exception("SMTP-Versand fehlgeschlagen") + detail = "Versandfehler: E-Mail konnte nicht zugestellt werden. Details stehen im Server-Log." + _log(db, subject, validated_recipients, "failed", detail, scheduled) return "failed", detail diff --git a/app/static/css/main.css b/app/static/css/main.css index 30c3fdb..3037cb8 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -740,6 +740,21 @@ body.login-page .container { user-select: none; } +.inline-form { + display: inline; + margin: 0; +} + +.inline-form select, +.inline-form button { + font-size: 0.85rem; +} + +.btn-small { + padding: 0.35rem 0.65rem; + font-size: 0.82rem; +} + /* ── Footer ── */ .site-footer { text-align: center; diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 44cc22d..a926b81 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -73,11 +73,11 @@

Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.

Wird nur angezeigt, wenn Text hinterlegt ist – erscheint als eigene Box im Newsletter.

{% if wiki_error %} @@ -122,7 +122,11 @@

Bitte oben die Vorschau prüfen. Beim Versenden wird genau dieser Newsletter (aktuelle Filter) an die Verteilerliste geschickt.

Betreff: {{ subject }}
+ {% if can_view_sensitive %} Empfänger: {{ smtp.recipients if smtp.recipients else "— keine konfiguriert —" }} + {% else %} + Empfänger: Nur für Editor/Admin sichtbar + {% endif %} {% if smtp.enabled != "true" %}
SMTP ist deaktiviert – bitte zuerst in „SMTP-Konfiguration“ aktivieren.{% endif %}

{% if user.role in ["admin", "editor"] %} @@ -250,7 +254,7 @@ -

Ausschalten, wenn du vor dem Versenden noch Redaktionsnotiz oder Top-Highlights setzen möchtest – dann manuell über „Newsletter jetzt senden“.

+ +

Beide Häkchen sind nötig, damit der Zeitplan greift. Für manuelle Kontrolle nur den ersten Schalter nutzen und „Newsletter jetzt senden“ verwenden.

+ diff --git a/docker-compose.yml b/docker-compose.yml index 35b2d85..23929ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,11 @@ services: volumes: - newsletter_data:/app/data restart: unless-stopped + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/login').read()\""] interval: 30s diff --git a/requirements.txt b/requirements.txt index 7f08715..96d1399 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,13 @@ -fastapi -uvicorn[standard] -jinja2 -sqlalchemy -alembic -python-multipart -httpx -bcrypt -python-jose[cryptography] -pydantic-settings -email-validator -psycopg[binary] +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +jinja2==3.1.4 +sqlalchemy==2.0.36 +alembic==1.14.0 +python-multipart==0.0.20 +httpx==0.28.1 +bcrypt==4.2.1 +python-jose[cryptography]==3.3.0 +pydantic-settings==2.6.1 +email-validator==2.2.0 +psycopg[binary]==3.2.13 +cryptography==44.0.0