Шхуна не тонет: security, infra и доки на русском.
Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт.
This commit is contained in:
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
||||
admin_initial_password: str = "Admin1234"
|
||||
demo_user_password: str = "User1234"
|
||||
demo_ops_password: str = "OpsAdmin1234"
|
||||
seed_demo_users: bool = True
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 1025
|
||||
smtp_user: str = ""
|
||||
|
||||
@@ -48,12 +48,14 @@ def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||
|
||||
def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str:
|
||||
from app.core.config import settings
|
||||
from app.core.jwt_denylist import get_auth_epoch
|
||||
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"is_superuser": is_superuser,
|
||||
"auth_epoch": get_auth_epoch(user_id),
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
||||
"jti": generate_secret_token_hex(16),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.jwt_denylist import validate_access_claims
|
||||
from app.core.security import decode_access_token
|
||||
from app.modules.users.repository import get_user_by_id
|
||||
|
||||
@@ -12,6 +13,12 @@ def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
validate_access_claims(payload)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_REVOKED":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
user = get_user_by_id(payload["sub"])
|
||||
|
||||
@@ -9,7 +9,8 @@ from uuid import uuid4
|
||||
|
||||
from app.core.crypto import generate_install_bundle
|
||||
|
||||
INSTALL_SECRETS_DIR = Path("data/secrets")
|
||||
_API_ROOT = Path(__file__).resolve().parents[2]
|
||||
INSTALL_SECRETS_DIR = _API_ROOT / "data" / "secrets"
|
||||
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
|
||||
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
||||
REQUIRED_KEYS = (
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_redis_client
|
||||
|
||||
AUTH_EPOCH_PREFIX = "auth:epoch:"
|
||||
JWT_DENY_PREFIX = "jwt:deny:"
|
||||
|
||||
_memory_epochs: dict[str, int] = {}
|
||||
_memory_denied_jti: dict[str, float] = {}
|
||||
|
||||
|
||||
def _purge_expired_memory_jtis() -> None:
|
||||
now = time.time()
|
||||
expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now]
|
||||
for jti in expired:
|
||||
_memory_denied_jti.pop(jti, None)
|
||||
|
||||
|
||||
def ensure_jwt_revocation_backend() -> None:
|
||||
if settings.app_env.lower() != "production":
|
||||
return
|
||||
if get_redis_client() is None:
|
||||
raise RuntimeError("Redis is required for JWT revocation in production")
|
||||
|
||||
|
||||
def get_auth_epoch(user_id: str) -> int:
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}")
|
||||
return int(value) if value is not None else 0
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
return _memory_epochs.get(user_id, 0)
|
||||
|
||||
|
||||
def bump_auth_epoch(user_id: str) -> int:
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}"))
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
next_epoch = _memory_epochs.get(user_id, 0) + 1
|
||||
_memory_epochs[user_id] = next_epoch
|
||||
return next_epoch
|
||||
|
||||
|
||||
def deny_jti(jti: str, exp: int) -> None:
|
||||
if not jti:
|
||||
return
|
||||
ttl = max(int(exp - time.time()), 1)
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1")
|
||||
return
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
raise
|
||||
_purge_expired_memory_jtis()
|
||||
_memory_denied_jti[jti] = time.time() + ttl
|
||||
|
||||
|
||||
def is_jti_denied(jti: str) -> bool:
|
||||
if not jti:
|
||||
return False
|
||||
client = get_redis_client()
|
||||
if client is not None:
|
||||
try:
|
||||
return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}"))
|
||||
except Exception:
|
||||
if settings.app_env.lower() == "production":
|
||||
return True
|
||||
_purge_expired_memory_jtis()
|
||||
return jti in _memory_denied_jti
|
||||
|
||||
|
||||
def revoke_access_token(token: str) -> None:
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except Exception:
|
||||
return
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp:
|
||||
deny_jti(str(jti), int(exp))
|
||||
|
||||
|
||||
def validate_access_claims(payload: dict) -> None:
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
jti = payload.get("jti")
|
||||
if jti and is_jti_denied(str(jti)):
|
||||
raise ValueError("TOKEN_REVOKED")
|
||||
token_epoch = int(payload.get("auth_epoch", 0))
|
||||
if token_epoch != get_auth_epoch(str(user_id)):
|
||||
raise ValueError("TOKEN_REVOKED")
|
||||
Reference in New Issue
Block a user