auth: Implement a generic OpenID Connect backend.

Fixes #11939.
This commit is contained in:
Mateusz Mandera
2021-05-21 16:45:43 +02:00
committed by Tim Abbott
parent 1103720f45
commit e17758f8ad
15 changed files with 316 additions and 0 deletions

View File

@@ -669,6 +669,19 @@ domain for your server).
[apple-get-started]: https://developer.apple.com/sign-in-with-apple/get-started/
[outgoing-email]: ../production/email.md
## OpenID Connect
Starting with Zulip 5.0, Zulip can be integrated with any OpenID
Connect (OIDC) authentication provider. You can configure it by
enabling `zproject.backends.GenericOpenIdConnectBackend` in
`AUTHENTICATION_BACKENDS` and following the steps outlined in the
comment documentation in `/etc/zulip/settings.py`.
Note that `SOCIAL_AUTH_OIDC_ENABLED_IDPS` only supports a single backend
The Return URL to authorize with the provider is
`https://yourzulipdomain.example.com/complete/oidc/`.
## Adding more authentication backends
Adding an integration with any of the more than 100 authentication

View File

@@ -95,6 +95,12 @@
The REMOTE_USER header is not set.
</p>
{% endif %}
{% if error_name == "oidc_error" %}
<p>
The OpenID Connect backend is not configured correctly.
</p>
{% endif %}
<p>After making your changes, remember to restart
the Zulip server.</p>
<p><a href=""> Refresh</a> to try again or <a href="/login/">click here</a> to go back to the login page.</p>

View File

@@ -25,6 +25,8 @@ FILES_WITH_LEGACY_SUBJECT = {
"zerver/tests/test_new_users.py",
"zerver/tests/test_email_mirror.py",
"zerver/tests/test_email_notifications.py",
# This uses subject in authentication protocols sense:
"zerver/tests/test_auth_backends.py",
# These are tied more to our API than our DB model.
"zerver/openapi/python_examples.py",
"zerver/tests/test_openapi.py",

View File

@@ -0,0 +1,34 @@
# Generated by Django 3.2.2 on 2021-05-19 11:53
import bitfield.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("zerver", "0325_alter_realmplayground_unique_together"),
]
operations = [
migrations.AlterField(
model_name="realm",
name="authentication_methods",
field=bitfield.models.BitField(
[
"Google",
"Email",
"GitHub",
"LDAP",
"Dev",
"RemoteUser",
"AzureAD",
"SAML",
"GitLab",
"Apple",
"OpenID Connect",
],
default=2147483647,
),
),
]

View File

@@ -199,6 +199,7 @@ class Realm(models.Model):
"SAML",
"GitLab",
"Apple",
"OpenID Connect",
]
SUBDOMAIN_FOR_ROOT_DOMAIN = ""
WILDCARD_MENTION_THRESHOLD = 15

View File

@@ -9209,6 +9209,10 @@ paths:
description: |
Whether the user can authenticate using SAML.
type: boolean
openid connect:
description: |
Whether the user can authenticate using OpenID Connect.
type: boolean
external_authentication_methods:
type: array
description: |

View File

@@ -374,6 +374,7 @@ class TestRealmAuditLog(ZulipTestCase):
"Dev": True,
"SAML": True,
"GitLab": False,
"OpenID Connect": False,
}
do_set_realm_authentication_methods(realm, auth_method_dict, acting_user=user)

View File

