Add OIDC back-channel logout for deactivated accounts
All checks were successful
Deploy production / deploy (push) Successful in 10m4s

This commit is contained in:
admin i 2026-10-27 20:01:26 +03:00
parent c5e2005944
commit 86fed54811
13 changed files with 850 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
backend/api/tasks.py Normal file
View File

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

View File

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

View File

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

View File

@ -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.
## Контракт