Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce SAML SP-initiated Logout to SATOSA proxy #431

Open
wants to merge 59 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
5714885
feat: register method to single_logout_service endpoints on saml fron…
sebulibah Mar 10, 2022
b260ba7
feat: register logout callback functions
sebulibah Mar 10, 2022
c680cef
feat: handle logout request on saml frontend
sebulibah Mar 15, 2022
b766961
feat: create logout request on saml backend
sebulibah Mar 15, 2022
5dec525
feat: build internal logout request
sebulibah Mar 30, 2022
9800529
feat: add database for session storage
sebulibah Mar 30, 2022
48c0493
feat: build saml backend logout request
sebulibah Mar 30, 2022
1f8c8f0
feat: register single_logout_service endpoints on saml backend
sebulibah Mar 31, 2022
29b73f8
feat: bind handle_logout_message to single_logout_service endpoint on…
sebulibah Apr 11, 2022
62483f3
feat: handle logout response at saml backend
sebulibah Jul 19, 2022
d4d7120
feat: add logout response handlers in saml frontend
sebulibah Jul 21, 2022
97c0aa0
feat: create logout response for sp that initiates logout
sebulibah Aug 3, 2022
8e21fe3
feat: create logout requests for sps with participating sessions
sebulibah Aug 12, 2022
ed1a461
feat: return response object on saml backend after handling logout re…
sebulibah Aug 17, 2022
0ec992e
feat: add postgres and dictionary state storage
sebulibah Feb 15, 2023
fa18536
test: add logout arguments to satosa/frontends/test_saml2 to fix tests
sebulibah Feb 24, 2023
415b409
test: add logout arguments to satosa/frontends/test_openid_connect to…
sebulibah Feb 24, 2023
1a9cf0d
fix: add logout callback function to samlvirtualcofrontend class
sebulibah Feb 24, 2023
2aaaf4c
fix: add logout callback function to openid connect frontend module
sebulibah Feb 24, 2023
cead543
test: add logout arguments to satosa/backends/test_saml2 to fix tests
sebulibah Feb 24, 2023
f676b13
test: add logout arguments to fix backend tests
sebulibah Feb 28, 2023
321be65
fix: add logout callback arguments to backends
sebulibah Feb 28, 2023
f908453
fix: add logout argument to ping frontend
sebulibah Feb 28, 2023
f4c8760
test: add logout arguments to fix failing test
sebulibah Mar 2, 2023
6358ade
fix: handle case where entity_id is None in start_logout
sebulibah Mar 14, 2023
22d9a9b
test: add single_logout_service endpoints to test configuration
sebulibah Apr 4, 2023
b307d26
test: add logout callback function arguments to test utils
sebulibah Apr 4, 2023
5f5dbf1
test: add assertion for single logout endpoints for saml frontend in …
sebulibah Apr 4, 2023
dd0d7d4
test: add assertion for single logout service endpoints for saml backend
sebulibah Apr 5, 2023
6a171e5
test: update rontends/test_saml2 with single_logout_service endpoints
sebulibah Apr 6, 2023
3b67c6a
refactor: improve error handling for logout request construction on …
sebulibah Apr 6, 2023
ba817d4
Merge remote-tracking branch 'upstream/master' into feat_saml_sp_logout
sebulibah Apr 18, 2023
1d470ea
feat: improve logout response handling
sebulibah Apr 25, 2023
8f2a3c1
feat: delete session before proceeding to the saml backend to handle …
sebulibah Apr 25, 2023
fcc57ee
fix(store): remove invalid parameter in delete_session method
sebulibah Jul 5, 2023
4301299
refactor(frontends/saml2): check for sp sessions in the store
sebulibah Jul 11, 2023
2f56941
refactor(frontends/saml2): check for extensions in the logoutrequest
sebulibah Jul 11, 2023
b3d4374
feat(saml_util): add content-type for soap binding responses
sebulibah Jul 13, 2023
f222b12
fix(frontends/saml2): handle key error on receiving SAMLResponse
sebulibah Aug 18, 2023
3eb672d
feat(frontends/saml2): sign outbound logout requests
sebulibah Aug 18, 2023
a8e127b
feat: prevent redundant logout for deleted sessions
sebulibah Sep 14, 2023
7d4b4eb
fix: handle empty authn_response to prevent IndexError
sebulibah Sep 14, 2023
8b77542
feat: add function to send requests from satosa
sebulibah Sep 15, 2023
ed0a7a7
fix: make_saml_response to handle multiple binding types
sebulibah Sep 18, 2023
7622bcf
Merge remote-tracking branch 'upstream/master' into feat_saml_sp_logout
sebulibah Oct 4, 2023
df3bbd1
feat: make logout_callback optional for fontends and backends
sebulibah Oct 6, 2023
3afae0b
fix: remove logout_callback function from backend constructors
sebulibah Oct 6, 2023
7fa68c5
fix: remove logout_callback function from frontend constructors
sebulibah Oct 6, 2023
2794b60
test: remove unused parameter from backend test fixtures
sebulibah Oct 6, 2023
f747cb8
fix: remove logout parameter from facebook backend
sebulibah Oct 10, 2023
bfbbbca
test: make logout_callback_func optional for saml2 frontend
sebulibah Oct 16, 2023
71f2af3
fix: move logout callback to the end in saml backend module
sebulibah Oct 16, 2023
c5f0228
fix: move logout callback to the end in saml frontend module class co…
sebulibah Oct 16, 2023
bdc6942
fix: make logout callback argument optional
sebulibah Oct 16, 2023
b2ed22f
feat: introduce proxy config parameter to enable slo and load databas…
sebulibah Oct 16, 2023
4f3906c
fix: correct typo when deleting context
sebulibah Oct 18, 2023
b4e0c66
feat: make logout request signing configurable for saml frontend and …
sebulibah Oct 27, 2023
b9c1a6b
fix: handle errors from SPs that don't support SLO during frontend pr…
sebulibah Oct 27, 2023
ce0a002
test: remove logout callback from SAMLVirtualCoFrontend
sebulibah Nov 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/satosa/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ class BackendModule(object):
Base class for a backend module.
"""

def __init__(self, auth_callback_func, internal_attributes, base_url, name):
def __init__(self, auth_callback_func, internal_attributes, base_url, name, logout_callback_func=None):
"""
:type auth_callback_func:
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
:type internal_attributes: dict[string, dict[str, str | list[str]]]
:type base_url: str
:type name: str
:type logout_callback_func:

