Corp/backend/api/oidc/backchannel.py
Игорь Надеин 86fed54811
All checks were successful
Deploy production / deploy (push) Successful in 10m4s
Add OIDC back-channel logout for deactivated accounts
2026-10-27 20:01:26 +03:00

219 lines
7.4 KiB
Python

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