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
186 lines
6.4 KiB
Python
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
|