:param auth_callback_func: Callback should be called by the module after
the authorization in the backend is done.
Expand All @@ -25,8 +26,11 @@ def __init__(self, auth_callback_func, internal_attributes, base_url, name):
RP's expects namevice.
:param base_url: base url of the service
:param name: name of the plugin
:param logout_callback_func: Callback should be called by the module after
the logout in the backend is complete
"""
self.auth_callback_func = auth_callback_func
self.logout_callback_func = logout_callback_func
self.internal_attributes = internal_attributes
self.converter = AttributeMapper(internal_attributes)
self.base_url = base_url
Expand All @@ -46,6 +50,20 @@ def start_auth(self, context, internal_request):
"""
raise NotImplementedError()

def start_logout(self, context, internal_request):
"""
This is the start up function of the backend logout.

:type context: satosa.context.Context
:type internal_request: satosa.internal.InternalData
:rtype

:param context: the request context
:param internal_request: Information about the logout request
:return:
"""
raise NotImplementedError()

def register_endpoints(self):
"""
Register backend functions to endpoint urls.
Expand Down
1 change: 1 addition & 0 deletions src/satosa/backends/reflector.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
"""
:type outgoing:
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response

:type internal_attributes: dict[str, dict[str, list[str] | str]]
:type config: dict[str, Any]
:type base_url: str
Expand Down
133 changes: 131 additions & 2 deletions src/satosa/backends/saml2.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from saml2.authn_context import requested_authn_context
from saml2.samlp import RequesterID
from saml2.samlp import Scoping
from saml2.saml import NameID

