feat(auth): add IAS ECP SSO
Some checks failed
State Corp Backend CI/CD / Quality gate (push) Failing after 1m46s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Has been skipped
State Corp Backend CI/CD / Release dev (push) Has been skipped
State Corp Backend CI/CD / Refresh and release internal main (push) Has been skipped
State Corp Backend CI/CD / Release customer main (push) Has been skipped

This commit is contained in:
Aleksandr Meshchryakov
2026-09-21 16:29:31 +02:00
parent 78412f9e3b
commit 528307e452
16 changed files with 888 additions and 15 deletions

View File

@@ -0,0 +1,64 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("user", "0008_auto_20260328_1630"),
]
operations = [
migrations.CreateModel(
name="SsoIdentity",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("issuer", models.CharField(max_length=64, verbose_name="issuer")),
(
"subject",
models.CharField(max_length=255, verbose_name="external user id"),
),
(
"last_authenticated_at",
models.DateTimeField(verbose_name="last authenticated at"),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="sso_identity",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "SSO identity",
"verbose_name_plural": "SSO identities",
"db_table": "user_sso_identities",
},
),
migrations.AddConstraint(
model_name="ssoidentity",
constraint=models.UniqueConstraint(
fields=("issuer", "subject"),
name="user_sso_identity_issuer_subject_unique",
),
),
]

View File

@@ -109,3 +109,33 @@ class Profile(models.Model):
parts = [self.first_name, self.mid_name, self.last_name]
full_name = " ".join(part for part in parts if part)
return full_name or self.user.username
class SsoIdentity(models.Model):
"""Неизменяемая связь локального пользователя с учётной записью ИАС ЕЦП."""
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="sso_identity",
verbose_name=_("user"),
)
issuer = models.CharField(_("issuer"), max_length=64)
subject = models.CharField(_("external user id"), max_length=255)
last_authenticated_at = models.DateTimeField(_("last authenticated at"))
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
db_table = "user_sso_identities"
verbose_name = _("SSO identity")
verbose_name_plural = _("SSO identities")
constraints = [
models.UniqueConstraint(
fields=("issuer", "subject"),
name="user_sso_identity_issuer_subject_unique",
)
]
def __str__(self):
return f"{self.issuer}:{self.subject}"

View 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

181
src/apps/user/sso_views.py Normal file
View File

