1# !/usr/bin/env python 2# -*- coding: utf-8 -*- 3# 4import six 5 6"""Contains classes and functions that a SAML2.0 Service Provider (SP) may use 7to conclude its tasks. 8""" 9from saml2.request import LogoutRequest 10import saml2 11 12from saml2 import saml, SAMLError 13from saml2 import BINDING_HTTP_REDIRECT 14from saml2 import BINDING_HTTP_POST 15from saml2 import BINDING_SOAP 16 17import saml2.xmldsig as ds 18 19from saml2.ident import decode, code 20from saml2.httpbase import HTTPError 21from saml2.s_utils import sid 22from saml2.s_utils import status_message_factory 23from saml2.s_utils import success_status_factory 24from saml2.samlp import STATUS_REQUEST_DENIED 25from saml2.samlp import STATUS_UNKNOWN_PRINCIPAL 26from saml2.time_util import not_on_or_after 27from saml2.saml import AssertionIDRef 28from saml2.client_base import Base 29from saml2.client_base import SignOnError 30from saml2.client_base import LogoutError 31from saml2.client_base import NoServiceDefined 32from saml2.mdstore import destinations 33 34import logging 35 36logger = logging.getLogger(__name__) 37 38 39class Saml2Client(Base): 40 """ The basic pySAML2 service provider class """ 41 42 def prepare_for_authenticate( 43 self, entityid=None, relay_state="", 44 binding=saml2.BINDING_HTTP_REDIRECT, vorg="", nameid_format=None, 45 scoping=None, consent=None, extensions=None, sign=None, 46 response_binding=saml2.BINDING_HTTP_POST, **kwargs): 47 """ Makes all necessary preparations for an authentication request. 48 49 :param entityid: The entity ID of the IdP to send the request to 50 :param relay_state: To where the user should be returned after 51 successfull log in. 52 :param binding: Which binding to use for sending the request 53 :param vorg: The entity_id of the virtual organization I'm a member of 54 :param nameid_format: 55 :param scoping: For which IdPs this query are aimed. 56 :param consent: Whether the principal have given her consent 57 :param extensions: Possible extensions 58 :param sign: Whether the request should be signed or not. 59 :param response_binding: Which binding to use for receiving the response 60 :param kwargs: Extra key word arguments 61 :return: session id and AuthnRequest info 62 """ 63 64 reqid, negotiated_binding, info = \ 65 self.prepare_for_negotiated_authenticate( 66 entityid=entityid, 67 relay_state=relay_state, 68 binding=binding, 69 vorg=vorg, 70 nameid_format=nameid_format, 71 scoping=scoping, 72 consent=consent, 73 extensions=extensions, 74 sign=sign, 75 response_binding=response_binding, 76 **kwargs) 77 78 assert negotiated_binding == binding 79 80 return reqid, info 81 82 def prepare_for_negotiated_authenticate( 83 self, entityid=None, relay_state="", binding=None, vorg="", 84 nameid_format=None, scoping=None, consent=None, extensions=None, 85 sign=None, response_binding=saml2.BINDING_HTTP_POST, **kwargs): 86 """ Makes all necessary preparations for an authentication request 87 that negotiates which binding to use for authentication. 88 89 :param entityid: The entity ID of the IdP to send the request to 90 :param relay_state: To where the user should be returned after 91 successfull log in. 92 :param binding: Which binding to use for sending the request 93 :param vorg: The entity_id of the virtual organization I'm a member of 94 :param nameid_format: 95 :param scoping: For which IdPs this query are aimed. 96 :param consent: Whether the principal have given her consent 97 :param extensions: Possible extensions 98 :param sign: Whether the request should be signed or not. 99 :param response_binding: Which binding to use for receiving the response 100 :param kwargs: Extra key word arguments 101 :return: session id and AuthnRequest info 102 """ 103 104 expected_binding = binding 105 106 for binding in [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST]: 107 if expected_binding and binding != expected_binding: 108 continue 109 110 destination = self._sso_location(entityid, binding) 111 logger.info("destination to provider: %s", destination) 112 113 reqid, request = self.create_authn_request( 114 destination, vorg, scoping, response_binding, nameid_format, 115 consent=consent, extensions=extensions, sign=sign, 116 **kwargs) 117 118 _req_str = str(request) 119 120 logger.info("AuthNReq: %s", _req_str) 121 122 try: 123 args = {'sigalg': kwargs["sigalg"]} 124 except KeyError: 125 args = {} 126 127 http_info = self.apply_binding(binding, _req_str, destination, 128 relay_state, sign=sign, **args) 129 130 return reqid, binding, http_info 131 else: 132 raise SignOnError( 133 "No supported bindings available for authentication") 134 135 def global_logout(self, name_id, reason="", expire=None, sign=None, 136 sign_alg=None, digest_alg=None): 137 """ More or less a layer of indirection :-/ 138 Bootstrapping the whole thing by finding all the IdPs that should 139 be notified. 140 141 :param name_id: The identifier of the subject that wants to be 142 logged out. 143 :param reason: Why the subject wants to log out 144 :param expire: The latest the log out should happen. 145 If this time has passed don't bother. 146 :param sign: Whether the request should be signed or not. 147 This also depends on what binding is used. 148 :return: Depends on which binding is used: 149 If the HTTP redirect binding then a HTTP redirect, 150 if SOAP binding has been used the just the result of that 151 conversation. 152 """ 153 154 if isinstance(name_id, six.string_types): 155 name_id = decode(name_id) 156 157 logger.info("logout request for: %s", name_id) 158 159 # find out which IdPs/AAs I should notify 160 entity_ids = self.users.issuers_of_info(name_id) 161 return self.do_logout(name_id, entity_ids, reason, expire, sign, 162 sign_alg=sign_alg, digest_alg=digest_alg) 163 164 def do_logout(self, name_id, entity_ids, reason, expire, sign=None, 165 expected_binding=None, sign_alg=None, digest_alg=None, 166 **kwargs): 167 """ 168 169 :param name_id: Identifier of the Subject (a NameID instance) 170 :param entity_ids: List of entity ids for the IdPs that have provided 171 information concerning the subject 172 :param reason: The reason for doing the logout 173 :param expire: Try to logout before this time. 174 :param sign: Whether to sign the request or not 175 :param expected_binding: Specify the expected binding then not try it 176 all 177 :param kwargs: Extra key word arguments. 178 :return: 179 """ 180 # check time 181 if not not_on_or_after(expire): # I've run out of time 182 # Do the local logout anyway 183 self.local_logout(name_id) 184 return 0, "504 Gateway Timeout", [], [] 185 186 not_done = entity_ids[:] 187 responses = {} 188 189 for entity_id in entity_ids: 190 logger.debug("Logout from '%s'", entity_id) 191 # for all where I can use the SOAP binding, do those first 192 for binding in [BINDING_SOAP, BINDING_HTTP_POST, 193 BINDING_HTTP_REDIRECT]: 194 if expected_binding and binding != expected_binding: 195 continue 196 try: 197 srvs = self.metadata.single_logout_service(entity_id, 198 binding, 199 "idpsso") 200 except: 201 srvs = None 202 203 if not srvs: 204 logger.debug("No SLO '%s' service", binding) 205 continue 206 207 destination = destinations(srvs)[0] 208 logger.info("destination to provider: %s", destination) 209 try: 210 session_info = self.users.get_info_from(name_id, 211 entity_id, 212 False) 213 session_indexes = [session_info['session_index']] 214 except KeyError: 215 session_indexes = None 216 req_id, request = self.create_logout_request( 217 destination, entity_id, name_id=name_id, reason=reason, 218 expire=expire, session_indexes=session_indexes) 219 220 # to_sign = [] 221 if binding.startswith("http://"): 222 sign = True 223 224 if sign is None: 225 sign = self.logout_requests_signed 226 227 sigalg = None 228 if sign: 229 if binding == BINDING_HTTP_REDIRECT: 230 sigalg = kwargs.get( 231 "sigalg", ds.DefaultSignature().get_sign_alg()) 232 # key = kwargs.get("key", self.signkey) 233 srequest = str(request) 234 else: 235 srequest = self.sign(request, sign_alg=sign_alg, 236 digest_alg=digest_alg) 237 else: 238 srequest = str(request) 239 240 relay_state = self._relay_state(req_id) 241 242 http_info = self.apply_binding(binding, srequest, destination, 243 relay_state, sign=sign, sigalg=sigalg) 244 245 if binding == BINDING_SOAP: 246 response = self.send(**http_info) 247 248 if response and response.status_code == 200: 249 not_done.remove(entity_id) 250 response = response.text 251 logger.info("Response: %s", response) 252 res = self.parse_logout_request_response(response, 253 binding) 254 responses[entity_id] = res 255 else: 256 logger.info("NOT OK response from %s", destination) 257 258 else: 259 self.state[req_id] = {"entity_id": entity_id, 260 "operation": "SLO", 261 "entity_ids": entity_ids, 262 "name_id": code(name_id), 263 "reason": reason, 264 "not_on_or_after": expire, 265 "sign": sign} 266 267 responses[entity_id] = (binding, http_info) 268 not_done.remove(entity_id) 269 270 # only try one binding 271 break 272 273 if not_done: 274 # upstream should try later 275 raise LogoutError("%s" % (entity_ids,)) 276 277 return responses 278 279 def local_logout(self, name_id): 280 """ Remove the user from the cache, equals local logout 281 282 :param name_id: The identifier of the subject 283 """ 284 self.users.remove_person(name_id) 285 return True 286 287 def is_logged_in(self, name_id): 288 """ Check if user is in the cache 289 290 :param name_id: The identifier of the subject 291 """ 292 identity = self.users.get_identity(name_id)[0] 293 return bool(identity) 294 295 def handle_logout_response(self, response, sign_alg=None, digest_alg=None): 296 """ handles a Logout response 297 298 :param response: A response.Response instance 299 :return: 4-tuple of (session_id of the last sent logout request, 300 response message, response headers and message) 301 """ 302 303 logger.info("state: %s", self.state) 304 status = self.state[response.in_response_to] 305 logger.info("status: %s", status) 306 issuer = response.issuer() 307 logger.info("issuer: %s", issuer) 308 del self.state[response.in_response_to] 309 if status["entity_ids"] == [issuer]: # done 310 self.local_logout(decode(status["name_id"])) 311 return 0, "200 Ok", [("Content-type", "text/html")], [] 312 else: 313 status["entity_ids"].remove(issuer) 314 if "sign_alg" in status: 315 sign_alg = status["sign_alg"] 316 return self.do_logout(decode(status["name_id"]), 317 status["entity_ids"], 318 status["reason"], status["not_on_or_after"], 319 status["sign"], sign_alg=sign_alg, 320 digest_alg=digest_alg) 321 322 def _use_soap(self, destination, query_type, **kwargs): 323 _create_func = getattr(self, "create_%s" % query_type) 324 _response_func = getattr(self, "parse_%s_response" % query_type) 325 try: 326 response_args = kwargs["response_args"] 327 del kwargs["response_args"] 328 except KeyError: 329 response_args = None 330 331 qid, query = _create_func(destination, **kwargs) 332 333 response = self.send_using_soap(query, destination) 334 335 if response.status_code == 200: 336 if not response_args: 337 response_args = {"binding": BINDING_SOAP} 338 else: 339 response_args["binding"] = BINDING_SOAP 340 341 logger.info("Verifying response") 342 if response_args: 343 response = _response_func(response.content, **response_args) 344 else: 345 response = _response_func(response.content) 346 else: 347 raise HTTPError("%d:%s" % (response.status_code, response.error)) 348 349 if response: 350 # not_done.remove(entity_id) 351 logger.info("OK response from %s", destination) 352 return response 353 else: 354 logger.info("NOT OK response from %s", destination) 355 356 return None 357 358 # noinspection PyUnusedLocal 359 def do_authz_decision_query(self, entity_id, action, 360 subject_id, nameid_format, 361 evidence=None, resource=None, 362 sp_name_qualifier=None, 363 name_qualifier=None, 364 consent=None, extensions=None, sign=False): 365 366 subject = saml.Subject( 367 name_id=saml.NameID(text=subject_id, format=nameid_format, 368 sp_name_qualifier=sp_name_qualifier, 369 name_qualifier=name_qualifier)) 370 371 srvs = self.metadata.authz_service(entity_id, BINDING_SOAP) 372 for dest in destinations(srvs): 373 resp = self._use_soap(dest, "authz_decision_query", 374 action=action, evidence=evidence, 375 resource=resource, subject=subject) 376 if resp: 377 return resp 378 379 return None 380 381 def do_assertion_id_request(self, assertion_ids, entity_id, 382 consent=None, extensions=None, sign=False): 383 384 srvs = self.metadata.assertion_id_request_service(entity_id, 385 BINDING_SOAP) 386 if not srvs: 387 raise NoServiceDefined("%s: %s" % (entity_id, 388 "assertion_id_request_service")) 389 390 if isinstance(assertion_ids, six.string_types): 391 assertion_ids = [assertion_ids] 392 393 _id_refs = [AssertionIDRef(_id) for _id in assertion_ids] 394 395 for destination in destinations(srvs): 396 res = self._use_soap(destination, "assertion_id_request", 397 assertion_id_refs=_id_refs, consent=consent, 398 extensions=extensions, sign=sign) 399 if res: 400 return res 401 402 return None 403 404 def do_authn_query(self, entity_id, 405 consent=None, extensions=None, sign=False): 406 407 srvs = self.metadata.authn_request_service(entity_id, BINDING_SOAP) 408 409 for destination in destinations(srvs): 410 resp = self._use_soap(destination, "authn_query", consent=consent, 411 extensions=extensions, sign=sign) 412 if resp: 413 return resp 414 415 return None 416 417 def do_attribute_query(self, entityid, subject_id, 418 attribute=None, sp_name_qualifier=None, 419 name_qualifier=None, nameid_format=None, 420 real_id=None, consent=None, extensions=None, 421 sign=False, binding=BINDING_SOAP, nsprefix=None): 422 """ Does a attribute request to an attribute authority, this is 423 by default done over SOAP. 424 425 :param entityid: To whom the query should be sent 426 :param subject_id: The identifier of the subject 427 :param attribute: A dictionary of attributes and values that is 428 asked for 429 :param sp_name_qualifier: The unique identifier of the 430 service provider or affiliation of providers for whom the 431 identifier was generated. 432 :param name_qualifier: The unique identifier of the identity 433 provider that generated the identifier. 434 :param nameid_format: The format of the name ID 435 :param real_id: The identifier which is the key to this entity in the 436 identity database 437 :param binding: Which binding to use 438 :param nsprefix: Namespace prefixes preferred before those automatically 439 produced. 440 :return: The attributes returned if BINDING_SOAP was used. 441 HTTP args if BINDING_HTT_POST was used. 442 """ 443 444 if real_id: 445 response_args = {"real_id": real_id} 446 else: 447 response_args = {} 448 449 if not binding: 450 binding, destination = self.pick_binding("attribute_service", 451 None, 452 "attribute_authority", 453 entity_id=entityid) 454 else: 455 srvs = self.metadata.attribute_service(entityid, binding) 456 if srvs is []: 457 raise SAMLError("No attribute service support at entity") 458 459 destination = destinations(srvs)[0] 460 461 if binding == BINDING_SOAP: 462 return self._use_soap(destination, "attribute_query", 463 consent=consent, extensions=extensions, 464 sign=sign, subject_id=subject_id, 465 attribute=attribute, 466 sp_name_qualifier=sp_name_qualifier, 467 name_qualifier=name_qualifier, 468 format=nameid_format, 469 response_args=response_args) 470 elif binding == BINDING_HTTP_POST: 471 mid = sid() 472 query = self.create_attribute_query(destination, subject_id, 473 attribute, mid, consent, 474 extensions, sign, nsprefix) 475 self.state[query.id] = {"entity_id": entityid, 476 "operation": "AttributeQuery", 477 "subject_id": subject_id, 478 "sign": sign} 479 relay_state = self._relay_state(query.id) 480 return self.apply_binding(binding, "%s" % query, destination, 481 relay_state, sign=sign) 482 else: 483 raise SAMLError("Unsupported binding") 484 485 def handle_logout_request(self, request, name_id, binding, sign=False, 486 sign_alg=None, relay_state=""): 487 """ 488 Deal with a LogoutRequest 489 490 :param request: The request as text string 491 :param name_id: The id of the current user 492 :param binding: Which binding the message came in over 493 :param sign: Whether the response will be signed or not 494 :return: Keyword arguments which can be used to send the response 495 what's returned follow different patterns for different bindings. 496 If the binding is BINDIND_SOAP, what is returned looks like this:: 497 498 { 499 "data": <the SOAP enveloped response> 500 "url": "", 501 'headers': [('content-type', 'application/soap+xml')] 502 'method': "POST 503 } 504 """ 505 logger.info("logout request: %s", request) 506 507 _req = self._parse_request(request, LogoutRequest, 508 "single_logout_service", binding) 509 510 if _req.message.name_id == name_id: 511 try: 512 if self.local_logout(name_id): 513 status = success_status_factory() 514 else: 515 status = status_message_factory("Server error", 516 STATUS_REQUEST_DENIED) 517 except KeyError: 518 status = status_message_factory("Server error", 519 STATUS_REQUEST_DENIED) 520 else: 521 status = status_message_factory("Wrong user", 522 STATUS_UNKNOWN_PRINCIPAL) 523 524 if binding == BINDING_SOAP: 525 response_bindings = [BINDING_SOAP] 526 elif binding == BINDING_HTTP_POST or BINDING_HTTP_REDIRECT: 527 response_bindings = [BINDING_HTTP_POST, BINDING_HTTP_REDIRECT] 528 else: 529 response_bindings = self.config.preferred_binding[ 530 "single_logout_service"] 531 532 response = self.create_logout_response(_req.message, response_bindings, 533 status, sign, sign_alg=sign_alg) 534 rinfo = self.response_args(_req.message, response_bindings) 535 536 return self.apply_binding(rinfo["binding"], response, 537 rinfo["destination"], relay_state, 538 response=True, sign=sign) 539