From 6934876f70c22140836fd463ad5c1eaf51f5241e Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:32:28 -0600 Subject: [PATCH 1/2] Isolate all SSO code into dojo/sso/ as a plain Python package Move all SSO functionality (OAuth2, SAML2, OIDC, remote user auth) into dojo/sso/ so that removing SSO requires only deleting the directory and removing packages from requirements.txt. All hooks in shared files use try/except ImportError guards that gracefully degrade when dojo/sso/ is absent. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dryrunsecurity.yaml | 4 +- dojo/context_processors.py | 16 - dojo/middleware.py | 30 -- dojo/settings/settings.dist.py | 447 +---------------- .../attribute-maps => sso}/__init__.py | 0 dojo/sso/attribute_maps/__init__.py | 0 .../attribute_maps}/django_saml_uri.py | 0 .../attribute_maps}/saml_uri.py | 0 dojo/sso/context_processors.py | 22 + dojo/sso/middleware.py | 35 ++ dojo/{ => sso}/pipeline.py | 0 dojo/{ => sso}/remote_user.py | 4 +- dojo/sso/settings.py | 456 ++++++++++++++++++ .../sso/templates/dojo/sso_login_buttons.html | 56 +++ dojo/sso/urls.py | 10 + dojo/sso/views.py | 43 ++ dojo/templates/dojo/login.html | 56 +-- dojo/urls.py | 9 +- dojo/user/urls.py | 3 - dojo/user/views.py | 45 +- unittests/test_remote_user.py | 4 +- .../test_social_auth_failure_handling.py | 2 +- 22 files changed, 660 insertions(+), 582 deletions(-) rename dojo/{settings/attribute-maps => sso}/__init__.py (100%) create mode 100644 dojo/sso/attribute_maps/__init__.py rename dojo/{settings/attribute-maps => sso/attribute_maps}/django_saml_uri.py (100%) rename dojo/{settings/attribute-maps => sso/attribute_maps}/saml_uri.py (100%) create mode 100644 dojo/sso/context_processors.py create mode 100644 dojo/sso/middleware.py rename dojo/{ => sso}/pipeline.py (100%) rename dojo/{ => sso}/remote_user.py (96%) create mode 100644 dojo/sso/settings.py create mode 100644 dojo/sso/templates/dojo/sso_login_buttons.html create mode 100644 dojo/sso/urls.py create mode 100644 dojo/sso/views.py diff --git a/.dryrunsecurity.yaml b/.dryrunsecurity.yaml index 1863e9a2027..a91f84564fa 100644 --- a/.dryrunsecurity.yaml +++ b/.dryrunsecurity.yaml @@ -40,8 +40,8 @@ sensitiveCodepaths: - 'dojo/middleware.py' - 'dojo/models.py' - 'dojo/okta.py' - - 'dojo/pipeline.py' - - 'dojo/remote_user.py' + - 'dojo/sso/pipeline.py' + - 'dojo/sso/remote_user.py' - 'dojo/tasks.py' - 'dojo/urls.py' - 'dojo/utils.py' diff --git a/dojo/context_processors.py b/dojo/context_processors.py index cc53af0f1e0..462041d6794 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -16,22 +16,6 @@ def globalize_vars(request): "FORGOT_PASSWORD": settings.FORGOT_PASSWORD, "FORGOT_USERNAME": settings.FORGOT_USERNAME, "CLASSIC_AUTH_ENABLED": settings.CLASSIC_AUTH_ENABLED, - "OIDC_ENABLED": settings.OIDC_AUTH_ENABLED, - "SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT": settings.SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT, - "AUTH0_ENABLED": settings.AUTH0_OAUTH2_ENABLED, - "GOOGLE_ENABLED": settings.GOOGLE_OAUTH_ENABLED, - "OKTA_ENABLED": settings.OKTA_OAUTH_ENABLED, - "GITLAB_ENABLED": settings.GITLAB_OAUTH2_ENABLED, - "AZUREAD_TENANT_OAUTH2_ENABLED": settings.AZUREAD_TENANT_OAUTH2_ENABLED, - "AZUREAD_TENANT_OAUTH2_GET_GROUPS": settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS, - "AZUREAD_TENANT_OAUTH2_GROUPS_FILTER": settings.AZUREAD_TENANT_OAUTH2_GROUPS_FILTER, - "AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS": settings.AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS, - "KEYCLOAK_ENABLED": settings.KEYCLOAK_OAUTH2_ENABLED, - "SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT": settings.SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT, - "GITHUB_ENTERPRISE_ENABLED": settings.GITHUB_ENTERPRISE_OAUTH2_ENABLED, - "SAML2_ENABLED": settings.SAML2_ENABLED, - "SAML2_LOGIN_BUTTON_TEXT": settings.SAML2_LOGIN_BUTTON_TEXT, - "SAML2_LOGOUT_URL": settings.SAML2_LOGOUT_URL, "DOCUMENTATION_URL": settings.DOCUMENTATION_URL, "API_TOKENS_ENABLED": settings.API_TOKENS_ENABLED, "API_TOKEN_AUTH_ENDPOINT_ENABLED": settings.API_TOKEN_AUTH_ENDPOINT_ENABLED, diff --git a/dojo/middleware.py b/dojo/middleware.py index 8d274202f90..a7a1bb7e798 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -6,15 +6,10 @@ from urllib.parse import quote import pghistory.middleware -import requests from django.conf import settings -from django.contrib import messages from django.db import models from django.http import HttpResponseRedirect -from django.shortcuts import redirect from django.urls import reverse -from social_core.exceptions import AuthCanceled, AuthFailed, AuthForbidden, AuthTokenError -from social_django.middleware import SocialAuthExceptionMiddleware from watson.middleware import SearchContextMiddleware from watson.search import search_context_manager @@ -79,31 +74,6 @@ def __call__(self, request): return response -class CustomSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): - def process_exception(self, request, exception): - if isinstance(exception, requests.exceptions.RequestException): - messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION) - return redirect("/login?force_login_form") - if isinstance(exception, AuthCanceled): - messages.warning(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED) - return redirect("/login?force_login_form") - if isinstance(exception, AuthFailed): - messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED) - return redirect("/login?force_login_form") - if isinstance(exception, AuthForbidden): - messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN) - return redirect("/login?force_login_form") - if isinstance(exception, AuthTokenError): - messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR) - return redirect("/login?force_login_form") - if isinstance(exception, TypeError) and "'NoneType' object is not iterable" in str(exception): - logger.warning("OIDC login error: NoneType is not iterable") - messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE) - return redirect("/login?force_login_form") - logger.error(f"Unhandled exception during social login: {exception}") - return super().process_exception(request, exception) - - class DojoSytemSettingsMiddleware: _thread_local = local() diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 1c6440058d4..57f6cdf2b37 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -17,16 +17,22 @@ import environ import pghistory from celery.schedules import crontab -from netaddr import IPNetwork, IPSet - from dojo import __version__ logger = logging.getLogger(__name__) root = environ.Path(__file__) - 3 # Three folders back +# SSO env schema is merged in if dojo.sso is available +_sso_env_schema = {} +try: + from dojo.sso.settings import SSO_ENV_SCHEMA + _sso_env_schema = SSO_ENV_SCHEMA +except ImportError: + pass + # reference: https://pypi.org/project/django-environ/ -env = environ.FileAwareEnv( +env = environ.FileAwareEnv(**{**dict( # Set casting and default values DD_SITE_URL=(str, "http://localhost:8080"), DD_DEBUG=(bool, False), @@ -113,116 +119,7 @@ DD_PASSWORD_RESET_TIMEOUT=(int, 259200), # 3 days, in seconds (the deafult) DD_FORGOT_USERNAME=(bool, True), # do we show link "I forgot my username" on login screen DD_SOCIAL_AUTH_SHOW_LOGIN_FORM=(bool, True), # do we show user/pass input - DD_SOCIAL_AUTH_CREATE_USER=(bool, True), # if True creates user at first login - DD_SOCIAL_AUTH_CREATE_USER_MAPPING=(str, "username"), # could also be email or fullname DD_SOCIAL_LOGIN_AUTO_REDIRECT=(bool, False), # auto-redirect if there is only one social login method - DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS=(bool, False), # If true, the redirect after login will use the HTTPS protocol - DD_SOCIAL_AUTH_TRAILING_SLASH=(bool, True), - DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED=(bool, False), - DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT=(str, ""), - DD_SOCIAL_AUTH_OIDC_ID_KEY=(str, ""), - DD_SOCIAL_AUTH_OIDC_KEY=(str, ""), - DD_SOCIAL_AUTH_OIDC_SECRET=(str, ""), - DD_SOCIAL_AUTH_OIDC_USERNAME_KEY=(str, ""), - DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS=(list, []), - DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS=(list, ["RS256", "HS256"]), - DD_SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER=(str, ""), - DD_SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL=(str, ""), - DD_SOCIAL_AUTH_OIDC_AUTHORIZATION_URL=(str, ""), - DD_SOCIAL_AUTH_OIDC_USERINFO_URL=(str, ""), - DD_SOCIAL_AUTH_OIDC_JWKS_URI=(str, ""), - DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT=(str, "Login with OIDC"), - DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_AUTH0_KEY=(str, ""), - DD_SOCIAL_AUTH_AUTH0_SECRET=(str, ""), - DD_SOCIAL_AUTH_AUTH0_DOMAIN=(str, ""), - DD_SOCIAL_AUTH_AUTH0_SCOPE=(list, ["openid", "profile", "email"]), - DD_SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=(str, ""), - DD_SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=(str, ""), - DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS=(list, [""]), - DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS=(list, [""]), - DD_SOCIAL_AUTH_OKTA_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_OKTA_OAUTH2_KEY=(str, ""), - DD_SOCIAL_AUTH_OKTA_OAUTH2_SECRET=(str, ""), - DD_SOCIAL_AUTH_OKTA_OAUTH2_API_URL=(str, "https://{your-org-url}/oauth2"), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY=(str, ""), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET=(str, ""), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID=(str, ""), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE=(str, "https://graph.microsoft.com/"), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS=(bool, False), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER=(str, ""), - DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS=(bool, True), - DD_SOCIAL_AUTH_GITLAB_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_GITLAB_PROJECT_AUTO_IMPORT=(bool, False), - DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_TAGS=(bool, False), - DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_URL=(bool, False), - DD_SOCIAL_AUTH_GITLAB_PROJECT_MIN_ACCESS_LEVEL=(int, 20), - DD_SOCIAL_AUTH_GITLAB_KEY=(str, ""), - DD_SOCIAL_AUTH_GITLAB_SECRET=(str, ""), - DD_SOCIAL_AUTH_GITLAB_API_URL=(str, "https://gitlab.com"), - DD_SOCIAL_AUTH_GITLAB_SCOPE=(list, ["read_user", "openid", "read_api", "read_repository"]), - DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_KEYCLOAK_KEY=(str, ""), - DD_SOCIAL_AUTH_KEYCLOAK_SECRET=(str, ""), - DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY=(str, ""), - DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL=(str, ""), - DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL=(str, ""), - DD_SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT=(str, "Login with Keycloak"), - DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_OAUTH2_ENABLED=(bool, False), - DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_URL=(str, ""), - DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL=(str, ""), - DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY=(str, ""), - DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET=(str, ""), - DD_SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL=(bool, True), - DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION=(str, "Please use the standard login below."), - DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED=(str, "Social login was canceled. Please try again or use the standard login."), - DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED=(str, "Social login failed. Please try again or use the standard login."), - DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN=(str, "You are not authorized to log in via this method. Please contact support or use the standard login."), - DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE=(str, "An unexpected error occurred during social login. Please use the standard login."), - DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR=(str, "Social login failed due to an invalid or expired token. Please try again or use the standard login."), - DD_SAML2_ENABLED=(bool, False), - # Allows to override default SAML authentication backend. Check https://djangosaml2.readthedocs.io/contents/setup.html#custom-user-attributes-processing - DD_SAML2_AUTHENTICATION_BACKENDS=(str, "djangosaml2.backends.Saml2Backend"), - # Force Authentication to make SSO possible with SAML2 - DD_SAML2_FORCE_AUTH=(bool, True), - DD_SAML2_LOGIN_BUTTON_TEXT=(str, "Login with SAML"), - # Optional: display the idp SAML Logout URL in DefectDojo - DD_SAML2_LOGOUT_URL=(str, ""), - # Metadata is required for SAML, choose either remote url or local file path - DD_SAML2_METADATA_AUTO_CONF_URL=(str, ""), - DD_SAML2_METADATA_LOCAL_FILE_PATH=(str, ""), # ex. '/public/share/idp_metadata.xml' - # Optional, default is SITE_URL + /saml2/metadata/ - DD_SAML2_ENTITY_ID=(str, ""), - # Allow to create user that are not already in the Django database - DD_SAML2_CREATE_USER=(bool, False), - DD_SAML2_ATTRIBUTES_MAP=(dict, { - # Change Email/UserName/FirstName/LastName to corresponding SAML2 userprofile attributes. - # format: SAML attrib:django_user_model - "Email": "email", - "UserName": "username", - "Firstname": "first_name", - "Lastname": "last_name", - }), - DD_SAML2_ALLOW_UNKNOWN_ATTRIBUTE=(bool, False), - # Authentication via HTTP Proxy which put username to HTTP Header REMOTE_USER - DD_AUTH_REMOTEUSER_ENABLED=(bool, False), - # Names of headers which will be used for processing user data. - # WARNING: Possible spoofing of headers. Read Warning in https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#configuration - DD_AUTH_REMOTEUSER_USERNAME_HEADER=(str, "REMOTE_USER"), - DD_AUTH_REMOTEUSER_EMAIL_HEADER=(str, ""), - DD_AUTH_REMOTEUSER_FIRSTNAME_HEADER=(str, ""), - DD_AUTH_REMOTEUSER_LASTNAME_HEADER=(str, ""), - DD_AUTH_REMOTEUSER_GROUPS_HEADER=(str, ""), - DD_AUTH_REMOTEUSER_GROUPS_CLEANUP=(bool, True), - # Comma separated list of IP ranges with trusted proxies - DD_AUTH_REMOTEUSER_TRUSTED_PROXY=(list, ["127.0.0.1/32"]), - # REMOTE_USER will be processed only on login page. Check https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#using-remote-user-on-login-pages-only - DD_AUTH_REMOTEUSER_LOGIN_ONLY=(bool, False), - # `RemoteUser` is usually used behind AuthN proxy and users should not know about this mechanism from Swagger because it is not usable by users. - # It should be hidden by default. - DD_AUTH_REMOTEUSER_VISIBLE_IN_SWAGGER=(bool, False), # Some security policies require allowing users to have only one active session DD_SINGLE_USER_SESSION=(bool, False), # if somebody is using own documentation how to use DefectDojo in his own company @@ -362,7 +259,7 @@ DD_V3_FEATURE_LOCATIONS=(bool, False), # Dictates if v3 org/asset relabeling (+url routing) will be enabled DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, False), -) +), **_sso_env_schema}) def generate_url(scheme, double_slashes, user, password, host, port, path, params): @@ -544,18 +441,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param LOGIN_REDIRECT_URL = env("DD_LOGIN_REDIRECT_URL") LOGIN_URL = env("DD_LOGIN_URL") -# These are the individidual modules supported by social-auth AUTHENTICATION_BACKENDS = ( - "social_core.backends.open_id_connect.OpenIdConnectAuth", - "social_core.backends.auth0.Auth0OAuth2", - "social_core.backends.google.GoogleOAuth2", - "social_core.backends.okta.OktaOAuth2", - "social_core.backends.azuread_tenant.AzureADTenantOAuth2", - "social_core.backends.gitlab.GitLabOAuth2", - "social_core.backends.keycloak.KeycloakOAuth2", - "social_core.backends.github_enterprise.GithubEnterpriseOAuth2", - "dojo.remote_user.RemoteUserBackend", - "django.contrib.auth.backends.RemoteUserBackend", "django.contrib.auth.backends.ModelBackend", ) @@ -572,22 +458,6 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "django.contrib.auth.hashers.MD5PasswordHasher", ] -SOCIAL_AUTH_PIPELINE = ( - "social_core.pipeline.social_auth.social_details", - "dojo.pipeline.social_uid", - "social_core.pipeline.social_auth.auth_allowed", - "social_core.pipeline.social_auth.social_user", - "social_core.pipeline.user.get_username", - "social_core.pipeline.social_auth.associate_by_email", - "dojo.pipeline.create_user", - "dojo.pipeline.modify_permissions", - "social_core.pipeline.social_auth.associate_user", - "social_core.pipeline.social_auth.load_extra_data", - "social_core.pipeline.user.user_details", - "dojo.pipeline.update_azure_groups", - "dojo.pipeline.update_product_access", -) - CLASSIC_AUTH_ENABLED = True FORGOT_PASSWORD = env("DD_FORGOT_PASSWORD") REQUIRE_PASSWORD_ON_USER = env("DD_REQUIRE_PASSWORD_ON_USER") @@ -596,107 +466,6 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # Showing login form (form is not needed for external auth: OKTA, Google Auth, etc.) SHOW_LOGIN_FORM = env("DD_SOCIAL_AUTH_SHOW_LOGIN_FORM") SOCIAL_LOGIN_AUTO_REDIRECT = env("DD_SOCIAL_LOGIN_AUTO_REDIRECT") -SOCIAL_AUTH_REDIRECT_IS_HTTPS = env("DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS") -SOCIAL_AUTH_CREATE_USER = env("DD_SOCIAL_AUTH_CREATE_USER") -SOCIAL_AUTH_CREATE_USER_MAPPING = env("DD_SOCIAL_AUTH_CREATE_USER_MAPPING") - -SOCIAL_AUTH_STRATEGY = "social_django.strategy.DjangoStrategy" -SOCIAL_AUTH_STORAGE = "social_django.models.DjangoStorage" -SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ["username", "first_name", "last_name", "email"] -SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = env("DD_SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL") - -GOOGLE_OAUTH_ENABLED = env("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED") -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = env("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = env("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") -SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = tuple(env.list("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS", default=[""])) -SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS = tuple(env.list("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS", default=[""])) -SOCIAL_AUTH_LOGIN_ERROR_URL = "/login" -SOCIAL_AUTH_BACKEND_ERROR_URL = "/login" - -OKTA_OAUTH_ENABLED = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_ENABLED") -SOCIAL_AUTH_OKTA_OAUTH2_KEY = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_KEY") -SOCIAL_AUTH_OKTA_OAUTH2_SECRET = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_SECRET") -SOCIAL_AUTH_OKTA_OAUTH2_API_URL = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_API_URL") - -AZUREAD_TENANT_OAUTH2_ENABLED = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_ENABLED") -SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY") -SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET") -SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID") -SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE") -AZUREAD_TENANT_OAUTH2_GET_GROUPS = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS") -AZUREAD_TENANT_OAUTH2_GROUPS_FILTER = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER") -AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS") - -GITLAB_OAUTH2_ENABLED = env("DD_SOCIAL_AUTH_GITLAB_OAUTH2_ENABLED") -GITLAB_PROJECT_AUTO_IMPORT = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_AUTO_IMPORT") -GITLAB_PROJECT_IMPORT_TAGS = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_TAGS") -GITLAB_PROJECT_IMPORT_URL = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_URL") -GITLAB_PROJECT_MIN_ACCESS_LEVEL = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_MIN_ACCESS_LEVEL") -SOCIAL_AUTH_GITLAB_KEY = env("DD_SOCIAL_AUTH_GITLAB_KEY") -SOCIAL_AUTH_GITLAB_SECRET = env("DD_SOCIAL_AUTH_GITLAB_SECRET") -SOCIAL_AUTH_GITLAB_API_URL = env("DD_SOCIAL_AUTH_GITLAB_API_URL") -SOCIAL_AUTH_GITLAB_SCOPE = env("DD_SOCIAL_AUTH_GITLAB_SCOPE") - -# Add required scope if auto import is enabled -if GITLAB_PROJECT_AUTO_IMPORT: - SOCIAL_AUTH_GITLAB_SCOPE += ["read_repository"] - -# Mandatory settings -OIDC_AUTH_ENABLED = env("DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED") -SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = env("DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT") -SOCIAL_AUTH_OIDC_KEY = env("DD_SOCIAL_AUTH_OIDC_KEY") -SOCIAL_AUTH_OIDC_SECRET = env("DD_SOCIAL_AUTH_OIDC_SECRET") -# Optional settings -if value := env("DD_LOGIN_REDIRECT_URL"): - SOCIAL_AUTH_LOGIN_REDIRECT_URL = value -if value := env("DD_SOCIAL_AUTH_OIDC_ID_KEY"): - SOCIAL_AUTH_OIDC_ID_KEY = value -if value := env("DD_SOCIAL_AUTH_OIDC_USERNAME_KEY"): - SOCIAL_AUTH_OIDC_USERNAME_KEY = value -if value := env("DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS"): - SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS = env("DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS") -if value := env("DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS"): - SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = env("DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS") -if value := env("DD_SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER"): - SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER = value -if value := env("DD_SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL"): - SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL = value -if value := env("DD_SOCIAL_AUTH_OIDC_AUTHORIZATION_URL"): - SOCIAL_AUTH_OIDC_AUTHORIZATION_URL = value -if value := env("DD_SOCIAL_AUTH_OIDC_USERINFO_URL"): - SOCIAL_AUTH_OIDC_USERINFO_URL = value -if value := env("DD_SOCIAL_AUTH_OIDC_JWKS_URI"): - SOCIAL_AUTH_OIDC_JWKS_URI = value -if value := env("DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT"): - SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT = value - -SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION") -SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED") -SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED") -SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN") -SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE") -SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR") - -AUTH0_OAUTH2_ENABLED = env("DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED") -SOCIAL_AUTH_AUTH0_KEY = env("DD_SOCIAL_AUTH_AUTH0_KEY") -SOCIAL_AUTH_AUTH0_SECRET = env("DD_SOCIAL_AUTH_AUTH0_SECRET") -SOCIAL_AUTH_AUTH0_DOMAIN = env("DD_SOCIAL_AUTH_AUTH0_DOMAIN") -SOCIAL_AUTH_AUTH0_SCOPE = env("DD_SOCIAL_AUTH_AUTH0_SCOPE") -SOCIAL_AUTH_TRAILING_SLASH = env("DD_SOCIAL_AUTH_TRAILING_SLASH") - -KEYCLOAK_OAUTH2_ENABLED = env("DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED") -SOCIAL_AUTH_KEYCLOAK_KEY = env("DD_SOCIAL_AUTH_KEYCLOAK_KEY") -SOCIAL_AUTH_KEYCLOAK_SECRET = env("DD_SOCIAL_AUTH_KEYCLOAK_SECRET") -SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY = env("DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY") -SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL = env("DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL") -SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL = env("DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL") -SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT = env("DD_SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT") - -GITHUB_ENTERPRISE_OAUTH2_ENABLED = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_OAUTH2_ENABLED") -SOCIAL_AUTH_GITHUB_ENTERPRISE_URL = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_URL") -SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL") -SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY") -SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET") DOCUMENTATION_URL = env("DD_DOCUMENTATION_URL") @@ -925,6 +694,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], "APP_DIRS": True, "OPTIONS": { "debug": env("DD_DEBUG"), @@ -933,8 +703,6 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - "social_django.context_processors.backends", - "social_django.context_processors.login_redirect", "dojo.context_processors.globalize_vars", "dojo.context_processors.bind_system_settings", "dojo.context_processors.bind_alert_count", @@ -968,7 +736,6 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "rest_framework.authtoken", "dbbackup", "django_celery_results", - "social_django", "drf_spectacular", "drf_spectacular_sidecar", # required for Django collectstatic discovery "tagulous", @@ -996,7 +763,6 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "django.middleware.clickjacking.XFrameOptionsMiddleware", "dojo.middleware.LoginRequiredMiddleware", "dojo.middleware.AdditionalHeaderMiddleware", - "dojo.middleware.CustomSocialAuthExceptionMiddleware", "crum.CurrentRequestUserMiddleware", "dojo.middleware.AsyncSearchContextMiddleware", "dojo.request_cache.middleware.RequestCacheMiddleware", @@ -1030,192 +796,13 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param vars().update(EMAIL_CONFIG) # ------------------------------------------------------------------------------ -# SAML +# SSO (loaded from dojo.sso if available) # ------------------------------------------------------------------------------ -# For more configuration and customization options, see djangosaml2 documentation -# https://djangosaml2.readthedocs.io/contents/setup.html#configuration -# To override not configurable settings, you can use local_settings.py -# function that helps convert env var into the djangosaml2 attribute mapping format -# https://djangosaml2.readthedocs.io/contents/setup.html#users-attributes-and-account-linking - - -def saml2_attrib_map_format(din): - dout = {} - for i in din: - dout[i] = (din[i],) - return dout - - -SAML2_ENABLED = env("DD_SAML2_ENABLED") -SAML2_LOGIN_BUTTON_TEXT = env("DD_SAML2_LOGIN_BUTTON_TEXT") -SAML2_LOGOUT_URL = env("DD_SAML2_LOGOUT_URL") -if SAML2_ENABLED: - import saml2 - import saml2.saml - # SSO_URL = env('DD_SSO_URL') - SAML_METADATA = {} - if len(env("DD_SAML2_METADATA_AUTO_CONF_URL")) > 0: - SAML_METADATA["remote"] = [{"url": env("DD_SAML2_METADATA_AUTO_CONF_URL")}] - if len(env("DD_SAML2_METADATA_LOCAL_FILE_PATH")) > 0: - SAML_METADATA["local"] = [env("DD_SAML2_METADATA_LOCAL_FILE_PATH")] - INSTALLED_APPS += ("djangosaml2",) - MIDDLEWARE.append("djangosaml2.middleware.SamlSessionMiddleware") - AUTHENTICATION_BACKENDS += (env("DD_SAML2_AUTHENTICATION_BACKENDS"),) - LOGIN_EXEMPT_URLS += (rf"^{URL_PREFIX}saml2/",) - SAML_LOGOUT_REQUEST_PREFERRED_BINDING = saml2.BINDING_HTTP_POST - SAML_IGNORE_LOGOUT_ERRORS = True - SAML_DJANGO_USER_MAIN_ATTRIBUTE = "username" -# SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP = '__iexact' - SAML_USE_NAME_ID_AS_USERNAME = True - SAML_CREATE_UNKNOWN_USER = env("DD_SAML2_CREATE_USER") - SAML_ATTRIBUTE_MAPPING = saml2_attrib_map_format(env("DD_SAML2_ATTRIBUTES_MAP")) - SAML_FORCE_AUTH = env("DD_SAML2_FORCE_AUTH") - SAML_ALLOW_UNKNOWN_ATTRIBUTES = env("DD_SAML2_ALLOW_UNKNOWN_ATTRIBUTE") - BASEDIR = Path(__file__).parent.absolute() - if len(env("DD_SAML2_ENTITY_ID")) == 0: - SAML2_ENTITY_ID = f"{SITE_URL}/saml2/metadata/" - else: - SAML2_ENTITY_ID = env("DD_SAML2_ENTITY_ID") - - SAML_CONFIG = { - # full path to the xmlsec1 binary programm - "xmlsec_binary": "/usr/bin/xmlsec1", - - # your entity id, usually your subdomain plus the url to the metadata view - "entityid": str(SAML2_ENTITY_ID), - - # directory with attribute mapping - "attribute_map_dir": str(Path(BASEDIR) / "attribute-maps"), - # do now discard attributes not specified in attribute-maps - "allow_unknown_attributes": SAML_ALLOW_UNKNOWN_ATTRIBUTES, - # this block states what services we provide - "service": { - # we are just a lonely SP - "sp": { - "name": "Defect_Dojo", - "name_id_format": saml2.saml.NAMEID_FORMAT_TRANSIENT, - "want_response_signed": False, - "want_assertions_signed": True, - "force_authn": SAML_FORCE_AUTH, - "allow_unsolicited": True, - - # For Okta add signed logout requets. Enable this: - # "logout_requests_signed": True, - - "endpoints": { - # url and binding to the assetion consumer service view - # do not change the binding or service name - "assertion_consumer_service": [ - (f"{SITE_URL}/saml2/acs/", - saml2.BINDING_HTTP_POST), - ], - # url and binding to the single logout service view - # do not change the binding or service name - "single_logout_service": [ - # Disable next two lines for HTTP_REDIRECT for IDP's that only support HTTP_POST. Ex. Okta: - (f"{SITE_URL}/saml2/ls/", - saml2.BINDING_HTTP_REDIRECT), - (f"{SITE_URL}/saml2/ls/post", - saml2.BINDING_HTTP_POST), - ], - }, - - # attributes that this project need to identify a user - "required_attributes": ["Email", "UserName"], - - # attributes that may be useful to have but not required - "optional_attributes": ["Firstname", "Lastname"], - - # in this section the list of IdPs we talk to are defined - # This is not mandatory! All the IdP available in the metadata will be considered. - # 'idp': { - # # we do not need a WAYF service since there is - # # only an IdP defined here. This IdP should be - # # present in our metadata - - # # the keys of this dictionary are entity ids - # 'https://localhost/simplesaml/saml2/idp/metadata.php': { - # 'single_sign_on_service': { - # saml2.BINDING_HTTP_REDIRECT: 'https://localhost/simplesaml/saml2/idp/SSOService.php', - # }, - # 'single_logout_service': { - # saml2.BINDING_HTTP_REDIRECT: 'https://localhost/simplesaml/saml2/idp/SingleLogoutService.php', - # }, - # }, - # }, - }, - }, - - # where the remote metadata is stored, local, remote or mdq server. - # One metadatastore or many ... - "metadata": SAML_METADATA, - - # set to 1 to output debugging information - "debug": 0, - - # Signing - # 'key_file': path.join(BASEDIR, 'private.key'), # private part - # 'cert_file': path.join(BASEDIR, 'public.pem'), # public part - - # Encryption - # 'encryption_keypairs': [{ - # 'key_file': path.join(BASEDIR, 'private.key'), # private part - # 'cert_file': path.join(BASEDIR, 'public.pem'), # public part - # }], - - # own metadata settings - "contact_person": [ - {"given_name": "Lorenzo", - "sur_name": "Gil", - "company": "Yaco Sistemas", - "email_address": "lgs@yaco.es", - "contact_type": "technical"}, - {"given_name": "Angel", - "sur_name": "Fernandez", - "company": "Yaco Sistemas", - "email_address": "angel@yaco.es", - "contact_type": "administrative"}, - ], - # you can set multilanguage information here - "organization": { - "name": [("Yaco Sistemas", "es"), ("Yaco Systems", "en")], - "display_name": [("Yaco", "es"), ("Yaco", "en")], - "url": [("http://www.yaco.es", "es"), ("http://www.yaco.com", "en")], - }, - "valid_for": 24, # how long is our metadata valid - } - -# ------------------------------------------------------------------------------ -# REMOTE_USER -# ------------------------------------------------------------------------------ - -AUTH_REMOTEUSER_ENABLED = env("DD_AUTH_REMOTEUSER_ENABLED") -AUTH_REMOTEUSER_USERNAME_HEADER = env("DD_AUTH_REMOTEUSER_USERNAME_HEADER") -AUTH_REMOTEUSER_EMAIL_HEADER = env("DD_AUTH_REMOTEUSER_EMAIL_HEADER") -AUTH_REMOTEUSER_FIRSTNAME_HEADER = env("DD_AUTH_REMOTEUSER_FIRSTNAME_HEADER") -AUTH_REMOTEUSER_LASTNAME_HEADER = env("DD_AUTH_REMOTEUSER_LASTNAME_HEADER") -AUTH_REMOTEUSER_GROUPS_HEADER = env("DD_AUTH_REMOTEUSER_GROUPS_HEADER") -AUTH_REMOTEUSER_GROUPS_CLEANUP = env("DD_AUTH_REMOTEUSER_GROUPS_CLEANUP") -AUTH_REMOTEUSER_VISIBLE_IN_SWAGGER = env("DD_AUTH_REMOTEUSER_VISIBLE_IN_SWAGGER") - -AUTH_REMOTEUSER_TRUSTED_PROXY = IPSet() -for ip_range in env("DD_AUTH_REMOTEUSER_TRUSTED_PROXY"): - AUTH_REMOTEUSER_TRUSTED_PROXY.add(IPNetwork(ip_range)) - -if env("DD_AUTH_REMOTEUSER_LOGIN_ONLY"): - RemoteUserMiddleware = "dojo.remote_user.PersistentRemoteUserMiddleware" -else: - RemoteUserMiddleware = "dojo.remote_user.RemoteUserMiddleware" -# we need to add middleware just behindAuthenticationMiddleware as described in https://docs.djangoproject.com/en/3.2/howto/auth-remote-user/#configuration -for i in range(len(MIDDLEWARE)): - if MIDDLEWARE[i] == "django.contrib.auth.middleware.AuthenticationMiddleware": - MIDDLEWARE.insert(i + 1, RemoteUserMiddleware) - break - -if AUTH_REMOTEUSER_ENABLED: - REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = \ - ("dojo.remote_user.RemoteUserAuthentication",) + \ - REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] +try: + from dojo.sso.settings import apply_sso_settings + apply_sso_settings(env, globals()) +except ImportError: + pass # ------------------------------------------------------------------------------ # SINGLE_USER_SESSION diff --git a/dojo/settings/attribute-maps/__init__.py b/dojo/sso/__init__.py similarity index 100% rename from dojo/settings/attribute-maps/__init__.py rename to dojo/sso/__init__.py diff --git a/dojo/sso/attribute_maps/__init__.py b/dojo/sso/attribute_maps/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/settings/attribute-maps/django_saml_uri.py b/dojo/sso/attribute_maps/django_saml_uri.py similarity index 100% rename from dojo/settings/attribute-maps/django_saml_uri.py rename to dojo/sso/attribute_maps/django_saml_uri.py diff --git a/dojo/settings/attribute-maps/saml_uri.py b/dojo/sso/attribute_maps/saml_uri.py similarity index 100% rename from dojo/settings/attribute-maps/saml_uri.py rename to dojo/sso/attribute_maps/saml_uri.py diff --git a/dojo/sso/context_processors.py b/dojo/sso/context_processors.py new file mode 100644 index 00000000000..0c1a46e0030 --- /dev/null +++ b/dojo/sso/context_processors.py @@ -0,0 +1,22 @@ +from django.conf import settings + + +def sso_context(request): + return { + "OIDC_ENABLED": settings.OIDC_AUTH_ENABLED, + "SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT": settings.SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT, + "AUTH0_ENABLED": settings.AUTH0_OAUTH2_ENABLED, + "GOOGLE_ENABLED": settings.GOOGLE_OAUTH_ENABLED, + "OKTA_ENABLED": settings.OKTA_OAUTH_ENABLED, + "GITLAB_ENABLED": settings.GITLAB_OAUTH2_ENABLED, + "AZUREAD_TENANT_OAUTH2_ENABLED": settings.AZUREAD_TENANT_OAUTH2_ENABLED, + "AZUREAD_TENANT_OAUTH2_GET_GROUPS": settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS, + "AZUREAD_TENANT_OAUTH2_GROUPS_FILTER": settings.AZUREAD_TENANT_OAUTH2_GROUPS_FILTER, + "AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS": settings.AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS, + "KEYCLOAK_ENABLED": settings.KEYCLOAK_OAUTH2_ENABLED, + "SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT": settings.SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT, + "GITHUB_ENTERPRISE_ENABLED": settings.GITHUB_ENTERPRISE_OAUTH2_ENABLED, + "SAML2_ENABLED": settings.SAML2_ENABLED, + "SAML2_LOGIN_BUTTON_TEXT": settings.SAML2_LOGIN_BUTTON_TEXT, + "SAML2_LOGOUT_URL": settings.SAML2_LOGOUT_URL, + } diff --git a/dojo/sso/middleware.py b/dojo/sso/middleware.py new file mode 100644 index 00000000000..6606f54afbd --- /dev/null +++ b/dojo/sso/middleware.py @@ -0,0 +1,35 @@ +import logging + +import requests +from django.conf import settings +from django.contrib import messages +from django.shortcuts import redirect +from social_core.exceptions import AuthCanceled, AuthFailed, AuthForbidden, AuthTokenError +from social_django.middleware import SocialAuthExceptionMiddleware + +logger = logging.getLogger(__name__) + + +class CustomSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): + def process_exception(self, request, exception): + if isinstance(exception, requests.exceptions.RequestException): + messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION) + return redirect("/login?force_login_form") + if isinstance(exception, AuthCanceled): + messages.warning(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED) + return redirect("/login?force_login_form") + if isinstance(exception, AuthFailed): + messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED) + return redirect("/login?force_login_form") + if isinstance(exception, AuthForbidden): + messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN) + return redirect("/login?force_login_form") + if isinstance(exception, AuthTokenError): + messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR) + return redirect("/login?force_login_form") + if isinstance(exception, TypeError) and "'NoneType' object is not iterable" in str(exception): + logger.warning("OIDC login error: NoneType is not iterable") + messages.error(request, settings.SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE) + return redirect("/login?force_login_form") + logger.error(f"Unhandled exception during social login: {exception}") + return super().process_exception(request, exception) diff --git a/dojo/pipeline.py b/dojo/sso/pipeline.py similarity index 100% rename from dojo/pipeline.py rename to dojo/sso/pipeline.py diff --git a/dojo/remote_user.py b/dojo/sso/remote_user.py similarity index 96% rename from dojo/remote_user.py rename to dojo/sso/remote_user.py index 2362a05ad30..5bfa2670428 100644 --- a/dojo/remote_user.py +++ b/dojo/sso/remote_user.py @@ -8,7 +8,7 @@ from rest_framework.authentication import RemoteUserAuthentication as OriginalRemoteUserAuthentication from dojo.models import Dojo_Group -from dojo.pipeline import assign_user_to_groups, cleanup_old_groups_for_user +from dojo.sso.pipeline import assign_user_to_groups, cleanup_old_groups_for_user logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ def configure_user(self, request, user, *, created=True): class RemoteUserScheme(OpenApiAuthenticationExtension): - target_class = "dojo.remote_user.RemoteUserAuthentication" + target_class = "dojo.sso.remote_user.RemoteUserAuthentication" name = "remoteUserAuth" match_subclasses = True priority = 1 diff --git a/dojo/sso/settings.py b/dojo/sso/settings.py new file mode 100644 index 00000000000..b666a3b9082 --- /dev/null +++ b/dojo/sso/settings.py @@ -0,0 +1,456 @@ +import os +from pathlib import Path + +SSO_ENV_SCHEMA = { + "DD_SOCIAL_AUTH_CREATE_USER": (bool, True), + "DD_SOCIAL_AUTH_CREATE_USER_MAPPING": (str, "username"), + "DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS": (bool, False), + "DD_SOCIAL_AUTH_TRAILING_SLASH": (bool, True), + "DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT": (str, ""), + "DD_SOCIAL_AUTH_OIDC_ID_KEY": (str, ""), + "DD_SOCIAL_AUTH_OIDC_KEY": (str, ""), + "DD_SOCIAL_AUTH_OIDC_SECRET": (str, ""), + "DD_SOCIAL_AUTH_OIDC_USERNAME_KEY": (str, ""), + "DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS": (list, []), + "DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS": (list, ["RS256", "HS256"]), + "DD_SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER": (str, ""), + "DD_SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL": (str, ""), + "DD_SOCIAL_AUTH_OIDC_AUTHORIZATION_URL": (str, ""), + "DD_SOCIAL_AUTH_OIDC_USERINFO_URL": (str, ""), + "DD_SOCIAL_AUTH_OIDC_JWKS_URI": (str, ""), + "DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT": (str, "Login with OIDC"), + "DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_AUTH0_KEY": (str, ""), + "DD_SOCIAL_AUTH_AUTH0_SECRET": (str, ""), + "DD_SOCIAL_AUTH_AUTH0_DOMAIN": (str, ""), + "DD_SOCIAL_AUTH_AUTH0_SCOPE": (list, ["openid", "profile", "email"]), + "DD_SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": (str, ""), + "DD_SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": (str, ""), + "DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS": (list, [""]), + "DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS": (list, [""]), + "DD_SOCIAL_AUTH_OKTA_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_OKTA_OAUTH2_KEY": (str, ""), + "DD_SOCIAL_AUTH_OKTA_OAUTH2_SECRET": (str, ""), + "DD_SOCIAL_AUTH_OKTA_OAUTH2_API_URL": (str, "https://{your-org-url}/oauth2"), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY": (str, ""), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET": (str, ""), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID": (str, ""), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE": (str, "https://graph.microsoft.com/"), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS": (bool, False), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER": (str, ""), + "DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS": (bool, True), + "DD_SOCIAL_AUTH_GITLAB_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_GITLAB_PROJECT_AUTO_IMPORT": (bool, False), + "DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_TAGS": (bool, False), + "DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_URL": (bool, False), + "DD_SOCIAL_AUTH_GITLAB_PROJECT_MIN_ACCESS_LEVEL": (int, 20), + "DD_SOCIAL_AUTH_GITLAB_KEY": (str, ""), + "DD_SOCIAL_AUTH_GITLAB_SECRET": (str, ""), + "DD_SOCIAL_AUTH_GITLAB_API_URL": (str, "https://gitlab.com"), + "DD_SOCIAL_AUTH_GITLAB_SCOPE": (list, ["read_user", "openid", "read_api", "read_repository"]), + "DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_KEYCLOAK_KEY": (str, ""), + "DD_SOCIAL_AUTH_KEYCLOAK_SECRET": (str, ""), + "DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY": (str, ""), + "DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL": (str, ""), + "DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL": (str, ""), + "DD_SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT": (str, "Login with Keycloak"), + "DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_OAUTH2_ENABLED": (bool, False), + "DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_URL": (str, ""), + "DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL": (str, ""), + "DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY": (str, ""), + "DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET": (str, ""), + "DD_SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL": (bool, True), + "DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION": (str, "Please use the standard login below."), + "DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED": (str, "Social login was canceled. Please try again or use the standard login."), + "DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED": (str, "Social login failed. Please try again or use the standard login."), + "DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN": (str, "You are not authorized to log in via this method. Please contact support or use the standard login."), + "DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE": (str, "An unexpected error occurred during social login. Please use the standard login."), + "DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR": (str, "Social login failed due to an invalid or expired token. Please try again or use the standard login."), + "DD_SAML2_ENABLED": (bool, False), + "DD_SAML2_AUTHENTICATION_BACKENDS": (str, "djangosaml2.backends.Saml2Backend"), + "DD_SAML2_FORCE_AUTH": (bool, True), + "DD_SAML2_LOGIN_BUTTON_TEXT": (str, "Login with SAML"), + "DD_SAML2_LOGOUT_URL": (str, ""), + "DD_SAML2_METADATA_AUTO_CONF_URL": (str, ""), + "DD_SAML2_METADATA_LOCAL_FILE_PATH": (str, ""), + "DD_SAML2_ENTITY_ID": (str, ""), + "DD_SAML2_CREATE_USER": (bool, False), + "DD_SAML2_ATTRIBUTES_MAP": (dict, { + "Email": "email", + "UserName": "username", + "Firstname": "first_name", + "Lastname": "last_name", + }), + "DD_SAML2_ALLOW_UNKNOWN_ATTRIBUTE": (bool, False), + "DD_AUTH_REMOTEUSER_ENABLED": (bool, False), + "DD_AUTH_REMOTEUSER_USERNAME_HEADER": (str, "REMOTE_USER"), + "DD_AUTH_REMOTEUSER_EMAIL_HEADER": (str, ""), + "DD_AUTH_REMOTEUSER_FIRSTNAME_HEADER": (str, ""), + "DD_AUTH_REMOTEUSER_LASTNAME_HEADER": (str, ""), + "DD_AUTH_REMOTEUSER_GROUPS_HEADER": (str, ""), + "DD_AUTH_REMOTEUSER_GROUPS_CLEANUP": (bool, True), + "DD_AUTH_REMOTEUSER_TRUSTED_PROXY": (list, ["127.0.0.1/32"]), + "DD_AUTH_REMOTEUSER_LOGIN_ONLY": (bool, False), + "DD_AUTH_REMOTEUSER_VISIBLE_IN_SWAGGER": (bool, False), +} + + +def _saml2_attrib_map_format(din): + dout = {} + for i in din: + dout[i] = (din[i],) + return dout + + +def apply_sso_settings(env, globs): + """Apply all SSO-related settings. Called from settings.dist.py inside a try/except ImportError block.""" + from netaddr import IPNetwork, IPSet + + SITE_URL = globs["SITE_URL"] + URL_PREFIX = globs.get("URL_PREFIX", "") + + # -------------------------------------------------------------------------- + # AUTHENTICATION_BACKENDS + # -------------------------------------------------------------------------- + globs["AUTHENTICATION_BACKENDS"] = ( + "social_core.backends.open_id_connect.OpenIdConnectAuth", + "social_core.backends.auth0.Auth0OAuth2", + "social_core.backends.google.GoogleOAuth2", + "social_core.backends.okta.OktaOAuth2", + "social_core.backends.azuread_tenant.AzureADTenantOAuth2", + "social_core.backends.gitlab.GitLabOAuth2", + "social_core.backends.keycloak.KeycloakOAuth2", + "social_core.backends.github_enterprise.GithubEnterpriseOAuth2", + "dojo.sso.remote_user.RemoteUserBackend", + "django.contrib.auth.backends.RemoteUserBackend", + "django.contrib.auth.backends.ModelBackend", + ) + + # -------------------------------------------------------------------------- + # SOCIAL_AUTH_PIPELINE + # -------------------------------------------------------------------------- + globs["SOCIAL_AUTH_PIPELINE"] = ( + "social_core.pipeline.social_auth.social_details", + "dojo.sso.pipeline.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.user.get_username", + "social_core.pipeline.social_auth.associate_by_email", + "dojo.sso.pipeline.create_user", + "dojo.sso.pipeline.modify_permissions", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", + "dojo.sso.pipeline.update_azure_groups", + "dojo.sso.pipeline.update_product_access", + ) + + # -------------------------------------------------------------------------- + # SOCIAL AUTH GENERAL + # -------------------------------------------------------------------------- + globs["SOCIAL_AUTH_REDIRECT_IS_HTTPS"] = env("DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS") + globs["SOCIAL_AUTH_CREATE_USER"] = env("DD_SOCIAL_AUTH_CREATE_USER") + globs["SOCIAL_AUTH_CREATE_USER_MAPPING"] = env("DD_SOCIAL_AUTH_CREATE_USER_MAPPING") + + globs["SOCIAL_AUTH_STRATEGY"] = "social_django.strategy.DjangoStrategy" + globs["SOCIAL_AUTH_STORAGE"] = "social_django.models.DjangoStorage" + globs["SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS"] = ["username", "first_name", "last_name", "email"] + globs["SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL"] = env("DD_SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL") + + # -------------------------------------------------------------------------- + # GOOGLE OAUTH2 + # -------------------------------------------------------------------------- + globs["GOOGLE_OAUTH_ENABLED"] = env("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_ENABLED") + globs["SOCIAL_AUTH_GOOGLE_OAUTH2_KEY"] = env("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") + globs["SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"] = env("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") + globs["SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"] = tuple(env.list("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS", default=[""])) + globs["SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS"] = tuple(env.list("DD_SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS", default=[""])) + globs["SOCIAL_AUTH_LOGIN_ERROR_URL"] = "/login" + globs["SOCIAL_AUTH_BACKEND_ERROR_URL"] = "/login" + + # -------------------------------------------------------------------------- + # OKTA OAUTH2 + # -------------------------------------------------------------------------- + globs["OKTA_OAUTH_ENABLED"] = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_ENABLED") + globs["SOCIAL_AUTH_OKTA_OAUTH2_KEY"] = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_KEY") + globs["SOCIAL_AUTH_OKTA_OAUTH2_SECRET"] = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_SECRET") + globs["SOCIAL_AUTH_OKTA_OAUTH2_API_URL"] = env("DD_SOCIAL_AUTH_OKTA_OAUTH2_API_URL") + + # -------------------------------------------------------------------------- + # AZURE AD TENANT OAUTH2 + # -------------------------------------------------------------------------- + globs["AZUREAD_TENANT_OAUTH2_ENABLED"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_ENABLED") + globs["SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY") + globs["SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET") + globs["SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID") + globs["SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE") + globs["AZUREAD_TENANT_OAUTH2_GET_GROUPS"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GET_GROUPS") + globs["AZUREAD_TENANT_OAUTH2_GROUPS_FILTER"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER") + globs["AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS"] = env("DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS") + + # -------------------------------------------------------------------------- + # GITLAB OAUTH2 + # -------------------------------------------------------------------------- + globs["GITLAB_OAUTH2_ENABLED"] = env("DD_SOCIAL_AUTH_GITLAB_OAUTH2_ENABLED") + globs["GITLAB_PROJECT_AUTO_IMPORT"] = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_AUTO_IMPORT") + globs["GITLAB_PROJECT_IMPORT_TAGS"] = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_TAGS") + globs["GITLAB_PROJECT_IMPORT_URL"] = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_IMPORT_URL") + globs["GITLAB_PROJECT_MIN_ACCESS_LEVEL"] = env("DD_SOCIAL_AUTH_GITLAB_PROJECT_MIN_ACCESS_LEVEL") + globs["SOCIAL_AUTH_GITLAB_KEY"] = env("DD_SOCIAL_AUTH_GITLAB_KEY") + globs["SOCIAL_AUTH_GITLAB_SECRET"] = env("DD_SOCIAL_AUTH_GITLAB_SECRET") + globs["SOCIAL_AUTH_GITLAB_API_URL"] = env("DD_SOCIAL_AUTH_GITLAB_API_URL") + globs["SOCIAL_AUTH_GITLAB_SCOPE"] = env("DD_SOCIAL_AUTH_GITLAB_SCOPE") + + # Add required scope if auto import is enabled + if globs["GITLAB_PROJECT_AUTO_IMPORT"]: + globs["SOCIAL_AUTH_GITLAB_SCOPE"] += ["read_repository"] + + # -------------------------------------------------------------------------- + # OIDC + # -------------------------------------------------------------------------- + globs["OIDC_AUTH_ENABLED"] = env("DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED") + globs["SOCIAL_AUTH_OIDC_OIDC_ENDPOINT"] = env("DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT") + globs["SOCIAL_AUTH_OIDC_KEY"] = env("DD_SOCIAL_AUTH_OIDC_KEY") + globs["SOCIAL_AUTH_OIDC_SECRET"] = env("DD_SOCIAL_AUTH_OIDC_SECRET") + # Optional OIDC settings + if value := env("DD_LOGIN_REDIRECT_URL"): + globs["SOCIAL_AUTH_LOGIN_REDIRECT_URL"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_ID_KEY"): + globs["SOCIAL_AUTH_OIDC_ID_KEY"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_USERNAME_KEY"): + globs["SOCIAL_AUTH_OIDC_USERNAME_KEY"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS"): + globs["SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS"] = env("DD_SOCIAL_AUTH_OIDC_WHITELISTED_DOMAINS") + if value := env("DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS"): + globs["SOCIAL_AUTH_OIDC_JWT_ALGORITHMS"] = env("DD_SOCIAL_AUTH_OIDC_JWT_ALGORITHMS") + if value := env("DD_SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER"): + globs["SOCIAL_AUTH_OIDC_ID_TOKEN_ISSUER"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL"): + globs["SOCIAL_AUTH_OIDC_ACCESS_TOKEN_URL"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_AUTHORIZATION_URL"): + globs["SOCIAL_AUTH_OIDC_AUTHORIZATION_URL"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_USERINFO_URL"): + globs["SOCIAL_AUTH_OIDC_USERINFO_URL"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_JWKS_URI"): + globs["SOCIAL_AUTH_OIDC_JWKS_URI"] = value + if value := env("DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT"): + globs["SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT"] = value + + # -------------------------------------------------------------------------- + # SOCIAL AUTH EXCEPTION MESSAGES + # -------------------------------------------------------------------------- + globs["SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION"] = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_REQUEST_EXCEPTION") + globs["SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED"] = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_CANCELED") + globs["SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED"] = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FAILED") + globs["SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN"] = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_FORBIDDEN") + globs["SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE"] = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_NONE_TYPE") + globs["SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR"] = env("DD_SOCIAL_AUTH_EXCEPTION_MESSAGE_AUTH_TOKEN_ERROR") + + # -------------------------------------------------------------------------- + # AUTH0 OAUTH2 + # -------------------------------------------------------------------------- + globs["AUTH0_OAUTH2_ENABLED"] = env("DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED") + globs["SOCIAL_AUTH_AUTH0_KEY"] = env("DD_SOCIAL_AUTH_AUTH0_KEY") + globs["SOCIAL_AUTH_AUTH0_SECRET"] = env("DD_SOCIAL_AUTH_AUTH0_SECRET") + globs["SOCIAL_AUTH_AUTH0_DOMAIN"] = env("DD_SOCIAL_AUTH_AUTH0_DOMAIN") + globs["SOCIAL_AUTH_AUTH0_SCOPE"] = env("DD_SOCIAL_AUTH_AUTH0_SCOPE") + globs["SOCIAL_AUTH_TRAILING_SLASH"] = env("DD_SOCIAL_AUTH_TRAILING_SLASH") + + # -------------------------------------------------------------------------- + # KEYCLOAK OAUTH2 + # -------------------------------------------------------------------------- + globs["KEYCLOAK_OAUTH2_ENABLED"] = env("DD_SOCIAL_AUTH_KEYCLOAK_OAUTH2_ENABLED") + globs["SOCIAL_AUTH_KEYCLOAK_KEY"] = env("DD_SOCIAL_AUTH_KEYCLOAK_KEY") + globs["SOCIAL_AUTH_KEYCLOAK_SECRET"] = env("DD_SOCIAL_AUTH_KEYCLOAK_SECRET") + globs["SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY"] = env("DD_SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY") + globs["SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL"] = env("DD_SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL") + globs["SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL"] = env("DD_SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL") + globs["SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT"] = env("DD_SOCIAL_AUTH_KEYCLOAK_LOGIN_BUTTON_TEXT") + + # -------------------------------------------------------------------------- + # GITHUB ENTERPRISE OAUTH2 + # -------------------------------------------------------------------------- + globs["GITHUB_ENTERPRISE_OAUTH2_ENABLED"] = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_OAUTH2_ENABLED") + globs["SOCIAL_AUTH_GITHUB_ENTERPRISE_URL"] = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_URL") + globs["SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL"] = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL") + globs["SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY"] = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY") + globs["SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET"] = env("DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET") + + # -------------------------------------------------------------------------- + # INSTALLED_APPS + # -------------------------------------------------------------------------- + globs["INSTALLED_APPS"] = globs["INSTALLED_APPS"] + ("social_django",) + + # -------------------------------------------------------------------------- + # MIDDLEWARE + # -------------------------------------------------------------------------- + MIDDLEWARE = globs["MIDDLEWARE"] + if isinstance(MIDDLEWARE, list): + MIDDLEWARE.append("dojo.sso.middleware.CustomSocialAuthExceptionMiddleware") + else: + globs["MIDDLEWARE"] = list(MIDDLEWARE) + ["dojo.sso.middleware.CustomSocialAuthExceptionMiddleware"] + MIDDLEWARE = globs["MIDDLEWARE"] + + # -------------------------------------------------------------------------- + # TEMPLATES - add SSO context processors and template dir + # -------------------------------------------------------------------------- + context_processors = globs["TEMPLATES"][0]["OPTIONS"]["context_processors"] + context_processors.append("social_django.context_processors.backends") + context_processors.append("social_django.context_processors.login_redirect") + context_processors.append("dojo.sso.context_processors.sso_context") + sso_template_dir = os.path.join(os.path.dirname(__file__), "templates") + globs["TEMPLATES"][0]["DIRS"].append(sso_template_dir) + + # -------------------------------------------------------------------------- + # SAML2 + # -------------------------------------------------------------------------- + globs["SAML2_ENABLED"] = env("DD_SAML2_ENABLED") + globs["SAML2_LOGIN_BUTTON_TEXT"] = env("DD_SAML2_LOGIN_BUTTON_TEXT") + globs["SAML2_LOGOUT_URL"] = env("DD_SAML2_LOGOUT_URL") + if globs["SAML2_ENABLED"]: + import saml2 + import saml2.saml + + SAML_METADATA = {} + if len(env("DD_SAML2_METADATA_AUTO_CONF_URL")) > 0: + SAML_METADATA["remote"] = [{"url": env("DD_SAML2_METADATA_AUTO_CONF_URL")}] + if len(env("DD_SAML2_METADATA_LOCAL_FILE_PATH")) > 0: + SAML_METADATA["local"] = [env("DD_SAML2_METADATA_LOCAL_FILE_PATH")] + globs["INSTALLED_APPS"] = globs["INSTALLED_APPS"] + ("djangosaml2",) + MIDDLEWARE.append("djangosaml2.middleware.SamlSessionMiddleware") + globs["AUTHENTICATION_BACKENDS"] = globs["AUTHENTICATION_BACKENDS"] + (env("DD_SAML2_AUTHENTICATION_BACKENDS"),) + globs["LOGIN_EXEMPT_URLS"] = globs["LOGIN_EXEMPT_URLS"] + (rf"^{URL_PREFIX}saml2/",) + globs["SAML_LOGOUT_REQUEST_PREFERRED_BINDING"] = saml2.BINDING_HTTP_POST + globs["SAML_IGNORE_LOGOUT_ERRORS"] = True + globs["SAML_DJANGO_USER_MAIN_ATTRIBUTE"] = "username" + globs["SAML_USE_NAME_ID_AS_USERNAME"] = True + globs["SAML_CREATE_UNKNOWN_USER"] = env("DD_SAML2_CREATE_USER") + globs["SAML_ATTRIBUTE_MAPPING"] = _saml2_attrib_map_format(env("DD_SAML2_ATTRIBUTES_MAP")) + globs["SAML_FORCE_AUTH"] = env("DD_SAML2_FORCE_AUTH") + SAML_ALLOW_UNKNOWN_ATTRIBUTES = env("DD_SAML2_ALLOW_UNKNOWN_ATTRIBUTE") + globs["SAML_ALLOW_UNKNOWN_ATTRIBUTES"] = SAML_ALLOW_UNKNOWN_ATTRIBUTES + + BASEDIR = Path(__file__).parent.absolute() + if len(env("DD_SAML2_ENTITY_ID")) == 0: + SAML2_ENTITY_ID = f"{SITE_URL}/saml2/metadata/" + else: + SAML2_ENTITY_ID = env("DD_SAML2_ENTITY_ID") + globs["SAML2_ENTITY_ID"] = SAML2_ENTITY_ID + + SAML_FORCE_AUTH = env("DD_SAML2_FORCE_AUTH") + + globs["SAML_CONFIG"] = { + # full path to the xmlsec1 binary programm + "xmlsec_binary": "/usr/bin/xmlsec1", + + # your entity id, usually your subdomain plus the url to the metadata view + "entityid": str(SAML2_ENTITY_ID), + + # directory with attribute mapping + "attribute_map_dir": str(BASEDIR / "attribute_maps"), + # do now discard attributes not specified in attribute-maps + "allow_unknown_attributes": SAML_ALLOW_UNKNOWN_ATTRIBUTES, + # this block states what services we provide + "service": { + # we are just a lonely SP + "sp": { + "name": "Defect_Dojo", + "name_id_format": saml2.saml.NAMEID_FORMAT_TRANSIENT, + "want_response_signed": False, + "want_assertions_signed": True, + "force_authn": SAML_FORCE_AUTH, + "allow_unsolicited": True, + + # For Okta add signed logout requets. Enable this: + # "logout_requests_signed": True, + + "endpoints": { + # url and binding to the assetion consumer service view + # do not change the binding or service name + "assertion_consumer_service": [ + (f"{SITE_URL}/saml2/acs/", + saml2.BINDING_HTTP_POST), + ], + # url and binding to the single logout service view + # do not change the binding or service name + "single_logout_service": [ + # Disable next two lines for HTTP_REDIRECT for IDP's that only support HTTP_POST. Ex. Okta: + (f"{SITE_URL}/saml2/ls/", + saml2.BINDING_HTTP_REDIRECT), + (f"{SITE_URL}/saml2/ls/post", + saml2.BINDING_HTTP_POST), + ], + }, + + # attributes that this project need to identify a user + "required_attributes": ["Email", "UserName"], + + # attributes that may be useful to have but not required + "optional_attributes": ["Firstname", "Lastname"], + }, + }, + + # where the remote metadata is stored, local, remote or mdq server. + # One metadatastore or many ... + "metadata": SAML_METADATA, + + # set to 1 to output debugging information + "debug": 0, + + # own metadata settings + "contact_person": [ + {"given_name": "Lorenzo", + "sur_name": "Gil", + "company": "Yaco Sistemas", + "email_address": "lgs@yaco.es", + "contact_type": "technical"}, + {"given_name": "Angel", + "sur_name": "Fernandez", + "company": "Yaco Sistemas", + "email_address": "angel@yaco.es", + "contact_type": "administrative"}, + ], + # you can set multilanguage information here + "organization": { + "name": [("Yaco Sistemas", "es"), ("Yaco Systems", "en")], + "display_name": [("Yaco", "es"), ("Yaco", "en")], + "url": [("http://www.yaco.es", "es"), ("http://www.yaco.com", "en")], + }, + "valid_for": 24, # how long is our metadata valid + } + + # -------------------------------------------------------------------------- + # REMOTE_USER + # -------------------------------------------------------------------------- + globs["AUTH_REMOTEUSER_ENABLED"] = env("DD_AUTH_REMOTEUSER_ENABLED") + globs["AUTH_REMOTEUSER_USERNAME_HEADER"] = env("DD_AUTH_REMOTEUSER_USERNAME_HEADER") + globs["AUTH_REMOTEUSER_EMAIL_HEADER"] = env("DD_AUTH_REMOTEUSER_EMAIL_HEADER") + globs["AUTH_REMOTEUSER_FIRSTNAME_HEADER"] = env("DD_AUTH_REMOTEUSER_FIRSTNAME_HEADER") + globs["AUTH_REMOTEUSER_LASTNAME_HEADER"] = env("DD_AUTH_REMOTEUSER_LASTNAME_HEADER") + globs["AUTH_REMOTEUSER_GROUPS_HEADER"] = env("DD_AUTH_REMOTEUSER_GROUPS_HEADER") + globs["AUTH_REMOTEUSER_GROUPS_CLEANUP"] = env("DD_AUTH_REMOTEUSER_GROUPS_CLEANUP") + globs["AUTH_REMOTEUSER_VISIBLE_IN_SWAGGER"] = env("DD_AUTH_REMOTEUSER_VISIBLE_IN_SWAGGER") + + AUTH_REMOTEUSER_TRUSTED_PROXY = IPSet() + for ip_range in env("DD_AUTH_REMOTEUSER_TRUSTED_PROXY"): + AUTH_REMOTEUSER_TRUSTED_PROXY.add(IPNetwork(ip_range)) + globs["AUTH_REMOTEUSER_TRUSTED_PROXY"] = AUTH_REMOTEUSER_TRUSTED_PROXY + + if env("DD_AUTH_REMOTEUSER_LOGIN_ONLY"): + RemoteUserMiddleware = "dojo.sso.remote_user.PersistentRemoteUserMiddleware" + else: + RemoteUserMiddleware = "dojo.sso.remote_user.RemoteUserMiddleware" + # we need to add middleware just behind AuthenticationMiddleware + for i in range(len(MIDDLEWARE)): + if MIDDLEWARE[i] == "django.contrib.auth.middleware.AuthenticationMiddleware": + MIDDLEWARE.insert(i + 1, RemoteUserMiddleware) + break + + if globs["AUTH_REMOTEUSER_ENABLED"]: + globs["REST_FRAMEWORK"]["DEFAULT_AUTHENTICATION_CLASSES"] = \ + ("dojo.sso.remote_user.RemoteUserAuthentication",) + \ + globs["REST_FRAMEWORK"]["DEFAULT_AUTHENTICATION_CLASSES"] diff --git a/dojo/sso/templates/dojo/sso_login_buttons.html b/dojo/sso/templates/dojo/sso_login_buttons.html new file mode 100644 index 00000000000..374f93adb58 --- /dev/null +++ b/dojo/sso/templates/dojo/sso_login_buttons.html @@ -0,0 +1,56 @@ +{% load i18n %} +
+ {% if OIDC_ENABLED is True %} + + {% endif %} + + {% if GOOGLE_ENABLED is True %} + + {% endif %} + + {% if OKTA_ENABLED is True %} + + {% endif %} + + {% if AZUREAD_TENANT_OAUTH2_ENABLED is True %} + + {% endif %} + + {% if GITLAB_ENABLED is True %} + + {% endif %} + + {% if AUTH0_ENABLED is True %} + + {% endif %} + + {% if KEYCLOAK_ENABLED is True %} + + {% endif %} + + {% if GITHUB_ENTERPRISE_ENABLED is True %} + + {% endif %} + + {% if SAML2_ENABLED is True %} + + {% endif %} +
diff --git a/dojo/sso/urls.py b/dojo/sso/urls.py new file mode 100644 index 00000000000..0e5a3506d4e --- /dev/null +++ b/dojo/sso/urls.py @@ -0,0 +1,10 @@ +from django.conf import settings +from django.conf.urls import include +from django.urls import re_path + +urlpatterns = [ + re_path("", include("social_django.urls", namespace="social")), +] + +if getattr(settings, "SAML2_ENABLED", False): + urlpatterns += [re_path(r"^saml2/", include("djangosaml2.urls"))] diff --git a/dojo/sso/views.py b/dojo/sso/views.py new file mode 100644 index 00000000000..b0f5b106336 --- /dev/null +++ b/dojo/sso/views.py @@ -0,0 +1,43 @@ +from django.conf import settings +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.utils.http import urlencode + + +def get_sso_auto_redirect(request): + """Return an HttpResponseRedirect to the SSO provider if auto-redirect conditions are met, or None.""" + if not settings.SHOW_LOGIN_FORM and settings.SOCIAL_LOGIN_AUTO_REDIRECT and sum([ + settings.GOOGLE_OAUTH_ENABLED, + settings.OKTA_OAUTH_ENABLED, + settings.AZUREAD_TENANT_OAUTH2_ENABLED, + settings.GITLAB_OAUTH2_ENABLED, + settings.AUTH0_OAUTH2_ENABLED, + settings.KEYCLOAK_OAUTH2_ENABLED, + settings.GITHUB_ENTERPRISE_OAUTH2_ENABLED, + settings.OIDC_AUTH_ENABLED, + settings.SAML2_ENABLED, + ]) == 1 and "force_login_form" not in request.GET: + if settings.GOOGLE_OAUTH_ENABLED: + social_auth = "google-oauth2" + elif settings.OKTA_OAUTH_ENABLED: + social_auth = "okta-oauth2" + elif settings.AZUREAD_TENANT_OAUTH2_ENABLED: + social_auth = "azuread-tenant-oauth2" + elif settings.GITLAB_OAUTH2_ENABLED: + social_auth = "gitlab" + elif settings.KEYCLOAK_OAUTH2_ENABLED: + social_auth = "keycloak" + elif settings.OIDC_AUTH_ENABLED: + social_auth = "oidc" + elif settings.AUTH0_OAUTH2_ENABLED: + social_auth = "auth0" + elif settings.GITHUB_ENTERPRISE_OAUTH2_ENABLED: + social_auth = "github-enterprise" + else: + return HttpResponseRedirect("/saml2/login") + try: + return HttpResponseRedirect("{}?{}".format(reverse("social:begin", args=[social_auth]), + urlencode({"next": request.GET.get("next", "/dashboard")}))) + except Exception: + return HttpResponseRedirect(reverse("social:begin", args=[social_auth])) + return None diff --git a/dojo/templates/dojo/login.html b/dojo/templates/dojo/login.html index fe54191f2a6..2f1ecb0334c 100644 --- a/dojo/templates/dojo/login.html +++ b/dojo/templates/dojo/login.html @@ -46,61 +46,7 @@

