Шхуна не тонет: 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")
+15 -14
View File
@@ -50,20 +50,21 @@ def run_seed(include_demo_pages: bool = True) -> None:
is_superuser=True,
status="active",
)
_ensure_user(
email="user@compton.example",
password=settings.demo_user_password,
role="user",
is_superuser=False,
status="active",
)
_ensure_user(
email="ops@compton.example",
password=settings.demo_ops_password,
role="admin",
is_superuser=False,
status="active",
)
if settings.seed_demo_users and settings.app_env.lower() != "production":
_ensure_user(
email="user@compton.example",
password=settings.demo_user_password,
role="user",
is_superuser=False,
status="active",
)
_ensure_user(
email="ops@compton.example",
password=settings.demo_ops_password,
role="admin",
is_superuser=False,
status="active",
)
if not include_demo_pages:
return
+2
View File
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings
from app.core.jwt_denylist import ensure_jwt_revocation_backend
from app.core.app_settings import bootstrap_settings
from app.core.storage import ensure_bucket
from app.core import database as db_module
@@ -50,6 +51,7 @@ def create_app() -> FastAPI:
async def lifespan(_: FastAPI):
ensure_install_secrets()
bootstrap_settings()
ensure_jwt_revocation_backend()
_assert_production_guards()
if settings.enable_test_routes:
Base.metadata.create_all(db_module.engine)
+3
View File
@@ -4,6 +4,7 @@ from app.core.app_settings import apply_settings_to_app, get_settings_payload, w
from app.core.audit_log import read_audit_events, write_audit_event
from app.core.config import settings
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
from app.core.jwt_denylist import bump_auth_epoch
from app.core.security import hash_password
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
from app.modules.auth.service import revoke_user_refresh_family
@@ -63,6 +64,7 @@ def patch_user(
if status:
target.status = status
if status == "blocked":
bump_auth_epoch(target.id)
revoke_user_refresh_family(target.id)
repository.update_user(target)
write_audit_event(
@@ -111,6 +113,7 @@ def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
raise ValueError("USER_NOT_FOUND")
target.password_hash = hash_password(password)
repository.update_user(target)
bump_auth_epoch(target.id)
revoke_user_refresh_family(target.id)
write_audit_event(
action="admin.user.reset_password",
+18 -7
View File
@@ -1,7 +1,8 @@
from app.core.datetime_utils import ensure_utc, utc_now
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from app.core.datetime_utils import ensure_utc, utc_now
from app.core.redis import check_rate_limit, client_ip
from app.modules.auth.schemas import (
ForgotPasswordIn,
@@ -15,16 +16,17 @@ from app.modules.auth.schemas import (
from app.modules.auth.service import (
forgot_password,
login,
logout,
refresh,
register,
resend_verification,
reset_password,
revoke_refresh_token,
verify_email_token,
)
from app.modules.users import repository
router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False)
def _allowed_origins() -> set[str]:
@@ -139,7 +141,12 @@ async def refresh_route(request: Request, response: Response):
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
try:
access_token, new_refresh, user = refresh(refresh_token)
except PermissionError:
except PermissionError as exc:
detail = str(exc)
if detail == "EMAIL_NOT_VERIFIED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
if detail == "ACCOUNT_BLOCKED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
_set_refresh_cookie(response, new_refresh)
return {
@@ -156,11 +163,15 @@ async def refresh_route(request: Request, response: Response):
@router.post("/logout")
async def logout_route(request: Request, response: Response):
async def logout_route(
request: Request,
response: Response,
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
):
_enforce_origin(request, require_header=True)
refresh_token = request.cookies.get("refresh_token")
if refresh_token:
revoke_refresh_token(refresh_token)
access_token = credentials.credentials if credentials else None
logout(refresh_token, access_token)
response.delete_cookie(
"refresh_token",
path="/api/v1/auth",
+19 -7
View File
@@ -6,6 +6,7 @@ from uuid import uuid4
from app.core.config import settings
from app.core.datetime_utils import ensure_utc, utc_now
from app.core.email import send_template_email
from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token
from app.core.security import (
create_access_token,
generate_opaque_token,
@@ -111,7 +112,7 @@ def resend_verification(email: str) -> None:
def forgot_password(email: str) -> None:
user = repository.get_user_by_email(email)
if not user:
if not user or user.status == "blocked":
return
token = _issue_password_reset_token(user.id)
_send_password_reset_email(user, token)
@@ -132,6 +133,7 @@ def reset_password(token: str, new_password: str) -> None:
user.password_hash = hash_password(new_password)
repository.update_user(user)
auth_repository.mark_password_reset_token_used(token_hash)
bump_auth_epoch(user.id)
revoke_user_refresh_family(user.id)
@@ -199,18 +201,21 @@ def refresh(refresh_token: str) -> tuple[str, str, User]:
token_row = auth_repository.get_refresh_token(token_hash)
if not token_row:
raise PermissionError("INVALID_REFRESH")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise PermissionError("INVALID_REFRESH")
if user.status == "pending":
raise PermissionError("EMAIL_NOT_VERIFIED")
if user.status == "blocked":
raise PermissionError("ACCOUNT_BLOCKED")
if token_row.revoked_at is not None:
auth_repository.revoke_family_tokens(token_row.family_id)
raise PermissionError("INVALID_REFRESH")
if ensure_utc(token_row.expires_at) < utc_now():
raise PermissionError("EXPIRED_REFRESH")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise PermissionError("INVALID_REFRESH")
if user.status in {"pending", "blocked"}:
raise PermissionError("INVALID_REFRESH")
auth_repository.revoke_refresh_token(token_hash)
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
access = create_access_token(user.id, user.role, user.is_superuser)
@@ -224,5 +229,12 @@ def revoke_refresh_token(refresh_token: str) -> None:
auth_repository.revoke_family_tokens(token_row.family_id)
def logout(refresh_token: str | None, access_token: str | None) -> None:
if access_token:
revoke_access_token(access_token)
if refresh_token:
revoke_refresh_token(refresh_token)
def revoke_user_refresh_family(user_id: str) -> None:
auth_repository.revoke_user_families(user_id)
+2
View File
@@ -1,3 +1,4 @@
from app.core.jwt_denylist import bump_auth_epoch
from app.core.security import hash_password, verify_password
from app.modules.auth.service import revoke_user_refresh_family
from app.modules.media.service import upload_user_avatar
@@ -26,4 +27,5 @@ def change_password(user: User, current_password: str, new_password: str) -> Non
raise ValueError("INVALID_CURRENT_PASSWORD")
user.password_hash = hash_password(new_password)
repository.update_user(user)
bump_auth_epoch(user.id)
revoke_user_refresh_family(user.id)