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 %}