Шхуна не тонет: 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:
влад
2026-07-15 00:06:13 +03:00
parent 86cc3fa541
commit 12c983c0fc
66 changed files with 2377 additions and 520 deletions
+1
View File
@@ -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 = ""
+2
View File
@@ -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),
+7
View File
@@ -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"])
+2 -1
View File
@@ -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 = (
+108
View File
@@ -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")