@@ -0,0 +1,181 @@
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from django.conf import settings
from django.contrib.auth import login
from django.db import IntegrityError
from django.http import HttpResponse
from django.middleware.csrf import get_token
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.parsers import FormParser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from .sso_services import (
SsoAuthenticationError,
provision_sso_user,
reserve_sso_token,
verify_sso_token,
)
def validate_local_redirect_path(value: str | None) -> str:
"""Возвращает безопасный локальный путь для завершения SSO-перехода."""
redirect_path = value or "/main"
parsed = urlsplit(redirect_path)
has_control_character = any(
ord(character) < 32 or ord(character) == 127 for character in redirect_path
)
if (
not redirect_path.startswith("/")
or redirect_path.startswith("//")
or "\\" in redirect_path
or has_control_character
or parsed.scheme
or parsed.netloc
):
raise ValueError("redirect_path должен быть относительным локальным путём")
return redirect_path
def build_sso_provider_url(redirect_path: str) -> str:
"""Добавляет код сервиса и обратный путь к URL провайдера SSO."""
provider_url = urlsplit(settings.SSO_PROVIDER_URL)
query = dict(parse_qsl(provider_url.query, keep_blank_values=True))
query.update({"target": settings.SSO_SERVICE_CODE, "path": redirect_path})
return urlunsplit(
(
provider_url.scheme,
provider_url.netloc,
provider_url.path,
urlencode(query),
provider_url.fragment,
)
)
class SsoStartView(APIView):
"""Перенаправляет браузер на вход ИАС ЕЦП."""
authentication_classes = []
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=["Аутентификация"],
operation_summary="Начать вход через ИАС ЕЦП",
manual_parameters=[
openapi.Parameter(
"redirect_path",
openapi.IN_QUERY,
description="Относительный локальный путь возврата",
type=openapi.TYPE_STRING,
required=False,
)
],
responses={307: "Переход к провайдеру SSO", 400: "Некорректный путь", 503: "SSO отключён"},
)
def get(self, request):
if not settings.SSO_ENABLED:
return Response(
{"detail": "SSO отключён."},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
try:
redirect_path = validate_local_redirect_path(
request.query_params.get("redirect_path")
)
except ValueError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
response = HttpResponse(status=307)
response["Location"] = build_sso_provider_url(redirect_path)
return response
class SsoCallbackView(APIView):
"""Принимает одноразовый SSO JWT и создаёт локальную Django-сессию."""
authentication_classes = []
parser_classes = [FormParser]
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=["Аутентификация"],
operation_summary="Завершить вход через ИАС ЕЦП",
manual_parameters=[
openapi.Parameter(
"sso_token",
openapi.IN_FORM,
description="Одноразовый ES256 SSO JWT",
type=openapi.TYPE_STRING,
required=True,
),
openapi.Parameter(
"redirect_path",
openapi.IN_FORM,
description="Относительный локальный путь возврата",
type=openapi.TYPE_STRING,
required=False,
),
],
responses={
303: "SSO-сессия создана",
400: "Некорректная форма или путь",
403: "SSO-аутентификация отклонена",
503: "SSO отключён",
},
)
def post(self, request):
if not settings.SSO_ENABLED:
return Response(
{"detail": "SSO отключён."},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if request.content_type != "application/x-www-form-urlencoded":
return Response(
{"detail": "Ожидается application/x-www-form-urlencoded."},
status=status.HTTP_400_BAD_REQUEST,
)
encoded_token = request.data.get("sso_token")
if not isinstance(encoded_token, str) or not encoded_token:
return Response(
{"detail": "sso_token обязателен."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
redirect_path = validate_local_redirect_path(
request.data.get("redirect_path")
)
except ValueError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
try:
claims = verify_sso_token(encoded_token)
reserve_sso_token(claims)
user = provision_sso_user(claims)
except (IntegrityError, SsoAuthenticationError):
return Response(
{"detail": "SSO-аутентификация отклонена."},
status=status.HTTP_403_FORBIDDEN,
)
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
request.session.set_expiry(settings.SSO_SESSION_TTL_SECONDS)
get_token(request)
response = HttpResponse(status=303)
response["Location"] = redirect_path
return response

View File

@@ -2,7 +2,7 @@ from urllib.parse import urlencode
from apps.core.models import BackgroundJob
from apps.core.services import BackgroundJobService
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth import authenticate, get_user_model, logout
from django.contrib.auth.hashers import check_password
from django.core.paginator import Paginator
from django.db.models import F
@@ -169,7 +169,7 @@ class LogoutView(APIView):
"""
Выход пользователя.
Логаут на JWT означает удаление токенов на клиенте.
Завершает Django-сессию. JWT по-прежнему удаляются на клиенте.
"""
permission_classes = [IsAuthenticated]
@@ -177,12 +177,15 @@ class LogoutView(APIView):
@swagger_auto_schema(
tags=[AUTH_TAG],
operation_summary="Выход",
operation_description="Выход из системы (удаление токенов на клиенте).",
operation_description=(
"Завершение Django-сессии; JWT удаляются на клиенте."
),
responses={200: "Успешный выход"},
)
def post(self, request):
# Для JWT логаут означает удаление токенов на клиенте.
# Сервер не хранит сессию и ничего не инвалидирует.
# Для JWT это сохраняет прежнее поведение: токены удаляются клиентом.
# Для SSO серверная сессия должна быть явно завершена.
logout(request)
return Response({"message": "Успешный выход"}, status=status.HTTP_200_OK)