mirror of
https://github.com/zulip/zulip.git
synced 2025-10-31 20:13:46 +00:00
committed by
Tim Abbott
parent
1103720f45
commit
e17758f8ad
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
34
zerver/migrations/0326_alter_realm_authentication_methods.py
Normal file
34
zerver/migrations/0326_alter_realm_authentication_methods.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -199,6 +199,7 @@ class Realm(models.Model):
|
||||
"SAML",
|
||||
"GitLab",
|
||||
"Apple",
|
||||
"OpenID Connect",
|
||||
]
|
||||
SUBDOMAIN_FOR_ROOT_DOMAIN = ""
|
||||
WILDCARD_MENTION_THRESHOLD = 15
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ AUTHENTICATION_BACKENDS: Tuple[str, ...] = (
|
||||
# 'zproject.backends.AzureADAuthBackend',
|
||||
"zproject.backends.GitLabAuthBackend",
|
||||
"zproject.backends.AppleAuthBackend",
|
||||
"zproject.backends.GenericOpenIdConnectBackend",
|
||||
)
|
||||
|
||||
EXTERNAL_URI_SCHEME = "http://"
|
||||
|
||||
@@ -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
|
||||
##
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user