import satosa.logging_util as lu
import satosa.util as util
Expand All @@ -25,10 +26,12 @@
from satosa.base import STATE_KEY as STATE_KEY_BASE
from satosa.context import Context
from satosa.internal import AuthenticationInformation
from satosa.internal import LogoutInformation
from satosa.internal import InternalData
from satosa.exception import SATOSAAuthenticationError
from satosa.exception import SATOSAMissingStateError
from satosa.exception import SATOSAAuthenticationFlowError
from satosa.exception import SATOSAUnknownError
from satosa.response import SeeOther, Response
from satosa.saml_util import make_saml_response
from satosa.metadata_creation.description import (
Expand Down Expand Up @@ -92,23 +95,25 @@ class SAMLBackend(BackendModule, SAMLBaseModule):

VALUE_ACR_COMPARISON_DEFAULT = 'exact'

def __init__(self, outgoing, internal_attributes, config, base_url, name):
def __init__(self, outgoing, internal_attributes, config, base_url, name, logout):
"""
:type outgoing:
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
:type internal_attributes: dict[str, dict[str, list[str] | str]]
:type config: dict[str, Any]
:type base_url: str
:type name: str
:type logout:

:param outgoing: Callback should be called by the module after
the authorization in the backend is done.
:param internal_attributes: Internal attribute map
:param config: The module config
:param base_url: base url of the service
:param name: name of the plugin
:param logout: Logout callback
"""
super().__init__(outgoing, internal_attributes, base_url, name)
super().__init__(outgoing, internal_attributes, base_url, name, logout)
self.config = self.init_config(config)

self.discosrv = config.get(SAMLBackend.KEY_DISCO_SRV)
Expand Down Expand Up @@ -196,6 +201,26 @@ def start_auth(self, context, internal_req):

return self.authn_request(context, entity_id)

def start_logout(self, context, internal_req, internal_authn_resp):
"""
See super class method satosa.backends.base.BackendModule#start_logout

:type context: satosa.context.Context
:type internal_req: satosa.internal.InternalData
:rtype: satosa.response.Response
"""

if internal_authn_resp is None:
message = "Session Information Deleted"
status = "500 FAILED"
return Response(message=message, status=status)
entity_id = internal_authn_resp["auth_info"]["issuer"]
if entity_id is None:
message = "Logout Failed"
status = "500 FAILED"
return Response(message=message, status=status)
return self.logout_request(context, entity_id, internal_authn_resp)

def disco_query(self, context):
"""
Makes a request to the discovery server
Expand Down Expand Up @@ -471,6 +496,83 @@ def authn_response(self, context, binding):
context.state.pop(Context.KEY_FORCE_AUTHN, None)
return self.auth_callback_func(context, self._translate_response(authn_response, context.state))

def logout_request(self, context, entity_id, internal_authn_resp):
"""
Perform Logout request on idp with given entity_id.
This is the start of single logout.

:type context: satosa.context.Context
:type entity_id: str
:rtype: satosa.response.Response

:param context: The current context
:param entity_id: Target IDP entity id
:return: response to the user agent
"""
try:
binding, destination = self.sp.pick_binding(
"single_logout_service", None, "idpsso", entity_id=entity_id
)
msg = "binding: {}, destination: {}".format(binding, destination)
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
logger.debug(logline)

slo_endp, response_binding = self.sp.config.getattr("endpoints", "sp")["single_logout_service"][0]
name_id_format = self.sp.config.getattr("name_id_format", "sp")
name_id = internal_authn_resp["subject_id"]
name_id = NameID(format=name_id_format, text=name_id)
session_indexes = internal_authn_resp["auth_info"]["session_index"]
sign = self.sp.config.getattr("logout_requests_signed", "sp")
req_id, req = self.sp.create_logout_request(
destination, issuer_entity_id=entity_id, name_id=name_id,
session_indexes=session_indexes, sign=sign
)
msg = "req_id: {}, req: {}".format(req_id, req)
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
logger.debug(logline)
relay_state = util.rndstr()
ht_args = self.sp.apply_binding(binding, "%s" % req, destination, relay_state=relay_state)
msg = "ht_args: {}".format(ht_args)
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
logger.debug(logline)
except Exception as exc:
msg = "Failed to construct the LogoutRequest for state"
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
logger.debug(logline, exc_info=True)
status = "500 FAILED"
return Response(message=msg, status=status)
return make_saml_response(binding, ht_args)

def logout_response(self, context, binding):
"""
Endpoint for the idp logout response

:type context: satosa.context.Context
:type binding: str
:rtype: satosa.response.Response

:param context: The current context
:param binding: SAML binding type
:return Response
"""
if not context.request.get("SAMLResponse"):
msg = "Missing Response for state"
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
logger.debug(logline)
raise SATOSAUnknownError(context.state, "Missing Response")

try:
logout_response = self.sp.parse_logout_request_response(
context.request["SAMLResponse"], binding)
except Exception as err:
msg = "Failed to parse logout response for state"
logline = lu.LOG_FMT.format(id=lu.get_session_id(context.state), message=msg)
logger.debug(logline, exc_info=True)
message = "Logout Failed"
status = "500 FAILED"
return Response(message=message, status=status)
return self.logout_callback_func(context)

def disco_response(self, context):
"""
Endpoint for the discovery server response
Expand Down Expand Up @@ -523,11 +625,14 @@ def _translate_response(self, response, state):
if authenticating_authorities
else None
)
session_indexes = []
session_indexes.append(response.session_info()['session_index'])
auth_info = AuthenticationInformation(
auth_class_ref=authn_context_ref,
timestamp=authn_instant,
authority=authenticating_authority,
issuer=issuer,
session_index=session_indexes,
)

