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:
Mateusz Mandera
2021-11-01 20:08:20 +01:00
committed by Tim Abbott
parent dda4603f94
commit 0bb0220ebb
8 changed files with 309 additions and 15 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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").
##

View File

@@ -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(