Files
state-corp-backend/src/apps/user/sso_views.py
Aleksandr Meshchryakov dde684c88d
Some checks failed
State Corp Backend CI/CD / Quality gate (push) Successful in 5m14s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Failing after 5m48s
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
style(auth): format SSO implementation
2026-09-21 16:45:19 +02:00

186 lines
6.4 KiB
Python

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