diff --git a/backend/api/admin.py b/backend/api/admin.py index 846f6b40..0ddac9ec 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -1 +1,58 @@ -# Register your models here. +from django.contrib import admin +from django.utils import timezone + +from api.oidc.models import BackchannelClient, BackchannelDelivery + + +@admin.register(BackchannelClient) +class BackchannelClientAdmin(admin.ModelAdmin): + list_display = ("application", "logout_uri", "enabled") + list_select_related = ("application",) + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + +@admin.register(BackchannelDelivery) +class BackchannelDeliveryAdmin(admin.ModelAdmin): + list_display = ( + "id", + "client", + "identity", + "state", + "attempts", + "next_attempt_at", + "last_error", + ) + list_filter = ("state",) + list_select_related = ("client__application", "identity__application") + readonly_fields = tuple(field.name for field in BackchannelDelivery._meta.fields) + actions = ("retry_failed",) + + @admin.action(description="Повторить неудавшуюся отправку") + def retry_failed(self, request, queryset): + if not request.user.is_superuser: + return + queryset.filter(state="failed").update( + state="pending", + next_attempt_at=timezone.now(), + completed_at=None, + locked_until=None, + lock_token="", + last_error="", + ) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return False diff --git a/backend/api/management/commands/seed_backchannel_identities.py b/backend/api/management/commands/seed_backchannel_identities.py new file mode 100644 index 00000000..deceec68 --- /dev/null +++ b/backend/api/management/commands/seed_backchannel_identities.py @@ -0,0 +1,37 @@ +from django.core.management.base import BaseCommand +from oauth2_provider.models import get_access_token_model + +from api.oidc.models import OIDCClientIdentity + + +class Command(BaseCommand): + help = "Remember OIDC logins still represented by stored access tokens." + + def add_arguments(self, parser): + parser.add_argument( + "--apply", action="store_true", help="Create missing identities" + ) + + def handle(self, *args, **options): + rows = ( + get_access_token_model() + .objects.filter(application__isnull=False, user__isnull=False) + .values_list("application_id", "user_id", "scope") + ) + pairs = { + (application, user) + for application, user, scope in rows.iterator() + if "openid" in scope.split() + } + created = 0 + if options["apply"]: + for application, user in pairs: + _, is_new = OIDCClientIdentity.objects.get_or_create( + application_id=application, + subject=str(user), + defaults={"user_id": user}, + ) + created += int(is_new) + self.stdout.write( + f"retained_oidc_identity_pairs={len(pairs)} created={created}" + ) diff --git a/backend/api/migrations/0005_backchannelclient_oidcclientidentity_and_more.py b/backend/api/migrations/0005_backchannelclient_oidcclientidentity_and_more.py new file mode 100644 index 00000000..3959a00b --- /dev/null +++ b/backend/api/migrations/0005_backchannelclient_oidcclientidentity_and_more.py @@ -0,0 +1,126 @@ +# Generated by Django 5.2.4 on 2026-10-27 16:44 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0004_qrloginrequest"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="BackchannelClient", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("logout_uri", models.URLField(max_length=2048)), + ("enabled", models.BooleanField(default=True)), + ( + "application", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="OIDCClientIdentity", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("subject", models.CharField(max_length=255)), + ("last_login", models.DateTimeField(default=django.utils.timezone.now)), + ("logout_queued", models.BooleanField(default=False)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="BackchannelDelivery", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state", models.CharField(default="pending", max_length=12)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "next_attempt_at", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now + ), + ), + ("locked_until", models.DateTimeField(blank=True, null=True)), + ("lock_token", models.CharField(blank=True, default="", max_length=36)), + ("attempts", models.PositiveIntegerField(default=0)), + ("last_error", models.CharField(blank=True, default="", max_length=80)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.backchannelclient", + ), + ), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.oidcclientidentity", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="oidcclientidentity", + constraint=models.UniqueConstraint( + fields=("application", "subject"), name="oidc_unique_client_subject" + ), + ), + migrations.AddIndex( + model_name="backchanneldelivery", + index=models.Index( + fields=["state", "next_attempt_at"], name="oidc_logout_due_idx" + ), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 502f271c..5db8df02 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,3 +1,11 @@ from api.auth.models import QrLoginRequest, QrLoginToken, UserAuthSession +from api.oidc.models import BackchannelClient, BackchannelDelivery, OIDCClientIdentity -__all__ = ["QrLoginRequest", "QrLoginToken", "UserAuthSession"] +__all__ = [ + "QrLoginRequest", + "QrLoginToken", + "UserAuthSession", + "BackchannelClient", + "BackchannelDelivery", + "OIDCClientIdentity", +] diff --git a/backend/api/oidc/backchannel.py b/backend/api/oidc/backchannel.py new file mode 100644 index 00000000..324c77c8 --- /dev/null +++ b/backend/api/oidc/backchannel.py @@ -0,0 +1,218 @@ +"""OIDC back-channel logout on account deactivation, independent of any RP.""" + +import logging +import uuid +from datetime import timedelta +from urllib.parse import urlsplit + +import requests +from django.conf import settings +from django.db import transaction +from django.db.models import Q +from django.utils import timezone +from jwcrypto import jwt +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.utils import jwk_from_pem + +from .models import BackchannelDelivery, OIDCClientIdentity +from .validators import _is_active_employee + +logger = logging.getLogger(__name__) +LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout" + + +def remember_login(request): + if not settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: + return + user = getattr(request, "user", None) + client = getattr(request, "client", None) + scopes = getattr(request, "scopes", None) or [] + if not user or not user.is_authenticated or not client or "openid" not in scopes: + return + OIDCClientIdentity.objects.update_or_create( + application_id=client.pk, + subject=str(user.pk), + defaults={"user": user, "last_login": timezone.now(), "logout_queued": False}, + ) + + +def enqueue_inactive_identities(): + """Reconcile remembered logins, including missed signals and future dates.""" + if not settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: + return 0 + ids = list( + OIDCClientIdentity.objects.filter( + logout_queued=False, application__backchannelclient__enabled=True + ).values_list("pk", flat=True) + ) + count = 0 + for identity_id in ids: + with transaction.atomic(): + identity = ( + OIDCClientIdentity.objects.select_for_update(of=("self",)) + .select_related("user", "application__backchannelclient") + .get(pk=identity_id) + ) + client = identity.application.backchannelclient + if ( + identity.logout_queued + or not client.enabled + or _is_active_employee(identity.user) + ): + continue + BackchannelDelivery.objects.create(identity=identity, client=client) + identity.logout_queued = True + identity.save(update_fields=["logout_queued"]) + count += 1 + return count + + +def make_logout_token(identity, now): + key = jwk_from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY) + token = jwt.JWT( + header={"alg": "RS256", "kid": key.thumbprint(), "typ": "logout+jwt"}, + claims={ + "iss": settings.OIDC_ISSUER_URL, + "sub": identity.subject, + "aud": identity.application.client_id, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=5)).timestamp()), + "jti": str(uuid.uuid4()), + "events": {LOGOUT_EVENT: {}}, + }, + ) + token.make_signed_token(key) + return token.serialize() + + +def dispatch_due_deliveries(limit=100): + if not settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: + return 0 + now = timezone.now() + due = list( + BackchannelDelivery.objects.filter(state="pending", next_attempt_at__lte=now) + .filter(Q(locked_until__isnull=True) | Q(locked_until__lte=now)) + .order_by("next_attempt_at", "pk") + .values_list("pk", flat=True)[:limit] + ) + processed = 0 + for pk in due: + claim_now = timezone.now() + token = str(uuid.uuid4()) + claimed = ( + BackchannelDelivery.objects.filter( + pk=pk, state="pending", next_attempt_at__lte=claim_now + ) + .filter(Q(locked_until__isnull=True) | Q(locked_until__lte=claim_now)) + .update(locked_until=claim_now + timedelta(seconds=60), lock_token=token) + ) + if not claimed: + continue + delivery = BackchannelDelivery.objects.select_related( + "client__application", "identity__user", "identity__application" + ).get(pk=pk) + identity, client = delivery.identity, delivery.client + if identity.application_id != client.application_id: + _finish(delivery, token, "failed", "client_changed") + processed += 1 + continue + if not client.enabled: + _retry(delivery, token, "client_disabled") + processed += 1 + continue + if ( + _is_active_employee(identity.user) + or identity.last_login > delivery.created_at + ): + _cancel_stale(delivery, token) + processed += 1 + continue + url = urlsplit(client.logout_uri) + if ( + url.scheme != "https" + or not url.hostname + or url.username + or url.password + or url.fragment + or client.application.algorithm != "RS256" + ): + _finish(delivery, token, "failed", "invalid_client_configuration") + processed += 1 + continue + try: + signed = make_logout_token(identity, timezone.now()) + response = requests.post( + client.logout_uri, + data={"logout_token": signed}, + timeout=(3, 5), + allow_redirects=False, + ) + status = response.status_code + except (requests.RequestException, ValueError) as error: + _retry(delivery, token, type(error).__name__) + else: + if status == 200: + _finish(delivery, token, "delivered", "") + elif status in (408, 429) or status >= 500: + _retry(delivery, token, f"http_{status}") + else: + _finish(delivery, token, "failed", f"http_{status}") + processed += 1 + return processed + + +def _finish(delivery, token, state, error): + BackchannelDelivery.objects.filter( + pk=delivery.pk, lock_token=token, state="pending" + ).update( + state=state, + attempts=delivery.attempts + 1, + last_error=error, + completed_at=timezone.now(), + locked_until=None, + lock_token="", + ) + + +def _cancel_stale(delivery, token): + # A re-hire or a later login invalidates this old subject-wide message. + # Permit a later dismissal to produce a fresh notification. + with transaction.atomic(): + identity = OIDCClientIdentity.objects.select_for_update().get( + pk=delivery.identity_id + ) + changed = BackchannelDelivery.objects.filter( + pk=delivery.pk, lock_token=token, state="pending" + ).update( + state="cancelled", + attempts=delivery.attempts + 1, + completed_at=timezone.now(), + locked_until=None, + lock_token="", + ) + if ( + changed + and not BackchannelDelivery.objects.filter( + identity=identity, state="pending" + ).exists() + ): + identity.logout_queued = False + identity.save(update_fields=["logout_queued"]) + + +def _retry(delivery, token, error): + attempts = delivery.attempts + 1 + delay = min(3600, 5 * 2 ** min(attempts, 10)) + BackchannelDelivery.objects.filter( + pk=delivery.pk, lock_token=token, state="pending" + ).update( + attempts=attempts, + last_error=error, + next_attempt_at=timezone.now() + timedelta(seconds=delay), + locked_until=None, + lock_token="", + ) + logger.warning( + "OIDC back-channel logout delivery will be retried", + extra={"delivery_id": delivery.pk, "reason": error}, + ) diff --git a/backend/api/oidc/models.py b/backend/api/oidc/models.py new file mode 100644 index 00000000..94cd94f7 --- /dev/null +++ b/backend/api/oidc/models.py @@ -0,0 +1,87 @@ +from urllib.parse import urlsplit + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone + + +class BackchannelClient(models.Model): + """An administrator-approved OIDC client that can receive logout tokens.""" + + application = models.OneToOneField( + settings.OAUTH2_PROVIDER_APPLICATION_MODEL, on_delete=models.CASCADE + ) + logout_uri = models.URLField(max_length=2048) + enabled = models.BooleanField(default=True) + + def clean(self): + super().clean() + url = urlsplit(self.logout_uri) + if ( + url.scheme != "https" + or not url.hostname + or url.username + or url.password + or url.fragment + ): + raise ValidationError( + { + "logout_uri": "Use an absolute HTTPS URL without credentials or fragment." + } + ) + if self.application_id and self.application.algorithm != "RS256": + raise ValidationError( + { + "application": "Back-channel logout currently supports RS256 OIDC clients." + } + ) + + def __str__(self): + return self.application.name + + +class OIDCClientIdentity(models.Model): + """Remember a subject only after it has authenticated to this OIDC client.""" + + application = models.ForeignKey( + settings.OAUTH2_PROVIDER_APPLICATION_MODEL, on_delete=models.CASCADE + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL + ) + subject = models.CharField(max_length=255) + last_login = models.DateTimeField(default=timezone.now) + logout_queued = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["application", "subject"], name="oidc_unique_client_subject" + ) + ] + + def __str__(self): + return f"{self.application.name} / {self.subject}" + + +class BackchannelDelivery(models.Model): + """Durable per-client logout queue; it never stores tokens or key material.""" + + identity = models.ForeignKey(OIDCClientIdentity, on_delete=models.CASCADE) + client = models.ForeignKey(BackchannelClient, on_delete=models.CASCADE) + state = models.CharField(max_length=12, default="pending") + created_at = models.DateTimeField(default=timezone.now) + next_attempt_at = models.DateTimeField(default=timezone.now, db_index=True) + locked_until = models.DateTimeField(null=True, blank=True) + lock_token = models.CharField(max_length=36, blank=True, default="") + attempts = models.PositiveIntegerField(default=0) + last_error = models.CharField(max_length=80, blank=True, default="") + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index( + fields=["state", "next_attempt_at"], name="oidc_logout_due_idx" + ) + ] diff --git a/backend/api/oidc/urls.py b/backend/api/oidc/urls.py index 05635849..b6753467 100644 --- a/backend/api/oidc/urls.py +++ b/backend/api/oidc/urls.py @@ -3,6 +3,7 @@ from oauth2_provider import views from .views import ( CorporateAuthorizationView, + CorporateConnectDiscoveryView, CorporateIntrospectTokenView, CorporateRevokeTokenView, CorporateTokenView, @@ -27,7 +28,7 @@ urlpatterns = [ ), path( ".well-known/openid-configuration", - views.ConnectDiscoveryInfoView.as_view(), + CorporateConnectDiscoveryView.as_view(), name="oidc-connect-discovery-info", ), path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"), diff --git a/backend/api/oidc/validators.py b/backend/api/oidc/validators.py index ed280d56..8509bccd 100644 --- a/backend/api/oidc/validators.py +++ b/backend/api/oidc/validators.py @@ -49,6 +49,13 @@ class CorporateOAuth2Validator(OAuth2Validator): "groups": "groups", } + def _save_bearer_token(self, token, request, *args, **kwargs): + result = super()._save_bearer_token(token, request, *args, **kwargs) + from .backchannel import remember_login + + remember_login(request) + return result + 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))) diff --git a/backend/api/oidc/views.py b/backend/api/oidc/views.py index 36fbd7b7..5c9192c9 100644 --- a/backend/api/oidc/views.py +++ b/backend/api/oidc/views.py @@ -1,3 +1,5 @@ +import json + from django.conf import settings from django.contrib.auth.views import redirect_to_login from django.http import HttpResponseNotFound @@ -14,6 +16,18 @@ class OidcProviderEnabledMixin: return super().dispatch(request, *args, **kwargs) +class CorporateConnectDiscoveryView(oauth_views.ConnectDiscoveryInfoView): + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + if response.status_code == 200 and settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: + data = json.loads(response.content) + data["backchannel_logout_supported"] = True + # Subject-wide logout is supported; per-device sid tracking is not. + data["backchannel_logout_session_supported"] = False + response.content = json.dumps(data).encode("utf-8") + return response + + class CorporateAuthorizationView( OidcProviderEnabledMixin, oauth_views.AuthorizationView, diff --git a/backend/api/tasks.py b/backend/api/tasks.py new file mode 100644 index 00000000..db6a27fb --- /dev/null +++ b/backend/api/tasks.py @@ -0,0 +1,12 @@ +from celery import shared_task + + +@shared_task(name="api.oidc.dispatch_backchannel_logout", ignore_result=True) +def dispatch_backchannel_logout(): + from api.oidc.backchannel import ( + dispatch_due_deliveries, + enqueue_inactive_identities, + ) + + enqueue_inactive_identities() + dispatch_due_deliveries() diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 2feba9f4..adab93a0 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -508,6 +508,14 @@ OIDC_PORTAL_LOGIN_URL = os.getenv( OIDC_LEGACY_NO_PKCE_CLIENT_IDS = _split_env_list( os.getenv("OIDC_LEGACY_NO_PKCE_CLIENT_IDS", "") ) +OIDC_BACKCHANNEL_LOGOUT_ENABLED = ( + os.getenv("OIDC_BACKCHANNEL_LOGOUT_ENABLED", "false").lower() == "true" +) +# Django needs this explicit setting when project migrations reference the +# OAuth Toolkit's swappable application model. +OAUTH2_PROVIDER_APPLICATION_MODEL = os.getenv( + "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application" +) OAUTH2_PROVIDER = { "OIDC_ENABLED": OIDC_PROVIDER_ENABLED, @@ -934,6 +942,10 @@ CELERYD_PIDFILE = os.path.join(tempfile.gettempdir(), "celery-worker.pid") # Расписание периодических задач CELERY_BEAT_SCHEDULE = { + "oidc-backchannel-logout": { + "task": "api.oidc.dispatch_backchannel_logout", + "schedule": 60.0, + }, "commerce-sync-due-connections": { "task": "commerce.dispatch_due_connection_syncs", "schedule": 60.0, diff --git a/backend/tests/api/auth/test_oidc_backchannel.py b/backend/tests/api/auth/test_oidc_backchannel.py new file mode 100644 index 00000000..a02520ed --- /dev/null +++ b/backend/tests/api/auth/test_oidc_backchannel.py @@ -0,0 +1,239 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from io import StringIO +from threading import Barrier +from time import sleep +from types import SimpleNamespace +from unittest.mock import patch + +import jwt +import pytest +import requests +from django.core.exceptions import ValidationError +from django.core.management import call_command +from django.db import close_old_connections, connection +from django.urls import reverse +from django.utils import timezone + +from api.oidc.backchannel import dispatch_due_deliveries, enqueue_inactive_identities +from api.oidc.models import BackchannelClient, BackchannelDelivery, OIDCClientIdentity +from employees.constants import ACTION_DISMISSED +from employees.models import EmployeeAction +from tests.api.auth.test_oidc_provider import ( + _authorize, + _exchange_code, +) +from tests.api.auth.test_oidc_provider import configured_oidc as configured_oidc +from tests.api.auth.test_oidc_provider import employee as employee +from tests.api.auth.test_oidc_provider import oidc_client as oidc_client + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def enable_backchannel(settings): + settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED = True + + +def _login(client, employee, application, secret): + client.force_login(employee) + return _exchange_code(client, application, secret, _authorize(client, application)) + + +def test_discovery_and_registration_validation(client, oidc_client): + response = client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + assert response.json()["backchannel_logout_supported"] is True + assert response.json()["backchannel_logout_session_supported"] is False + application, _ = oidc_client + endpoint = BackchannelClient( + application=application, logout_uri="http://attacker.test/logout" + ) + with pytest.raises(ValidationError): + endpoint.full_clean() + + +def test_dismissal_delivers_signed_standard_token_once( + client, employee, oidc_client, configured_oidc +): + application, secret = oidc_client + endpoint = BackchannelClient.objects.create( + application=application, logout_uri="https://rp.example.test/logout" + ) + _login(client, employee, application, secret) + identity = OIDCClientIdentity.objects.get(application=application, user=employee) + assert identity.subject == str(employee.pk) + assert enqueue_inactive_identities() == 0 + action = EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=timezone.now() + timedelta(days=2), + ) + assert enqueue_inactive_identities() == 0 + action.date = timezone.now() + action.save(update_fields=["date"]) + assert enqueue_inactive_identities() == 1 + assert enqueue_inactive_identities() == 0 + with patch("api.oidc.backchannel.requests.post") as post: + post.return_value.status_code = 200 + assert dispatch_due_deliveries() == 1 + assert post.call_args.args[0] == endpoint.logout_uri + assert post.call_args.kwargs["allow_redirects"] is False + signed = post.call_args.kwargs["data"]["logout_token"] + claims = jwt.decode( + signed, + configured_oidc, + algorithms=["RS256"], + audience=application.client_id, + issuer="https://corp.example.test/o", + ) + assert claims["sub"] == str(employee.pk) + assert claims["events"] == { + "http://schemas.openid.net/event/backchannel-logout": {} + } + assert "nonce" not in claims and "sid" not in claims + assert jwt.get_unverified_header(signed)["typ"] == "logout+jwt" + assert BackchannelDelivery.objects.get().state == "delivered" + assert dispatch_due_deliveries() == 0 + + +def test_retry_and_rehire_do_not_send_stale_logout(client, employee, oidc_client): + application, secret = oidc_client + BackchannelClient.objects.create( + application=application, logout_uri="https://rp.example.test/logout" + ) + _login(client, employee, application, secret) + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert enqueue_inactive_identities() == 1 + with patch("api.oidc.backchannel.requests.post", side_effect=requests.Timeout): + assert dispatch_due_deliveries() == 1 + delivery = BackchannelDelivery.objects.get() + assert delivery.state == "pending" and delivery.attempts == 1 + assert delivery.next_attempt_at > timezone.now() + employee.is_active = True + employee.save(update_fields=["is_active"]) + delivery.next_attempt_at = timezone.now() - timedelta(seconds=1) + delivery.save(update_fields=["next_attempt_at"]) + with patch("api.oidc.backchannel.requests.post") as post: + assert dispatch_due_deliveries() == 1 + post.assert_not_called() + delivery.refresh_from_db() + assert delivery.state == "cancelled" + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert enqueue_inactive_identities() == 1 + + +def test_http_error_classification(client, employee, oidc_client): + application, secret = oidc_client + BackchannelClient.objects.create( + application=application, logout_uri="https://rp.example.test/logout" + ) + _login(client, employee, application, secret) + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert enqueue_inactive_identities() == 1 + with patch("api.oidc.backchannel.requests.post") as post: + post.return_value.status_code = 500 + dispatch_due_deliveries() + delivery = BackchannelDelivery.objects.get() + assert delivery.state == "pending" and delivery.last_error == "http_500" + delivery.next_attempt_at = timezone.now() - timedelta(seconds=1) + delivery.save(update_fields=["next_attempt_at"]) + post.return_value.status_code = 400 + dispatch_due_deliveries() + delivery.refresh_from_db() + assert delivery.state == "failed" and delivery.last_error == "http_400" + + +def test_only_clients_the_employee_used_are_notified(client, employee, oidc_client): + application, secret = oidc_client + model = type(application) + unrelated = model.objects.create( + name="Other application", + client_id="other-client", + client_type=model.CLIENT_CONFIDENTIAL, + authorization_grant_type=model.GRANT_AUTHORIZATION_CODE, + algorithm=model.RS256_ALGORITHM, + ) + BackchannelClient.objects.create( + application=application, logout_uri="https://one.example.test/logout" + ) + BackchannelClient.objects.create( + application=unrelated, logout_uri="https://two.example.test/logout" + ) + _login(client, employee, application, secret) + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert enqueue_inactive_identities() == 1 + with patch("api.oidc.backchannel.requests.post") as post: + post.return_value.status_code = 200 + dispatch_due_deliveries() + assert post.call_count == 1 + assert post.call_args.args[0] == "https://one.example.test/logout" + + +def test_disabled_integration_does_not_track_or_advertise( + settings, client, employee, oidc_client +): + settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED = False + application, secret = oidc_client + _login(client, employee, application, secret) + assert not OIDCClientIdentity.objects.exists() + assert ( + "backchannel_logout_supported" + not in client.get(reverse("oauth2_provider:oidc-connect-discovery-info")).json() + ) + + +def test_seed_retained_login_tokens_is_explicit_and_idempotent( + settings, client, employee, oidc_client +): + settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED = False + application, secret = oidc_client + _login(client, employee, application, secret) + assert not OIDCClientIdentity.objects.exists() + call_command("seed_backchannel_identities", stdout=StringIO()) + assert not OIDCClientIdentity.objects.exists() + call_command("seed_backchannel_identities", "--apply", stdout=StringIO()) + call_command("seed_backchannel_identities", "--apply", stdout=StringIO()) + assert ( + OIDCClientIdentity.objects.filter( + application=application, user=employee + ).count() + == 1 + ) + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_workers_claim_one_delivery(client, employee, oidc_client): + if connection.vendor != "postgresql": + pytest.skip("Concurrent queue locking requires PostgreSQL") + application, secret = oidc_client + BackchannelClient.objects.create( + application=application, logout_uri="https://rp.example.test/logout" + ) + _login(client, employee, application, secret) + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert enqueue_inactive_identities() == 1 + barrier = Barrier(2) + + def worker(): + close_old_connections() + try: + barrier.wait(timeout=5) + return dispatch_due_deliveries() + finally: + close_old_connections() + + def send(*args, **kwargs): + sleep(0.2) + return SimpleNamespace(status_code=200) + + with patch("api.oidc.backchannel.requests.post", side_effect=send) as post: + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: worker(), range(2))) + assert sum(results) == 1 + assert post.call_count == 1 + assert BackchannelDelivery.objects.get().state == "delivered" diff --git a/docs/guides/OIDC.md b/docs/guides/OIDC.md index c2f3a831..4c0beecd 100644 --- a/docs/guides/OIDC.md +++ b/docs/guides/OIDC.md @@ -1,5 +1,34 @@ # Корпоративный OpenID Connect +## Back-Channel Logout при отключении сотрудника + +Необязательный механизм включается настройкой `OIDC_BACKCHANNEL_LOGOUT_ENABLED=true`. +Он не синхронизирует справочник сотрудников: провайдер запоминает только пары +«сотрудник — OIDC-клиент», для которых прошёл вход. В админке суперпользователь +может зарегистрировать для отдельного клиента HTTPS-адрес back-channel logout. +Без регистрации адресов уведомления никуда не отправляются. Поддерживаются +RS256-клиенты и выход всех сессий по `sub`; механизм с отдельными `sid` не реализован. + +Раз в минуту задача Celery проверяет фактический статус запомненных сотрудников, +включая кадровые события с будущей датой. Когда учётная запись отключена или +увольнение вступило в силу, для каждого зарегистрированного клиента создаётся +запись очереди. Отправляется подписанный JWT `logout_token` согласно OIDC +Back-Channel Logout. Временные ошибки сети, 408, 429 и 5xx повторяются с +увеличением интервала; остальные HTTP-ошибки отмечаются как неудачные в админке, +где суперпользователь может запустить повтор. Если сотрудник восстановлен до +отправки, устаревшее уведомление отменяется. + +Для входов, состоявшихся до включения механизма, можно дополнить список по ещё +сохранённым OIDC-токенам: команда `python manage.py seed_backchannel_identities` +показывает количество, а с `--apply` добавляет отсутствующие записи. Это не +восстанавливает информацию об уже удалённых или очищенных токенах; старые сессии +таких пользователей требуют отдельной проверки или повторного входа. + +Корректное закрытие сессий, собственных токенов и SSH-ключей зависит от каждого +подключённого приложения; регистрация адреса не подтверждает его совместимость. +При передаче уведомления в момент повторного приёма возможна гонка с новой +сессией: после восстановления в случае её закрытия потребуется повторный вход. + Портал выступает доверенным OpenID Connect Provider для внутренних приложений. Первое подключение — Gitea; тот же контракт можно использовать для других клиентов с поддержкой OIDC Authorization Code Flow. ## Контракт