Шхуна не тонет: 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")
|
||||
+15
-14
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user