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:
@@ -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()
|
||||
|
||||
16
app/core/email_utils.py
Normal file
16
app/core/email_utils.py
Normal file
@@ -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
|
||||
18
app/core/password_policy.py
Normal file
18
app/core/password_policy.py
Normal file
@@ -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])
|
||||
35
app/core/rate_limit.py
Normal file
35
app/core/rate_limit.py
Normal file
@@ -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)
|
||||
28
app/core/secrets_crypto.py
Normal file
28
app/core/secrets_crypto.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
34
app/core/startup_checks.py
Normal file
34
app/core/startup_checks.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user