feat(auth): add IAS ECP SSO [skip ci]
This commit was merged in pull request #19.
This commit is contained in:
221
src/apps/user/sso_services.py
Normal file
221
src/apps/user/sso_services.py
Normal file
@@ -0,0 +1,221 @@
|
||||
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
|
||||
Reference in New Issue
Block a user