Files
state-corp-backend/src/apps/user/sso_services.py
2026-09-21 16:44:44 +02:00

226 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from jwt import InvalidTokenError
from .models import SsoIdentity
User = get_user_model()
class SsoAuthenticationError(Exception):
"""Безопасная ошибка проверки или связывания SSO-пользователя."""
@dataclass(frozen=True)
class SsoClaims:
issuer: str
subject: str
username: str
email: str
first_name: str
middle_name: str
last_name: str
jti: str
expires_at: int
def _load_public_key():
key_path = Path(settings.SSO_PUBLIC_KEY_PATH)
try:
key_data = key_path.read_bytes()
public_key = serialization.load_pem_public_key(key_data)
except (OSError, ValueError, TypeError) as exc:
raise SsoAuthenticationError("Публичный ключ SSO недоступен.") from exc
if not isinstance(public_key, ec.EllipticCurvePublicKey) or not isinstance(
public_key.curve, ec.SECP256R1
):
raise SsoAuthenticationError("Публичный ключ SSO должен использовать P-256.")
return public_key
def _decode_sso_payload(encoded_token: str) -> dict:
try:
header = jwt.get_unverified_header(encoded_token)
if header.get("alg") != "ES256":
raise SsoAuthenticationError("Недопустимый алгоритм SSO-токена.")
return jwt.decode(
encoded_token,
_load_public_key(),
algorithms=["ES256"],
issuer=settings.SSO_ISSUER,
leeway=settings.SSO_CLOCK_SKEW_SECONDS,
options={
"require": [
"iss",
"exp",
"iat",
"jti",
"token_type",
"sso_target",
"user_id",
"username",
"email",
]
},
)
except SsoAuthenticationError:
raise
except (InvalidTokenError, ValueError, TypeError) as exc:
raise SsoAuthenticationError("SSO-токен не прошёл проверку.") from exc
def _validate_token_lifetime(payload: dict) -> tuple[int, int]:
issued_at = payload.get("iat")
expires_at = payload.get("exp")
if (
not isinstance(issued_at, int)
or isinstance(issued_at, bool)
or not isinstance(expires_at, int)
or isinstance(expires_at, bool)
):
raise SsoAuthenticationError("Некорректное время жизни SSO-токена.")
if expires_at <= issued_at or (
expires_at - issued_at > settings.SSO_TOKEN_MAX_AGE_SECONDS
):
raise SsoAuthenticationError("Недопустимое время жизни SSO-токена.")
return issued_at, expires_at
def verify_sso_token(encoded_token: str) -> SsoClaims:
"""Проверяет ES256 SSO JWT и возвращает только нужные локальному сервису claims."""
payload = _decode_sso_payload(encoded_token)
if payload.get("token_type") != "sso":
raise SsoAuthenticationError("Недопустимый тип SSO-токена.")
if payload.get("sso_target") != settings.SSO_SERVICE_CODE:
raise SsoAuthenticationError("SSO-токен предназначен другому сервису.")
_, expires_at = _validate_token_lifetime(payload)
username = payload.get("username")
email = payload.get("email")
jti = payload.get("jti")
subject = payload.get("user_id")
if not isinstance(username, str) or not username.strip():
raise SsoAuthenticationError("В SSO-токене отсутствует username.")
if not isinstance(email, str) or not email.strip():
raise SsoAuthenticationError("В SSO-токене отсутствует email.")
if not isinstance(jti, str) or not jti.strip():
raise SsoAuthenticationError("В SSO-токене отсутствует jti.")
if isinstance(subject, bool) or not isinstance(subject, int | str):
raise SsoAuthenticationError("В SSO-токене отсутствует user_id.")
if not str(subject).strip():
raise SsoAuthenticationError("В SSO-токене отсутствует user_id.")
try:
validate_email(email)
User._meta.get_field("username").run_validators(username)
except DjangoValidationError as exc:
raise SsoAuthenticationError(
"Некорректные данные пользователя в SSO-токене."
) from exc
return SsoClaims(
issuer=str(payload["iss"]),
subject=str(subject),
username=username,
email=email,
first_name=str(payload.get("first_name") or ""),
middle_name=str(payload.get("surname") or ""),
last_name=str(payload.get("last_name") or ""),
jti=jti,
expires_at=expires_at,
)
def reserve_sso_token(claims: SsoClaims) -> None:
"""Атомарно помечает одноразовый SSO-токен использованным."""
digest = hashlib.sha256(f"{claims.issuer}\0{claims.jti}".encode()).hexdigest()
cache_key = f"sso:jti:{digest}"
now = int(datetime.now(tz=UTC).timestamp())
timeout = max(1, claims.expires_at - now + settings.SSO_CLOCK_SKEW_SECONDS)
if not cache.add(cache_key, "used", timeout=timeout):
raise SsoAuthenticationError("SSO-токен уже использован.")
def _ensure_user_claims_available(claims: SsoClaims, *, exclude_user_id=None) -> None:
conflicts = User.objects.filter(
Q(username=claims.username) | Q(email__iexact=claims.email)
)
if exclude_user_id is not None:
conflicts = conflicts.exclude(id=exclude_user_id)
if conflicts.exists():
raise SsoAuthenticationError("Данные SSO-пользователя уже заняты.")
@transaction.atomic
def provision_sso_user(claims: SsoClaims):
"""Создаёт или обновляет локального пользователя по неизменяемой SSO identity."""
identity = (
SsoIdentity.objects.select_for_update()
.select_related("user", "user__profile")
.filter(issuer=claims.issuer, subject=claims.subject)
.first()
)
if identity is not None:
user = identity.user
if not user.is_active:
raise SsoAuthenticationError("Локальная учётная запись отключена.")
_ensure_user_claims_available(claims, exclude_user_id=user.id)
else:
username_user = User.objects.filter(username=claims.username).first()
email_users = list(User.objects.filter(email__iexact=claims.email)[:2])
if username_user is not None or email_users:
if (
username_user is None
or len(email_users) != 1
or email_users[0].id != username_user.id
):
raise SsoAuthenticationError(
"Неоднозначное совпадение локальной учётной записи."
)
user = username_user
if not user.is_active:
raise SsoAuthenticationError("Локальная учётная запись отключена.")
else:
user = User(username=claims.username, email=claims.email, is_verified=True)
user.set_unusable_password()
user.save()
identity = SsoIdentity.objects.create(
user=user,
issuer=claims.issuer,
subject=claims.subject,
last_authenticated_at=timezone.now(),
)
user.username = claims.username
user.email = claims.email
user.is_verified = True
user.save(update_fields=["username", "email", "is_verified", "updated_at"])
profile = user.profile
profile.first_name = claims.first_name
profile.mid_name = claims.middle_name
profile.last_name = claims.last_name
profile.save(update_fields=["first_name", "mid_name", "last_name", "updated_at"])
identity.last_authenticated_at = timezone.now()
identity.save(update_fields=["last_authenticated_at", "updated_at"])
return user