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

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