|
8 | 8 | import re
|
9 | 9 | from base64 import urlsafe_b64decode
|
10 | 10 | from base64 import urlsafe_b64encode
|
| 11 | +from base64 import b64encode |
11 | 12 | from urllib.parse import quote
|
12 | 13 | from urllib.parse import quote_plus
|
13 | 14 | from urllib.parse import unquote
|
|
16 | 17 | from http.cookies import SimpleCookie
|
17 | 18 |
|
18 | 19 | from saml2 import SAMLError, xmldsig
|
19 |
| -from saml2.config import IdPConfig |
| 20 | +from saml2 import BINDING_HTTP_POST |
| 21 | +from saml2.client_base import Base |
| 22 | +from saml2.config import IdPConfig, SPConfig |
20 | 23 | from saml2.extension.ui import NAMESPACE as UI_NAMESPACE
|
21 | 24 | from saml2.metadata import create_metadata_string
|
22 | 25 | from saml2.saml import NameID
|
|
28 | 31 | from saml2.server import Server
|
29 | 32 |
|
30 | 33 | from satosa.base import SAMLBaseModule
|
| 34 | +from satosa.backends.saml2 import SAMLBackend |
31 | 35 | from satosa.context import Context
|
32 | 36 | from .base import FrontendModule
|
33 | 37 | from ..logging_util import satosa_logging
|
@@ -1014,3 +1018,191 @@ def _register_endpoints(self, backend_names):
|
1014 | 1018 | logger.debug("Adding mapping {}".format(mapping))
|
1015 | 1019 |
|
1016 | 1020 | return url_to_callable_mappings
|
| 1021 | + |
| 1022 | + |
| 1023 | +class SAMLUnsolicitedFrontend(SAMLFrontend): |
| 1024 | + """ |
| 1025 | + Frontend module that provides all of the functionality of the base class |
| 1026 | + SAMLFrontend but also provides a proprietary endpoint for initiating |
| 1027 | + unsolicted SAML flows. The unsolicited SAML flows are not part of any |
| 1028 | + SAML standard. |
| 1029 | + """ |
| 1030 | + KEY_ENDPOINT = "endpoint" |
| 1031 | + KEY_DISCO_WHITE = "discovery_service_whitelist" |
| 1032 | + KEY_QUERY_SP = "providerId" |
| 1033 | + KEY_QUERY_ACS = "shire" |
| 1034 | + KEY_QUERY_RELAY = "target" |
| 1035 | + KEY_QUERY_DISCO = "discoveryURL" |
| 1036 | + KEY_SAML_DISCOVERY_SERVICE_URL = SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_URL |
| 1037 | + KEY_UNSOLICITED = "unsolicited" |
| 1038 | + |
| 1039 | + def __init__(self, auth_req_callback_func, internal_attributes, config, |
| 1040 | + base_url, name): |
| 1041 | + super().__init__(auth_req_callback_func, internal_attributes, config, |
| 1042 | + base_url, name) |
| 1043 | + |
| 1044 | + def register_endpoints(self, backend_names): |
| 1045 | + """ |
| 1046 | + See super class |
| 1047 | + satosa.frontends.saml2.SAMLFrontend#register_endpoints |
| 1048 | +
|
| 1049 | + :type providers: list[str] |
| 1050 | + :rtype: list[(str, ((satosa.context.Context, Any) |
| 1051 | + -> satosa.response.Response, Any))] |
| 1052 | + :param providers: A list of backend providers |
| 1053 | + :return: A list of endpoint/method pairs |
| 1054 | + """ |
| 1055 | + url_map = super().register_endpoints(backend_names) |
| 1056 | + |
| 1057 | + path = urlparse( |
| 1058 | + self.config[self.KEY_UNSOLICITED] |
| 1059 | + .get(self.KEY_ENDPOINT)).path |
| 1060 | + |
| 1061 | + for backend in backend_names: |
| 1062 | + pat = '(^{})/{}$'.format(backend, path) |
| 1063 | + url_map.append((pat, self.unsolicited_endpoint)) |
| 1064 | + |
| 1065 | + logger.debug("URL maps to be registered are {}".format(url_map)) |
| 1066 | + |
| 1067 | + return url_map |
| 1068 | + |
| 1069 | + def unsolicited_endpoint(self, context): |
| 1070 | + """ |
| 1071 | + Endpoint to process unsolicited SAML flows. The unsolicited flows |
| 1072 | + are proprietary and not defined as part of any SAML standard. |
| 1073 | +
|
| 1074 | + :type context: satosa.context.Context |
| 1075 | + :rtype: satosa.response.Response |
| 1076 | +
|
| 1077 | + :param context: The current context |
| 1078 | + :return: response |
| 1079 | + """ |
| 1080 | + request = context.request |
| 1081 | + |
| 1082 | + target_sp_entity_id = request.get(self.KEY_QUERY_SP, None) |
| 1083 | + target_sp_acs_url = request.get(self.KEY_QUERY_ACS, None) |
| 1084 | + target_sp_relay_state_url = request.get(self.KEY_QUERY_RELAY, None) |
| 1085 | + requested_disco_url = request.get(self.KEY_QUERY_DISCO, None) |
| 1086 | + |
| 1087 | + logger.debug("Unsolicited target SP is {}".format(target_sp_entity_id)) |
| 1088 | + logger.debug("Unsolicited ACS URL is {}".format(target_sp_acs_url)) |
| 1089 | + logger.debug("Unsolicited relay state is {}".format( |
| 1090 | + target_sp_relay_state_url)) |
| 1091 | + logger.debug("Unsolicted discovery URL is {}".format( |
| 1092 | + requested_disco_url)) |
| 1093 | + |
| 1094 | + # We only proceed with known federated SPs. |
| 1095 | + try: |
| 1096 | + target_sp_metadata = self.idp.metadata[target_sp_entity_id] |
| 1097 | + except KeyError: |
| 1098 | + msg = "Target SP with entityID {} is unknown in metadata" |
| 1099 | + msg = msg.format(target_sp_entity_id) |
| 1100 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1101 | + raise SATOSAError(msg) |
| 1102 | + |
| 1103 | + # The SP ACS URL if input must match one from the trusted metadata. |
| 1104 | + # We assume the SP only has one SPSSODescriptor element in metadata. |
| 1105 | + acs_ob_list = (target_sp_metadata.get("spsso_descriptor", [{}])[0] |
| 1106 | + .get("assertion_consumer_service", [{}])) |
| 1107 | + acs_locations = [acs_ob['location'] for acs_ob in acs_ob_list] |
| 1108 | + |
| 1109 | + if target_sp_acs_url: |
| 1110 | + if target_sp_acs_url not in acs_locations: |
| 1111 | + msg = "Target ACS URL {} not allowed" |
| 1112 | + msg = msg.format(target_sp_acs_url) |
| 1113 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1114 | + raise SATOSAError(msg) |
| 1115 | + else: |
| 1116 | + for acs_ob in acs_ob_list: |
| 1117 | + # We assume the SP has HTTP_POST binding and we simply |
| 1118 | + # take the first one we find. |
| 1119 | + if acs_ob['binding'] == BINDING_HTTP_POST: |
| 1120 | + target_sp_acs_url = acs_ob['location'] |
| 1121 | + logger.debug("Unsolicited found SP ACS URL {}".format( |
| 1122 | + target_sp_acs_url)) |
| 1123 | + break |
| 1124 | + |
| 1125 | + if not target_sp_acs_url: |
| 1126 | + msg = "No ACS for SP with entityID {}".format(target_sp_entity_id) |
| 1127 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1128 | + raise SATOSAError(msg) |
| 1129 | + |
| 1130 | + # If provided the exact scheme, host, and port for relay state URL |
| 1131 | + # must match that of the target SP ACS URL. |
| 1132 | + if target_sp_relay_state_url: |
| 1133 | + target = urlparse(target_sp_relay_state_url) |
| 1134 | + acs = urlparse(target_sp_acs_url) |
| 1135 | + if not (target.scheme == acs.scheme and |
| 1136 | + target.netloc == acs.netloc and target.port == acs.port): |
| 1137 | + msg = "RelayState {} is not permitted" |
| 1138 | + msg = msg.format(target_sp_relay_state_url) |
| 1139 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1140 | + raise SATOSAError(msg) |
| 1141 | + |
| 1142 | + # Create a temporary SP configuration to represent the target SP. |
| 1143 | + acs = [[target_sp_acs_url, BINDING_HTTP_POST]] |
| 1144 | + sp_config_dict = { |
| 1145 | + "entityid": target_sp_entity_id, |
| 1146 | + "service": { |
| 1147 | + "sp": { |
| 1148 | + "endpoints": { |
| 1149 | + "assertion_consumer_service": acs |
| 1150 | + } |
| 1151 | + } |
| 1152 | + } |
| 1153 | + } |
| 1154 | + sp_config = SPConfig().load(sp_config_dict, False) |
| 1155 | + |
| 1156 | + # Create a temporary SP object and use it to create a authn request |
| 1157 | + # with a destination of our own SingleSignOnService location with |
| 1158 | + # HTTP-POST binding. |
| 1159 | + target_sp = Base(sp_config) |
| 1160 | + |
| 1161 | + destination = None |
| 1162 | + endpoints = self.idp.config.getattr('endpoints') |
| 1163 | + sso_service_list = endpoints['single_sign_on_service'] |
| 1164 | + for location, binding in sso_service_list: |
| 1165 | + if binding == BINDING_HTTP_POST: |
| 1166 | + destination = location |
| 1167 | + break |
| 1168 | + |
| 1169 | + if not destination: |
| 1170 | + msg = ("Could not determine location for SingleSignOnService " |
| 1171 | + "with HTTP-POST binding") |
| 1172 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1173 | + raise SATOSAError(msg) |
| 1174 | + |
| 1175 | + logger.debug("Unsolicited using destination {}".format(destination)) |
| 1176 | + |
| 1177 | + req_id, authn_request = target_sp.create_authn_request(destination) |
| 1178 | + |
| 1179 | + # Convert the authn request object to an encoded set of bytes. |
| 1180 | + authn_request_str = "{}".format(authn_request) |
| 1181 | + logger.debug("Unsolicted authn request is {}".format( |
| 1182 | + authn_request_str)) |
| 1183 | + authn_request_bytes = authn_request_str.encode('utf-8') |
| 1184 | + authn_request_encoded = b64encode(authn_request_bytes) |
| 1185 | + |
| 1186 | + # Add the authn request to the context as if it arrived through |
| 1187 | + # an endpoint. |
| 1188 | + context.request["SAMLRequest"] = authn_request_encoded |
| 1189 | + |
| 1190 | + # Add the relay state to the context if provided. |
| 1191 | + if target_sp_relay_state_url: |
| 1192 | + context.request["RelayState"] = target_sp_relay_state_url |
| 1193 | + |
| 1194 | + # If provided and is whitelisted set the discovery service to use. |
| 1195 | + if requested_disco_url: |
| 1196 | + allowed = (self.config[self.KEY_UNSOLICITED] |
| 1197 | + .get(self.KEY_DISCO_WHITE)) |
| 1198 | + if requested_disco_url not in allowed: |
| 1199 | + msg = "Discovery service {} not allowed" |
| 1200 | + msg = msg.format(requested_disco_url) |
| 1201 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1202 | + raise SATOSAError(msg) |
| 1203 | + |
| 1204 | + context.decorate(self.KEY_SAML_DISCOVERY_SERVICE_URL, |
| 1205 | + requested_disco_url) |
| 1206 | + |
| 1207 | + # Handle the authn request use the base class. |
| 1208 | + return self._handle_authn_request(context, BINDING_HTTP_POST, self.idp) |
0 commit comments