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
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:
181
src/apps/user/sso_views.py
Normal file
181
src/apps/user/sso_views.py
Normal 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
|
||||
Reference in New Issue
Block a user