Files
site/apps/api/app/core/crypto.py
T
влад 12c983c0fc Шхуна не тонет: 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

Код готов к плаванию. Капитан может идти писать фронт.
2026-07-15 00:06:13 +03:00

120 lines
3.7 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode
import bcrypt
from jose import jwt
def generate_secret_token_urlsafe(length: int = 32) -> str:
return secrets.token_urlsafe(length)
def generate_secret_token_hex(length: int = 32) -> str:
return secrets.token_hex(length)
def generate_install_bundle() -> dict[str, str]:
postgres_user = "compton_app"
postgres_password = generate_secret_token_urlsafe(32)
postgres_db = "compton"
minio_root_user = "minio"
minio_root_password = generate_secret_token_urlsafe(32)
return {
"POSTGRES_USER": postgres_user,
"POSTGRES_PASSWORD": postgres_password,
"POSTGRES_DB": postgres_db,
"DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}",
"JWT_ACCESS_SECRET": generate_secret_token_hex(32),
"JWT_REFRESH_PEPPER": generate_secret_token_hex(32),
"S3_ACCESS_KEY": minio_root_user,
"S3_SECRET_KEY": minio_root_password,
"MINIO_ROOT_USER": minio_root_user,
"MINIO_ROOT_PASSWORD": minio_root_password,
}
def hash_password(raw_password: str) -> str:
return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def verify_password(raw_password: str, password_hash: str) -> bool:
return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8"))
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),
}
return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256")
def decode_access_token(token: str) -> dict:
from app.core.config import settings
return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"])
def generate_refresh_token() -> str:
return generate_secret_token_urlsafe(48)
def generate_opaque_token() -> str:
return generate_secret_token_urlsafe(32)
def hash_opaque_token(token: str) -> str:
return hash_refresh_token(token)
def hash_refresh_token(token: str) -> str:
from app.core.config import settings
return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest()
def build_signed_media_url(stored_url: str | None) -> str | None:
from app.core.config import settings
if not stored_url:
return None
if not stored_url.startswith("/api/v1/media/files/"):
return stored_url
path = stored_url.removeprefix("/api/v1/media/files/")
expires = int(time.time()) + settings.media_url_ttl_seconds
signature = _sign_media_path(path, expires)
query = urlencode({"expires": expires, "sig": signature})
return f"/api/v1/media/files/{path}?{query}"
def verify_signed_media(path: str, expires: int, signature: str) -> bool:
if expires < int(time.time()):
return False
expected = _sign_media_path(path, expires)
return hmac.compare_digest(expected, signature)
def _sign_media_path(path: str, expires: int) -> str:
from app.core.config import settings
payload = f"{path}:{expires}"
return hmac.new(
settings.jwt_access_secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()