From b3769a783dd1543d70c2de59cd0ea9e16e023e60 Mon Sep 17 00:00:00 2001 From: Steve Breker Date: Fri, 4 Oct 2024 14:39:30 -0700 Subject: [PATCH] Enhance OIDC authentication flow This adds two new enhancements for OIDC authentication flow: * Add a new setting to control whether local AM authentication is available when OIDC authentication is in use. If local AM authentication is disabled, then users will only be able to authenticate via the OIDC provider. If the new setting is not configured, local AM authentication is available. * Add ability to define more than one OIDC provider in AM. Specific providers can be chosen using HTTP query params passed to the server when authenticating. --- .../src/components/accounts/backends.py | 44 +++++++ src/dashboard/src/components/accounts/urls.py | 18 +++ .../src/components/accounts/views.py | 116 ++++++++++++++++++ src/dashboard/src/middleware/common.py | 20 +++ src/dashboard/src/settings/base.py | 29 ++++- .../src/settings/components/oidc_auth.py | 57 ++++++++- src/dashboard/src/templates/layout.html | 4 +- .../components/accounts/test_views.py | 38 ++++++ tests/dashboard/test_middleware.py | 23 ++++ tests/integration/docker-compose.yml | 10 ++ tests/integration/etc/keycloak/realm.json | 50 ++++++++ tests/integration/test_oidc_auth.py | 70 +++++++++++ 12 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 tests/dashboard/components/accounts/test_views.py diff --git a/src/dashboard/src/components/accounts/backends.py b/src/dashboard/src/components/accounts/backends.py index 1e251cb7df..8be862ad7e 100644 --- a/src/dashboard/src/components/accounts/backends.py +++ b/src/dashboard/src/components/accounts/backends.py @@ -2,6 +2,7 @@ from components.helpers import generate_api_key from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django_auth_ldap.backend import LDAPBackend from django_cas_ng.backends import CASBackend from josepy.jws import JWS @@ -42,6 +43,49 @@ class CustomOIDCBackend(OIDCAuthenticationBackend): Provide OpenID Connect authentication """ + def get_settings(self, attr, *args): + if attr in [ + "OIDC_RP_CLIENT_ID", + "OIDC_RP_CLIENT_SECRET", + "OIDC_OP_AUTHORIZATION_ENDPOINT", + "OIDC_OP_TOKEN_ENDPOINT", + "OIDC_OP_USER_ENDPOINT", + "OIDC_OP_JWKS_ENDPOINT", + "OIDC_OP_LOGOUT_ENDPOINT", + ]: + # Retrieve the request object stored in the instance. + request = getattr(self, "request", None) + + if request: + provider_name = request.session.get("providername") + + if ( + provider_name + and provider_name in settings.OIDC_SECONDARY_PROVIDER_NAMES + ): + provider_settings = settings.OIDC_PROVIDERS.get(provider_name, {}) + value = provider_settings.get(attr) + + if value is None: + raise ImproperlyConfigured( + f"Setting {attr} for provider {provider_name} not found" + ) + return value + + # If request is None or provider_name session var is not set or attr is + # not in the list, call the superclass's get_settings method. + return OIDCAuthenticationBackend.get_settings(attr, *args) + + def authenticate(self, request, **kwargs): + self.request = request + self.OIDC_RP_CLIENT_ID = self.get_settings("OIDC_RP_CLIENT_ID") + self.OIDC_RP_CLIENT_SECRET = self.get_settings("OIDC_RP_CLIENT_SECRET") + self.OIDC_OP_TOKEN_ENDPOINT = self.get_settings("OIDC_OP_TOKEN_ENDPOINT") + self.OIDC_OP_USER_ENDPOINT = self.get_settings("OIDC_OP_USER_ENDPOINT") + self.OIDC_OP_JWKS_ENDPOINT = self.get_settings("OIDC_OP_JWKS_ENDPOINT") + + return super().authenticate(request, **kwargs) + def get_userinfo(self, access_token, id_token, verified_id): """ Extract user details from JSON web tokens diff --git a/src/dashboard/src/components/accounts/urls.py b/src/dashboard/src/components/accounts/urls.py index 343fc20f11..e9e360fb99 100644 --- a/src/dashboard/src/components/accounts/urls.py +++ b/src/dashboard/src/components/accounts/urls.py @@ -37,6 +37,24 @@ path("logout/", django_cas_ng.views.LogoutView.as_view(), name="logout"), ] +elif "mozilla_django_oidc" in settings.INSTALLED_APPS: + from components.accounts.views import CustomOIDCLogoutView + + urlpatterns += [ + path( + "login/", + django.contrib.auth.views.LoginView.as_view( + template_name="accounts/login.html" + ), + name="login", + ), + path( + "logout/", + CustomOIDCLogoutView.as_view(), + name="logout", + ), + ] + else: urlpatterns += [ path( diff --git a/src/dashboard/src/components/accounts/views.py b/src/dashboard/src/components/accounts/views.py index 7a06666715..00f81df19d 100644 --- a/src/dashboard/src/components/accounts/views.py +++ b/src/dashboard/src/components/accounts/views.py @@ -14,6 +14,8 @@ # # You should have received a copy of the GNU General Public License # along with Archivematica. If not, see . +from urllib.parse import urlencode + import components.decorators as decorators from components.accounts.forms import ApiKeyForm from components.accounts.forms import UserChangeForm @@ -24,6 +26,8 @@ from django.contrib import messages from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.models import User +from django.contrib.auth.views import logout_then_login +from django.core.exceptions import ImproperlyConfigured from django.http import Http404 from django.shortcuts import get_object_or_404 from django.shortcuts import redirect @@ -31,6 +35,8 @@ from django.urls import reverse from django.utils.translation import gettext as _ from main.models import UserProfile +from mozilla_django_oidc.views import OIDCAuthenticationRequestView +from mozilla_django_oidc.views import OIDCLogoutView from tastypie.models import ApiKey @@ -193,3 +199,113 @@ def delete(request, id): return redirect("accounts:accounts_index") except Exception: raise Http404 + + +class CustomOIDCAuthenticationRequestView(OIDCAuthenticationRequestView): + """ + Provide OpenID Connect authentication + """ + + def get_settings(self, attr, *args): + if attr in [ + "OIDC_RP_CLIENT_ID", + "OIDC_RP_CLIENT_SECRET", + "OIDC_OP_AUTHORIZATION_ENDPOINT", + "OIDC_OP_TOKEN_ENDPOINT", + "OIDC_OP_USER_ENDPOINT", + "OIDC_OP_JWKS_ENDPOINT", + "OIDC_OP_LOGOUT_ENDPOINT", + ]: + # Retrieve the request object stored in the instance. + request = getattr(self, "request", None) + + if request: + provider_name = request.session.get("providername") + + if ( + provider_name + and provider_name in settings.OIDC_SECONDARY_PROVIDER_NAMES + ): + provider_settings = settings.OIDC_PROVIDERS.get(provider_name, {}) + value = provider_settings.get(attr) + + if value is None: + raise ImproperlyConfigured( + f"Setting {attr} for provider {provider_name} not found" + ) + return value + + # If request is None or provider_name session var is not set or attr is + # not in the list, call the superclass's get_settings method. + return OIDCAuthenticationRequestView.get_settings(attr, *args) + + def get(self, request): + self.request = request + self.OIDC_RP_CLIENT_ID = self.get_settings("OIDC_RP_CLIENT_ID") + self.OIDC_RP_CLIENT_SECRET = self.get_settings("OIDC_RP_CLIENT_SECRET") + self.OIDC_OP_AUTH_ENDPOINT = self.get_settings("OIDC_OP_AUTHORIZATION_ENDPOINT") + + return super().get(request) + + +class CustomOIDCLogoutView(OIDCLogoutView): + """ + Provide OpenID Logout capability + """ + + def get(self, request): + self.request = request + + if "oidc_id_token" in request.session: + # If the user authenticated via OIDC, perform the OIDC logout. + redirect = super().post(request) + + if "providername" in request.session: + del request.session["providername"] + + return redirect + else: + # If the user did not authenticate via OIDC, perform a local logout and redirect to login. + return logout_then_login(request) + + +def get_oidc_logout_url(request): + """ + Constructs the OIDC logout URL used in OIDCLogoutView. + """ + # Retrieve the ID token from the session. + id_token = request.session.get("oidc_id_token") + + if not id_token: + raise ValueError("ID token not found in session.") + + # Get the end session endpoint. + end_session_endpoint = getattr(settings, "OIDC_OP_LOGOUT_ENDPOINT", None) + + # Override the end session endpoint from the provider settings if available. + if request: + provider_name = request.session.get("providername") + + if provider_name and provider_name in settings.OIDC_SECONDARY_PROVIDER_NAMES: + provider_settings = settings.OIDC_PROVIDERS.get(provider_name, {}) + end_session_endpoint = provider_settings.get("OIDC_OP_LOGOUT_ENDPOINT") + + if end_session_endpoint is None: + raise ImproperlyConfigured( + f"Setting OIDC_OP_LOGOUT_ENDPOINT for provider {provider_name} not found" + ) + + if not end_session_endpoint: + raise ValueError("OIDC logout endpoint not configured for provider.") + + # Define the post logout redirect URL. + post_logout_redirect_uri = request.build_absolute_uri("/") + + # Construct the logout URL with required parameters. + params = { + "id_token_hint": id_token, + "post_logout_redirect_uri": post_logout_redirect_uri, + } + logout_url = f"{end_session_endpoint}?{urlencode(params)}" + + return logout_url diff --git a/src/dashboard/src/middleware/common.py b/src/dashboard/src/middleware/common.py index 0750ecc05b..9f5b6ec0e0 100644 --- a/src/dashboard/src/middleware/common.py +++ b/src/dashboard/src/middleware/common.py @@ -122,3 +122,23 @@ def make_profile(self, user, shib_meta): entitlements = shib_meta["entitlement"].split(";") user.is_superuser = settings.SHIBBOLETH_ADMIN_ENTITLEMENT in entitlements user.save() + + +class OidcCaptureQueryParamMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.user.is_authenticated: + # Capture query parameter value and store it in the session. + provider_name = request.GET.get( + settings.OIDC_PROVIDER_QUERY_PARAM_NAME, "" + ).upper() + + if provider_name and provider_name in settings.OIDC_PROVIDERS: + request.session["providername"] = provider_name + + # Continue processing the request. + response = self.get_response(request) + + return response diff --git a/src/dashboard/src/settings/base.py b/src/dashboard/src/settings/base.py index 67eaef1121..462cb75175 100644 --- a/src/dashboard/src/settings/base.py +++ b/src/dashboard/src/settings/base.py @@ -126,6 +126,11 @@ def _get_settings_from_file(path): "option": "oidc_authentication", "type": "boolean", }, + "oidc_allow_local_authentication": { + "section": "Dashboard", + "option": "oidc_allow_local_authentication", + "type": "boolean", + }, "storage_service_client_timeout": { "section": "Dashboard", "option": "storage_service_client_timeout", @@ -204,6 +209,7 @@ def _get_settings_from_file(path): csrf_trusted_origins = use_x_forwarded_host = False oidc_authentication = False +oidc_allow_local_authentication = True storage_service_client_timeout = 86400 storage_service_client_quick_timeout = 5 agentarchives_client_timeout = 300 @@ -629,11 +635,32 @@ def _get_settings_from_file(path): OIDC_AUTHENTICATION = config.get("oidc_authentication") if OIDC_AUTHENTICATION: + OIDC_ALLOW_LOCAL_AUTHENTICATION = config.get("oidc_allow_local_authentication") + + INSTALLED_APPS += ["mozilla_django_oidc"] ALLOW_USER_EDITS = False + OIDC_STORE_ID_TOKEN = True + + OIDC_AUTHENTICATE_CLASS = ( + "components.accounts.views.CustomOIDCAuthenticationRequestView" + ) AUTHENTICATION_BACKENDS += ["components.accounts.backends.CustomOIDCBackend"] LOGIN_EXEMPT_URLS.append(r"^oidc") - INSTALLED_APPS += ["mozilla_django_oidc"] + + if not OIDC_ALLOW_LOCAL_AUTHENTICATION: + LOGIN_URL = "/oidc/authenticate/" + AUTHENTICATION_BACKENDS = [ + backend + for backend in AUTHENTICATION_BACKENDS + if backend != "django.contrib.auth.backends.ModelBackend" + ] + + # Insert OIDC before the redirect to LOGIN_URL + MIDDLEWARE.insert( + MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") + 1, + "middleware.common.OidcCaptureQueryParamMiddleware", + ) from .components.oidc_auth import * diff --git a/src/dashboard/src/settings/components/oidc_auth.py b/src/dashboard/src/settings/components/oidc_auth.py index 94b8978145..1a15be2359 100644 --- a/src/dashboard/src/settings/components/oidc_auth.py +++ b/src/dashboard/src/settings/components/oidc_auth.py @@ -1,5 +1,41 @@ import os + +def get_oidc_secondary_providers(oidc_secondary_provider_names): + """Build secondary OIDC provider details dict. Takes a list of secondary + OIDC providers and gathers details about these providers from env vars. + Output dict contains details for each OIDC connection which can then be + referenced by name. + """ + + providers = {} + + for provider_name in oidc_secondary_provider_names: + provider_name = provider_name.strip() + client_id = os.environ.get(f"OIDC_RP_CLIENT_ID_{provider_name}") + client_secret = os.environ.get(f"OIDC_RP_CLIENT_SECRET_{provider_name}") + authorization_endpoint = os.environ.get( + f"OIDC_OP_AUTHORIZATION_ENDPOINT_{provider_name}", "" + ) + token_endpoint = os.environ.get(f"OIDC_OP_TOKEN_ENDPOINT_{provider_name}", "") + user_endpoint = os.environ.get(f"OIDC_OP_USER_ENDPOINT_{provider_name}", "") + jwks_endpoint = os.environ.get(f"OIDC_OP_JWKS_ENDPOINT_{provider_name}", "") + logout_endpoint = os.environ.get(f"OIDC_OP_LOGOUT_ENDPOINT_{provider_name}", "") + + if client_id and client_secret: + providers[provider_name] = { + "OIDC_RP_CLIENT_ID": client_id, + "OIDC_RP_CLIENT_SECRET": client_secret, + "OIDC_OP_AUTHORIZATION_ENDPOINT": authorization_endpoint, + "OIDC_OP_TOKEN_ENDPOINT": token_endpoint, + "OIDC_OP_USER_ENDPOINT": user_endpoint, + "OIDC_OP_JWKS_ENDPOINT": jwks_endpoint, + "OIDC_OP_LOGOUT_ENDPOINT": logout_endpoint, + } + + return providers + + OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "") OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "") @@ -7,6 +43,7 @@ OIDC_OP_TOKEN_ENDPOINT = "" OIDC_OP_USER_ENDPOINT = "" OIDC_OP_JWKS_ENDPOINT = "" +OIDC_OP_LOGOUT_ENDPOINT = "" AZURE_TENANT_ID = os.environ.get("AZURE_TENANT_ID", "") if AZURE_TENANT_ID: @@ -23,10 +60,24 @@ "https://login.microsoftonline.com/%s/discovery/v2.0/keys" % AZURE_TENANT_ID ) else: - OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ["OIDC_OP_AUTHORIZATION_ENDPOINT"] - OIDC_OP_TOKEN_ENDPOINT = os.environ["OIDC_OP_TOKEN_ENDPOINT"] - OIDC_OP_USER_ENDPOINT = os.environ["OIDC_OP_USER_ENDPOINT"] + OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get( + "OIDC_OP_AUTHORIZATION_ENDPOINT", "" + ) + OIDC_OP_TOKEN_ENDPOINT = os.environ.get("OIDC_OP_TOKEN_ENDPOINT", "") + OIDC_OP_USER_ENDPOINT = os.environ.get("OIDC_OP_USER_ENDPOINT", "") OIDC_OP_JWKS_ENDPOINT = os.environ.get("OIDC_OP_JWKS_ENDPOINT", "") + OIDC_OP_LOGOUT_ENDPOINT = os.environ.get("OIDC_OP_LOGOUT_ENDPOINT", "") + +OIDC_SECONDARY_PROVIDER_NAMES = os.environ.get( + "OIDC_SECONDARY_PROVIDER_NAMES", "" +).split(",") +OIDC_PROVIDER_QUERY_PARAM_NAME = os.environ.get( + "OIDC_PROVIDER_QUERY_PARAM_NAME", "secondary" +) +OIDC_PROVIDERS = get_oidc_secondary_providers(OIDC_SECONDARY_PROVIDER_NAMES) + +if OIDC_OP_LOGOUT_ENDPOINT: + OIDC_OP_LOGOUT_URL_METHOD = "components.accounts.views.get_oidc_logout_url" OIDC_RP_SIGN_ALGO = os.environ.get("OIDC_RP_SIGN_ALGO", "HS256") diff --git a/src/dashboard/src/templates/layout.html b/src/dashboard/src/templates/layout.html index 5eddb7e82c..9c05e46de1 100644 --- a/src/dashboard/src/templates/layout.html +++ b/src/dashboard/src/templates/layout.html @@ -19,7 +19,9 @@ - + {% if user.is_authenticated %} + + {% endif %} {% block js %}{% endblock %}