Skip to content

Commit

Permalink
Add secondary OIDC provider capability
Browse files Browse the repository at this point in the history
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.

Add a new setting to control whether local AMSS 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.
  • Loading branch information
sbreker authored Oct 4, 2024
1 parent a384809 commit 6b22515
Show file tree
Hide file tree
Showing 9 changed files with 427 additions and 1 deletion.
5 changes: 5 additions & 0 deletions install/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,11 @@ If `SS_OIDC_AUTHENTICATION` is false, none of the other ones are used.
- **Type:** `boolean`
- **Default:** `false`

- **`SS_OIDC_ALLOW_LOCAL_AUTHENTICATION`**:
- **Description:** Allows local authentication and authentication via OIDC.
- **Type:** `boolean`
- **Default:** `true`

- **`OIDC_RP_CLIENT_ID`**:
- **Description:** OIDC client ID
- **Type:** `string`
Expand Down
44 changes: 44 additions & 0 deletions storage_service/common/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from administration import roles
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ImproperlyConfigured
from django_cas_ng.backends import CASBackend
from josepy.jws import JWS
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
Expand All @@ -24,6 +25,49 @@ def configure_user(self, user):
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: str, id_token: str, verified_id: Dict[str, Any]
) -> Dict[str, Any]:
Expand Down
20 changes: 20 additions & 0 deletions storage_service/common/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,23 @@ class ForceDefaultLanguageMiddleware(MiddlewareMixin):
def process_request(self, request):
if "accept-language" in request.headers:
del request.META["HTTP_ACCEPT_LANGUAGE"]


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
80 changes: 79 additions & 1 deletion storage_service/storage_service/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,13 +565,76 @@ def _get_settings_from_file(path):

USE_X_FORWARDED_HOST = is_true(environ.get("USE_X_FORWARDED_HOST", ""))

######### OIDC CONFIGURATION #########
OIDC_AUTHENTICATION = is_true(environ.get("SS_OIDC_AUTHENTICATION", ""))
if OIDC_AUTHENTICATION:

def get_oidc_secondary_providers(oidc_secondary_provider_names):
providers = {}

for provider_name in oidc_secondary_provider_names:
provider_name = provider_name.strip()
client_id = environ.get(f"OIDC_RP_CLIENT_ID_{provider_name.upper()}")
client_secret = environ.get(
f"OIDC_RP_CLIENT_SECRET_{provider_name.upper()}"
)
authorization_endpoint = environ.get(
f"OIDC_OP_AUTHORIZATION_ENDPOINT_{provider_name.upper()}", ""
)
token_endpoint = environ.get(
f"OIDC_OP_TOKEN_ENDPOINT_{provider_name.upper()}", ""
)
user_endpoint = environ.get(
f"OIDC_OP_USER_ENDPOINT_{provider_name.upper()}", ""
)
jwks_endpoint = environ.get(
f"OIDC_OP_JWKS_ENDPOINT_{provider_name.upper()}", ""
)
logout_endpoint = environ.get(
f"OIDC_OP_LOGOUT_ENDPOINT_{provider_name.upper()}", ""
)

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

ALLOW_USER_EDITS = False
INSTALLED_APPS += ["mozilla_django_oidc"]

OIDC_STORE_ID_TOKEN = True
OIDC_AUTHENTICATE_CLASS = (
"storage_service.views.CustomOIDCAuthenticationRequestView"
)

AUTHENTICATION_BACKENDS += ["common.backends.CustomOIDCBackend"]
LOGIN_EXEMPT_URLS.append(r"^oidc")
INSTALLED_APPS += ["mozilla_django_oidc"]

# Insert OIDC before the redirect to LOGIN_URL
MIDDLEWARE.insert(
MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") + 1,
"common.middleware.OidcCaptureQueryParamMiddleware",
)

OIDC_ALLOW_LOCAL_AUTHENTICATION = is_true(
environ.get("SS_OIDC_ALLOW_LOCAL_AUTHENTICATION", "true")
)

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"
]

# AUTH_SERVER = 'https://login.microsoftonline.com/common/v2.0/'
OIDC_RP_CLIENT_ID = environ.get("OIDC_RP_CLIENT_ID", "")
Expand All @@ -581,6 +644,7 @@ def _get_settings_from_file(path):
OIDC_OP_TOKEN_ENDPOINT = ""
OIDC_OP_USER_ENDPOINT = ""
OIDC_OP_JWKS_ENDPOINT = ""
OIDC_OP_LOGOUT_ENDPOINT = ""

