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.
This commit is contained in:
smueller
2026-07-07 16:31:10 +02:00
parent d2df1d2bed
commit 7788c74cfe
22 changed files with 509 additions and 82 deletions

View File

@@ -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"}

View File

@@ -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

View File

@@ -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()

View File

@@ -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