|
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,222 @@ 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 | + |
| 1031 | + KEY_ENDPOINT = "endpoint" |
| 1032 | + KEY_DISCO_URL_WHITE = "discovery_service_url_whitelist" |
| 1033 | + KEY_DISCO_POLICY_WHITE = "discovery_service_policy_whitelist" |
| 1034 | + KEY_QUERY_IDP = "authId" |
| 1035 | + KEY_QUERY_SP = "providerId" |
| 1036 | + KEY_QUERY_ACS = "shire" |
| 1037 | + KEY_QUERY_RELAY = "target" |
| 1038 | + KEY_QUERY_DISCO_URL = "discoveryURL" |
| 1039 | + KEY_QUERY_DISCO_POLICY = "discoveryPolicy" |
| 1040 | + KEY_SAML_DISCOVERY_SERVICE_URL = SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_URL |
| 1041 | + KEY_SAML_DISCOVERY_SERVICE_POLICY = SAMLBackend.KEY_SAML_DISCOVERY_SERVICE_POLICY |
| 1042 | + KEY_UNSOLICITED = "unsolicited" |
| 1043 | + |
| 1044 | + def __init__( |
| 1045 | + self, auth_req_callback_func, internal_attributes, config, base_url, name |
| 1046 | + ): |
| 1047 | + super().__init__( |
| 1048 | + auth_req_callback_func, internal_attributes, config, base_url, name |
| 1049 | + ) |
| 1050 | + |
| 1051 | + def register_endpoints(self, backend_names): |
| 1052 | + """ |
| 1053 | + See super class |
| 1054 | + satosa.frontends.saml2.SAMLFrontend#register_endpoints |
| 1055 | +
|
| 1056 | + :type providers: list[str] |
| 1057 | + :rtype: list[(str, ((satosa.context.Context, Any) |
| 1058 | + -> satosa.response.Response, Any))] |
| 1059 | + :param providers: A list of backend providers |
| 1060 | + :return: A list of endpoint/method pairs |
| 1061 | + """ |
| 1062 | + url_map = super().register_endpoints(backend_names) |
| 1063 | + |
| 1064 | + path = urlparse(self.config[self.KEY_UNSOLICITED].get(self.KEY_ENDPOINT)).path |
| 1065 | + |
| 1066 | + for backend in backend_names: |
| 1067 | + pat = "(^{})/{}$".format(backend, path) |
| 1068 | + url_map.append((pat, self.unsolicited_endpoint)) |
| 1069 | + |
| 1070 | + logger.debug("URL maps to be registered are {}".format(url_map)) |
| 1071 | + |
| 1072 | + return url_map |
| 1073 | + |
| 1074 | + def unsolicited_endpoint(self, context): |
| 1075 | + """ |
| 1076 | + Endpoint to process unsolicited SAML flows. The unsolicited flows |
| 1077 | + are proprietary and not defined as part of any SAML standard. |
| 1078 | +
|
| 1079 | + :type context: satosa.context.Context |
| 1080 | + :rtype: satosa.response.Response |
| 1081 | +
|
| 1082 | + :param context: The current context |
| 1083 | + :return: response |
| 1084 | + """ |
| 1085 | + request = context.request |
| 1086 | + |
| 1087 | + target_idp_entity_id = request.get(self.KEY_QUERY_IDP, None) |
| 1088 | + target_sp_entity_id = request.get(self.KEY_QUERY_SP, None) |
| 1089 | + target_sp_acs_url = request.get(self.KEY_QUERY_ACS, None) |
| 1090 | + target_sp_relay_state_url = request.get(self.KEY_QUERY_RELAY, None) |
| 1091 | + requested_disco_url = request.get(self.KEY_QUERY_DISCO_URL, None) |
| 1092 | + requested_disco_policy = request.get(self.KEY_QUERY_DISCO_POLICY, None) |
| 1093 | + |
| 1094 | + logger.debug( |
| 1095 | + "Unsolicited target authenticating IdP is {}".format(target_idp_entity_id) |
| 1096 | + ) |
| 1097 | + logger.debug("Unsolicited target SP is {}".format(target_sp_entity_id)) |
| 1098 | + logger.debug("Unsolicited ACS URL is {}".format(target_sp_acs_url)) |
| 1099 | + logger.debug("Unsolicited relay state is {}".format(target_sp_relay_state_url)) |
| 1100 | + logger.debug("Unsolicted discovery URL is {}".format(requested_disco_url)) |
| 1101 | + logger.debug("Unsolicted discovery policy is {}".format(requested_disco_policy)) |
| 1102 | + |
| 1103 | + # We only proceed with known federated SPs. |
| 1104 | + try: |
| 1105 | + target_sp_metadata = self.idp.metadata[target_sp_entity_id] |
| 1106 | + except KeyError: |
| 1107 | + msg = "Target SP with entityID {} is unknown in metadata" |
| 1108 | + msg = msg.format(target_sp_entity_id) |
| 1109 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1110 | + raise SATOSAError(msg) |
| 1111 | + |
| 1112 | + # The SP ACS URL if input must match one from the trusted metadata. |
| 1113 | + # We assume the SP only has one SPSSODescriptor element in metadata. |
| 1114 | + acs_ob_list = target_sp_metadata.get("spsso_descriptor", [{}])[0].get( |
| 1115 | + "assertion_consumer_service", [{}] |
| 1116 | + ) |
| 1117 | + acs_locations = [acs_ob["location"] for acs_ob in acs_ob_list] |
| 1118 | + |
| 1119 | + if target_sp_acs_url: |
| 1120 | + if target_sp_acs_url not in acs_locations: |
| 1121 | + msg = "Target ACS URL {} not allowed" |
| 1122 | + msg = msg.format(target_sp_acs_url) |
| 1123 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1124 | + raise SATOSAError(msg) |
| 1125 | + else: |
| 1126 | + for acs_ob in acs_ob_list: |
| 1127 | + # We assume the SP has HTTP_POST binding and we simply |
| 1128 | + # take the first one we find. |
| 1129 | + if acs_ob["binding"] == BINDING_HTTP_POST: |
| 1130 | + target_sp_acs_url = acs_ob["location"] |
| 1131 | + logger.debug( |
| 1132 | + "Unsolicited found SP ACS URL {}".format(target_sp_acs_url) |
| 1133 | + ) |
| 1134 | + break |
| 1135 | + |
| 1136 | + if not target_sp_acs_url: |
| 1137 | + msg = "No ACS for SP with entityID {}".format(target_sp_entity_id) |
| 1138 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1139 | + raise SATOSAError(msg) |
| 1140 | + |
| 1141 | + # If provided the exact scheme, host, and port for relay state URL |
| 1142 | + # must match that of the target SP ACS URL. |
| 1143 | + if target_sp_relay_state_url: |
| 1144 | + target = urlparse(target_sp_relay_state_url) |
| 1145 | + acs = urlparse(target_sp_acs_url) |
| 1146 | + if not ( |
| 1147 | + target.scheme == acs.scheme |
| 1148 | + and target.netloc == acs.netloc |
| 1149 | + and target.port == acs.port |
| 1150 | + ): |
| 1151 | + msg = "RelayState {} is not permitted" |
| 1152 | + msg = msg.format(target_sp_relay_state_url) |
| 1153 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1154 | + raise SATOSAError(msg) |
| 1155 | + |
| 1156 | + # Create a temporary SP configuration to represent the target SP. |
| 1157 | + acs = [[target_sp_acs_url, BINDING_HTTP_POST]] |
| 1158 | + sp_config_dict = { |
| 1159 | + "entityid": target_sp_entity_id, |
| 1160 | + "service": {"sp": {"endpoints": {"assertion_consumer_service": acs}}}, |
| 1161 | + } |
| 1162 | + sp_config = SPConfig().load(sp_config_dict, False) |
| 1163 | + |
| 1164 | + # Create a temporary SP object and use it to create a authn request |
| 1165 | + # with a destination of our own SingleSignOnService location with |
| 1166 | + # HTTP-POST binding. |
| 1167 | + target_sp = Base(sp_config) |
| 1168 | + |
| 1169 | + destination = None |
| 1170 | + endpoints = self.idp.config.getattr("endpoints") |
| 1171 | + sso_service_list = endpoints["single_sign_on_service"] |
| 1172 | + for location, binding in sso_service_list: |
| 1173 | + if binding == BINDING_HTTP_POST: |
| 1174 | + destination = location |
| 1175 | + break |
| 1176 | + |
| 1177 | + if not destination: |
| 1178 | + msg = ( |
| 1179 | + "Could not determine location for SingleSignOnService " |
| 1180 | + "with HTTP-POST binding" |
| 1181 | + ) |
| 1182 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1183 | + raise SATOSAError(msg) |
| 1184 | + |
| 1185 | + logger.debug("Unsolicited using destination {}".format(destination)) |
| 1186 | + |
| 1187 | + req_id, authn_request = target_sp.create_authn_request(destination) |
| 1188 | + |
| 1189 | + # Convert the authn request object to an encoded set of bytes. |
| 1190 | + authn_request_str = "{}".format(authn_request) |
| 1191 | + logger.debug("Unsolicted authn request is {}".format(authn_request_str)) |
| 1192 | + authn_request_bytes = authn_request_str.encode("utf-8") |
| 1193 | + authn_request_encoded = b64encode(authn_request_bytes) |
| 1194 | + |
| 1195 | + # Add the authn request to the context as if it arrived through |
| 1196 | + # an endpoint. |
| 1197 | + context.request["SAMLRequest"] = authn_request_encoded |
| 1198 | + |
| 1199 | + # Add the relay state to the context if provided. |
| 1200 | + if target_sp_relay_state_url: |
| 1201 | + context.request["RelayState"] = target_sp_relay_state_url |
| 1202 | + |
| 1203 | + # If provided and is whitelisted set the discovery service to use. |
| 1204 | + if requested_disco_url: |
| 1205 | + allowed = self.config[self.KEY_UNSOLICITED].get(self.KEY_DISCO_URL_WHITE) |
| 1206 | + if requested_disco_url not in allowed: |
| 1207 | + msg = "Discovery service URL {} not allowed" |
| 1208 | + msg = msg.format(requested_disco_url) |
| 1209 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1210 | + raise SATOSAError(msg) |
| 1211 | + |
| 1212 | + context.decorate(self.KEY_SAML_DISCOVERY_SERVICE_URL, requested_disco_url) |
| 1213 | + |
| 1214 | + # If provided and is whitelisted set the discovery policy to use. |
| 1215 | + if requested_disco_policy: |
| 1216 | + allowed = self.config[self.KEY_UNSOLICITED].get(self.KEY_DISCO_POLICY_WHITE) |
| 1217 | + if requested_disco_policy not in allowed: |
| 1218 | + msg = "Discovery service policy {} not allowed" |
| 1219 | + msg = msg.format(requested_disco_policy) |
| 1220 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1221 | + raise SATOSAError(msg) |
| 1222 | + |
| 1223 | + context.decorate( |
| 1224 | + self.KEY_SAML_DISCOVERY_SERVICE_POLICY, requested_disco_policy |
| 1225 | + ) |
| 1226 | + |
| 1227 | + # If provided and known in the SAML metadata set the entityID for |
| 1228 | + # the IdP to use for authentication. |
| 1229 | + if target_idp_entity_id: |
| 1230 | + if target_idp_entity_id in self.idp.metadata: |
| 1231 | + context.decorate(Context.KEY_TARGET_ENTITYID, target_idp_entity_id) |
| 1232 | + else: |
| 1233 | + msg = "Target IdP with entityID {} is unknown in metadata" |
| 1234 | + msg = msg.format(target_idp_entity_id) |
| 1235 | + satosa_logging(logger, logging.ERROR, msg, context.state) |
| 1236 | + raise SATOSAError(msg) |
| 1237 | + |
| 1238 | + # Handle the authn request use the base class. |
| 1239 | + return self._handle_authn_request(context, BINDING_HTTP_POST, self.idp) |
0 commit comments