AZURE_TENANT_ID = environ.get("AZURE_TENANT_ID", "")
if AZURE_TENANT_ID:
Expand All @@ -604,6 +668,18 @@ def _get_settings_from_file(path):
OIDC_OP_TOKEN_ENDPOINT = environ.get("OIDC_OP_TOKEN_ENDPOINT", "")
OIDC_OP_USER_ENDPOINT = environ.get("OIDC_OP_USER_ENDPOINT", "")
OIDC_OP_JWKS_ENDPOINT = environ.get("OIDC_OP_JWKS_ENDPOINT", "")
OIDC_OP_LOGOUT_ENDPOINT = environ.get("OIDC_OP_LOGOUT_ENDPOINT", "")

OIDC_SECONDARY_PROVIDER_NAMES = environ.get(
"OIDC_SECONDARY_PROVIDER_NAMES", ""
).split(",")
OIDC_PROVIDER_QUERY_PARAM_NAME = 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 = "storage_service.views.get_oidc_logout_url"

OIDC_RP_SIGN_ALGO = environ.get("OIDC_RP_SIGN_ALGO", "HS256")

Expand All @@ -619,6 +695,8 @@ def _get_email(email):
# map attributes from id token
OIDC_ID_ATTRIBUTE_MAP = {"email": "email"}

######### END OIDC CONFIGURATION #########

# WARNING: if Gunicorn is being used to serve the Storage Service and its
# worker class is set to `gevent`, then BagIt validation must use 1 process.
# Otherwise, calls to `validate` will hang because of the incompatibility
Expand Down
17 changes: 17 additions & 0 deletions storage_service/storage_service/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@
path("login/", django_cas_ng.views.LoginView.as_view(), name="login"),
path("logout/", django_cas_ng.views.LogoutView.as_view(), name="logout"),
]

elif "mozilla_django_oidc" in settings.INSTALLED_APPS:
from storage_service.views import CustomOIDCLogoutView

urlpatterns += [
path(
"login/",
django.contrib.auth.views.LoginView.as_view(template_name="login.html"),
name="login",
),
path(
"logout/",
CustomOIDCLogoutView.as_view(),
name="logout",
),
]

else:
urlpatterns += [
path(
Expand Down
117 changes: 117 additions & 0 deletions storage_service/storage_service/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
from urllib.parse import urlencode

from django.conf import settings
from django.contrib.auth.views import logout_then_login
from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone
from django.utils.translation import get_language
from django.views.decorators.cache import cache_page
from django.views.decorators.http import last_modified
from django.views.i18n import JavaScriptCatalog
from mozilla_django_oidc.views import OIDCAuthenticationRequestView
from mozilla_django_oidc.views import OIDCLogoutView
from shibboleth.views import ShibbolethLogoutView


Expand All @@ -14,3 +21,113 @@ def cached_javascript_catalog(request, domain="djangojs", packages=None):

class CustomShibbolethLogoutView(ShibbolethLogoutView):
pass


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
8 changes: 8 additions & 0 deletions tests/integration/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ services:
OIDC_OP_USER_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/userinfo"
OIDC_OP_JWKS_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/certs"
OIDC_OP_LOGOUT_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/logout"
OIDC_SECONDARY_PROVIDER_NAMES: "SECONDARY"
OIDC_RP_CLIENT_ID_SECONDARY: "am-storage-service-secondary"
OIDC_RP_CLIENT_SECRET_SECONDARY: "example-secret-secondary"
OIDC_OP_AUTHORIZATION_ENDPOINT_SECONDARY: "http://keycloak:8080/realms/secondary/protocol/openid-connect/auth"
OIDC_OP_TOKEN_ENDPOINT_SECONDARY: "http://keycloak:8080/realms/secondary/protocol/openid-connect/token"
OIDC_OP_USER_ENDPOINT_SECONDARY: "http://keycloak:8080/realms/secondary/protocol/openid-connect/userinfo"
OIDC_OP_JWKS_ENDPOINT_SECONDARY: "http://keycloak:8080/realms/secondary/protocol/openid-connect/certs"
OIDC_OP_LOGOUT_ENDPOINT_SECONDARY: "http://keycloak:8080/realms/secondary/protocol/openid-connect/logout"
OIDC_RP_SIGN_ALGO: "RS256"
volumes:
- "../../:/src"
Expand Down
Loading

0 comments on commit 6b22515

Please sign in to comment.