# The SAML response may not include a NameID.
Expand Down Expand Up @@ -562,6 +667,25 @@ def _translate_response(self, response, state):

return internal_resp

def _translate_logout_response(self, response, state):
timestamp = response.response.issue_instant
issuer = response.response.issuer.text

status = {
"status_code": response.response.status.status_code.value,
}

logout_info = LogoutInformation(
timestamp=timestamp,
issuer=issuer,
status=status
)

msg = "logout response content"
logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg)
logger.debug(logline)
return logout_info

def _metadata_endpoint(self, context):
"""
Endpoint for retrieving the backend metadata
Expand Down Expand Up @@ -621,6 +745,11 @@ def register_endpoints(self):
url_map.append(
("^%s/%s$" % (self.name, "reload-metadata"), self._reload_metadata))

for endp, binding in sp_endpoints["single_logout_service"]:
parsed_endp = urlparse(endp)
url_map.append(("^%s$" % parsed_endp.path[1:],
functools.partial(self.logout_response, binding=binding)))

return url_map

def _reload_metadata(self, context):
Expand Down
76 changes: 73 additions & 3 deletions src/satosa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
from satosa.response import NotFound
from satosa.response import Redirect
from .context import Context
from .exception import SATOSAError, SATOSAAuthenticationError, SATOSAUnknownError
from .plugin_loader import load_backends, load_frontends
from .plugin_loader import load_request_microservices, load_response_microservices
from .plugin_loader import load_database
from .routing import ModuleRouter, SATOSANoBoundEndpointError
from .state import cookie_to_state, SATOSAStateError, State, state_to_cookie
from .exception import SATOSAAuthenticationError
from .exception import SATOSAAuthenticationFlowError
from .exception import SATOSABadRequestError
Expand Down Expand Up @@ -54,10 +60,14 @@ def __init__(self, config):

logger.info("Loading backend modules...")
backends = load_backends(self.config, self._auth_resp_callback_func,
self.config["INTERNAL_ATTRIBUTES"])
self.config["INTERNAL_ATTRIBUTES"],
self._logout_resp_callback_func
)
logger.info("Loading frontend modules...")
frontends = load_frontends(self.config, self._auth_req_callback_func,
self.config["INTERNAL_ATTRIBUTES"])
self.config["INTERNAL_ATTRIBUTES"],
self._logout_req_callback_func
)

self.response_micro_services = []
self.request_micro_services = []
Expand All @@ -77,6 +87,11 @@ def __init__(self, config):
self.config["BASE"]))
self._link_micro_services(self.response_micro_services, self._auth_resp_finish)

load_db = self.config.get("LOGOUT_ENABLED", False)
if load_db:
logger.info("Loading database...")
self.db = load_database(self.config)

self.module_router = ModuleRouter(frontends, backends,
self.request_micro_services + self.response_micro_services)

Expand Down Expand Up @@ -115,23 +130,63 @@ def _auth_req_callback_func(self, context, internal_request):

return self._auth_req_finish(context, internal_request)

def _logout_req_callback_func(self, context, internal_request):
"""
This function is called by a frontend module when a logout request has been processed.

