Add corporate OpenID Connect provider and browser sessions
This commit is contained in:
parent
4ea807ee4b
commit
cae8e97146
14
.env.example
14
.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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
83
backend/api/auth/endpoints/browser_sessions.py
Normal file
83
backend/api/auth/endpoints/browser_sessions.py
Normal file
@ -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)
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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",
|
||||
|
||||
1
backend/api/management/__init__.py
Normal file
1
backend/api/management/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/api/management/commands/__init__.py
Normal file
1
backend/api/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
109
backend/api/management/commands/provision_oidc_client.py
Normal file
109
backend/api/management/commands/provision_oidc_client.py
Normal file
@ -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)"
|
||||
)
|
||||
1
backend/api/oidc/__init__.py
Normal file
1
backend/api/oidc/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Corporate OAuth2 and OpenID Connect provider integration."""
|
||||
35
backend/api/oidc/urls.py
Normal file
35
backend/api/oidc/urls.py
Normal file
@ -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"),
|
||||
]
|
||||
91
backend/api/oidc/validators.py
Normal file
91
backend/api/oidc/validators.py
Normal file
@ -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
|
||||
66
backend/api/oidc/views.py
Normal file
66
backend/api/oidc/views.py
Normal file
@ -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
|
||||
@ -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/", # Обрабатывается веб-сервером
|
||||
)
|
||||
|
||||
@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@ -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")),
|
||||
|
||||
@ -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
|
||||
|
||||
46
backend/templates/oauth2_provider/authorize.html
Normal file
46
backend/templates/oauth2_provider/authorize.html
Normal file
@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Авторизация приложения — {{ SITE_NAME|default:"CORP" }}</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #09111b; color: #f8fafc; }
|
||||
main { width: min(100%, 520px); padding: 30px; border: 1px solid #263445; border-radius: 24px; background: #111a25; box-shadow: 0 24px 80px rgba(0,0,0,.32); }
|
||||
.eyebrow { margin: 0 0 10px; color: #67c7ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: 26px; line-height: 1.2; }
|
||||
.muted { color: #9fb0c3; line-height: 1.55; }
|
||||
ul { margin: 20px 0; padding: 0; list-style: none; display: grid; gap: 10px; }
|
||||
li { padding: 12px 14px; border: 1px solid #2a394b; border-radius: 12px; background: #182331; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 24px; }
|
||||
button { min-height: 44px; border-radius: 12px; border: 1px solid #304154; padding: 10px 16px; color: inherit; background: #172231; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
button[name="allow"] { border-color: #169ddd; background: #087eb7; }
|
||||
.errors { color: #ff9e9e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{% if not error %}
|
||||
<p class="eyebrow">Корпоративный доступ</p>
|
||||
<h1>Разрешить доступ приложению «{{ application.name }}»?</h1>
|
||||
<p class="muted">Приложение получит только перечисленные данные. Пароль портала ему не передаётся.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}{% if field.is_hidden %}{{ field }}{% endif %}{% endfor %}
|
||||
<ul>{% for scope in scopes_descriptions %}<li>{{ scope }}</li>{% endfor %}</ul>
|
||||
<div class="errors">{{ form.errors }}{{ form.non_field_errors }}</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Отмена</button>
|
||||
<button type="submit" name="allow" value="Authorize">Разрешить</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="eyebrow">Ошибка авторизации</p>
|
||||
<h1>{{ error.error }}</h1>
|
||||
<p class="muted">{{ error.description }}</p>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
310
backend/tests/api/auth/test_oidc_provider.py
Normal file
310
backend/tests/api/auth/test_oidc_provider.py
Normal file
@ -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()
|
||||
152
docs/guides/OIDC.md
Normal file
152
docs/guides/OIDC.md
Normal file
@ -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-<pk>`
|
||||
|
||||
Пароль портала стороннему приложению не передаётся. Обычный 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 публично не маршрутизируются. Новый клиент регистрирует только администратор.
|
||||
@ -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-прогресс.
|
||||
|
||||
@ -33,9 +33,10 @@ type QrLoginViewState =
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const { refreshUser } = useUser();
|
||||
const { refreshUser, status } = useUser();
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const qrCompletedRef = useRef(false);
|
||||
const oidcRedirectStartedRef = useRef(false);
|
||||
const [mode, setMode] = useState<LoginMode>("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;
|
||||
|
||||
@ -117,6 +117,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
clearRetryTimer();
|
||||
try {
|
||||
const cleanupResults = await Promise.allSettled([
|
||||
apiClient.logoutBrowserSession(),
|
||||
apiClient.unsubscribePushForDeviceGroup(getDeviceGroupId()),
|
||||
unsubscribeAllLocalPushSubscriptions(),
|
||||
]);
|
||||
|
||||
@ -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<string> {
|
||||
const data = await this.request<BrowserSessionResponse>('/api/auth/browser-session/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ next: nextUrl }),
|
||||
});
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
async logoutBrowserSession(): Promise<void> {
|
||||
await this.request<void>('/api/auth/browser-session/logout/', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async loginWithQrToken(token: string): Promise<void> {
|
||||
const data = await this.unauthenticatedRequest<LoginResponse>('/api/auth/qr-login/exchange/', {
|
||||
method: 'POST',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user