saml: Rework SP-initiated logout config to support IdP-level config.

This gives more flexibility on a server with multiple organizations and
SAML IdPs. Such a server can have some organizations handled by IdPs
with SLO set up, and some without it set up. In such a scenario, having
a generic True/False server-wide setting is insufficient and instead
being able to specify the IdPs/orgs for SLO is needed.
This commit is contained in:
Mateusz Mandera 2022-11-12 21:44:02 +01:00 committed by Tim Abbott
parent e8f3b87b17
commit 3f55c10685
8 changed files with 33 additions and 25 deletions

View File

@ -703,7 +703,8 @@ this with another IdP.
#### SP-initiated Single Logout #### SP-initiated Single Logout
After configuring IdP-initiated Logout, you only need to set After configuring IdP-initiated Logout, you only need to set
`SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT = True` in `"sp_initiated_logout_enabled": True` in the appropriate IdP
configuration dict in `SOCIAL_AUTH_SAML_ENABLED_IDPS` in
`/etc/zulip/settings.py` to also enable SP-initiated Logout. When this `/etc/zulip/settings.py` to also enable SP-initiated Logout. When this
is active, a user who logged in to Zulip via SAML, upon clicking is active, a user who logged in to Zulip via SAML, upon clicking
"Logout" in the Zulip web app will be redirected to the IdP's Single "Logout" in the Zulip web app will be redirected to the IdP's Single

View File

@ -2067,7 +2067,6 @@ class SAMLAuthBackendTest(SocialAuthBase):
def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]: def get_account_data_dict(self, email: str, name: str) -> Dict[str, Any]:
return dict(email=email, name=name) return dict(email=email, name=name)
@override_settings(SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT=True)
def test_saml_sp_initiated_logout_success(self) -> None: def test_saml_sp_initiated_logout_success(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
@ -2126,7 +2125,6 @@ class SAMLAuthBackendTest(SocialAuthBase):
self.client_get(result["Location"]) self.client_get(result["Location"])
self.assert_logged_in_user_id(None) self.assert_logged_in_user_id(None)
@override_settings(SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT=True)
def test_saml_sp_initiated_logout_invalid_logoutresponse(self) -> None: def test_saml_sp_initiated_logout_invalid_logoutresponse(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
self.login("hamlet") self.login("hamlet")
@ -2148,14 +2146,13 @@ class SAMLAuthBackendTest(SocialAuthBase):
) )
self.assert_logged_in_user_id(hamlet.id) self.assert_logged_in_user_id(hamlet.id)
@override_settings(SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT=True)
def test_saml_sp_initiated_logout_endpoint_when_not_logged_in(self) -> None: def test_saml_sp_initiated_logout_endpoint_when_not_logged_in(self) -> None:
self.assert_logged_in_user_id(None) self.assert_logged_in_user_id(None)
result = self.client_post("/accounts/logout/") result = self.client_post("/accounts/logout/")
self.assert_json_error(result, "Not logged in.") self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/accounts/login/")
@override_settings(SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT=True)
def test_saml_sp_initiated_logout_logged_in_not_via_saml(self) -> None: def test_saml_sp_initiated_logout_logged_in_not_via_saml(self) -> None:
""" """
If the user is logged in, but not via SAML, the normal logout flow If the user is logged in, but not via SAML, the normal logout flow
@ -2171,7 +2168,6 @@ class SAMLAuthBackendTest(SocialAuthBase):
self.client_get(result["Location"]) self.client_get(result["Location"])
self.assert_logged_in_user_id(None) self.assert_logged_in_user_id(None)
@override_settings(SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT=True)
def test_saml_sp_initiated_logout_when_saml_not_enabled(self) -> None: def test_saml_sp_initiated_logout_when_saml_not_enabled(self) -> None:
""" """
If SAML is not enabled, the normal logout flow should be correctly executed. If SAML is not enabled, the normal logout flow should be correctly executed.

View File

