From cae8e97146786c7b2d957824b0ca35d1cdf8fd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 4 Sep 2026 09:50:56 +0300 Subject: [PATCH] Add corporate OpenID Connect provider and browser sessions --- .env.example | 14 + README.ru.md | 1 + backend/api/auth/endpoints/__init__.py | 3 + .../api/auth/endpoints/browser_sessions.py | 83 +++++ backend/api/auth/serializers.py | 13 + backend/api/auth/urls.py | 12 + backend/api/auth/views.py | 4 + backend/api/management/__init__.py | 1 + backend/api/management/commands/__init__.py | 1 + .../commands/provision_oidc_client.py | 109 ++++++ backend/api/oidc/__init__.py | 1 + backend/api/oidc/urls.py | 35 ++ backend/api/oidc/validators.py | 91 +++++ backend/api/oidc/views.py | 66 ++++ backend/eusrr_backend/middleware.py | 2 + backend/eusrr_backend/settings.py | 95 +++++- backend/eusrr_backend/urls.py | 1 + backend/requirements.txt | 3 + .../templates/oauth2_provider/authorize.html | 46 +++ backend/tests/api/auth/test_oidc_provider.py | 310 ++++++++++++++++++ docs/guides/OIDC.md | 152 +++++++++ docs/guides/README.md | 1 + frontend/src/app/login/page.tsx | 50 ++- frontend/src/contexts/UserContext.tsx | 1 + frontend/src/lib/api/client.ts | 19 ++ 25 files changed, 1108 insertions(+), 6 deletions(-) create mode 100644 backend/api/auth/endpoints/browser_sessions.py create mode 100644 backend/api/management/__init__.py create mode 100644 backend/api/management/commands/__init__.py create mode 100644 backend/api/management/commands/provision_oidc_client.py create mode 100644 backend/api/oidc/__init__.py create mode 100644 backend/api/oidc/urls.py create mode 100644 backend/api/oidc/validators.py create mode 100644 backend/api/oidc/views.py create mode 100644 backend/templates/oauth2_provider/authorize.html create mode 100644 backend/tests/api/auth/test_oidc_provider.py create mode 100644 docs/guides/OIDC.md diff --git a/.env.example b/.env.example index 700c7c23..264a0c7c 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,20 @@ LDAP_WRITE_ENABLED=False # Other Settings SITE_URL=http://localhost:8000 +PORTAL_FRONTEND_URL=http://localhost:3000 + +# Corporate OAuth2 / OpenID Connect provider +# Generate a dedicated RSA key and mount it outside the repository. +OIDC_PROVIDER_ENABLED=false +OIDC_RSA_PRIVATE_KEY_PATH=/run/secrets/eusrr_oidc_private_key.pem +# Comma-separated old signing keys during a planned rotation. +# OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE=/run/secrets/eusrr_oidc_private_key_old.pem +OIDC_ISSUER_URL=http://localhost:8000/o +OIDC_PORTAL_LOGIN_URL=http://localhost:3000/login +OIDC_BROWSER_SESSION_SECONDS=28800 +OIDC_ACCESS_TOKEN_SECONDS=3600 +OIDC_ID_TOKEN_SECONDS=600 +OIDC_REFRESH_TOKEN_SECONDS=43200 # Web Push (generate a dedicated VAPID key pair for each environment) # VAPID_PUBLIC_KEY=change-me-vapid-public-key diff --git a/README.ru.md b/README.ru.md index a5084437..56f282b2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -32,6 +32,7 @@ EUSRR — внутренний корпоративный портал для с - [Индекс документации](docs/README.md) - [HTTP API](docs/guides/API.md) - [LDAP и Active Directory](docs/guides/LDAP.md) +- [Корпоративный OpenID Connect](docs/guides/OIDC.md) - [WebSocket и realtime](docs/guides/WEBSOCKET.md) - [Уведомления](docs/guides/NOTIFICATIONS.md) diff --git a/backend/api/auth/endpoints/__init__.py b/backend/api/auth/endpoints/__init__.py index 6d79e29b..37f725b1 100644 --- a/backend/api/auth/endpoints/__init__.py +++ b/backend/api/auth/endpoints/__init__.py @@ -1,3 +1,4 @@ +from .browser_sessions import BrowserSessionAPIView, BrowserSessionLogoutAPIView from .email_verification import ResendEmailAPIView, VerifyEmailAPIView from .passwords import ( ChangePasswordAPIView, @@ -24,6 +25,8 @@ from .tokens import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView from .websocket import WebSocketTicketAPIView __all__ = [ + "BrowserSessionAPIView", + "BrowserSessionLogoutAPIView", "ChangePasswordAPIView", "JWTTokenRefreshView", "LogoutOtherSessionsAPIView", diff --git a/backend/api/auth/endpoints/browser_sessions.py b/backend/api/auth/endpoints/browser_sessions.py new file mode 100644 index 00000000..1319802d --- /dev/null +++ b/backend/api/auth/endpoints/browser_sessions.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from urllib.parse import urlsplit + +from django.conf import settings +from django.contrib.auth import login as django_login +from django.contrib.auth import logout as django_logout +from django.urls import reverse +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..authentication import SessionAwareJWTAuthentication +from ..serializers import ( + BrowserSessionRequestSerializer, + BrowserSessionResponseSerializer, +) + +SESSION_BACKEND = "django.contrib.auth.backends.ModelBackend" + + +def validate_oidc_return_url(value: str) -> str: + """Allow redirects only back to this portal's authorization endpoint.""" + + candidate = (value or "").strip() + if not candidate: + return "/" + + parsed = urlsplit(candidate) + expected_path = reverse("oauth2_provider:authorize") + if parsed.path.rstrip("/") != expected_path.rstrip("/") or parsed.fragment: + raise serializers.ValidationError("invalid_oidc_return_url") + + if not parsed.scheme and not parsed.netloc: + return candidate + + issuer = urlsplit(settings.OIDC_ISSUER_URL) + if (parsed.scheme, parsed.netloc) != (issuer.scheme, issuer.netloc): + raise serializers.ValidationError("invalid_oidc_return_url") + return candidate + + +class BrowserSessionAPIView(APIView): + """Turn an existing portal JWT into a short-lived server browser session.""" + + authentication_classes = [SessionAwareJWTAuthentication] + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Auth"], + summary="Создать браузерную сессию для корпоративного SSO", + request=BrowserSessionRequestSerializer, + responses={200: BrowserSessionResponseSerializer}, + ) + def post(self, request): + if not settings.OIDC_PROVIDER_ENABLED: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = BrowserSessionRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + redirect_url = validate_oidc_return_url( + serializer.validated_data.get("next", "") + ) + + django_login(request._request, request.user, backend=SESSION_BACKEND) + request._request.session.set_expiry(settings.OIDC_BROWSER_SESSION_SECONDS) + return Response({"redirect_url": redirect_url}, status=status.HTTP_200_OK) + + +class BrowserSessionLogoutAPIView(APIView): + authentication_classes = [SessionAwareJWTAuthentication] + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Auth"], + summary="Завершить браузерную SSO-сессию", + responses={204: OpenApiResponse(description="Сессия завершена.")}, + ) + def post(self, request): + django_logout(request._request) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 937d65a6..739f5090 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -101,6 +101,19 @@ class SessionBulkActionResponseSerializer(serializers.Serializer): revoked = serializers.IntegerField(read_only=True) +class BrowserSessionRequestSerializer(serializers.Serializer): + next = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=True, + max_length=4096, + ) + + +class BrowserSessionResponseSerializer(serializers.Serializer): + redirect_url = serializers.CharField(read_only=True) + + class ChangePasswordRequestSerializer(serializers.Serializer): current_password = serializers.CharField( write_only=True, diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py index 5e70219e..5d7ec316 100644 --- a/backend/api/auth/urls.py +++ b/backend/api/auth/urls.py @@ -1,6 +1,8 @@ from django.urls import path from .views import ( + BrowserSessionAPIView, + BrowserSessionLogoutAPIView, ChangePasswordAPIView, JWTTokenRefreshView, LogoutOtherSessionsAPIView, @@ -24,6 +26,16 @@ from .views import ( ) urlpatterns = [ + path( + "browser-session/", + BrowserSessionAPIView.as_view(), + name="browser-session", + ), + path( + "browser-session/logout/", + BrowserSessionLogoutAPIView.as_view(), + name="browser-session-logout", + ), path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"), path("ws-ticket/", WebSocketTicketAPIView.as_view(), name="ws-ticket"), diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index f2bee7b9..32ac79b4 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -4,6 +4,8 @@ from django.contrib.auth import authenticate from .base import AnonymousAPIView, SessionBaseAPIView from .endpoints import ( + BrowserSessionAPIView, + BrowserSessionLogoutAPIView, ChangePasswordAPIView, JWTTokenRefreshView, LogoutOtherSessionsAPIView, @@ -28,6 +30,8 @@ from .endpoints import ( __all__ = [ "AnonymousAPIView", + "BrowserSessionAPIView", + "BrowserSessionLogoutAPIView", "ChangePasswordAPIView", "JWTTokenRefreshView", "LogoutOtherSessionsAPIView", diff --git a/backend/api/management/__init__.py b/backend/api/management/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/api/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/management/commands/__init__.py b/backend/api/management/commands/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/api/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/management/commands/provision_oidc_client.py b/backend/api/management/commands/provision_oidc_client.py new file mode 100644 index 00000000..f48a65c3 --- /dev/null +++ b/backend/api/management/commands/provision_oidc_client.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import secrets + +from django.core.management.base import BaseCommand, CommandError +from oauth2_provider.models import get_application_model + + +class Command(BaseCommand): + help = "Create or update a trusted corporate OpenID Connect client." + + def add_arguments(self, parser): + parser.add_argument( + "--name", + required=True, + help="Human-readable application name", + ) + parser.add_argument( + "--redirect-uri", + action="append", + dest="redirect_uris", + required=True, + help="Exact HTTPS callback URL; repeat for multiple callbacks", + ) + parser.add_argument( + "--client-id", + help="Stable client ID. Generated for a new client when omitted.", + ) + parser.add_argument( + "--rotate-secret", + action="store_true", + help=( + "Replace the secret of an existing client and print the new " + "value once." + ), + ) + parser.add_argument( + "--require-consent", + action="store_true", + help=( + "Show a consent screen instead of trusting this corporate " + "client automatically." + ), + ) + + def handle(self, *args, **options): + Application = get_application_model() + client_id = (options.get("client_id") or "").strip() + name = options["name"].strip() + redirect_uris = [uri.strip() for uri in options["redirect_uris"] if uri.strip()] + + if not name: + raise CommandError("--name must not be empty") + if not redirect_uris: + raise CommandError("At least one non-empty --redirect-uri is required") + + application = None + if client_id: + application = Application.objects.filter(client_id=client_id).first() + if application is None: + application = Application.objects.filter(name=name).first() + + created = application is None + if created: + raw_secret = secrets.token_urlsafe(48) + create_kwargs = { + "name": name, + "redirect_uris": " ".join(redirect_uris), + "client_type": Application.CLIENT_CONFIDENTIAL, + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": Application.RS256_ALGORITHM, + "skip_authorization": not options["require_consent"], + "client_secret": raw_secret, + "hash_client_secret": True, + } + if client_id: + create_kwargs["client_id"] = client_id + application = Application(**create_kwargs) + else: + raw_secret = None + application.name = name + application.redirect_uris = " ".join(redirect_uris) + application.client_type = Application.CLIENT_CONFIDENTIAL + application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE + application.algorithm = Application.RS256_ALGORITHM + application.skip_authorization = not options["require_consent"] + application.hash_client_secret = True + if options["rotate_secret"]: + raw_secret = secrets.token_urlsafe(48) + application.client_secret = raw_secret + + application.full_clean() + application.save() + + action = "created" if created else "updated" + self.stdout.write(self.style.SUCCESS(f"OIDC client {action}.")) + self.stdout.write(f"Client ID: {application.client_id}") + self.stdout.write(f"Redirect URIs: {application.redirect_uris}") + if raw_secret: + self.stdout.write( + self.style.WARNING( + "Store this client secret now; it is shown only once:" + ) + ) + self.stdout.write(f"Client secret: {raw_secret}") + else: + self.stdout.write( + "Client secret: unchanged (use --rotate-secret to replace it)" + ) diff --git a/backend/api/oidc/__init__.py b/backend/api/oidc/__init__.py new file mode 100644 index 00000000..7b0aa36a --- /dev/null +++ b/backend/api/oidc/__init__.py @@ -0,0 +1 @@ +"""Corporate OAuth2 and OpenID Connect provider integration.""" diff --git a/backend/api/oidc/urls.py b/backend/api/oidc/urls.py new file mode 100644 index 00000000..05635849 --- /dev/null +++ b/backend/api/oidc/urls.py @@ -0,0 +1,35 @@ +from django.urls import path +from oauth2_provider import views + +from .views import ( + CorporateAuthorizationView, + CorporateIntrospectTokenView, + CorporateRevokeTokenView, + CorporateTokenView, +) + +app_name = "oauth2_provider" + +# Client registration is intentionally absent. Administrators register trusted +# applications in Django Admin, while public OAuth/OIDC endpoints stay standard. +urlpatterns = [ + path("authorize/", CorporateAuthorizationView.as_view(), name="authorize"), + path("token/", CorporateTokenView.as_view(), name="token"), + path( + "revoke_token/", + CorporateRevokeTokenView.as_view(), + name="revoke-token", + ), + path( + "introspect/", + CorporateIntrospectTokenView.as_view(), + name="introspect", + ), + path( + ".well-known/openid-configuration", + views.ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info", + ), + path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"), + path("userinfo/", views.UserInfoView.as_view(), name="user-info"), +] diff --git a/backend/api/oidc/validators.py b/backend/api/oidc/validators.py new file mode 100644 index 00000000..0e225908 --- /dev/null +++ b/backend/api/oidc/validators.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from urllib.parse import urljoin + +from django.conf import settings +from oauth2_provider.oauth2_validators import OAuth2Validator + + +def _preferred_username(user) -> str: + username = (getattr(user, "username", "") or "").strip() + return username or f"employee-{user.pk}" + + +def _is_active_employee(user) -> bool: + if not user or not getattr(user, "is_authenticated", False): + return False + if not getattr(user, "is_active", False): + return False + if not getattr(user, "email_verified", False): + return False + return bool(getattr(user, "is_actually_active", True)) + + +def _employee_groups(user) -> list[str]: + groups = {"portal-users"} + groups.update(user.groups.values_list("name", flat=True)) + if getattr(user, "is_staff", False): + groups.add("portal-staff") + if getattr(user, "is_superuser", False): + groups.add("portal-admins") + return sorted(groups) + + +def _picture_url(user) -> str | None: + avatar = getattr(user, "avatar", None) + if not avatar: + return None + try: + return urljoin(f"{settings.SITE_URL}/", avatar.url.lstrip("/")) + except (ValueError, AttributeError): + return None + + +class CorporateOAuth2Validator(OAuth2Validator): + """Expose only verified corporate identity claims to OIDC clients.""" + + oidc_claim_scope = { + **OAuth2Validator.oidc_claim_scope, + "groups": "groups", + } + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + valid = super().validate_code( + client_id, code, client, request, *args, **kwargs + ) + return bool(valid and _is_active_employee(getattr(request, "user", None))) + + def validate_bearer_token(self, token, scopes, request): + valid = super().validate_bearer_token(token, scopes, request) + return bool(valid and _is_active_employee(getattr(request, "user", None))) + + def validate_refresh_token( + self, refresh_token, client, request, *args, **kwargs + ): + valid = super().validate_refresh_token( + refresh_token, client, request, *args, **kwargs + ) + return bool(valid and _is_active_employee(getattr(request, "user", None))) + + def get_additional_claims(self, request): + user = request.user + full_name = user.get_full_name().strip() + updated_at = getattr(user, "updated_at", None) + claims = { + "name": full_name or str(user), + "given_name": user.first_name, + "family_name": user.last_name, + "preferred_username": _preferred_username(user), + "nickname": _preferred_username(user), + "email": user.email, + "email_verified": bool(user.email_verified), + "locale": "ru-RU", + "zoneinfo": settings.TIME_ZONE, + "groups": _employee_groups(user), + } + picture = _picture_url(user) + if picture: + claims["picture"] = picture + if updated_at: + claims["updated_at"] = int(updated_at.timestamp()) + return claims diff --git a/backend/api/oidc/views.py b/backend/api/oidc/views.py new file mode 100644 index 00000000..36fbd7b7 --- /dev/null +++ b/backend/api/oidc/views.py @@ -0,0 +1,66 @@ +from django.conf import settings +from django.contrib.auth.views import redirect_to_login +from django.http import HttpResponseNotFound +from django.shortcuts import resolve_url +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from oauth2_provider import views as oauth_views + + +class OidcProviderEnabledMixin: + def dispatch(self, request, *args, **kwargs): + if not settings.OIDC_PROVIDER_ENABLED: + return HttpResponseNotFound() + return super().dispatch(request, *args, **kwargs) + + +class CorporateAuthorizationView( + OidcProviderEnabledMixin, + oauth_views.AuthorizationView, +): + """Send unauthenticated authorization requests through the portal login.""" + + def get_login_url(self): + return settings.OIDC_PORTAL_LOGIN_URL + + def handle_no_permission(self): + # Keep the return target relative. This remains safe behind a reverse proxy + # even if Django receives an internal host name and the SPA is public. + return redirect_to_login( + self.request.get_full_path(), + resolve_url(self.get_login_url()), + self.get_redirect_field_name(), + ) + + def handle_prompt_login(self): + query = self.request.GET.copy() + query.pop("prompt", None) + return_path = self.request.path + if query: + return_path = f"{return_path}?{query.urlencode()}" + return redirect_to_login( + return_path, + resolve_url(self.get_login_url()), + self.get_redirect_field_name(), + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class CorporateTokenView(OidcProviderEnabledMixin, oauth_views.TokenView): + pass + + +@method_decorator(csrf_exempt, name="dispatch") +class CorporateRevokeTokenView( + OidcProviderEnabledMixin, + oauth_views.RevokeTokenView, +): + pass + + +@method_decorator(csrf_exempt, name="dispatch") +class CorporateIntrospectTokenView( + OidcProviderEnabledMixin, + oauth_views.IntrospectTokenView, +): + pass diff --git a/backend/eusrr_backend/middleware.py b/backend/eusrr_backend/middleware.py index c94fb69a..b2f650c9 100644 --- a/backend/eusrr_backend/middleware.py +++ b/backend/eusrr_backend/middleware.py @@ -107,6 +107,7 @@ class AuthRequiredMiddleware: EXEMPT_PREFIXES = ( "/api/", # REST API управляет своей аутентификацией "/admin/", # Django Admin использует встроенную аутентификацию + "/o/", # OAuth2/OIDC endpoints сами проверяют клиента и пользователя "/static/", # Статика "/media/", # Медиа ) @@ -153,6 +154,7 @@ class CacheControlMiddleware: NO_CACHE_PREFIXES = ( "/api/", # API управляет своим кэшем "/admin/", # Админка всегда свежая + "/o/", # OAuth2/OIDC responses могут содержать одноразовые коды "/static/", # Обрабатывается веб-сервером "/media/", # Обрабатывается веб-сервером ) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index aa0d75f2..f0c35046 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -3,6 +3,8 @@ import tempfile from datetime import timedelta from pathlib import Path +from django.core.exceptions import ImproperlyConfigured + from celery.schedules import crontab from dotenv import load_dotenv @@ -49,6 +51,7 @@ INSTALLED_APPS = [ "simple_history", "rest_framework", "drf_spectacular", + "oauth2_provider", "rules", # django-rules для декларативных permissions # django-filer и зависимости "easy_thumbnails", @@ -414,10 +417,100 @@ DEFAULT_FROM_EMAIL = os.getenv( # ----------------------------------------------------------------------------- # Название сайта для email уведомлений и UI SITE_NAME = os.getenv("SITE_NAME", "CORP - HiRo") -SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro") +SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro").rstrip("/") +PORTAL_FRONTEND_URL = os.getenv("PORTAL_FRONTEND_URL", SITE_URL).rstrip("/") QR_LOGIN_TOKEN_TTL_SECONDS = int(os.getenv("QR_LOGIN_TOKEN_TTL_SECONDS", "120")) QR_LOGIN_REQUEST_TTL_SECONDS = int(os.getenv("QR_LOGIN_REQUEST_TTL_SECONDS", "120")) +# ----------------------------------------------------------------------------- +# CORPORATE OAUTH2 / OPENID CONNECT PROVIDER +# ----------------------------------------------------------------------------- +# The signing key is deliberately kept outside Git. A mounted Docker secret is +# preferred; an inline value is supported for environments without secret files. +OIDC_PROVIDER_ENABLED = os.getenv("OIDC_PROVIDER_ENABLED", "false").lower() == "true" +OIDC_RSA_PRIVATE_KEY_PATH = os.getenv("OIDC_RSA_PRIVATE_KEY_PATH", "").strip() +OIDC_RSA_PRIVATE_KEY = os.getenv("OIDC_RSA_PRIVATE_KEY", "").replace("\\n", "\n").strip() +if OIDC_RSA_PRIVATE_KEY_PATH: + oidc_key_path = Path(OIDC_RSA_PRIVATE_KEY_PATH) + if not oidc_key_path.is_file(): + raise ImproperlyConfigured( + f"OIDC_RSA_PRIVATE_KEY_PATH does not exist: {oidc_key_path}" + ) + OIDC_RSA_PRIVATE_KEY = oidc_key_path.read_text(encoding="utf-8").strip() +OIDC_RSA_PRIVATE_KEYS_INACTIVE = [] +for inactive_key_path_value in _split_env_list( + os.getenv("OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE", "") +): + inactive_key_path = Path(inactive_key_path_value) + if not inactive_key_path.is_file(): + raise ImproperlyConfigured( + "OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE contains a missing file: " + f"{inactive_key_path}" + ) + OIDC_RSA_PRIVATE_KEYS_INACTIVE.append( + inactive_key_path.read_text(encoding="utf-8").strip() + ) +if OIDC_PROVIDER_ENABLED and not OIDC_RSA_PRIVATE_KEY: + raise ImproperlyConfigured( + "OIDC_PROVIDER_ENABLED requires OIDC_RSA_PRIVATE_KEY_PATH or " + "OIDC_RSA_PRIVATE_KEY" + ) + +OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", f"{SITE_URL}/o").rstrip("/") +OIDC_BROWSER_SESSION_SECONDS = int( + os.getenv("OIDC_BROWSER_SESSION_SECONDS", str(8 * 60 * 60)) +) +OIDC_PORTAL_LOGIN_URL = os.getenv( + "OIDC_PORTAL_LOGIN_URL", f"{PORTAL_FRONTEND_URL}/login" +) + +OAUTH2_PROVIDER = { + "OIDC_ENABLED": OIDC_PROVIDER_ENABLED, + "OIDC_ISS_ENDPOINT": OIDC_ISSUER_URL, + "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, + "OIDC_RSA_PRIVATE_KEYS_INACTIVE": OIDC_RSA_PRIVATE_KEYS_INACTIVE, + "OIDC_RESPONSE_TYPES_SUPPORTED": ["code"], + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ + "client_secret_basic", + "client_secret_post", + ], + "OAUTH2_VALIDATOR_CLASS": "api.oidc.validators.CorporateOAuth2Validator", + "SCOPES": { + "openid": "Подтвердить личность", + "profile": "Имя, логин и фотография профиля", + "email": "Корпоративная электронная почта", + "groups": "Корпоративные группы доступа", + }, + "DEFAULT_SCOPES": ["openid", "profile", "email"], + "ACCESS_TOKEN_EXPIRE_SECONDS": int( + os.getenv("OIDC_ACCESS_TOKEN_SECONDS", "3600") + ), + "ID_TOKEN_EXPIRE_SECONDS": int(os.getenv("OIDC_ID_TOKEN_SECONDS", "600")), + "REFRESH_TOKEN_EXPIRE_SECONDS": timedelta( + seconds=int(os.getenv("OIDC_REFRESH_TOKEN_SECONDS", str(12 * 60 * 60))) + ), + "ROTATE_REFRESH_TOKEN": True, + "REFRESH_TOKEN_REUSE_PROTECTION": True, + "REQUEST_APPROVAL_PROMPT": "auto", + # Gitea 1.23.x does not send a PKCE challenge for its OIDC confidential + # client. The exact HTTPS redirect URI and client secret remain mandatory. + "PKCE_REQUIRED": False, + "ALLOWED_REDIRECT_URI_SCHEMES": ["https"] + (["http"] if DEBUG else []), + "ALLOW_URI_WILDCARDS": False, + "ALLOW_LOCALHOST_LOOPBACK": DEBUG, + "COMPLIANT_BCP_RFC9700_IMPLICIT_GRANT": True, + "COMPLIANT_BCP_RFC9700_PASSWORD_GRANT": True, + "COMPLIANT_BCP_RFC9700_PKCE_METHOD": True, + "COMPLIANT_BCP_RFC9700_ACCESS_TOKEN_TRANSPORT": True, + "COMPLIANT_BCP_RFC9700_TOKEN_STORAGE": True, +} + +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SECURE = os.getenv( + "SESSION_COOKIE_SECURE", "true" if not DEBUG else "false" +).lower() == "true" + # ----------------------------------------------------------------------------- # NOTIFICATIONS # ----------------------------------------------------------------------------- diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index a958e7b8..76bfeadf 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -6,6 +6,7 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("api/", include(("api.urls", "api"), namespace="api")), + path("o/", include("api.oidc.urls", namespace="oauth2_provider")), # django-filer URLs для обработки приватных файлов # (с проверкой прав доступа) path("", include("filer.server.urls")), diff --git a/backend/requirements.txt b/backend/requirements.txt index 7ba486f9..b6eed8cf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -69,6 +69,9 @@ pillow==11.3.0 PyJWT==2.10.1 cryptography==45.0.6 pyOpenSSL==25.1.0 +django-oauth-toolkit==3.4.1 +# 1.5.9+ requires cryptography>=49, while pyOpenSSL 25.1 requires <46. +jwcrypto==1.5.8 cffi==2.0.0 pycparser==2.23 pycryptodome==3.23.0 diff --git a/backend/templates/oauth2_provider/authorize.html b/backend/templates/oauth2_provider/authorize.html new file mode 100644 index 00000000..7fdf474f --- /dev/null +++ b/backend/templates/oauth2_provider/authorize.html @@ -0,0 +1,46 @@ + + + + + + Авторизация приложения — {{ SITE_NAME|default:"CORP" }} + + + +
+ {% if not error %} +

