Add OIDC back-channel logout for deactivated accounts
All checks were successful
Deploy production / deploy (push) Successful in 10m4s
All checks were successful
Deploy production / deploy (push) Successful in 10m4s
This commit is contained in:
parent
c5e2005944
commit
86fed54811
@ -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
|
||||
|
||||
@ -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}"
|
||||
)
|
||||
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
]
|
||||
|
||||
218
backend/api/oidc/backchannel.py
Normal file
218
backend/api/oidc/backchannel.py
Normal 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},
|
||||
)
|
||||
87
backend/api/oidc/models.py
Normal file
87
backend/api/oidc/models.py
Normal 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"
|
||||
)
|
||||
]
|
||||
@ -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"),
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
12
backend/api/tasks.py
Normal 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()
|
||||
@ -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,
|
||||
|
||||
239
backend/tests/api/auth/test_oidc_backchannel.py
Normal file
239
backend/tests/api/auth/test_oidc_backchannel.py
Normal 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"
|
||||
@ -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.
|
||||
|
||||
## Контракт
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user