@ -1118,24 +1118,35 @@ def json_fetch_api_key(
logout_then_login = require_post(django_logout_then_login) logout_then_login = require_post(django_logout_then_login)
@require_post def should_do_saml_sp_initiated_logout(request: HttpRequest) -> bool:
def logout_view(request: HttpRequest, /, **kwargs: Any) -> HttpResponse:
realm = RequestNotes.get_notes(request).realm realm = RequestNotes.get_notes(request).realm
assert realm is not None assert realm is not None
if not settings.SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT or not saml_auth_enabled(realm):
return logout_then_login(request, **kwargs)
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise JsonableError(_("Not logged in.")) return False
if not saml_auth_enabled(realm):
return False
idp_name = SAMLSPInitiatedLogout.get_logged_in_user_idp(request)
if idp_name is None:
# This session wasn't authenticated via SAML, so proceed with normal logout process.
return False
return settings.SOCIAL_AUTH_SAML_ENABLED_IDPS[idp_name].get(
"sp_initiated_logout_enabled", False
)
@require_post
def logout_view(request: HttpRequest, /, **kwargs: Any) -> HttpResponse:
if not should_do_saml_sp_initiated_logout(request):
return logout_then_login(request, **kwargs)
# This will first redirect to the IdP with a LogoutRequest and if successful on the IdP side, # This will first redirect to the IdP with a LogoutRequest and if successful on the IdP side,
# the user will be redirected to our SAMLResponse-handling endpoint with a success LogoutResponse, # the user will be redirected to our SAMLResponse-handling endpoint with a success LogoutResponse,
# where we will finally terminate their session. # where we will finally terminate their session.
result = SAMLSPInitiatedLogout.slo_request_to_idp(request, return_to=None) result = SAMLSPInitiatedLogout.slo_request_to_idp(request, return_to=None)
if result is None:
# This session wasn't authenticated via SAML, so proceed with normal logout process.
return logout_then_login(request, **kwargs)
return result return result

View File

@ -2882,11 +2882,11 @@ class SAMLSPInitiatedLogout:
@classmethod @classmethod
def slo_request_to_idp( def slo_request_to_idp(
cls, request: HttpRequest, return_to: Optional[str] = None cls, request: HttpRequest, return_to: Optional[str] = None
) -> Optional[HttpResponse]: ) -> HttpResponse:
""" """
Generates the redirect to the IdP's SLO endpoint with Generates the redirect to the IdP's SLO endpoint with
the appropriately generated LogoutRequest or None if the session the appropriately generated LogoutRequest. This should only be called
wasn't authenticated via SAML. on requests with a session that was indeed obtained via SAML.
""" """
user_profile = request.user user_profile = request.user
@ -2900,7 +2900,7 @@ class SAMLSPInitiatedLogout:
idp_name = cls.get_logged_in_user_idp(request) idp_name = cls.get_logged_in_user_idp(request)
if idp_name is None: if idp_name is None:
return None raise AssertionError("User not logged in via SAML")
idp = saml_backend.get_idp(idp_name) idp = saml_backend.get_idp(idp_name)
auth = saml_backend._create_saml_auth(idp) auth = saml_backend._create_saml_auth(idp)

View File

@ -92,8 +92,6 @@ SOCIAL_AUTH_SAML_SECURITY_CONFIG: Dict[str, Any] = {}
# the limit_to_subdomains setting to be considered valid: # the limit_to_subdomains setting to be considered valid:
SAML_REQUIRE_LIMIT_TO_SUBDOMAINS = False SAML_REQUIRE_LIMIT_TO_SUBDOMAINS = False
SAML_ENABLE_SP_INITIATED_SINGLE_LOGOUT = False
# Historical name for SOCIAL_AUTH_GITHUB_KEY; still allowed in production. # Historical name for SOCIAL_AUTH_GITHUB_KEY; still allowed in production.
GOOGLE_OAUTH2_CLIENT_ID: Optional[str] = None GOOGLE_OAUTH2_CLIENT_ID: Optional[str] = None

View File

@ -450,6 +450,9 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS: Dict[str, Any] = {
## default, Zulip asks the user whether they want to create an ## default, Zulip asks the user whether they want to create an
## account or try to log in again using another method. ## account or try to log in again using another method.
# "auto_signup": False, # "auto_signup": False,
## Determines whether Service Provider initiated SAML Single Logout should be enabled.
## Note that IdP-initiated Single Logout must be configured before enabling this.
# "sp_initiated_logout_enabled": False,
}, },
} }
@ -494,9 +497,6 @@ 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"). ## Apple authentication ("Sign in with Apple").
## ##

View File

@ -12,6 +12,7 @@ class SAMLIdPConfigDict(TypedDict, total=False):
entity_id: str entity_id: str
url: str url: str
slo_url: str slo_url: str
sp_initiated_logout_enabled: bool
attr_user_permanent_id: str attr_user_permanent_id: str
attr_first_name: str attr_first_name: str
attr_last_name: str attr_last_name: str

View File

@ -245,6 +245,7 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS: Dict[str, SAMLIdPConfigDict] = {
"entity_id": "https://idp.testshib.org/idp/shibboleth", "entity_id": "https://idp.testshib.org/idp/shibboleth",
"url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
"slo_url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/Logout", "slo_url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/Logout",
"sp_initiated_logout_enabled": True,
"x509cert": get_from_file_if_exists("zerver/tests/fixtures/saml/idp.crt"), "x509cert": get_from_file_if_exists("zerver/tests/fixtures/saml/idp.crt"),
"attr_user_permanent_id": "email", "attr_user_permanent_id": "email",
"attr_first_name": "first_name", "attr_first_name": "first_name",