Skip to content

Commit

Permalink
Enhance OIDC authentication flow
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sbreker authored Oct 4, 2024
1 parent df00d21 commit b3769a7
Show file tree
Hide file tree
Showing 12 changed files with 474 additions and 5 deletions.
44 changes: 44 additions & 0 deletions src/dashboard/src/components/accounts/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/dashboard/src/components/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
116 changes: 116 additions & 0 deletions src/dashboard/src/components/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Archivematica. If not, see <http://www.gnu.org/licenses/>.
from urllib.parse import urlencode

import components.decorators as decorators
from components.accounts.forms import ApiKeyForm
from components.accounts.forms import UserChangeForm
Expand All @@ -24,13 +26,17 @@
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
from django.shortcuts import render
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


Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions src/dashboard/src/middleware/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 28 additions & 1 deletion src/dashboard/src/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 *

Expand Down
57 changes: 54 additions & 3 deletions src/dashboard/src/settings/components/oidc_auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
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", "")

OIDC_OP_AUTHORIZATION_ENDPOINT = ""
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:
Expand All @@ -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")

Expand Down
4 changes: 3 additions & 1 deletion src/dashboard/src/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
<script src="{% static 'vendor/jquery.idle-timer.js' %}" type="text/javascript"></script>
<script src="{% static 'vendor/underscore.js' %}" type="text/javascript"></script>
<script src="{% static 'vendor/backbone.js' %}" type="text/javascript"></script>
<script src="{% static 'js/status.js' %}" type="text/javascript"></script>
{% if user.is_authenticated %}
<script src="{% static 'js/status.js' %}" type="text/javascript"></script>
{% endif %}
<script src="{% static 'js/misc.js' %}" type="text/javascript"></script>
{% block js %}{% endblock %}
<script type="text/javascript">
Expand Down
Loading

0 comments on commit b3769a7

Please sign in to comment.