{% trans "Login" %}

{% endif %} {% endif %} -
- {% if OIDC_ENABLED is True %} - - {% endif %} - - {% if GOOGLE_ENABLED is True %} - - {% endif %} - - {% if OKTA_ENABLED is True %} - - {% endif %} - - {% if AZUREAD_TENANT_OAUTH2_ENABLED is True %} - - {% endif %} - - {% if GITLAB_ENABLED is True %} - - {% endif %} - - {% if AUTH0_ENABLED is True %} - - {% endif %} - - {% if KEYCLOAK_ENABLED is True %} - - {% endif %} - - {% if GITHUB_ENTERPRISE_ENABLED is True %} - - {% endif %} - - {% if SAML2_ENABLED is True %} - - {% endif %} -
+ {% include "dojo/sso_login_buttons.html" ignore missing %} {% endblock %} diff --git a/dojo/urls.py b/dojo/urls.py index 2d04ca7c078..9ac42350695 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -282,10 +282,11 @@ if settings.DJANGO_METRICS_ENABLED: urlpatterns += [re_path(r"^{}django_metrics/".format(get_system_setting("url_prefix")), include("django_prometheus.urls"))] -if hasattr(settings, "SAML2_ENABLED"): - if settings.SAML2_ENABLED: - # django saml2 - urlpatterns += [re_path(r"^saml2/", include("djangosaml2.urls"))] +try: + from dojo.sso.urls import urlpatterns as sso_urlpatterns + urlpatterns += sso_urlpatterns +except ImportError: + pass if hasattr(settings, "DJANGO_ADMIN_ENABLED"): if settings.DJANGO_ADMIN_ENABLED: diff --git a/dojo/user/urls.py b/dojo/user/urls.py index d560bdb5f55..57fc37f050f 100644 --- a/dojo/user/urls.py +++ b/dojo/user/urls.py @@ -1,13 +1,10 @@ from django.conf import settings -from django.conf.urls import include from django.contrib.auth import views as auth_views from django.urls import re_path, reverse_lazy from dojo.user import views urlpatterns = [ - # social-auth-django required url package - re_path("", include("social_django.urls", namespace="social")), # user specific re_path(r"^login$", views.login_view, name="login"), re_path(r"^logout$", views.logout_view, name="logout"), diff --git a/dojo/user/views.py b/dojo/user/views.py index dd732b5c91e..7db1619b4b2 100644 --- a/dojo/user/views.py +++ b/dojo/user/views.py @@ -22,7 +22,6 @@ from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse -from django.utils.http import urlencode from django.utils.timezone import now from django.utils.translation import gettext as _ from rest_framework.authtoken.models import Token @@ -128,42 +127,14 @@ def api_v2_key(request): @dojo_ratelimit(key="post:username") @dojo_ratelimit(key="post:password") def login_view(request): - if not settings.SHOW_LOGIN_FORM and settings.SOCIAL_LOGIN_AUTO_REDIRECT and sum([ - settings.GOOGLE_OAUTH_ENABLED, - settings.OKTA_OAUTH_ENABLED, - settings.AZUREAD_TENANT_OAUTH2_ENABLED, - settings.GITLAB_OAUTH2_ENABLED, - settings.AUTH0_OAUTH2_ENABLED, - settings.KEYCLOAK_OAUTH2_ENABLED, - settings.GITHUB_ENTERPRISE_OAUTH2_ENABLED, - settings.OIDC_AUTH_ENABLED, - settings.SAML2_ENABLED, - ]) == 1 and "force_login_form" not in request.GET: - if settings.GOOGLE_OAUTH_ENABLED: - social_auth = "google-oauth2" - elif settings.OKTA_OAUTH_ENABLED: - social_auth = "okta-oauth2" - elif settings.AZUREAD_TENANT_OAUTH2_ENABLED: - social_auth = "azuread-tenant-oauth2" - elif settings.GITLAB_OAUTH2_ENABLED: - social_auth = "gitlab" - elif settings.KEYCLOAK_OAUTH2_ENABLED: - social_auth = "keycloak" - elif settings.OIDC_AUTH_ENABLED: - social_auth = "oidc" - elif settings.AUTH0_OAUTH2_ENABLED: - social_auth = "auth0" - elif settings.GITHUB_ENTERPRISE_OAUTH2_ENABLED: - social_auth = "github-enterprise" - else: - return HttpResponseRedirect("/saml2/login") - try: - return HttpResponseRedirect("{}?{}".format(reverse("social:begin", args=[social_auth]), - urlencode({"next": request.GET.get("next", "/dashboard")}))) - except: - return HttpResponseRedirect(reverse("social:begin", args=[social_auth])) - else: - return DojoLoginView.as_view(template_name="dojo/login.html", authentication_form=AuthenticationForm)(request) + try: + from dojo.sso.views import get_sso_auto_redirect + redirect_response = get_sso_auto_redirect(request) + if redirect_response is not None: + return redirect_response + except ImportError: + pass + return DojoLoginView.as_view(template_name="dojo/login.html", authentication_form=AuthenticationForm)(request) def logout_view(request): diff --git a/unittests/test_remote_user.py b/unittests/test_remote_user.py index a1d3706c6a7..be0804d7d93 100644 --- a/unittests/test_remote_user.py +++ b/unittests/test_remote_user.py @@ -2,7 +2,7 @@ from netaddr import IPSet from dojo.models import Dojo_Group, Dojo_Group_Member, User -from dojo.remote_user import RemoteUserScheme +from dojo.sso.remote_user import RemoteUserScheme from .dojo_test_case import DojoTestCase @@ -152,7 +152,7 @@ def test_trusted_proxy(self): AUTH_REMOTEUSER_TRUSTED_PROXY=IPSet(["192.168.0.0/24", "192.168.2.0/24"]), ) def test_untrusted_proxy(self): - with self.assertLogs("dojo.remote_user", level="DEBUG") as cm: + with self.assertLogs("dojo.sso.remote_user", level="DEBUG") as cm: resp = self.client1.get("/profile", REMOTE_ADDR="192.168.1.42", headers={ diff --git a/unittests/test_social_auth_failure_handling.py b/unittests/test_social_auth_failure_handling.py index 808a5bb7c97..ea21245cf46 100644 --- a/unittests/test_social_auth_failure_handling.py +++ b/unittests/test_social_auth_failure_handling.py @@ -9,7 +9,7 @@ from requests.exceptions import ConnectionError as RequestsConnectionError from social_core.exceptions import AuthCanceled, AuthFailed, AuthForbidden, AuthTokenError -from dojo.middleware import CustomSocialAuthExceptionMiddleware +from dojo.sso.middleware import CustomSocialAuthExceptionMiddleware from .dojo_test_case import DojoTestCase From 6ca8fb5486e531b6e59a79b29ded1c0e26785036 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:34:32 -0600 Subject: [PATCH 2/2] Fix ruff lint errors in SSO isolation changes - Fix import sorting in settings.dist.py - Suppress C408 for dict() kwargs merging pattern - Use Path() instead of os.path in sso/settings.py - Use augmented assignment (+=) for dict key concatenation - Use iterable unpacking instead of list concatenation - Add noqa for intentional non-top-level imports (try/except guards) Co-Authored-By: Claude Opus 4.6 (1M context) --- dojo/settings/settings.dist.py | 3 ++- dojo/sso/settings.py | 19 +++++++++---------- dojo/user/views.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 57f6cdf2b37..a9f7a8633bb 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -17,6 +17,7 @@ import environ import pghistory from celery.schedules import crontab + from dojo import __version__ logger = logging.getLogger(__name__) @@ -32,7 +33,7 @@ pass # reference: https://pypi.org/project/django-environ/ -env = environ.FileAwareEnv(**{**dict( +env = environ.FileAwareEnv(**{**dict( # noqa: C408 # Set casting and default values DD_SITE_URL=(str, "http://localhost:8080"), DD_DEBUG=(bool, False), diff --git a/dojo/sso/settings.py b/dojo/sso/settings.py index b666a3b9082..3a27799077a 100644 --- a/dojo/sso/settings.py +++ b/dojo/sso/settings.py @@ -1,4 +1,3 @@ -import os from pathlib import Path SSO_ENV_SCHEMA = { @@ -108,7 +107,7 @@ def _saml2_attrib_map_format(din): def apply_sso_settings(env, globs): """Apply all SSO-related settings. Called from settings.dist.py inside a try/except ImportError block.""" - from netaddr import IPNetwork, IPSet + from netaddr import IPNetwork, IPSet # noqa: PLC0415 SITE_URL = globs["SITE_URL"] URL_PREFIX = globs.get("URL_PREFIX", "") @@ -283,7 +282,7 @@ def apply_sso_settings(env, globs): # -------------------------------------------------------------------------- # INSTALLED_APPS # -------------------------------------------------------------------------- - globs["INSTALLED_APPS"] = globs["INSTALLED_APPS"] + ("social_django",) + globs["INSTALLED_APPS"] += ("social_django",) # -------------------------------------------------------------------------- # MIDDLEWARE @@ -292,7 +291,7 @@ def apply_sso_settings(env, globs): if isinstance(MIDDLEWARE, list): MIDDLEWARE.append("dojo.sso.middleware.CustomSocialAuthExceptionMiddleware") else: - globs["MIDDLEWARE"] = list(MIDDLEWARE) + ["dojo.sso.middleware.CustomSocialAuthExceptionMiddleware"] + globs["MIDDLEWARE"] = [*MIDDLEWARE, "dojo.sso.middleware.CustomSocialAuthExceptionMiddleware"] MIDDLEWARE = globs["MIDDLEWARE"] # -------------------------------------------------------------------------- @@ -302,7 +301,7 @@ def apply_sso_settings(env, globs): context_processors.append("social_django.context_processors.backends") context_processors.append("social_django.context_processors.login_redirect") context_processors.append("dojo.sso.context_processors.sso_context") - sso_template_dir = os.path.join(os.path.dirname(__file__), "templates") + sso_template_dir = str(Path(__file__).parent / "templates") globs["TEMPLATES"][0]["DIRS"].append(sso_template_dir) # -------------------------------------------------------------------------- @@ -312,18 +311,18 @@ def apply_sso_settings(env, globs): globs["SAML2_LOGIN_BUTTON_TEXT"] = env("DD_SAML2_LOGIN_BUTTON_TEXT") globs["SAML2_LOGOUT_URL"] = env("DD_SAML2_LOGOUT_URL") if globs["SAML2_ENABLED"]: - import saml2 - import saml2.saml + import saml2 # noqa: PLC0415 + import saml2.saml # noqa: PLC0415 SAML_METADATA = {} if len(env("DD_SAML2_METADATA_AUTO_CONF_URL")) > 0: SAML_METADATA["remote"] = [{"url": env("DD_SAML2_METADATA_AUTO_CONF_URL")}] if len(env("DD_SAML2_METADATA_LOCAL_FILE_PATH")) > 0: SAML_METADATA["local"] = [env("DD_SAML2_METADATA_LOCAL_FILE_PATH")] - globs["INSTALLED_APPS"] = globs["INSTALLED_APPS"] + ("djangosaml2",) + globs["INSTALLED_APPS"] += ("djangosaml2",) MIDDLEWARE.append("djangosaml2.middleware.SamlSessionMiddleware") - globs["AUTHENTICATION_BACKENDS"] = globs["AUTHENTICATION_BACKENDS"] + (env("DD_SAML2_AUTHENTICATION_BACKENDS"),) - globs["LOGIN_EXEMPT_URLS"] = globs["LOGIN_EXEMPT_URLS"] + (rf"^{URL_PREFIX}saml2/",) + globs["AUTHENTICATION_BACKENDS"] += (env("DD_SAML2_AUTHENTICATION_BACKENDS"),) + globs["LOGIN_EXEMPT_URLS"] += (rf"^{URL_PREFIX}saml2/",) globs["SAML_LOGOUT_REQUEST_PREFERRED_BINDING"] = saml2.BINDING_HTTP_POST globs["SAML_IGNORE_LOGOUT_ERRORS"] = True globs["SAML_DJANGO_USER_MAIN_ATTRIBUTE"] = "username" diff --git a/dojo/user/views.py b/dojo/user/views.py index 7db1619b4b2..a04272effa7 100644 --- a/dojo/user/views.py +++ b/dojo/user/views.py @@ -128,7 +128,7 @@ def api_v2_key(request): @dojo_ratelimit(key="post:password") def login_view(request): try: - from dojo.sso.views import get_sso_auto_redirect + from dojo.sso.views import get_sso_auto_redirect # noqa: PLC0415 redirect_response = get_sso_auto_redirect(request) if redirect_response is not None: return redirect_response