All checks were successful
Deploy production / deploy (push) Successful in 10m4s
219 lines
7.4 KiB
Python
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},
|
|
)
|