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:
181
app/main.py
181
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)
|
||||
|
||||
Reference in New Issue
Block a user