Enhance CSRF protection and user management features
- Introduce CSRF token generation and management improvements, ensuring tokens are consistently retrieved from cookies or request state. - Update user creation logic to handle validation errors more gracefully, providing user feedback for invalid input and existing email addresses. - Revise user management UI to display success and error messages, improving user experience during user creation. - Refactor admin user access checks to include additional role validation.
This commit is contained in:
87
app/main.py
87
app/main.py
@@ -1,5 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
|
||||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
@@ -17,7 +19,7 @@ from app.models.user import User
|
|||||||
from app.schemas.user import UserCreate
|
from app.schemas.user import UserCreate
|
||||||
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
|
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.services.config_store import get_config, set_config
|
||||||
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf
|
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
|
||||||
from app.services.newsletter import (
|
from app.services.newsletter import (
|
||||||
DEFAULT_DISPLAY,
|
DEFAULT_DISPLAY,
|
||||||
article_url,
|
article_url,
|
||||||
@@ -62,13 +64,22 @@ def _ensure_schema_upgrades() -> None:
|
|||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def add_security_headers(request: Request, call_next):
|
async def add_security_headers(request: Request, call_next):
|
||||||
|
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||||||
|
request.state.csrf_token = cookie_token or generate_csrf_token()
|
||||||
|
|
||||||
response: Response = await call_next(request)
|
response: Response = await call_next(request)
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "same-origin"
|
response.headers["Referrer-Policy"] = "same-origin"
|
||||||
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
|
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
|
||||||
if not request.cookies.get(CSRF_COOKIE_NAME):
|
if not cookie_token:
|
||||||
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=cookie_secure(request), samesite="strict")
|
response.set_cookie(
|
||||||
|
CSRF_COOKIE_NAME,
|
||||||
|
request.state.csrf_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=cookie_secure(request),
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +110,7 @@ def _base_context(request: Request, user: User, db: Session) -> dict:
|
|||||||
active_nav = "users"
|
active_nav = "users"
|
||||||
return {
|
return {
|
||||||
"user": user,
|
"user": user,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
"csrf_token": ensure_csrf_cookie(request),
|
||||||
"smtp": get_smtp_settings(db),
|
"smtp": get_smtp_settings(db),
|
||||||
"active_nav": active_nav,
|
"active_nav": active_nav,
|
||||||
}
|
}
|
||||||
@@ -138,7 +149,7 @@ def login_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
request,
|
request,
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
{
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
"csrf_token": ensure_csrf_cookie(request),
|
||||||
"error_message": None,
|
"error_message": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -159,7 +170,7 @@ def login(
|
|||||||
request,
|
request,
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
{
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
"csrf_token": ensure_csrf_cookie(request),
|
||||||
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -169,7 +180,7 @@ def login(
|
|||||||
request,
|
request,
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
{
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
"csrf_token": ensure_csrf_cookie(request),
|
||||||
"error_message": "Ungültige Zugangsdaten.",
|
"error_message": "Ungültige Zugangsdaten.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -325,14 +336,41 @@ def update_smtp_settings(
|
|||||||
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/users", response_class=HTMLResponse)
|
def _users_context(request: Request, admin: User, db: Session, **extra) -> dict:
|
||||||
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
|
||||||
context = _base_context(request, admin, db)
|
context = _base_context(request, admin, db)
|
||||||
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
|
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
|
||||||
return _render(request, "users.html", context)
|
context.setdefault("error_message", None)
|
||||||
|
context.setdefault("success_message", None)
|
||||||
|
context.update(extra)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/users")
|
def _format_validation_error(exc: ValidationError) -> str:
|
||||||
|
messages: list[str] = []
|
||||||
|
for err in exc.errors():
|
||||||
|
msg = err.get("msg", "Ungültige Eingabe")
|
||||||
|
if msg.startswith("Value error, "):
|
||||||
|
msg = msg[13:]
|
||||||
|
messages.append(msg)
|
||||||
|
return " ".join(messages) if messages else "Ungültige Eingabe."
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/users", response_class=HTMLResponse)
|
||||||
|
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||||
|
created = request.query_params.get("created") == "1"
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"users.html",
|
||||||
|
_users_context(
|
||||||
|
request,
|
||||||
|
admin,
|
||||||
|
db,
|
||||||
|
success_message="Benutzer wurde erfolgreich angelegt." if created else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users", response_class=HTMLResponse)
|
||||||
def create_user(
|
def create_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
@@ -343,11 +381,32 @@ def create_user(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
payload = UserCreate(email=email, full_name=full_name, password=password, role=role)
|
except HTTPException:
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"users.html",
|
||||||
|
_users_context(request, admin, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = UserCreate(email=email.strip(), full_name=full_name.strip(), password=password, role=role)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"users.html",
|
||||||
|
_users_context(request, admin, db, error_message=_format_validation_error(exc)),
|
||||||
|
)
|
||||||
|
|
||||||
existing = db.query(User).filter(User.email == payload.email).first()
|
existing = db.query(User).filter(User.email == payload.email).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="E-Mail existiert bereits.")
|
return _render(
|
||||||
|
request,
|
||||||
|
"users.html",
|
||||||
|
_users_context(request, admin, db, error_message="Diese E-Mail-Adresse ist bereits registriert."),
|
||||||
|
)
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
email=payload.email,
|
email=payload.email,
|
||||||
full_name=payload.full_name,
|
full_name=payload.full_name,
|
||||||
@@ -358,4 +417,4 @@ def create_user(
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||||||
|
|
||||||
|
|
||||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin" and not current_user.is_admin:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ CSRF_COOKIE_NAME = "csrf_token"
|
|||||||
CSRF_FORM_FIELD = "csrf_token"
|
CSRF_FORM_FIELD = "csrf_token"
|
||||||
|
|
||||||
|
|
||||||
def ensure_csrf_cookie(request: Request) -> str:
|
def generate_csrf_token() -> str:
|
||||||
token = request.cookies.get(CSRF_COOKIE_NAME)
|
|
||||||
if token:
|
|
||||||
return token
|
|
||||||
return secrets.token_urlsafe(32)
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_csrf_cookie(request: Request) -> str:
|
||||||
|
"""Liefert das CSRF-Token für die aktuelle Anfrage (Cookie oder request.state)."""
|
||||||
|
state_token = getattr(request.state, "csrf_token", None)
|
||||||
|
if state_token:
|
||||||
|
return state_token
|
||||||
|
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||||||
|
if cookie_token:
|
||||||
|
return cookie_token
|
||||||
|
return generate_csrf_token()
|
||||||
|
|
||||||
|
|
||||||
def validate_csrf(request: Request, form_token: str) -> None:
|
def validate_csrf(request: Request, form_token: str) -> None:
|
||||||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||||||
if not cookie_token or not form_token or not secrets.compare_digest(cookie_token, form_token):
|
if not cookie_token or not form_token or not secrets.compare_digest(cookie_token, form_token):
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Neuen Benutzer anlegen</h3>
|
<h3>Neuen Benutzer anlegen</h3>
|
||||||
</div>
|
</div>
|
||||||
|
{% if error_message %}
|
||||||
|
<p class="error">{{ error_message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if success_message %}
|
||||||
|
<p class="hint" style="color:var(--tk-success);font-weight:600;">{{ success_message }}</p>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/admin/users" class="form-grid">
|
<form method="post" action="/admin/users" class="form-grid">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -25,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Passwort
|
<label>Passwort
|
||||||
<input type="password" name="password" required minlength="10">
|
<input type="password" name="password" required minlength="10" placeholder="Mindestens 10 Zeichen">
|
||||||
</label>
|
</label>
|
||||||
<label>Rolle
|
<label>Rolle
|
||||||
<select name="role">
|
<select name="role">
|
||||||
|
|||||||
Reference in New Issue
Block a user