From c9f80ed52fd5a7b0b44f344145e09ef1aa519fc1 Mon Sep 17 00:00:00 2001 From: Conor Holden Date: Wed, 10 Jul 2024 11:35:02 +0200 Subject: [PATCH] :sparkles:[#114] add optional setup config support --- CHANGELOG.rst | 5 + .../setupconfig/__init__.py | 0 mozilla_django_oidc_db/setupconfig/auth.py | 89 +++++++ testapp/settings.py | 32 +++ tests/setupconfig/__init__.py | 0 tests/setupconfig/test_auth.py | 226 ++++++++++++++++++ 6 files changed, 352 insertions(+) create mode 100644 mozilla_django_oidc_db/setupconfig/__init__.py create mode 100644 mozilla_django_oidc_db/setupconfig/auth.py create mode 100644 tests/setupconfig/__init__.py create mode 100644 tests/setupconfig/test_auth.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 901237a..48a940a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,11 @@ Changelog ========= +0.20.0 (????) +============= + + + 0.19.0 (2024-07-02) =================== diff --git a/mozilla_django_oidc_db/setupconfig/__init__.py b/mozilla_django_oidc_db/setupconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mozilla_django_oidc_db/setupconfig/auth.py b/mozilla_django_oidc_db/setupconfig/auth.py new file mode 100644 index 0000000..821a8a9 --- /dev/null +++ b/mozilla_django_oidc_db/setupconfig/auth.py @@ -0,0 +1,89 @@ +from django.conf import settings +from django.contrib.auth.models import Group + +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from ..forms import OpenIDConnectConfigForm +from ..models import OpenIDConnectConfig + + +class AdminOIDCConfigurationStep(BaseConfigurationStep): + """ + Configure admin login via OpenID Connect + """ + + verbose_name = "Configuration for admin login via OpenID Connect" + required_settings = [ + "ADMIN_OIDC_OIDC_RP_CLIENT_ID", + "ADMIN_OIDC_OIDC_RP_CLIENT_SECRET", + ] + all_settings = required_settings + [ + "ADMIN_OIDC_OIDC_RP_SCOPES_LIST", + "ADMIN_OIDC_OIDC_RP_SIGN_ALGO", + "ADMIN_OIDC_OIDC_RP_IDP_SIGN_KEY", + "ADMIN_OIDC_OIDC_OP_DISCOVERY_ENDPOINT", + "ADMIN_OIDC_OIDC_OP_JWKS_ENDPOINT", + "ADMIN_OIDC_OIDC_OP_AUTHORIZATION_ENDPOINT", + "ADMIN_OIDC_OIDC_OP_TOKEN_ENDPOINT", + "ADMIN_OIDC_OIDC_OP_USER_ENDPOINT", + "ADMIN_OIDC_USERNAME_CLAIM", + "ADMIN_OIDC_GROUPS_CLAIM", + "ADMIN_OIDC_CLAIM_MAPPING", + "ADMIN_OIDC_SYNC_GROUPS", + "ADMIN_OIDC_SYNC_GROUPS_GLOB_PATTERN", + "ADMIN_OIDC_DEFAULT_GROUPS", + "ADMIN_OIDC_MAKE_USERS_STAFF", + "ADMIN_OIDC_SUPERUSER_GROUP_NAMES", + "ADMIN_OIDC_OIDC_USE_NONCE", + "ADMIN_OIDC_OIDC_NONCE_SIZE", + "ADMIN_OIDC_OIDC_STATE_SIZE", + "ADMIN_OIDC_OIDC_EXEMPT_URLS", + "ADMIN_OIDC_USERINFO_CLAIMS_SOURCE", + ] + enable_setting = "ADMIN_OIDC_CONFIG_ENABLE" + + def is_configured(self) -> bool: + return OpenIDConnectConfig.get_solo().enabled + + def configure(self): + config = OpenIDConnectConfig.get_solo() + + # Use the model defaults + form_data = { + field.name: getattr(config, field.name) + for field in OpenIDConnectConfig._meta.fields + } + + # `email` is in the claim_mapping by default, but email is used as the username field + # by OIP, and you cannot map the username field when using OIDC + if "email" in form_data["claim_mapping"]: + del form_data["claim_mapping"]["email"] + + # Only override field values with settings if they are defined + for setting in self.all_settings: + value = getattr(settings, setting, None) + if value is not None: + model_field_name = setting.split("ADMIN_OIDC_")[1].lower() + if model_field_name == "default_groups": + for group_name in value: + Group.objects.get_or_create(name=group_name) + value = Group.objects.filter(name__in=value) + + form_data[model_field_name] = value + form_data["enabled"] = True + + # Use the admin form to apply validation and fetch URLs from the discovery endpoint + form = OpenIDConnectConfigForm(data=form_data) + if not form.is_valid(): + raise ConfigurationRunFailed( + f"Something went wrong while saving configuration: {form.errors.as_json()}" + ) + + form.save() + + def test_configuration(self): + """ + TODO not sure if it is feasible (because there are different possible IdPs), + but it would be nice if we could test the login automatically + """ diff --git a/testapp/settings.py b/testapp/settings.py index 1ac9c6d..696855c 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -80,3 +80,35 @@ LOGIN_REDIRECT_URL = reverse_lazy("admin:index") STATIC_URL = "/static/" + + +# Setup Configuration Settings + +IDENTITY_PROVIDER = "https://keycloak.local/realms/digid/" + +ADMIN_OIDC_OIDC_RP_CLIENT_ID = "client-id" +ADMIN_OIDC_OIDC_RP_CLIENT_SECRET = "secret" +ADMIN_OIDC_OIDC_RP_SCOPES_LIST = ["open_id", "email", "profile", "extra_scope"] +ADMIN_OIDC_OIDC_RP_SIGN_ALGO = "RS256" +ADMIN_OIDC_OIDC_RP_IDP_SIGN_KEY = "key" +ADMIN_OIDC_OIDC_OP_DISCOVERY_ENDPOINT = None +ADMIN_OIDC_OIDC_OP_JWKS_ENDPOINT = f"{IDENTITY_PROVIDER}protocol/openid-connect/certs" +ADMIN_OIDC_OIDC_OP_AUTHORIZATION_ENDPOINT = ( + f"{IDENTITY_PROVIDER}protocol/openid-connect/auth" +) +ADMIN_OIDC_OIDC_OP_TOKEN_ENDPOINT = f"{IDENTITY_PROVIDER}protocol/openid-connect/token" +ADMIN_OIDC_OIDC_OP_USER_ENDPOINT = ( + f"{IDENTITY_PROVIDER}protocol/openid-connect/userinfo" +) +ADMIN_OIDC_USERNAME_CLAIM = ["claim_name"] +ADMIN_OIDC_GROUPS_CLAIM = ["groups_claim_name"] +ADMIN_OIDC_CLAIM_MAPPING = {"first_name": "given_name"} +ADMIN_OIDC_SYNC_GROUPS = False +ADMIN_OIDC_SYNC_GROUPS_GLOB_PATTERN = "local.groups.*" +ADMIN_OIDC_DEFAULT_GROUPS = ["Admins", "Read-only"] +ADMIN_OIDC_MAKE_USERS_STAFF = True +ADMIN_OIDC_SUPERUSER_GROUP_NAMES = ["superuser"] +ADMIN_OIDC_OIDC_USE_NONCE = False +ADMIN_OIDC_OIDC_NONCE_SIZE = 48 +ADMIN_OIDC_OIDC_STATE_SIZE = 48 +ADMIN_OIDC_USERINFO_CLAIMS_SOURCE = "id_token" diff --git a/tests/setupconfig/__init__.py b/tests/setupconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/setupconfig/test_auth.py b/tests/setupconfig/test_auth.py new file mode 100644 index 0000000..cca7689 --- /dev/null +++ b/tests/setupconfig/test_auth.py @@ -0,0 +1,226 @@ +from django.conf import settings as django_settings +from django.test import override_settings + +import pytest +import requests +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from mozilla_django_oidc_db.models import ( + OpenIDConnectConfig, + UserInformationClaimsSources, +) +from mozilla_django_oidc_db.setupconfig.auth import AdminOIDCConfigurationStep + +IDENTITY_PROVIDER = django_settings.IDENTITY_PROVIDER + + +@pytest.mark.django_db +def test_configure(): + AdminOIDCConfigurationStep().configure() + + config = OpenIDConnectConfig.get_solo() + + assert config.enabled + assert config.oidc_rp_client_id == "client-id" + assert config.oidc_rp_client_secret == "secret" + assert config.oidc_rp_scopes_list == ["open_id", "email", "profile", "extra_scope"] + assert config.oidc_rp_sign_algo == "RS256" + assert config.oidc_rp_idp_sign_key == "key" + assert config.oidc_op_discovery_endpoint == "" + assert ( + config.oidc_op_jwks_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/certs" + ) + assert ( + config.oidc_op_authorization_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/auth" + ) + assert ( + config.oidc_op_token_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/token" + ) + assert ( + config.oidc_op_user_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/userinfo" + ) + assert config.username_claim == ["claim_name"] + assert config.groups_claim == ["groups_claim_name"] + assert config.claim_mapping == {"first_name": "given_name"} + assert not config.sync_groups + assert config.sync_groups_glob_pattern == "local.groups.*" + assert list(group.name for group in config.default_groups.all()) == [ + "Admins", + "Read-only", + ] + assert config.make_users_staff + assert config.superuser_group_names == ["superuser"] + assert not config.oidc_use_nonce + assert config.oidc_nonce_size == 48 + assert config.oidc_state_size == 48 + assert config.userinfo_claims_source == UserInformationClaimsSources.id_token + + +@override_settings( + ADMIN_OIDC_OIDC_RP_SCOPES_LIST=None, + ADMIN_OIDC_OIDC_RP_SIGN_ALGO=None, + ADMIN_OIDC_OIDC_RP_IDP_SIGN_KEY=None, + ADMIN_OIDC_USERNAME_CLAIM=None, + ADMIN_OIDC_CLAIM_MAPPING=None, + ADMIN_OIDC_SYNC_GROUPS=None, + ADMIN_OIDC_SYNC_GROUPS_GLOB_PATTERN=None, + ADMIN_OIDC_MAKE_USERS_STAFF=None, + ADMIN_OIDC_OIDC_USE_NONCE=None, + ADMIN_OIDC_OIDC_NONCE_SIZE=None, + ADMIN_OIDC_OIDC_STATE_SIZE=None, + ADMIN_OIDC_OIDC_EXEMPT_URLS=None, + ADMIN_OIDC_USERINFO_CLAIMS_SOURCE=None, +) +@pytest.mark.django_db +def test_configure_use_defaults(): + + AdminOIDCConfigurationStep().configure() + + config = OpenIDConnectConfig.get_solo() + + assert config.enabled + assert config.oidc_rp_client_id == "client-id" + assert config.oidc_rp_client_secret == "secret" + assert config.oidc_rp_scopes_list == ["openid", "email", "profile"] + assert config.oidc_rp_sign_algo == "HS256" + assert config.oidc_rp_idp_sign_key == "" + assert config.oidc_op_discovery_endpoint == "" + assert ( + config.oidc_op_jwks_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/certs" + ) + assert ( + config.oidc_op_authorization_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/auth" + ) + assert ( + config.oidc_op_token_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/token" + ) + assert ( + config.oidc_op_user_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/userinfo" + ) + assert config.username_claim == ["sub"] + assert config.groups_claim == ["groups_claim_name"] + assert config.claim_mapping == { + "last_name": ["family_name"], + "first_name": ["given_name"], + } + assert config.sync_groups + assert config.sync_groups_glob_pattern == "*" + assert list(group.name for group in config.default_groups.all()) == [ + "Admins", + "Read-only", + ] + assert not config.make_users_staff + assert config.superuser_group_names == ["superuser"] + assert config.oidc_use_nonce + assert config.oidc_nonce_size == 32 + assert config.oidc_state_size == 32 + assert ( + config.userinfo_claims_source == UserInformationClaimsSources.userinfo_endpoint + ) + + +@pytest.fixture +def discovery_endpoint_response(): + + return { + "issuer": IDENTITY_PROVIDER, + "authorization_endpoint": f"{IDENTITY_PROVIDER}protocol/openid-connect/auth", + "token_endpoint": f"{IDENTITY_PROVIDER}protocol/openid-connect/token", + "userinfo_endpoint": f"{IDENTITY_PROVIDER}protocol/openid-connect/userinfo", + "end_session_endpoint": f"{IDENTITY_PROVIDER}protocol/openid-connect/logout", + "jwks_uri": f"{IDENTITY_PROVIDER}protocol/openid-connect/certs", + } + + +@override_settings( + ADMIN_OIDC_OIDC_OP_DISCOVERY_ENDPOINT=IDENTITY_PROVIDER, + ADMIN_OIDC_OIDC_OP_JWKS_ENDPOINT=None, + ADMIN_OIDC_OIDC_OP_AUTHORIZATION_ENDPOINT=None, + ADMIN_OIDC_OIDC_OP_TOKEN_ENDPOINT=None, + ADMIN_OIDC_OIDC_OP_USER_ENDPOINT=None, +) +@pytest.mark.django_db +def test_configure_use_discovery_endpoint(requests_mock, discovery_endpoint_response): + requests_mock.get( + f"{IDENTITY_PROVIDER}.well-known/openid-configuration", + json=discovery_endpoint_response, + ) + + AdminOIDCConfigurationStep().configure() + + config = OpenIDConnectConfig.get_solo() + + assert config.enabled + assert config.oidc_op_discovery_endpoint == IDENTITY_PROVIDER + assert ( + config.oidc_op_jwks_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/certs" + ) + assert ( + config.oidc_op_authorization_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/auth" + ) + assert ( + config.oidc_op_token_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/token" + ) + assert ( + config.oidc_op_user_endpoint + == f"{IDENTITY_PROVIDER}protocol/openid-connect/userinfo" + ) + + +@override_settings( + ADMIN_OIDC_OIDC_OP_DISCOVERY_ENDPOINT=IDENTITY_PROVIDER, + ADMIN_OIDC_OIDC_OP_JWKS_ENDPOINT=None, + ADMIN_OIDC_OIDC_OP_AUTHORIZATION_ENDPOINT=None, + ADMIN_OIDC_OIDC_OP_TOKEN_ENDPOINT=None, + ADMIN_OIDC_OIDC_OP_USER_ENDPOINT=None, +) +@pytest.mark.django_db +def test_configure_failure(requests_mock): + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + requests_mock.get( + f"{IDENTITY_PROVIDER}.well-known/openid-configuration", **mock_config, + ) + + with pytest.raises(ConfigurationRunFailed): + AdminOIDCConfigurationStep().configure() + + assert not OpenIDConnectConfig.get_solo().enabled + + +@pytest.mark.skip(reason="Testing config for DigiD OIDC is not implemented yet") +def test_configuration_check_ok(): + raise NotImplementedError + + +@pytest.mark.skip(reason="Testing config for DigiD OIDC is not implemented yet") +def test_configuration_check_failures(): + raise NotImplementedError + + +@pytest.mark.django_db +def test_is_configured(): + config = AdminOIDCConfigurationStep() + + assert not config.is_configured() + + config.configure() + + assert config.is_configured()