@@ -103,6 +103,7 @@ from zproject.backends import (
EmailAuthBackend,
ExternalAuthDataDict,
ExternalAuthResult,
GenericOpenIdConnectBackend,
GitHubAuthBackend,
GitLabAuthBackend,
GoogleAuthBackend,
@@ -2829,6 +2830,159 @@ class AppleAuthBackendNativeFlowTest(AppleAuthMixin, SocialAuthBase):
pass
class GenericOpenIdConnectTest(SocialAuthBase):
__unittest_skip__ = False
BACKEND_CLASS = GenericOpenIdConnectBackend
CLIENT_KEY_SETTING = "SOCIAL_AUTH_TESTOIDC_KEY"
CLIENT_SECRET_SETTING = "SOCIAL_AUTH_TESTOIDC_SECRET"
LOGIN_URL = "/accounts/login/social/oidc"
SIGNUP_URL = "/accounts/register/social/oidc"
BASE_OIDC_URL = "https://example.com/api/openid"
AUTHORIZATION_URL = f"{BASE_OIDC_URL}/authorize"
ACCESS_TOKEN_URL = f"{BASE_OIDC_URL}/token"
JWKS_URL = f"{BASE_OIDC_URL}/jwks"
USER_INFO_URL = f"{BASE_OIDC_URL}/userinfo"
AUTH_FINISH_URL = "/complete/oidc/"
CONFIG_ERROR_URL = "/config-error/oidc"
def social_auth_test(
self,
*args: Any,
**kwargs: Any,
) -> HttpResponse:
# Example payload of the discovery endpoint (with appropriate values filled
# in to match our test setup).
# All the attributes below are REQUIRED per OIDC specification:
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
# or at least required for the `code` flow with userinfo - that this implementation uses.
# Other flows are not supported right now.
idp_discovery_endpoint_payload_dict = {
"issuer": self.BASE_OIDC_URL,
"authorization_endpoint": self.AUTHORIZATION_URL,
"token_endpoint": self.ACCESS_TOKEN_URL,
"userinfo_endpoint": self.USER_INFO_URL,
"response_types_supported": [
"code",
"id_token",
"id_token token",
"code token",
"code id_token",
"code id_token token",
],
"jwks_uri": self.JWKS_URL,
"id_token_signing_alg_values_supported": ["HS256", "RS256"],
"subject_types_supported": ["public"],
}
# We need to run the social_auth_test procedure with a mock response set up for the
# OIDC discovery endpoint as that's the first thing requested by the server when a user
# starts trying to authenticate.
with responses.RequestsMock(assert_all_requests_are_fired=False) as requests_mock:
requests_mock.add(
requests_mock.GET,
f"{self.BASE_OIDC_URL}/.well-known/openid-configuration",
match_querystring=False,
status=200,
body=json.dumps(idp_discovery_endpoint_payload_dict),
)
result = super().social_auth_test(*args, **kwargs)
return result
def social_auth_test_finish(self, *args: Any, **kwargs: Any) -> HttpResponse:
# Trying to generate a (access_token, id_token) pair here in tests that would
# successfully pass validation by validate_and_return_id_token is impractical
# and unnecessary (see python-social-auth implementation of the method for
# how the validation works).
# We can simply mock the method to make it succeed and return an empty dict, because
# the return value is not used for anything.
with mock.patch.object(
GenericOpenIdConnectBackend, "validate_and_return_id_token", return_value={}
):
return super().social_auth_test_finish(*args, **kwargs)
def register_extra_endpoints(
self,
requests_mock: responses.RequestsMock,
account_data_dict: Dict[str, str],
**extra_data: Any,
) -> None:
requests_mock.add(
requests_mock.GET,
self.JWKS_URL,
status=200,
json=json.loads(settings.EXAMPLE_JWK),
)
def generate_access_token_url_payload(self, account_data_dict: Dict[str, str]) -> str:
return json.dumps(
{
"access_token": "foobar",
"expires_in": time.time() + 60 * 5,
"id_token": "abcd1234",
"token_type": "bearer",
}
)
def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]:
return dict(
email=email,
name=name,
nickname="somenickname",
given_name=name.split(" ")[0],
family_name=name.split(" ")[1],
)
def test_social_auth_no_key(self) -> None:
"""
Requires overriding because client key/secret are configured
in a different way than default for social auth backends.
"""
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
mock_oidc_setting_dict = copy.deepcopy(settings.SOCIAL_AUTH_OIDC_ENABLED_IDPS)
idp_config_dict = list(mock_oidc_setting_dict.values())[0]
del idp_config_dict["client_id"]
with self.settings(SOCIAL_AUTH_OIDC_ENABLED_IDPS=mock_oidc_setting_dict):
result = self.social_auth_test(
account_data_dict, subdomain="zulip", next="/user_uploads/image"
)
self.assert_in_success_response(["Configuration error", "OpenID Connect"], result)
def test_too_many_idps(self) -> None:
"""
Only one IdP is supported for now.
"""
account_data_dict = self.get_account_data_dict(email=self.email, name=self.name)
mock_oidc_setting_dict = copy.deepcopy(settings.SOCIAL_AUTH_OIDC_ENABLED_IDPS)
idp_config_dict = list(mock_oidc_setting_dict.values())[0]
mock_oidc_setting_dict["secondprovider"] = idp_config_dict
with self.settings(SOCIAL_AUTH_OIDC_ENABLED_IDPS=mock_oidc_setting_dict):
result = self.social_auth_test(
account_data_dict, subdomain="zulip", next="/user_uploads/image"
)
self.assert_in_success_response(["Configuration error", "OpenID Connect"], result)
def test_config_error_development(self) -> None:
"""
This test is redundant for now, as test_social_auth_no_key already
tests this basic case, since this backend doesn't yet have more
comprehensive config_error pages.
"""
return
def test_config_error_production(self) -> None:
"""
This test is redundant for now, as test_social_auth_no_key already
tests this basic case, since this backend doesn't yet have more
comprehensive config_error pages.
"""
return
class GitHubAuthBackendTest(SocialAuthBase):
__unittest_skip__ = False

