ISO-27001-Risk-Management/.venv/lib/python3.11/site-packages/mozilla_django_oidc/middleware.py
2025-09-24 18:13:40 +02:00

202 lines
7.7 KiB
Python

import logging
import time
from re import Pattern as re_Pattern
from urllib.parse import quote, urlencode
from django.contrib.auth import BACKEND_SESSION_KEY
from django.http import HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import cached_property
from django.utils.module_loading import import_string
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from mozilla_django_oidc.utils import (
absolutify,
add_state_and_verifier_and_nonce_to_session,
generate_code_challenge,
import_from_settings,
)
LOGGER = logging.getLogger(__name__)
class SessionRefresh(MiddlewareMixin):
"""Refreshes the session with the OIDC RP after expiry seconds
For users authenticated with the OIDC RP, verify tokens are still valid and
if not, force the user to re-authenticate silently.
"""
def __init__(self, get_response):
super(SessionRefresh, self).__init__(get_response)
self.OIDC_EXEMPT_URLS = self.get_settings("OIDC_EXEMPT_URLS", [])
self.OIDC_OP_AUTHORIZATION_ENDPOINT = self.get_settings(
"OIDC_OP_AUTHORIZATION_ENDPOINT"
)
self.OIDC_RP_CLIENT_ID = self.get_settings("OIDC_RP_CLIENT_ID")
self.OIDC_STATE_SIZE = self.get_settings("OIDC_STATE_SIZE", 32)
self.OIDC_AUTHENTICATION_CALLBACK_URL = self.get_settings(
"OIDC_AUTHENTICATION_CALLBACK_URL",
"oidc_authentication_callback",
)
self.OIDC_RP_SCOPES = self.get_settings("OIDC_RP_SCOPES", "openid email")
self.OIDC_USE_NONCE = self.get_settings("OIDC_USE_NONCE", True)
self.OIDC_NONCE_SIZE = self.get_settings("OIDC_NONCE_SIZE", 32)
@staticmethod
def get_settings(attr, *args):
return import_from_settings(attr, *args)
@cached_property
def exempt_urls(self):
"""Generate and return a set of url paths to exempt from SessionRefresh
This takes the value of ``settings.OIDC_EXEMPT_URLS`` and appends three
urls that mozilla-django-oidc uses. These values can be view names or
absolute url paths.
:returns: list of url paths (for example "/oidc/callback/")
"""
exempt_urls = []
for url in self.OIDC_EXEMPT_URLS:
if not isinstance(url, re_Pattern):
exempt_urls.append(url)
exempt_urls.extend(
[
"oidc_authentication_init",
"oidc_authentication_callback",
"oidc_logout",
]
)
return set(
[url if url.startswith("/") else reverse(url) for url in exempt_urls]
)
@cached_property
def exempt_url_patterns(self):
"""Generate and return a set of url patterns to exempt from SessionRefresh
This takes the value of ``settings.OIDC_EXEMPT_URLS`` and returns the
values that are compiled regular expression patterns.
:returns: list of url patterns (for example,
``re.compile(r"/user/[0-9]+/image")``)
"""
exempt_patterns = set()
for url_pattern in self.OIDC_EXEMPT_URLS:
if isinstance(url_pattern, re_Pattern):
exempt_patterns.add(url_pattern)
return exempt_patterns
def is_refreshable_url(self, request):
"""Takes a request and returns whether it triggers a refresh examination
:arg HttpRequest request:
:returns: boolean
"""
# Do not attempt to refresh the session if the OIDC backend is not used
backend_session = request.session.get(BACKEND_SESSION_KEY)
is_oidc_enabled = True
if backend_session:
auth_backend = import_string(backend_session)
is_oidc_enabled = issubclass(auth_backend, OIDCAuthenticationBackend)
return (
request.method == "GET"
and request.user.is_authenticated
and is_oidc_enabled
and request.path not in self.exempt_urls
and not any(pat.match(request.path) for pat in self.exempt_url_patterns)
)
def process_request(self, request):
if not self.is_refreshable_url(request):
LOGGER.debug("request is not refreshable")
return
expiration = request.session.get("oidc_id_token_expiration", 0)
now = time.time()
if expiration > now:
# The id_token is still valid, so we don't have to do anything.
LOGGER.debug("id token is still valid (%s > %s)", expiration, now)
return
LOGGER.debug("id token has expired")
# The id_token has expired, so we have to re-authenticate silently.
auth_url = self.OIDC_OP_AUTHORIZATION_ENDPOINT
client_id = self.OIDC_RP_CLIENT_ID
state = get_random_string(self.OIDC_STATE_SIZE)
# Build the parameters as if we were doing a real auth handoff, except
# we also include prompt=none.
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": absolutify(
request, reverse(self.OIDC_AUTHENTICATION_CALLBACK_URL)
),
"state": state,
"scope": self.OIDC_RP_SCOPES,
"prompt": "none",
}
params.update(self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", {}))
if self.OIDC_USE_NONCE:
nonce = get_random_string(self.OIDC_NONCE_SIZE)
params.update({"nonce": nonce})
if self.get_settings("OIDC_USE_PKCE", False):
code_verifier_length = self.get_settings("OIDC_PKCE_CODE_VERIFIER_SIZE", 64)
# Check that code_verifier_length is between the min and max length
# defined in https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
if not (43 <= code_verifier_length <= 128):
raise ValueError("code_verifier_length must be between 43 and 128")
# Generate code_verifier and code_challenge pair
code_verifier = get_random_string(code_verifier_length)
code_challenge_method = self.get_settings(
"OIDC_PKCE_CODE_CHALLENGE_METHOD", "S256"
)
code_challenge = generate_code_challenge(
code_verifier, code_challenge_method
)
# Append code_challenge to authentication request parameters
params.update(
{
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
}
)
else:
code_verifier = None
add_state_and_verifier_and_nonce_to_session(
request, state, params, code_verifier
)
request.session["oidc_login_next"] = request.get_full_path()
query = urlencode(params, quote_via=quote)
redirect_url = "{url}?{query}".format(url=auth_url, query=query)
if request.headers.get("x-requested-with") == "XMLHttpRequest":
# Almost all XHR request handling in client-side code struggles
# with redirects since redirecting to a page where the user
# is supposed to do something is extremely unlikely to work
# in an XHR request. Make a special response for these kinds
# of requests.
# The use of 403 Forbidden is to match the fact that this
# middleware doesn't really want the user in if they don't
# refresh their session.
response = JsonResponse({"refresh_url": redirect_url}, status=403)
response["refresh_url"] = redirect_url
return response
return HttpResponseRedirect(redirect_url)