:type context: satosa.context.Context
:typr internal_request:
:rtype:

:param context: The request context
:param internal_request: request processed by the frontend
:return Response
"""
state = context.state
state[STATE_KEY] = {"requester": internal_request.requester}
msg = "Requesting provider: {}".format(internal_request.requester)
logline = lu.LOG_FMT.format(id=lu.get_session_id(state), message=msg)
logger.info(logline)
return self._logout_req_finish(context, internal_request)

def _auth_req_finish(self, context, internal_request):
backend = self.module_router.backend_routing(context)
context.request = None
return backend.start_auth(context, internal_request)

def _logout_req_finish(self, context, internal_request):
backend = self.module_router.backend_routing(context)
context.request = None
if hasattr(self, "db"):
internal_authn_resp = self.db.get_authn_resp(context.state)
self.db.delete_session(context.state)
else:
internal_authn_resp = None
context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True)
return backend.start_logout(context, internal_request, internal_authn_resp)

def _auth_resp_finish(self, context, internal_response):
user_id_to_attr = self.config["INTERNAL_ATTRIBUTES"].get("user_id_to_attr", None)
if user_id_to_attr:
internal_response.attributes[user_id_to_attr] = [internal_response.subject_id]

# remove all session state unless CONTEXT_STATE_DELETE is False
context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True)
context.request = None
if hasattr(self, "db"):
self.db.store_authn_resp(context.state, internal_response)
self.db.get_authn_resp(context.state)
else:
context.state.delete = self.config.get("CONTEXT_STATE_DELETE", True)

frontend = self.module_router.frontend_routing(context)
return frontend.handle_authn_response(context, internal_response)

def _logout_resp_finish(self, context):
context.request = None

frontend = self.module_router.frontend_routing(context)
return frontend.handle_logout_response(context)

def _auth_resp_callback_func(self, context, internal_response):
"""
This function is called by a backend module when the authorization is
Expand Down Expand Up @@ -163,6 +218,21 @@ def _auth_resp_callback_func(self, context, internal_response):

return self._auth_resp_finish(context, internal_response)

def _logout_resp_callback_func(self, context):
"""
This function is called by a backend module when logout is complete

:type context: satosa.context.Context
:type internal_response: satosa.internal.LogoutInformation
:rtype: satosa.response.Response

:param context: The request context
:param internal_response: The logout response
"""
context.request = None
context.state["ROUTER"] = "idp"
return self._logout_resp_finish(context)

def _handle_satosa_authentication_error(self, error):
"""
Sends a response to the requester about the error
Expand Down
Loading