From 86168ce0897eae4c648825f034e0f48e0b4e1b80 Mon Sep 17 00:00:00 2001 From: Sjoerd Simons Date: Mon, 14 Mar 2022 16:26:17 +0100 Subject: [PATCH] Add support for Open ID Connect (OIDC) identity provider Open ID Connect is an authentication protocol build on top of OAuth2. It is used for example in Azure AD and Keycloack django-allauth unfortunately does not support the generic OIDC. However, Mozzila developed a OIDC library for django called `mozilla-django-oidc` The integration is entirely optional and nothing is imported from `mozilla-django-oidc` unless it is activated with `AUTH_OIDC` key in YAML configuration files. --- doc/v2/authentication.rst | 32 ++++ lava_server/context_processors.py | 7 + lava_server/oidc_sso.py | 8 +- lava_server/security.py | 9 + lava_server/settings/common.py | 158 +++++++++++------- lava_server/templates/registration/login.html | 4 +- lava_server/urls.py | 8 + 7 files changed, 159 insertions(+), 67 deletions(-) diff --git a/doc/v2/authentication.rst b/doc/v2/authentication.rst index 6e02b0dd8..f7d9965d2 100644 --- a/doc/v2/authentication.rst +++ b/doc/v2/authentication.rst @@ -131,3 +131,35 @@ need to be performed (following example covers `GitLab OAuth2 authentication`_): .. note:: If SMTP is not set up in LAVA, you can get a 500 Internal server error. Login will still work despite the error. + +Using Open ID Connect (OIDC) authentication providers +----------------------------------------------------- + +LAVA server can be configured to authenticate using OIDC providers +such as Keycloack or Azure AD. The OIDC library used is +`mozilla-django-oidc `_. + +The library does not come pre-installed and must be installed through +external means. (for example, with ``pip``) + +To enable OIDC authorization set ``AUTH_OIDC`` dictionary in one of the +configuration files. + +Example:: + + --- + + AUTH_OIDC: + OIDC_RP_CLIENT_ID: "1" + OIDC_RP_CLIENT_SECRET: "bd01adf93cfb" + OIDC_OP_AUTHORIZATION_ENDPOINT: "http://testprovider:8080/openid/authorize" + OIDC_OP_TOKEN_ENDPOINT: "http://testprovider:8080/openid/token" + OIDC_OP_USER_ENDPOINT: "http://testprovider:8080/openid/userinfo" + +See `mozilla-django-oidc settings `_ +for the list of configuration keys. + +One extra setting that LAVA provides is ``LAVA_OIDC_ACCOUNT_NAME`` +which sets the login message for OIDC login prompt. For example, +it can be set to ``Azure AD account``. By default it is set to +``Open ID Connect account``. diff --git a/lava_server/context_processors.py b/lava_server/context_processors.py index 0973cf2bc..420856272 100644 --- a/lava_server/context_processors.py +++ b/lava_server/context_processors.py @@ -48,6 +48,13 @@ def ldap_available(request): return {"ldap_available": ldap_enabled, "login_message_ldap": login_message_ldap} +def oidc_context(request): + return { + "oidc_enabled": settings.OIDC_ENABLED, + "oidc_account_name": settings.LAVA_OIDC_ACCOUNT_NAME, + } + + def socialaccount(request): return { "socialaccount_enabled": settings.AUTH_SOCIALACCOUNT is not None, diff --git a/lava_server/oidc_sso.py b/lava_server/oidc_sso.py index 37b553461..2af5bd911 100644 --- a/lava_server/oidc_sso.py +++ b/lava_server/oidc_sso.py @@ -1,11 +1,13 @@ from mozilla_django_oidc.auth import OIDCAuthenticationBackend import logging -class OIDCAuthenticationBackend(OIDCAuthenticationBackend): + +class OIDCAuthenticationBackendUsernameFromEmail(OIDCAuthenticationBackend): def create_user(self, claims): # TODO try multiple options and fallbacks - logger = logging.getLogger() - email = claims.get('email') + logger = logging.getLogger("mozilla_django_oidc") + email = claims.get("email") logger.info(f"Creating new user for e-mail: {email}") + # On Azure AD the username is not part of the claims username = email.split("@")[0] return self.UserModel.objects.create_user(username, email=email) diff --git a/lava_server/security.py b/lava_server/security.py index 2f08b5e20..661a656da 100644 --- a/lava_server/security.py +++ b/lava_server/security.py @@ -34,6 +34,7 @@ class LavaRequireLoginMiddleware: SCHEDULER_INTERNALS_PATH: ClassVar[PurePosixPath] = ( HOME_PATH / "scheduler/internal/v1" ) + OIDC_PATH: ClassVar[PurePosixPath] = HOME_PATH / "oidc" def __init__(self, get_response): self.get_response = get_response @@ -54,6 +55,14 @@ class LavaRequireLoginMiddleware: else: return True + if settings.OIDC_ENABLED: + try: + path.relative_to(cls.OIDC_PATH) + except ValueError: + ... + else: + return True + return False @classmethod diff --git a/lava_server/settings/common.py b/lava_server/settings/common.py index 7fb7e49f7..f48b4651b 100644 --- a/lava_server/settings/common.py +++ b/lava_server/settings/common.py @@ -114,6 +114,7 @@ TEMPLATES = [ # LAVA context processors "lava_server.context_processors.lava", "lava_server.context_processors.ldap_available", + "lava_server.context_processors.oidc_context", "lava_server.context_processors.socialaccount", ] }, @@ -231,6 +232,8 @@ AUTH_SOCIALACCOUNT = None # OIDC support AUTH_OIDC = None +OIDC_ENABLED = False +LAVA_OIDC_ACCOUNT_NAME = "Open ID Connect account" # Gitlab support AUTH_GITLAB_URL = None @@ -411,30 +414,58 @@ def update(values): SOCIALACCOUNT_PROVIDERS = auth_socialaccount # OIDC authentication - if AUTH_OIDC: + if AUTH_OIDC is not None: try: - auth_oidc = yaml_safe_load(AUTH_OIDC) - if not isinstance(auth_oidc, dict): - auth_oidc = {} - except YAMLError: + LAVA_OIDC_ACCOUNT_NAME = AUTH_OIDC.pop("LAVA_OIDC_ACCOUNT_NAME") + except KeyError: + ... + + oidc_keys = { + "OIDC_OP_AUTHORIZATION_ENDPOINT", + "OIDC_OP_TOKEN_ENDPOINT", + "OIDC_OP_USER_ENDPOINT", + "OIDC_RP_CLIENT_ID", + "OIDC_RP_CLIENT_SECRET", + "OIDC_VERIFY_JWT", + "OIDC_VERIFY_KID", + "OIDC_USE_NONCE", + "OIDC_VERIFY_SSL", + "OIDC_TIMEOUT", + "OIDC_PROXY", + "OIDC_EXEMPT_URLS", + "OIDC_CREATE_USER", + "OIDC_STATE_SIZE", + "OIDC_NONCE_SIZE", + "OIDC_MAX_STATES", + "OIDC_REDIRECT_FIELD_NAME", + "OIDC_CALLBACK_CLASS", + "OIDC_AUTHENTICATE_CLASS", + "OIDC_RP_SCOPES", + "OIDC_STORE_ACCESS_TOKEN", + "OIDC_STORE_ID_TOKEN", + "OIDC_AUTH_REQUEST_EXTRA_PARAMS", + "OIDC_RP_SIGN_ALGO", + "OIDC_RP_IDP_SIGN_KEY", + "OIDC_OP_LOGOUT_URL_METHOD", + "OIDC_AUTHENTICATION_CALLBACK_URL", + "OIDC_ALLOW_UNSECURED_JWT", + "OIDC_TOKEN_USE_BASIC_AUTH", + } + + if not oidc_keys.issuperset(AUTH_OIDC.keys()): raise ImproperlyConfigured( - "Failed to load oidc account configuration." + "Unknown OIDC configuration keys: ", + set(AUTH_OIDC.keys()).difference(oidc_keys), ) INSTALLED_APPS.append("mozilla_django_oidc") - AUTHENTICATION_BACKENDS.append("lava_server.oidc_sso.OIDCAuthenticationBackend") + AUTHENTICATION_BACKENDS.append( + "lava_server.oidc_sso.OIDCAuthenticationBackendUsernameFromEmail" + ) MIDDLEWARE.append("mozilla_django_oidc.middleware.SessionRefresh") - OIDC_RP_CLIENT_ID = auth_oidc["client_id"] - OIDC_RP_CLIENT_SECRET = auth_oidc["client_secret"] - OIDC_OP_AUTHORIZATION_ENDPOINT = auth_oidc["authorization_endpoint"] - OIDC_OP_TOKEN_ENDPOINT = auth_oidc["token_endpoint"] - OIDC_OP_USER_ENDPOINT = auth_oidc["user_endpoint"] - if "sign_algo" in auth_oidc: - OIDC_RP_SIGN_ALGO = auth_oidc["sign_algo"] - OIDC_RP_IDP_SIGN_KEY = auth_oidc.get("sign_key") - OIDC_OP_JWKS_ENDPOINT = auth_oidc.get("jwks_endpoint") - + OIDC_ENABLED = True + locals().update(AUTH_OIDC) # LDAP authentication config if AUTH_LDAP_SERVER_URI: @@ -496,56 +527,57 @@ def update(values): re.compile(r"%s" % reg, re.IGNORECASE) for reg in DISALLOWED_USER_AGENTS ] - LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "filters": { - "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"} - }, - "formatters": { - "lava": {"format": "%(levelname)s %(asctime)s %(module)s %(message)s"} - }, - "handlers": { - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "lava", - }, - "logfile": { - "class": "logging.handlers.WatchedFileHandler", - "filename": DJANGO_LOGFILE, - "formatter": "lava", + if LOGGING is None: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"} }, - }, - "loggers": { - "django": { - "handlers": ["logfile"], - # DEBUG outputs all SQL statements - "level": "ERROR", - "propagate": True, - }, - "django_auth_ldap": { - "handlers": ["logfile"], - "level": "INFO", - "propagate": True, - }, - "lava_results_app": { - "handlers": ["logfile"], - "level": "INFO", - "propagate": True, + "formatters": { + "lava": {"format": "%(levelname)s %(asctime)s %(module)s %(message)s"} }, - "lava_scheduler_app": { - "handlers": ["logfile"], - "level": "INFO", - "propagate": True, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "lava", + }, + "logfile": { + "class": "logging.handlers.WatchedFileHandler", + "filename": DJANGO_LOGFILE, + "formatter": "lava", + }, }, - 'mozilla_django_oidc': { - 'handlers': ['logfile'], - 'level': 'DEBUG', - "propagate": True, + "loggers": { + "django": { + "handlers": ["logfile"], + # DEBUG outputs all SQL statements + "level": "ERROR", + "propagate": True, + }, + "django_auth_ldap": { + "handlers": ["logfile"], + "level": "INFO", + "propagate": True, + }, + "lava_results_app": { + "handlers": ["logfile"], + "level": "INFO", + "propagate": True, + }, + "lava_scheduler_app": { + "handlers": ["logfile"], + "level": "INFO", + "propagate": True, + }, + "mozilla_django_oidc": { + "handlers": ["logfile"], + "level": "DEBUG", + "propagate": True, + }, }, - }, - } + } if SENTRY_DSN: import sentry_sdk diff --git a/lava_server/templates/registration/login.html b/lava_server/templates/registration/login.html index faa5948f0..fab5a3e0d 100644 --- a/lava_server/templates/registration/login.html +++ b/lava_server/templates/registration/login.html @@ -52,10 +52,12 @@ {% endif %} + {% if oidc_enabled %}
- + Login
+ {% endif %}
diff --git a/lava_server/urls.py b/lava_server/urls.py index d0d6cd2c4..6ad10278f 100644 --- a/lava_server/urls.py +++ b/lava_server/urls.py @@ -170,6 +170,14 @@ urlpatterns = [ ), ] +if settings.OIDC_ENABLED: + urlpatterns.append( + url( + r"^{mount_point}oidc/".format(mount_point=settings.MOUNT_POINT), + include("mozilla_django_oidc.urls"), + ) + ) + if settings.USE_DEBUG_TOOLBAR: import debug_toolbar -- GitLab