Files
site/apps/api/app/modules/auth/service.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

241 lines
8.2 KiB
Python

from __future__ import annotations
from datetime import timedelta
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,
generate_refresh_token,
hash_opaque_token,
hash_password,
hash_refresh_token,
verify_password,
)
from app.modules.auth import repository as auth_repository
from app.modules.users import repository
from app.modules.users.models import User
def _token_expires_at():
return utc_now() + timedelta(hours=settings.auth_token_ttl_hours)
def _send_verification_email(user: User, token: str) -> None:
verify_url = f"{settings.frontend_url}/verify?token={token}"
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
token_line = f"TOKEN:{token}\n" if show_token else ""
body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line
send_template_email(
to=user.email,
template="verify_email",
subject="Confirm your Compton account",
body=body,
)
def _send_password_reset_email(user: User, token: str) -> None:
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
token_line = f"TOKEN:{token}\n" if show_token else ""
body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line
send_template_email(
to=user.email,
template="reset_password",
subject="Reset your Compton password",
body=body,
)
def _issue_verification_token(user_id: str) -> str:
token = generate_opaque_token()
auth_repository.create_email_verification_token(
user_id=user_id,
token_hash=hash_opaque_token(token),
expires_at=_token_expires_at(),
)
return token
def _issue_password_reset_token(user_id: str) -> str:
token = generate_opaque_token()
auth_repository.create_password_reset_token(
user_id=user_id,
token_hash=hash_opaque_token(token),
expires_at=_token_expires_at(),
)
return token
def register(email: str, password: str) -> User:
existing = repository.get_user_by_email(email)
if existing:
if existing.status == "pending":
token = _issue_verification_token(existing.id)
_send_verification_email(existing, token)
return existing
user = repository.create_user(email=email, password_hash=hash_password(password), status="pending")
token = _issue_verification_token(user.id)
_send_verification_email(user, token)
return user
def verify_email_token(token: str) -> User:
token_hash = hash_opaque_token(token)
token_row = auth_repository.get_email_verification_token(token_hash)
if not token_row or token_row.used_at is not None:
raise ValueError("INVALID_TOKEN")
if ensure_utc(token_row.expires_at) < utc_now():
raise ValueError("TOKEN_EXPIRED")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise ValueError("INVALID_TOKEN")
user.status = "active"
user.email_verified_at = utc_now()
repository.update_user(user)
auth_repository.mark_email_verification_token_used(token_hash)
return user
def resend_verification(email: str) -> None:
user = repository.get_user_by_email(email)
if not user or user.status != "pending":
return
token = _issue_verification_token(user.id)
_send_verification_email(user, token)
def forgot_password(email: str) -> None:
user = repository.get_user_by_email(email)
if not user or user.status == "blocked":
return
token = _issue_password_reset_token(user.id)
_send_password_reset_email(user, token)
def reset_password(token: str, new_password: str) -> None:
token_hash = hash_opaque_token(token)
token_row = auth_repository.get_password_reset_token(token_hash)
if not token_row or token_row.used_at is not None:
raise ValueError("INVALID_TOKEN")
if ensure_utc(token_row.expires_at) < utc_now():
raise ValueError("TOKEN_EXPIRED")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise ValueError("INVALID_TOKEN")
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)
def _is_locked(user: User) -> bool:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until > utc_now():
return True
if locked_until and locked_until <= utc_now():
user.failed_login_attempts = 0
user.locked_until = None
repository.update_user(user)
return False
def _record_failed_login(user: User) -> None:
locked_until = ensure_utc(user.locked_until)
if locked_until and locked_until <= utc_now():
user.failed_login_attempts = 0
user.locked_until = None
user.failed_login_attempts += 1
if user.failed_login_attempts >= settings.auth_lockout_attempts:
user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes)
repository.update_user(user)
def _reset_login_attempts(user: User) -> None:
user.failed_login_attempts = 0
user.locked_until = None
repository.update_user(user)
def login(email: str, password: str) -> tuple[str, str, User]:
user = repository.get_user_by_email(email)
if user and _is_locked(user):
raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED")
if not user or not verify_password(password, user.password_hash):
if user:
_record_failed_login(user)
raise ValueError("INVALID_CREDENTIALS")
if user.status == "pending":
raise PermissionError("EMAIL_NOT_VERIFIED")
if user.status == "blocked":
raise PermissionError("ACCOUNT_BLOCKED")
_reset_login_attempts(user)
access_token = create_access_token(user.id, user.role, user.is_superuser)
refresh_token = issue_refresh_token(user.id)
return access_token, refresh_token, user
def issue_refresh_token(user_id: str, family_id: str | None = None) -> str:
token = generate_refresh_token()
token_hash = hash_refresh_token(token)
family = family_id or str(uuid4())
auth_repository.create_refresh_token(
user_id=user_id,
token_hash=token_hash,
family_id=family,
expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days),
)
return token
def refresh(refresh_token: str) -> tuple[str, str, User]:
token_hash = hash_refresh_token(refresh_token)
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")
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)
return access, new_refresh, user
def revoke_refresh_token(refresh_token: str) -> None:
token_hash = hash_refresh_token(refresh_token)
token_row = auth_repository.revoke_refresh_token(token_hash)
if token_row:
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)