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