mirror of
https://github.com/zulip/zulip.git
synced 2025-11-03 05:23:35 +00:00
saml: Implement SP-initiated Logout.
Closes #20084 This is the flow that this implements: 1. A logged-in user clicks "Logout". 2. If they didn't auth via SAML, just do normal logout. Otherwise: 3. Form a LogoutRequest and redirect the user to https://idp.example.com/slo-endpoint?SAMLRequest=<LogoutRequest here> 4. The IdP validates the LogoutRequest, terminates its own user session and redirects the user to https://thezuliporg.example.com/complete/saml/?SAMLRequest=<LogoutResponse> with the appropriate LogoutResponse. In case of failure, the LogoutResponse is expected to express that. 5. Zulip validates the LogoutResponse and if the response is a success response, it executes the regular Zulip logout and the full flow is finished.
This commit is contained in:
committed by
Tim Abbott
parent
dda4603f94
commit
0bb0220ebb
@@ -49,11 +49,15 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django_auth_ldap.backend import LDAPBackend, _LDAPUser, ldap_error
|
||||
from lxml.etree import XMLSyntaxError
|
||||
from onelogin.saml2 import compat as onelogin_saml2_compat
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
||||
from onelogin.saml2.logout_request import OneLogin_Saml2_Logout_Request
|
||||
from onelogin.saml2.logout_response import OneLogin_Saml2_Logout_Response
|
||||
from onelogin.saml2.response import OneLogin_Saml2_Response
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||
from onelogin.saml2.xml_utils import OneLogin_Saml2_XML
|
||||
from requests import HTTPError
|
||||
from social_core.backends.apple import AppleIdAuth
|
||||
from social_core.backends.azuread import AzureADOAuth2
|
||||
@@ -71,6 +75,7 @@ from social_core.exceptions import (
|
||||
SocialAuthBaseException,
|
||||
)
|
||||
from social_core.pipeline.partial import partial
|
||||
from social_django.utils import load_backend, load_strategy
|
||||
from zxcvbn import zxcvbn
|
||||
|
||||
from zerver.actions.create_user import do_create_user, do_reactivate_user
|
||||
@@ -1301,6 +1306,10 @@ class ExternalAuthDataDict(TypedDict, total=False):
|
||||
desktop_flow_otp: Optional[str]
|
||||
multiuse_object_key: str
|
||||
full_name_validated: bool
|
||||
# TODO: This currently does not get plumbed through to the final session
|
||||
# in the desktop flow, but it should.
|
||||
# Also, the mobile app doesn't actually use a session, so this
|
||||
# data is not applicable there either.
|
||||
params_to_store_in_authenticated_session: Dict[str, str]
|
||||
|
||||
|
||||
@@ -2258,10 +2267,29 @@ class SAMLDocument:
|
||||
self.encoded_saml_message = encoded_saml_message
|
||||
self.backend = backend
|
||||
|
||||
self._decoded_saml_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
return self.backend.logger
|
||||
|
||||
@property
|
||||
def decoded_saml_message(self) -> str:
|
||||
"""
|
||||
Returns the decoded SAMLRequest/SAMLResponse.
|
||||
"""
|
||||
if self._decoded_saml_message is None:
|
||||
# This logic is taken from how
|
||||
# python3-saml handles decoding received SAMLRequest
|
||||
# and SAMLResponse params.
|
||||
self._decoded_saml_message = onelogin_saml2_compat.to_string(
|
||||
OneLogin_Saml2_Utils.decode_base64_and_inflate(
|
||||
self.encoded_saml_message, ignore_zip=True
|
||||
)
|
||||
)
|
||||
|
||||
return self._decoded_saml_message
|
||||
|
||||
def document_type(self) -> str:
|
||||
"""
|
||||
Returns whether the instance is a SAMLRequest or SAMLResponse.
|
||||
@@ -2318,14 +2346,32 @@ class SAMLResponse(SAMLDocument):
|
||||
saml_settings = OneLogin_Saml2_Settings(config, sp_validation_only=True)
|
||||
|
||||
try:
|
||||
resp = OneLogin_Saml2_Response(
|
||||
settings=saml_settings, response=self.encoded_saml_message
|
||||
)
|
||||
return resp.get_issuers()
|
||||
if not self.is_logout_response():
|
||||
resp = OneLogin_Saml2_Response(
|
||||
settings=saml_settings, response=self.encoded_saml_message
|
||||
)
|
||||
return resp.get_issuers()
|
||||
else:
|
||||
logout_response = OneLogin_Saml2_Logout_Response(
|
||||
settings=saml_settings, response=self.encoded_saml_message
|
||||
)
|
||||
return logout_response.get_issuer()
|
||||
except self.SAML_PARSING_EXCEPTIONS as e:
|
||||
self.logger.error("Error parsing SAMLResponse: %s", str(e))
|
||||
return []
|
||||
|
||||
def is_logout_response(self) -> bool:
|
||||
"""
|
||||
Checks whether the SAMLResponse is a LogoutResponse based on some
|
||||
basic XML parsing.
|
||||
"""
|
||||
|
||||
try:
|
||||
parsed_xml = OneLogin_Saml2_XML.to_etree(self.decoded_saml_message)
|
||||
return bool(OneLogin_Saml2_XML.query(parsed_xml, "/samlp:LogoutResponse"))
|
||||
except self.SAML_PARSING_EXCEPTIONS:
|
||||
return False
|
||||
|
||||
|
||||
@external_auth_method
|
||||
class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
|
||||
@@ -2623,7 +2669,14 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
|
||||
return None
|
||||
|
||||
if isinstance(saml_document, SAMLRequest):
|
||||
# We're a Service Provider, so the only SAMLRequest we accept is a LogoutRequest. Thus
|
||||
# we can proceed with process_logout and it's for the lower level libraries to reject it
|
||||
# if it's not a valid LogoutRequest.
|
||||
return self.process_logout(subdomain, idp_name)
|
||||
elif isinstance(saml_document, SAMLResponse) and saml_document.is_logout_response():
|
||||
# As a Service Provider, we process SAMLResponse which can be either LogoutResponse
|
||||
# or an authentication response.
|
||||
return SAMLSPInitiatedLogout.process_logout_response(saml_document, idp_name)
|
||||
|
||||
result = None
|
||||
try:
|
||||
@@ -2808,6 +2861,75 @@ def validate_otp_params(
|
||||
raise JsonableError(_("Can't use both mobile_flow_otp and desktop_flow_otp together."))
|
||||
|
||||
|
||||
class SAMLSPInitiatedLogout:
|
||||
@classmethod
|
||||
def get_logged_in_user_idp(cls, request: HttpRequest) -> Optional[str]:
|
||||
"""
|
||||
Information about the authentication method which was used for
|
||||
this session is stored in social_auth_backend session attribute.
|
||||
If SAML was used, this extracts the IdP name and returns it.
|
||||
"""
|
||||
# Some asserts to ensure this doesn't get called incorrectly:
|
||||
assert hasattr(request, "user")
|
||||
assert isinstance(request.user, UserProfile)
|
||||
|
||||
authentication_method = request.session.get("social_auth_backend", "")
|
||||
if not authentication_method.startswith("saml:"):
|
||||
return None
|
||||
|
||||
return authentication_method.split("saml:")[1]
|
||||
|
||||
@classmethod
|
||||
def slo_request_to_idp(
|
||||
cls, request: HttpRequest, return_to: Optional[str] = None
|
||||
) -> Optional[HttpResponse]:
|
||||
"""
|
||||
Generates the redirect to the IdP's SLO endpoint with
|
||||
the appropriately generated LogoutRequest or None if the session
|
||||
wasn't authenticated via SAML.
|
||||
"""
|
||||
|
||||
user_profile = request.user
|
||||
assert isinstance(user_profile, UserProfile)
|
||||
|
||||
realm = user_profile.realm
|
||||
assert saml_auth_enabled(realm)
|
||||
|
||||
complete_url = reverse("social:complete", args=("saml",))
|
||||
saml_backend = load_backend(load_strategy(request), "saml", complete_url)
|
||||
|
||||
idp_name = cls.get_logged_in_user_idp(request)
|
||||
if idp_name is None:
|
||||
return None
|
||||
|
||||
idp = saml_backend.get_idp(idp_name)
|
||||
auth = saml_backend._create_saml_auth(idp)
|
||||
slo_url = auth.logout(name_id=user_profile.delivery_email, return_to=return_to)
|
||||
|
||||
return HttpResponseRedirect(slo_url)
|
||||
|
||||
@classmethod
|
||||
def process_logout_response(cls, logout_response: SAMLResponse, idp_name: str) -> HttpResponse:
|
||||
"""
|
||||
Validates the LogoutResponse and logs out the user if successful,
|
||||
finishing the SP-initiated logout flow.
|
||||
"""
|
||||
from django.contrib.auth.views import logout_then_login as django_logout_then_login
|
||||
|
||||
idp = logout_response.backend.get_idp(idp_name)
|
||||
auth = logout_response.backend._create_saml_auth(idp)
|
||||
auth.process_slo(keep_local_session=True)
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
# These errors should essentially only happen in case of misconfiguration,
|
||||
# so we give a json error response with the direct error codes from python3-saml.
|
||||
# They're informative but generic enough to not leak any sensitive information.
|
||||
raise JsonableError(f"LogoutResponse error: {errors}")
|
||||
|
||||
# We call Django's version of logout_then_login so that POST isn't required.
|
||||
return django_logout_then_login(logout_response.backend.strategy.request)
|
||||
|
||||
|
||||
def get_external_method_dicts(realm: Optional[Realm] = None) -> List[ExternalAuthMethodDictT]:
|
||||
"""
|
||||
Returns a list of dictionaries that represent social backends, sorted
|
||||
|
||||
@@ -91,6 +91,9 @@ SOCIAL_AUTH_SAML_SECURITY_CONFIG: Dict[str, Any] = {}
|
||||
# Set this to True to enforce that any configured IdP needs to specify
|
||||
# the limit_to_subdomains setting to be considered valid:
|
||||
SAML_REQUIRE_LIMIT_TO_SUBDOMAINS = False
|
||||
|
||||
SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT = False
|
||||
|
||||
# Historical name for SOCIAL_AUTH_GITHUB_KEY; still allowed in production.
|
||||
GOOGLE_OAUTH2_CLIENT_ID: Optional[str] = None
|
||||
|
||||
|
||||
@@ -494,6 +494,9 @@ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {
|
||||
# }
|
||||
# }
|
||||
|
||||
# This setting allows enabling of SP-initiated logout with SAML.
|
||||
# SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT = True
|
||||
|
||||
########
|
||||
## Apple authentication ("Sign in with Apple").
|
||||
##
|
||||
|
||||
@@ -29,7 +29,7 @@ from zerver.views.auth import (
|
||||
jwt_fetch_api_key,
|
||||
log_into_subdomain,
|
||||
login_page,
|
||||
logout_then_login,
|
||||
logout_view,
|
||||
password_reset,
|
||||
remote_user_jwt,
|
||||
remote_user_sso,
|
||||
@@ -533,7 +533,7 @@ i18n_urls = [
|
||||
# return `/accounts/login/`.
|
||||
path("accounts/login/", login_page, {"template_name": "zerver/login.html"}, name="login_page"),
|
||||
path("accounts/login/", LoginView.as_view(template_name="zerver/login.html"), name="login"),
|
||||
path("accounts/logout/", logout_then_login),
|
||||
path("accounts/logout/", logout_view),
|
||||
path("accounts/webathena_kerberos_login/", webathena_kerberos_login),
|
||||
path("accounts/password/reset/", password_reset, name="password_reset"),
|
||||
path(
|
||||
|
||||
Reference in New Issue
Block a user