Add corporate OpenID Connect provider and browser sessions

This commit is contained in:
admin i 2026-09-04 09:50:56 +03:00
parent 4ea807ee4b
commit cae8e97146
25 changed files with 1108 additions and 6 deletions

View File

@ -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

View File

@ -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)

View File

@ -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",

View 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)

View File

@ -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,

View File

@ -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"),

View File

@ -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",

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View 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)"
)

View File

@ -0,0 +1 @@
"""Corporate OAuth2 and OpenID Connect provider integration."""

35
backend/api/oidc/urls.py Normal file
View 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"),
]

View 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
View 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

View File

@ -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/", # Обрабатывается веб-сервером
)

View File

@ -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
# -----------------------------------------------------------------------------

View File

@ -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")),

View File

@ -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

View 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>

View 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
View 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 публично не маршрутизируются. Новый клиент регистрирует только администратор.

View File

@ -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-прогресс.

View File

@ -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;

View File

@ -117,6 +117,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
clearRetryTimer();
try {
const cleanupResults = await Promise.allSettled([
apiClient.logoutBrowserSession(),
apiClient.unsubscribePushForDeviceGroup(getDeviceGroupId()),
unsubscribeAllLocalPushSubscriptions(),
]);

View File

@ -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',