View File

@@ -107,6 +107,9 @@ class PublicURLTest(ZulipTestCase):
"azuread",
"email",
"remoteuser",
# The endpoint is generated dynamically based on the configuration of the OIDC backend,
# so it can't be tested here.
"openid connect",
]: # We do not have configerror pages for AzureAD and Email.
auth_types.remove(auth)

View File

@@ -69,6 +69,7 @@ from zproject.backends import (
AppleAuthBackend,
ExternalAuthDataDict,
ExternalAuthResult,
GenericOpenIdConnectBackend,
SAMLAuthBackend,
ZulipLDAPAuthBackend,
ZulipLDAPConfigurationError,
@@ -560,6 +561,8 @@ def start_social_login(
if backend == "apple" and not AppleAuthBackend.check_config():
return config_error(request, "apple")
if backend == "oidc" and not GenericOpenIdConnectBackend.check_config():
return config_error(request, "oidc")
# TODO: Add AzureAD also.
if backend in ["github", "google", "gitlab"]:
@@ -997,6 +1000,8 @@ def config_error(request: HttpRequest, error_category_name: str) -> HttpResponse
"smtp": {"error_name": "smtp_error"},
"remote_user_backend_disabled": {"error_name": "remoteuser_error_backend_disabled"},
"remote_user_header_missing": {"error_name": "remoteuser_error_remote_user_header_missing"},
# TODO: Improve the config error page for OIDC.
"oidc": {"error_name": "oidc_error"},
}
return render(request, "zerver/config_error.html", contexts[error_category_name])

View File

@@ -43,6 +43,7 @@ from social_core.backends.base import BaseAuth
from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, GithubTeamOAuth2
from social_core.backends.gitlab import GitLabOAuth2
from social_core.backends.google import GoogleOAuth2
from social_core.backends.open_id_connect import OpenIdConnectAuth
from social_core.backends.saml import SAMLAuth, SAMLIdentityProvider
from social_core.exceptions import (
AuthCanceled,
@@ -2263,6 +2264,54 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
return result
@external_auth_method
class GenericOpenIdConnectBackend(SocialAuthMixin, OpenIdConnectAuth):
name = "oidc"
auth_backend_name = "OpenID Connect"
sort_order = 100
# Hack: We don't yet support multiple IdPs, but we want this
# module to import if nothing has been configured yet.
settings_dict = list(settings.SOCIAL_AUTH_OIDC_ENABLED_IDPS.values() or [{}])[0]
display_icon = settings_dict.get("display_icon")
display_name = settings_dict.get("display_name", "OIDC")
# Discovery endpoint for the superclass to read all the appropriate
# configuration from.
OIDC_ENDPOINT = settings_dict.get("oidc_url")
def get_key_and_secret(self) -> Tuple[str, str]:
client_id = self.settings_dict.get("client_id", "")
secret = self.settings_dict.get("secret", "")
return client_id, secret
@classmethod
def check_config(cls) -> bool:
if len(settings.SOCIAL_AUTH_OIDC_ENABLED_IDPS.keys()) != 1:
# Only one IdP supported for now.
return False
mandatory_config_keys = ["oidc_url", "client_id", "secret"]
idp_config_dict = list(settings.SOCIAL_AUTH_OIDC_ENABLED_IDPS.values())[0]
if not all(idp_config_dict.get(key) for key in mandatory_config_keys):
return False
return True
@classmethod
def dict_representation(cls, realm: Optional[Realm] = None) -> List[ExternalAuthMethodDictT]:
return [
dict(
name=f"oidc:{cls.name}",
display_name=cls.display_name,
display_icon=cls.display_icon,
login_url=reverse("login-social", args=(cls.name,)),
signup_url=reverse("signup-social", args=(cls.name,)),
)
]
def validate_otp_params(
mobile_flow_otp: Optional[str] = None, desktop_flow_otp: Optional[str] = None
) -> None:

View File

@@ -97,6 +97,9 @@ SOCIAL_AUTH_APPLE_TEAM = get_secret("social_auth_apple_team", development_only=T
SOCIAL_AUTH_APPLE_SCOPE = ["name", "email"]
SOCIAL_AUTH_APPLE_EMAIL_AS_USERNAME = True
# Generic OpenID Connect:
SOCIAL_AUTH_OIDC_ENABLED_IDPS: Dict[str, Dict[str, Optional[str]]] = {}
# Other auth
SSO_APPEND_DOMAIN: Optional[str] = None

View File

@@ -57,6 +57,7 @@ AUTHENTICATION_BACKENDS: Tuple[str, ...] = (
# 'zproject.backends.AzureADAuthBackend',
"zproject.backends.GitLabAuthBackend",
"zproject.backends.AppleAuthBackend",
"zproject.backends.GenericOpenIdConnectBackend",
)
EXTERNAL_URI_SCHEME = "http://"

View File

@@ -1,5 +1,7 @@
from typing import Any, Dict, Tuple
from .config import get_secret
################################################################
## Zulip Server settings.
##
@@ -147,6 +149,7 @@ AUTHENTICATION_BACKENDS: Tuple[str, ...] = (
# 'zproject.backends.SAMLAuthBackend', # SAML, setup below
# 'zproject.backends.ZulipLDAPAuthBackend', # LDAP, setup below
# 'zproject.backends.ZulipRemoteUserBackend', # Local SSO, setup docs on readthedocs
# 'zproject.backends.GenericOpenIdConnectBackend', # Generic OIDC integration, setup below
)
## LDAP integration.
@@ -340,6 +343,32 @@ AUTH_LDAP_USER_ATTR_MAP = {
#
# SOCIAL_AUTH_SUBDOMAIN = 'auth'
########
## Generic OpenID Connect (OIDC). See also documentation here:
##
## https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#openid-connect
##
SOCIAL_AUTH_OIDC_ENABLED_IDPS = {
## This field (example: "idp_name") may appear in URLs during
## authentication, but is otherwise not user-visible.
"idp_name": {
## The base path to the provider's OIDC API. Zulip fetches the
## IdP's configuration from the discovery endpoint, which will be
## "{oidc_url}/.well-known/openid-configuration".
"oidc_url": "https://example.com/api/openid",
## The display name, used for "Log in with <display name>" buttons.
"display_name": "Example",
## Optional: URL of an icon to decorate "Log in with <display name>" buttons.
"display_icon": None,
## The client_id and secret provided by your OIDC IdP. To keep
## settings.py free of secrets, the get_secret call below
## reads the secret with the specified name from zulip-secrets.conf.
"client_id": "<your client id>",
"secret": get_secret("social_auth_oidc_secret"),
}
}
########
## SAML authentication
##

View File

@@ -193,6 +193,17 @@ APPLE_ID_TOKEN_GENERATION_KEY = get_from_file_if_exists(
"zerver/tests/fixtures/apple/token_gen_private_key"
)
SOCIAL_AUTH_OIDC_ENABLED_IDPS = {
"testoidc": {
"display_name": "Test OIDC",
"oidc_url": "https://example.com/api/openid",
"display_icon": None,
"client_id": "key",
"secret": "secret",
}
}
VIDEO_ZOOM_CLIENT_ID = "client_id"
VIDEO_ZOOM_CLIENT_SECRET = "client_secret"