Корпоративный доступ

+

Разрешить доступ приложению «{{ application.name }}»?

+

Приложение получит только перечисленные данные. Пароль портала ему не передаётся.

+
+ {% csrf_token %} + {% for field in form %}{% if field.is_hidden %}{{ field }}{% endif %}{% endfor %} +
    {% for scope in scopes_descriptions %}
  • {{ scope }}
  • {% endfor %}
+
{{ form.errors }}{{ form.non_field_errors }}
+
+ + +
+
+ {% else %} +

Ошибка авторизации

+

{{ error.error }}

+

{{ error.description }}

+ {% endif %} +
+ + diff --git a/backend/tests/api/auth/test_oidc_provider.py b/backend/tests/api/auth/test_oidc_provider.py new file mode 100644 index 00000000..d6b31d56 --- /dev/null +++ b/backend/tests/api/auth/test_oidc_provider.py @@ -0,0 +1,310 @@ +import base64 +from io import StringIO +from urllib.parse import parse_qs, urlparse + +import pytest +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password +from django.core.management import call_command +from django.urls import reverse +from oauth2_provider.models import get_application_model +from oauth2_provider.settings import oauth2_settings +from rest_framework.test import APIClient + +from api.oidc.validators import _employee_groups, _preferred_username + +pytestmark = pytest.mark.django_db +User = get_user_model() + + +@pytest.fixture(autouse=True) +def configured_oidc(settings): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + + settings.OIDC_PROVIDER_ENABLED = True + settings.OIDC_ISSUER_URL = "https://corp.example.test/o" + settings.OIDC_PORTAL_LOGIN_URL = "https://corp.example.test/login" + settings.OIDC_BROWSER_SESSION_SECONDS = 3600 + settings.OAUTH2_PROVIDER = { + **settings.OAUTH2_PROVIDER, + "OIDC_ENABLED": True, + "OIDC_ISS_ENDPOINT": settings.OIDC_ISSUER_URL, + "OIDC_RSA_PRIVATE_KEY": pem, + "ALWAYS_RELOAD_OAUTHLIB_CORE": True, + } + oauth2_settings.reload() + yield private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + oauth2_settings.reload() + + +@pytest.fixture +def employee(): + user = User.objects.create_user( + email="ivan.petrov@example.test", + phone_number="+79990000091", + password="Str0ngPass!", + username="ipetrov", + first_name="Иван", + last_name="Петров", + is_active=True, + send_activation_email=False, + ) + user.email_verified = True + user.email_activation_code = None + user.email_activation_sent_at = None + user.save( + update_fields=[ + "email_verified", + "email_activation_code", + "email_activation_sent_at", + ] + ) + return user + + +@pytest.fixture +def oidc_client(): + Application = get_application_model() + raw_secret = "gitea-test-secret" + application = Application.objects.create( + name="Gitea", + client_id="gitea-test-client", + client_secret=raw_secret, + hash_client_secret=True, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + redirect_uris="https://git.example.test/user/oauth2/corp/callback", + algorithm=Application.RS256_ALGORITHM, + skip_authorization=True, + ) + return application, raw_secret + + +def _authorize(client, application): + response = client.get( + reverse("oauth2_provider:authorize"), + { + "response_type": "code", + "client_id": application.client_id, + "redirect_uri": application.redirect_uris, + "scope": "openid profile email groups", + "state": "test-state", + "nonce": "test-nonce", + }, + ) + assert response.status_code == 302 + query = parse_qs(urlparse(response["Location"]).query) + assert query["state"] == ["test-state"] + return query["code"][0] + + +def _exchange_code(client, application, raw_secret, code): + basic = base64.b64encode( + f"{application.client_id}:{raw_secret}".encode("utf-8") + ).decode("ascii") + response = client.post( + reverse("oauth2_provider:token"), + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": application.redirect_uris, + }, + HTTP_AUTHORIZATION=f"Basic {basic}", + ) + assert response.status_code == 200, response.content + return response.json() + + +def test_discovery_and_jwks_publish_standard_provider_metadata(client): + discovery = client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + assert discovery.status_code == 200 + metadata = discovery.json() + assert metadata["issuer"] == "https://corp.example.test/o" + assert metadata["response_types_supported"] == ["code"] + assert "groups" in metadata["scopes_supported"] + assert metadata["token_endpoint_auth_methods_supported"] == [ + "client_secret_basic", + "client_secret_post", + ] + + jwks = client.get(reverse("oauth2_provider:jwks-info")) + assert jwks.status_code == 200 + public_key = jwks.json()["keys"][0] + assert public_key["alg"] == "RS256" + assert public_key["use"] == "sig" + assert public_key["kty"] == "RSA" + assert "d" not in public_key + + +def test_provider_endpoints_are_closed_when_feature_is_disabled(client, settings): + settings.OIDC_PROVIDER_ENABLED = False + settings.OAUTH2_PROVIDER = { + **settings.OAUTH2_PROVIDER, + "OIDC_ENABLED": False, + } + oauth2_settings.reload() + + assert client.get(reverse("oauth2_provider:authorize")).status_code == 404 + assert client.post(reverse("oauth2_provider:token")).status_code == 404 + assert ( + client.get(reverse("oauth2_provider:oidc-connect-discovery-info")).status_code + == 404 + ) + + +def test_anonymous_authorization_is_redirected_to_portal_login(client, oidc_client): + application, _ = oidc_client + response = client.get( + reverse("oauth2_provider:authorize"), + { + "response_type": "code", + "client_id": application.client_id, + "redirect_uri": application.redirect_uris, + "scope": "openid profile email", + "state": "test-state", + }, + ) + assert response.status_code == 302 + location = urlparse(response["Location"]) + assert f"{location.scheme}://{location.netloc}{location.path}" == ( + "https://corp.example.test/login" + ) + assert parse_qs(location.query)["next"][0].startswith("/o/authorize/") + + +def test_authorization_code_flow_returns_corporate_claims( + client, employee, oidc_client, configured_oidc +): + application, raw_secret = oidc_client + client.force_login(employee) + + code = _authorize(client, application) + tokens = _exchange_code(client, application, raw_secret, code) + + assert tokens["token_type"] == "Bearer" + assert tokens["access_token"] + assert tokens["id_token"] + assert tokens["refresh_token"] + + id_claims = jwt.decode( + tokens["id_token"], + configured_oidc, + algorithms=["RS256"], + audience=application.client_id, + issuer="https://corp.example.test/o", + ) + assert id_claims["nonce"] == "test-nonce" + assert id_claims["preferred_username"] == "ipetrov" + assert id_claims["email"] == employee.email + assert id_claims["groups"] == ["portal-users"] + + userinfo = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=f"Bearer {tokens['access_token']}", + ) + assert userinfo.status_code == 200, userinfo.content + claims = userinfo.json() + assert claims["sub"] == str(employee.pk) + assert claims["preferred_username"] == "ipetrov" + assert claims["name"] == "Иван Петров" + assert claims["email"] == employee.email + assert claims["email_verified"] is True + assert claims["groups"] == ["portal-users"] + + +def test_inactive_employee_cannot_use_an_existing_access_token( + client, employee, oidc_client +): + application, raw_secret = oidc_client + client.force_login(employee) + code = _authorize(client, application) + tokens = _exchange_code(client, application, raw_secret, code) + + employee.is_active = False + employee.save(update_fields=["is_active"]) + response = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=f"Bearer {tokens['access_token']}", + ) + assert response.status_code == 401 + + +def test_browser_session_bridge_accepts_only_own_authorization_endpoint(employee): + api = APIClient() + api.force_authenticate(employee) + next_url = ( + "/o/authorize/?response_type=code&client_id=gitea&" + "redirect_uri=https%3A%2F%2Fgit.example.test%2Fcallback" + ) + + response = api.post( + reverse("api:browser-session"), + {"next": next_url}, + format="json", + ) + assert response.status_code == 200 + assert response.json() == {"redirect_url": next_url} + assert api.session["_auth_user_id"] == str(employee.pk) + + rejected = api.post( + reverse("api:browser-session"), + {"next": "https://attacker.example/steal"}, + format="json", + ) + assert rejected.status_code == 400 + + +def test_claim_helpers_have_stable_fallback_username_and_sorted_groups(employee): + employee.username = None + employee.save(update_fields=["username"]) + employee.groups.create(name="Developers") + + assert _preferred_username(employee) == f"employee-{employee.pk}" + assert _employee_groups(employee) == ["Developers", "portal-users"] + + +def test_oidc_client_secret_is_hashed_at_rest(oidc_client): + application, raw_secret = oidc_client + application.refresh_from_db() + assert application.client_secret != raw_secret + assert check_password(raw_secret, application.client_secret) + + +def test_provision_command_creates_and_updates_client_without_implicit_rotation(): + created_output = StringIO() + call_command( + "provision_oidc_client", + name="Gitea Production", + client_id="gitea-production", + redirect_uris=["https://git.example.test/user/oauth2/corp/callback"], + stdout=created_output, + ) + assert "Client secret:" in created_output.getvalue() + + Application = get_application_model() + application = Application.objects.get(client_id="gitea-production") + original_secret_hash = application.client_secret + + updated_output = StringIO() + call_command( + "provision_oidc_client", + name="Gitea Production", + client_id="gitea-production", + redirect_uris=["https://git.example.test/user/oauth2/company/callback"], + stdout=updated_output, + ) + application.refresh_from_db() + assert application.redirect_uris.endswith("/company/callback") + assert application.client_secret == original_secret_hash + assert "Client secret: unchanged" in updated_output.getvalue() diff --git a/docs/guides/OIDC.md b/docs/guides/OIDC.md new file mode 100644 index 00000000..c5b06d06 --- /dev/null +++ b/docs/guides/OIDC.md @@ -0,0 +1,152 @@ +# Корпоративный OpenID Connect + +Портал выступает доверенным OpenID Connect Provider для внутренних приложений. Первое подключение — Gitea; тот же контракт можно использовать для других клиентов с поддержкой OIDC Authorization Code Flow. + +## Контракт + +- issuer: `https://corp.example.com/o` +- discovery: `https://corp.example.com/o/.well-known/openid-configuration` +- flow: Authorization Code +- подпись ID token: RS256 +- client authentication: `client_secret_basic` или `client_secret_post` +- scopes: `openid`, `profile`, `email`, `groups` +- идентификатор пользователя `sub`: неизменяемый Django PK сотрудника +- рекомендуемый логин: claim `preferred_username` из LDAP username; fallback `employee-` + +Пароль портала стороннему приложению не передаётся. Обычный JWT-вход портала создаёт короткую browser session только перед возвратом на `/o/authorize/`. Redirect из bridge ограничен собственным authorization endpoint и не может увести пользователя на произвольный сайт. + +Неактивный, уволенный или не подтвердивший email сотрудник не может обменять authorization code, использовать access token либо обновить токен. Внешний клиент получает только claims из согласованных scopes. + +## Production-настройка портала + +OIDC и callback клиентов должны работать по HTTPS. Ключ подписи хранится вне Git и резервируется как production secret. + +Создать отдельный RSA-ключ: + +```bash +install -d -m 700 /srv/eusrr/secrets +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \ + -out /srv/eusrr/secrets/oidc-private.pem +chmod 600 /srv/eusrr/secrets/oidc-private.pem +``` + +Передать файл backend-контейнеру read-only и задать окружение: + +```dotenv +SITE_URL=https://corp.example.com +PORTAL_FRONTEND_URL=https://corp.example.com +OIDC_PROVIDER_ENABLED=true +OIDC_RSA_PRIVATE_KEY_PATH=/run/secrets/eusrr_oidc_private_key.pem +# При ротации: OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE=/run/secrets/oidc-private-old.pem +OIDC_ISSUER_URL=https://corp.example.com/o +OIDC_PORTAL_LOGIN_URL=https://corp.example.com/login +OIDC_BROWSER_SESSION_SECONDS=28800 +OIDC_ACCESS_TOKEN_SECONDS=3600 +OIDC_ID_TOKEN_SECONDS=600 +OIDC_REFRESH_TOKEN_SECONDS=43200 +SESSION_COOKIE_SECURE=true +``` + +`OIDC_ISSUER_URL` — постоянный публичный идентификатор. Его нельзя менять после подключения клиентов без их перенастройки. Reverse proxy должен сохранять исходный HTTPS scheme и host. + +Публичный reverse proxy должен отправлять весь prefix `/o/` в Django так же, как `/api/`. Пример для Nginx: + +```nginx +location ^~ /o/ { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://django_backend; +} +``` + +После установки зависимостей выполнить миграции: + +```bash +python manage.py migrate +python manage.py check --deploy +``` + +## Регистрация Gitea в портале + +Сначала выберите неизменяемое имя authentication source в Gitea, например `corp`. Оно входит в callback URL: + +```text +https://git.example.com/user/oauth2/corp/callback +``` + +Создать confidential-клиент и сохранить показанный secret в менеджер секретов: + +```bash +python manage.py provision_oidc_client \ + --name Gitea \ + --client-id gitea \ + --redirect-uri https://git.example.com/user/oauth2/corp/callback +``` + +Повторный запуск обновляет параметры, не меняя secret. Для плановой ротации используйте `--rotate-secret` и сразу обновите Gitea. Клиент также доступен администраторам в Django Admin в разделе OAuth2 Provider, но management command безопаснее для первичного получения секрета. + +## Настройка Gitea + +В `app.ini`: + +```ini +[oauth2_client] +ENABLE_AUTO_REGISTRATION = true +USERNAME = preferred_username +ACCOUNT_LINKING = login +UPDATE_AVATAR = true +OPENID_CONNECT_SCOPES = profile email groups +REGISTER_EMAIL_CONFIRM = false +``` + +Затем в `Site Administration → Identity & Access → Authentication Sources` добавить источник: + +| Поле | Значение | +| --- | --- | +| Authentication Type | OAuth2 | +| Authentication Name | `corp` | +| OAuth2 Provider | OpenID Connect | +| Client ID | значение из `provision_oidc_client` | +| Client Secret | одноразово показанный secret | +| Auto Discovery URL | `https://corp.example.com/o/.well-known/openid-configuration` | +| Additional Scopes | `profile email groups` | +| Group Claim Name | `groups` | + +До переключения пользователей оставить локальный вход Gitea включённым и сохранить отдельную аварийную локальную учётную запись администратора. `ACCOUNT_LINKING = login` не удаляет и не разлогинивает существующие локальные учётки: при первом конфликте username/email Gitea попросит один раз подтвердить привязку существующим локальным входом. + +Gitea 1.23.x не отправляет PKCE challenge для confidential OIDC-клиента, поэтому портал временно допускает Authorization Code без PKCE для совместимости. После обновления Gitea и проверки поддержки PKCE следует включить `PKCE_REQUIRED` в настройках провайдера. + +## Claims и группы + +`userinfo` и ID token могут содержать: + +| Scope | Claims | +| --- | --- | +| `openid` | `sub` | +| `profile` | `name`, `given_name`, `family_name`, `preferred_username`, `nickname`, `picture`, `locale`, `zoneinfo`, `updated_at` | +| `email` | `email`, `email_verified` | +| `groups` | Django groups, `portal-users`, а также `portal-staff`/`portal-admins` для служебных ролей | + +Не назначайте Gitea administrator по общей группе `portal-users`. Для автоматического администрирования заведите отдельную узкую Django group и явно сопоставьте её в Gitea. + +## Проверка + +До включения кнопки входа проверить с хоста Gitea: + +```bash +curl -fsS https://corp.example.com/o/.well-known/openid-configuration +curl -fsS https://corp.example.com/o/.well-known/jwks.json +``` + +Discovery должен публиковать один и тот же issuer, HTTPS endpoints и `RS256`; JWKS содержит только публичную часть ключа. После этого пройти вход тестовой активной учёткой, проверить создание/привязку пользователя, имя, email и avatar, затем проверить отказ для деактивированной тестовой учётки. + +## Отзыв доступа и ротация + +- отключение сотрудника начинает блокировать access/refresh tokens при следующем обращении; +- OAuth grants и токены можно отозвать через Django Admin; +- client secret ротируется `provision_oidc_client --rotate-secret`; +- при RSA rotation укажите старые ключи через `OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE` на переходный период; не удаляйте их до истечения ранее выданных ID token; +- периодическая очистка выполняется командой `python manage.py cleartokens`. + +Dynamic Client Registration и пользовательское создание OAuth applications публично не маршрутизируются. Новый клиент регистрирует только администратор. diff --git a/docs/guides/README.md b/docs/guides/README.md index 79e92030..cff3ff71 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -6,6 +6,7 @@ - [API.md](API.md) — HTTP API, JWT/auth sessions, OpenAPI, frontend proxy и тестирование. - [LDAP.md](LDAP.md) — LDAP/AD authentication, write-back, sync, retry queue и admin. +- [OIDC.md](OIDC.md) — корпоративный OpenID Connect Provider и подключение Gitea. - [WEBSOCKET.md](WEBSOCKET.md) — единый `/ws/`, JWT, actions, events и production proxy. - [NOTIFICATIONS.md](NOTIFICATIONS.md) — создание, доставка, REST, realtime, email и Web Push. - [COMMERCE.md](COMMERCE.md) — каталог, МойСклад, изображения, Celery и realtime-прогресс. diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index d2ddad4d..c7425799 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -33,9 +33,10 @@ type QrLoginViewState = export default function Login() { const router = useRouter(); - const { refreshUser } = useUser(); + const { refreshUser, status } = useUser(); const pollTimerRef = useRef | null>(null); const qrCompletedRef = useRef(false); + const oidcRedirectStartedRef = useRef(false); const [mode, setMode] = useState("password"); const [login, setLogin] = useState(""); const [password, setPassword] = useState(""); @@ -59,6 +60,24 @@ export default function Login() { } }, []); + const continueAfterLogin = useCallback(async () => { + const nextUrl = new URLSearchParams(window.location.search).get("next"); + if (!nextUrl) { + router.push("/"); + return; + } + if (oidcRedirectStartedRef.current) return; + + oidcRedirectStartedRef.current = true; + try { + const redirectUrl = await apiClient.establishBrowserSession(nextUrl); + window.location.assign(redirectUrl); + } catch (caught) { + oidcRedirectStartedRef.current = false; + throw caught; + } + }, [router]); + const startQrLogin = useCallback(async () => { clearPollTimer(); qrCompletedRef.current = false; @@ -111,12 +130,12 @@ export default function Login() { await refreshUser().catch((err) => { console.warn("Не удалось обновить пользователя после QR-входа:", err); }); - window.location.replace("/"); + await continueAfterLogin(); return; } if (status === "claimed" && apiClient.getToken()) { qrCompletedRef.current = true; - window.location.replace("/"); + await continueAfterLogin(); return; } if (status === "denied") { @@ -130,7 +149,7 @@ export default function Login() { setQrError("Этот QR-код уже был использован"); } }, - [clearPollTimer, refreshUser], + [clearPollTimer, continueAfterLogin, refreshUser], ); const handleSubmit = async (e: React.FormEvent) => { @@ -145,15 +164,36 @@ export default function Login() { await apiClient.login(credentials); await refreshUser(); - router.push("/"); } catch (err) { console.error("Ошибка авторизации:", err); setError("Неверный логин или пароль"); + setLoading(false); + return; + } + + try { + await continueAfterLogin(); + } catch (err) { + console.error("Ошибка продолжения корпоративного входа:", err); + setError("Вход выполнен, но не удалось открыть внешнее приложение"); } finally { setLoading(false); } }; + useEffect(() => { + if (status !== "authenticated" || oidcRedirectStartedRef.current) return; + const nextUrl = new URLSearchParams(window.location.search).get("next"); + if (!nextUrl) return; + + setLoading(true); + void continueAfterLogin().catch((caught) => { + console.error("Ошибка продолжения корпоративного входа:", caught); + setError("Не удалось продолжить вход во внешнее приложение"); + setLoading(false); + }); + }, [continueAfterLogin, status]); + const handleCancelQrLogin = async () => { clearPollTimer(); qrCompletedRef.current = true; diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx index d08f9b29..ee0dd329 100644 --- a/frontend/src/contexts/UserContext.tsx +++ b/frontend/src/contexts/UserContext.tsx @@ -117,6 +117,7 @@ export function UserProvider({ children }: { children: ReactNode }) { clearRetryTimer(); try { const cleanupResults = await Promise.allSettled([ + apiClient.logoutBrowserSession(), apiClient.unsubscribePushForDeviceGroup(getDeviceGroupId()), unsubscribeAllLocalPushSubscriptions(), ]); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index aba895b2..8f3bb19d 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -16,6 +16,10 @@ interface LoginCredentials { password: string; } +interface BrowserSessionResponse { + redirect_url: string; +} + export type ApiRequestOptions = RequestInit & { timeoutMs?: number; }; @@ -308,6 +312,21 @@ export class ApiClientBase { this.setToken(data.access); } + async establishBrowserSession(nextUrl: string): Promise { + const data = await this.request('/api/auth/browser-session/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ next: nextUrl }), + }); + return data.redirect_url; + } + + async logoutBrowserSession(): Promise { + await this.request('/api/auth/browser-session/logout/', { + method: 'POST', + }); + } + async loginWithQrToken(token: string): Promise { const data = await this.unauthenticatedRequest('/api/auth/qr-login/exchange/', { method: 'POST',