class TestSamlMixins: """ This class contains contains methods copied from djangosaml2.tests.SAML2Tests. """ def b64_for_post(self, xml_text, encoding='utf-8'): return base64.b64encode(xml_text.encode(encoding)).decode('ascii') def add_outstanding_query(self, session_id, came_from): settings.SESSION_ENGINE = 'django.contrib.sessions.backends.db' engine = import_string(settings.SESSION_ENGINE) self.saml_session = engine.SessionStore() self.saml_session.save() self.oq_cache = OutstandingQueriesCache(self.saml_session) self.oq_cache.set( session_id if isinstance(session_id, str) else session_id.decode(), came_from, ) self.saml_session.save() self.client.cookies[ settings.SESSION_COOKIE_NAME] = self.saml_session.session_key
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html', post_binding_form_template='djangosaml2/post_binding_form.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. * post_binding_form_template - path to a template containing HTML form with hidden input elements, used to send the SAML message data when HTTP POST binding is being used. You can customize this template to include custom branding and/or text explaining the automatic redirection process. Please see the example template in templates/djangosaml2/example_post_binding_form.html If set to None or nonexistent template, default form from the saml2 library will be rendered. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if not request.user.is_anonymous(): try: redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN except AttributeError: redirect_authenticated_user = True if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render_to_response(authorization_error_template, { 'came_from': came_from, }, context_instance=RequestContext(request)) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render_to_response(wayf_template, { 'available_idps': idps.items(), 'came_from': came_from, }, context_instance=RequestContext(request)) # Choose binding (REDIRECT vs. POST). # When authn_requests_signed is turned on, HTTP Redirect binding cannot be # used the same way as without signatures; proper usage in this case involves # stripping out the signature from SAML XML message and creating a new # signature, following precise steps defined in the SAML2.0 standard. # # It is not feasible to implement this since we wouldn't be able to use an # external (xmlsec1) library to handle the signatures - more (higher level) # context is needed in order to create such signature (like the value of # RelayState parameter). # # Therefore it is much easier to use the HTTP POST binding in this case, as # it can relay the whole signed SAML message as is, without the need to # manipulate the signature or the XML message itself. # # Read more in the official SAML2 specs (3.4.4.1): # http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf binding = BINDING_HTTP_POST if getattr(conf, '_sp_authn_requests_signed', False) else BINDING_HTTP_REDIRECT client = Saml2Client(conf) try: (session_id, result) = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=binding, ) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(unicode(e)) logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) logger.debug('Redirecting user to the IdP via %s binding.', binding.split(':')[-1]) if binding == BINDING_HTTP_REDIRECT: return HttpResponseRedirect(get_location(result)) elif binding == BINDING_HTTP_POST: if not post_binding_form_template: return HttpResponse(result['data']) try: params = get_hidden_form_inputs(result['data'][3]) return render_to_response(post_binding_form_template, { 'target_url': result['url'], 'params': params, }, context_instance=RequestContext(request)) except TemplateDoesNotExist: return HttpResponse(result['data']) else: raise NotImplementedError('Unsupported binding: %s', binding)
def add_outstanding_query(self, session_id, came_from): session = self.client.session oq_cache = OutstandingQueriesCache(session) oq_cache.set(session_id, came_from) session.save() self.client.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
def spid_login( request, config_loader_path=None, wayf_template="wayf.html", authorization_error_template="djangosaml2/auth_error.html", ): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http POST protocol binding. """ logger.debug("SPID Login process started") next_url = request.GET.get("next", settings.LOGIN_REDIRECT_URL) if not next_url: logger.warning("The next parameter exists but is empty") next_url = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not validate_referral_url(request, next_url): next_url = settings.LOGIN_REDIRECT_URL if request.user.is_authenticated: redirect_authenticated_user = getattr( settings, "SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN", True) if redirect_authenticated_user: return HttpResponseRedirect(next_url) else: # pragma: no cover logger.debug("User is already logged in") return render(request, authorization_error_template, {"came_from": next_url}) # this works only if request came from wayf selected_idp = request.GET.get("idp", None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug("A discovery process is needed") return render( request, wayf_template, { "available_idps": idps.items(), "next_url": next_url }, ) else: # otherwise is the first one _msg = "Unable to know which IdP to use" try: selected_idp = selected_idp or list(idps.keys())[0] except TypeError as e: # pragma: no cover logger.error(f"{_msg}: {e}") return HttpResponseNotFound(_msg) except IndexError as e: # pragma: no cover logger.error(f"{_msg}: {e}") return HttpResponseNotFound(_msg) # ensure our selected binding is supported by the IDP logger.debug( f"Trying binding {SAML2_DEFAULT_BINDING} for IDP {selected_idp}") supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf) if not supported_bindings: _msg = "IdP Metadata not found or not valid" return HttpResponseNotFound(_msg) if SAML2_DEFAULT_BINDING not in supported_bindings: _msg = (f"Requested: {SAML2_DEFAULT_BINDING} but the selected " f"IDP [{selected_idp}] doesn't support " f"{BINDING_HTTP_POST} or {BINDING_HTTP_REDIRECT}. " f"Check if IdP Metadata is correctly loaded and updated.") logger.error(_msg) raise UnsupportedBinding(_msg) # SPID things here try: login_response = spid_sp_authn_request(conf, selected_idp, next_url) except UnknownSystemEntity as e: # pragma: no cover _msg = f"Unknown IDP Entity ID: {selected_idp}" logger.error(f"{_msg}: {e}") return HttpResponseNotFound(_msg) session_id = login_response["session_id"] http_response = login_response["http_response"] # success, so save the session ID and return our response logger.debug( f"Saving session-id {session_id} in the OutstandingQueries cache") oq_cache = OutstandingQueriesCache(request.saml_session) oq_cache.set(session_id, next_url) if SAML2_DEFAULT_BINDING == saml2.BINDING_HTTP_POST: return HttpResponse(http_response["data"]) elif SAML2_DEFAULT_BINDING == saml2.BINDING_HTTP_REDIRECT: headers = dict(login_response["http_response"]["headers"]) return HttpResponseRedirect(headers["Location"])
context_instance=RequestContext(request)) client = Saml2Client(conf) try: (session_id, result) = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=BINDING_HTTP_REDIRECT, ) except TypeError, e: logger.error('Unable to know which IdP to use') return HttpResponse(unicode(e)) logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) logger.debug('Redirecting the user to the IdP') return HttpResponseRedirect(get_location(result)) @require_POST @csrf_exempt def assertion_consumer_service(request, config_loader_path=None, attribute_mapping=None, create_unknown_user=None): """SAML Authorization Response endpoint The IdP will send its response to this view, which will process it with pysaml2 help and log the user
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html', post_binding_form_template='djangosaml2/post_binding_form.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. * post_binding_form_template - path to a template containing HTML form with hidden input elements, used to send the SAML message data when HTTP POST binding is being used. You can customize this template to include custom branding and/or text explaining the automatic redirection process. Please see the example template in templates/djangosaml2/example_post_binding_form.html If set to None or nonexistent template, default form from the saml2 library will be rendered. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not is_safe_url(url=came_from, host=request.get_host()): came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if not request.user.is_anonymous(): try: redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN except AttributeError: redirect_authenticated_user = True if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render(request, authorization_error_template, { 'came_from': came_from, }) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render(request, wayf_template, { 'available_idps': idps.items(), 'came_from': came_from, }) # choose a binding to try first sign_requests = getattr(conf, '_sp_authn_requests_signed', False) binding = BINDING_HTTP_POST if sign_requests else BINDING_HTTP_REDIRECT logger.debug('Trying binding %s for IDP %s', binding, selected_idp) # ensure our selected binding is supported by the IDP supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf) if binding not in supported_bindings: logger.debug('Binding %s not in IDP %s supported bindings: %s', binding, selected_idp, supported_bindings) if binding == BINDING_HTTP_POST: logger.warning('IDP %s does not support %s, trying %s', selected_idp, binding, BINDING_HTTP_REDIRECT) binding = BINDING_HTTP_REDIRECT else: logger.warning('IDP %s does not support %s, trying %s', selected_idp, binding, BINDING_HTTP_POST) binding = BINDING_HTTP_POST # if switched binding still not supported, give up if binding not in supported_bindings: raise UnsupportedBinding('IDP %s does not support %s or %s', selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT) client = Saml2Client(conf) http_response = None logger.debug('Redirecting user to the IdP via %s binding.', binding) if binding == BINDING_HTTP_REDIRECT: try: # do not sign the xml itself, instead us the sigalg to # generate the signature as a URL param sigalg = SIG_RSA_SHA1 if sign_requests else None session_id, result = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=binding, sign=False, sigalg=sigalg) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) else: http_response = HttpResponseRedirect(get_location(result)) elif binding == BINDING_HTTP_POST: # use the html provided by pysaml2 if no template specified if not post_binding_form_template: try: session_id, result = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=binding) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) else: http_response = HttpResponse(result['data']) # get request XML to build our own html based on the template else: try: location = client.sso_location(selected_idp, binding) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) session_id, request_xml = client.create_authn_request( location, binding=binding) http_response = render( request, post_binding_form_template, { 'target_url': location, 'params': { 'SAMLRequest': base64.b64encode( binary_type(request_xml)), 'RelayState': came_from, }, }) else: raise UnsupportedBinding('Unsupported binding: %s', binding) # success, so save the session ID and return our response logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) return http_response
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if not request.user.is_anonymous(): try: redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN except AttributeError: redirect_authenticated_user = True if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render_to_response(authorization_error_template, { 'came_from': came_from, }, context_instance=RequestContext(request)) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render_to_response(wayf_template, { 'available_idps': idps.items(), 'came_from': came_from, }, context_instance=RequestContext(request)) client = Saml2Client(conf) try: (session_id, result) = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=BINDING_HTTP_REDIRECT, ) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(str(e)) logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) logger.debug('Redirecting the user to the IdP') return HttpResponseRedirect(get_location(result))
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html', post_binding_form_template='djangosaml2/post_binding_form.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. * post_binding_form_template - path to a template containing HTML form with hidden input elements, used to send the SAML message data when HTTP POST binding is being used. You can customize this template to include custom branding and/or text explaining the automatic redirection process. Please see the example template in templates/djangosaml2/example_post_binding_form.html If set to None or nonexistent template, default form from the saml2 library will be rendered. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not is_safe_url(url=came_from, host=request.get_host()): came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if not request.user.is_anonymous(): try: redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN except AttributeError: redirect_authenticated_user = True if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render(request, authorization_error_template, { 'came_from': came_from, }) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render(request, wayf_template, { 'available_idps': idps.items(), 'came_from': came_from, }) # Choose binding (REDIRECT vs. POST). # When authn_requests_signed is turned on, HTTP Redirect binding cannot be # used the same way as without signatures; proper usage in this case involves # stripping out the signature from SAML XML message and creating a new # signature, following precise steps defined in the SAML2.0 standard. # # It is not feasible to implement this since we wouldn't be able to use an # external (xmlsec1) library to handle the signatures - more (higher level) # context is needed in order to create such signature (like the value of # RelayState parameter). # # Therefore it is much easier to use the HTTP POST binding in this case, as # it can relay the whole signed SAML message as is, without the need to # manipulate the signature or the XML message itself. # # Read more in the official SAML2 specs (3.4.4.1): # http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf binding = BINDING_HTTP_POST if getattr(conf, '_sp_authn_requests_signed', False) else BINDING_HTTP_REDIRECT client = Saml2Client(conf) try: (session_id, result) = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=binding, ) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(unicode(e)) logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) logger.debug('Redirecting user to the IdP via %s binding.', binding.split(':')[-1]) if binding == BINDING_HTTP_REDIRECT: return HttpResponseRedirect(get_location(result)) elif binding == BINDING_HTTP_POST: if not post_binding_form_template: return HttpResponse(result['data']) try: params = get_hidden_form_inputs(result['data'][3]) return render(request, post_binding_form_template, { 'target_url': result['url'], 'params': params, }) except (DTDForbidden, EntitiesForbidden, ExternalReferenceForbidden): raise PermissionDenied except TemplateDoesNotExist: return HttpResponse(result['data']) else: raise NotImplementedError('Unsupported binding: %s', binding)
def spid_login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http POST protocol binding. """ logger.debug('SPID Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not is_safe_url_compat(url=came_from, allowed_hosts={request.get_host()}): came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if callable(request.user.is_authenticated): redirect_authenticated_user = getattr(settings, 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True) if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render(request, authorization_error_template, { 'came_from': came_from}) # this works only if request came from wayf selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render(request, wayf_template, { 'available_idps': idps.items(), 'came_from': came_from}) else: # otherwise is the first one try: selected_idp = list(idps.keys())[0] except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) # choose a binding to try first # sign_requests = getattr(conf, '_sp_authn_requests_signed', False) binding = BINDING_HTTP_POST logger.debug('Trying binding %s for IDP %s', binding, selected_idp) # ensure our selected binding is supported by the IDP supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf) if binding != BINDING_HTTP_POST: raise UnsupportedBinding('IDP %s does not support %s or %s', selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT) client = Saml2Client(conf) logger.debug('Redirecting user to the IdP via %s binding.', binding) # use the html provided by pysaml2 if no template was specified or it didn't exist # SPID want the fqdn of the IDP, not the SSO endpoint location_fixed = selected_idp location = client.sso_location(selected_idp, binding) # ...hope to see the SSO endpoint soon in spid-testenv2 authn_req = saml2.samlp.AuthnRequest() authn_req.destination = location_fixed # spid-testenv2 preleva l'attribute consumer service dalla authnRequest (anche se questo sta già nei metadati...) authn_req.attribute_consuming_service_index = "0" # import pdb; pdb.set_trace() issuer = saml2.saml.Issuer() issuer.name_qualifier = client.config.entityid issuer.text = client.config.entityid issuer.format = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity" authn_req.issuer = issuer # message id authn_req.id = saml2.s_utils.sid() authn_req.version = saml2.VERSION # "2.0" authn_req.issue_instant = saml2.time_util.instant() name_id_policy = saml2.samlp.NameIDPolicy() # del(name_id_policy.allow_create) name_id_policy.format = settings.SPID_NAMEID_FORMAT authn_req.name_id_policy = name_id_policy authn_context = requested_authn_context(class_ref=settings.SPID_AUTH_CONTEXT) authn_req.requested_authn_context = authn_context authn_req.protocol_binding = settings.SPID_DEFAULT_BINDING assertion_consumer_service_url = client.config._sp_endpoints['assertion_consumer_service'][0][0] authn_req.assertion_consumer_service_url = assertion_consumer_service_url #'http://sp1.testunical.it:8000/saml2/acs/' authn_req_signed = client.sign(authn_req, sign_prepare=False, sign_alg=settings.SPID_ENC_ALG, digest_alg=settings.SPID_DIG_ALG) session_id = authn_req.id _req_str = authn_req_signed logger.debug('AuthRequest to {}: {}'.format(selected_idp, (_req_str))) http_info = client.apply_binding(binding, _req_str, location, sign=True, sigalg=settings.SPID_ENC_ALG) # success, so save the session ID and return our response logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) return HttpResponse(http_info['data'])
def spid_login(request, config_loader_path=None, wayf_template='wayf.html', authorization_error_template='djangosaml2/auth_error.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http POST protocol binding. """ logger.debug('SPID Login process started') next_url = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not next_url: logger.warning('The next parameter exists but is empty') next_url = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not validate_referral_url(request, next_url): next_url = settings.LOGIN_REDIRECT_URL if callable(request.user.is_authenticated): redirect_authenticated_user = getattr( settings, 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True) if redirect_authenticated_user: return HttpResponseRedirect(next_url) else: logger.debug('User is already logged in') return render(request, authorization_error_template, {'came_from': next_url}) # this works only if request came from wayf selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render(request, wayf_template, { 'available_idps': idps.items(), 'next_url': next_url }) else: # otherwise is the first one try: selected_idp = selected_idp or list(idps.keys())[0] except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) binding = BINDING_HTTP_POST logger.debug(f'Trying binding {binding} for IDP {selected_idp}') # ensure our selected binding is supported by the IDP supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf) if binding != BINDING_HTTP_POST: raise UnsupportedBinding('IDP %s does not support %s or %s', selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT) # SPID things here login_response = spid_sp_authn_request(conf, selected_idp, binding, settings.SPID_NAMEID_FORMAT, settings.SPID_AUTH_CONTEXT, settings.SPID_SIG_ALG, settings.SPID_DIG_ALG, next_url) session_id = login_response['session_id'] http_response = login_response['http_response'] # success, so save the session ID and return our response logger.debug( f'Saving session-id {session_id} in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.saml_session) oq_cache.set(session_id, next_url) return HttpResponse(http_response['data'])
def test_valid_form(self): url, data, session_id = self._get_sp_authn_request() response = self.client.post(url, data, follow=True) # csrf_regexp = '<input type="hidden" name="csrfmiddlewaretoken" value="(?P<value>[a-zA-Z0-9+=]*)">' # login_data['csrfmiddlewaretoken'] = re.findall(csrf_regexp, response.content.decode())[0] self.login_data['password'] = '******' login_response = self.client.post(login_url, data=self.login_data, follow=True) # is there a SAML response? saml_resp = re.findall(samlresponse_form_regexp, login_response.content.decode()) assert saml_resp # test agreement screens self.sp.agreement_screen = 1 self.sp.save() login_response = self.client.post(login_url, data=self.login_data, follow=True) assert 'has requested the following informations' in login_response.content.decode( ) # don't show again agr_data = dict(dont_show_again=1, confirm=1) agr_url = reverse('uniauth:saml_user_agreement') agr_response = self.client.post(agr_url, data=agr_data, follow=True) # login again, agreement screen should not be displayed anymore # purge persistent_id from storage self.user.persistentid_set.all().delete() login_response = self.client.post(login_url, data=self.login_data, follow=True) saml_resp = re.findall(samlresponse_form_regexp, login_response.content.decode()) assert saml_resp # transient name_id format, remove persistent_id sp_conf = copy.deepcopy(SAML_SP_CONFIG) del (sp_conf['service']['sp']['name_id_format'][0]) self.sp_conf.load(sp_conf) url, data, session_id = self._get_sp_authn_request() response = self.client.post(url, data, follow=True) login_response = self.client.post(login_url, data=self.login_data, follow=True) # test logout session = self.client.session state = StateCache(session) identity_cache = IdentityCache(session) oq_cache = OutstandingQueriesCache(session) oq_cache.set(session_id, '/') outstanding_queries = oq_cache.outstanding_queries() client = Saml2Client(self.sp_conf, state_cache=state, identity_cache=IdentityCache(session)) response = client.parse_authn_request_response(saml_resp[0], BINDING_HTTP_POST, outstanding_queries) # this should take name_id dict # result = client.global_logout(session['SAML']['subject_id']) logout_result = client.global_logout(response.name_id) # is there a SAML response? saml_req_logout = re.findall(samlrequest_form_regexp, logout_result[idp_eid][1]['data']) assert saml_req_logout logout_response = self.client.post( logout_url, data={'SAMLRequest': saml_req_logout}, follow=True)
class SAML2Tests(TestCase): urls = 'djangosaml2.tests.urls' def init_cookies(self): self.client.cookies[settings.SESSION_COOKIE_NAME] = 'testing' def add_outstanding_query(self, session_id, came_from): settings.SESSION_ENGINE = 'django.contrib.sessions.backends.db' engine = import_module(settings.SESSION_ENGINE) self.saml_session = engine.SessionStore() self.saml_session.save() self.oq_cache = OutstandingQueriesCache(self.saml_session) self.oq_cache.set( session_id if isinstance(session_id, str) else session_id.decode(), came_from) self.saml_session.save() self.client.cookies[ settings.SESSION_COOKIE_NAME] = self.saml_session.session_key def b64_for_post(self, xml_text, encoding='utf-8'): return base64.b64encode(xml_text.encode(encoding)).decode('ascii') def test_get_idp_sso_supported_bindings_noargs(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) idp_id = 'https://idp.example.com/simplesaml/saml2/idp/metadata.php' self.assertEqual( get_idp_sso_supported_bindings()[0], list(settings.SAML_CONFIG['service']['sp']['idp'][idp_id] ['single_sign_on_service'].keys())[0]) def test_get_idp_sso_supported_bindings_unknown_idp(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.assertEqual( get_idp_sso_supported_bindings(idp_entity_id='random'), []) def test_get_idp_sso_supported_bindings_no_idps(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=[], metadata_file='remote_metadata_no_idp.xml', ) with self.assertRaisesMessage(ImproperlyConfigured, "No IdP configured!"): get_idp_sso_supported_bindings() def test_unsigned_post_authn_request(self): """ Test that unsigned authentication requests via POST binding does not error. https://github.com/knaperek/djangosaml2/issues/168 """ settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_post_binding.xml', authn_requests_signed=False) response = self.client.get(reverse('saml2_login')) self.assertEqual(response.status_code, 200) # Using POST-binding returns a page with form containing the SAMLRequest response_parser = SAMLPostFormParser() response_parser.feed(response.content.decode('utf-8')) saml_request = response_parser.saml_request_value self.assertIsNotNone(saml_request) self.assertIn('AuthnRequest xmlns', base64.b64decode(saml_request).decode('utf-8')) def test_login_evil_redirect(self): """ Make sure that if we give an URL other than our own host as the next parameter, it is replaced with the default LOGIN_REDIRECT_URL. """ # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get( reverse('saml2_login') + '?next=http://evil.com') url = urlparse(response['Location']) params = parse_qs(url.query) self.assertEqual(params['RelayState'], [ settings.LOGIN_REDIRECT_URL, ]) def test_no_redirect(self): """ Make sure that if we give an empty path as the next parameter, it is replaced with the default LOGIN_REDIRECT_URL. """ # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login') + '?next=') url = urlparse(response['Location']) params = parse_qs(url.query) self.assertEqual(params['RelayState'], [ settings.LOGIN_REDIRECT_URL, ]) def test_login_one_idp(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login')) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) self.assertIn('RelayState', params) saml_request = params['SAMLRequest'][0] self.assertIn('AuthnRequest xmlns', decode_base64_and_inflate(saml_request).decode('utf-8')) # if we set a next arg in the login view, it is preserverd # in the RelayState argument nexturl = '/another-view/' response = self.client.get(reverse('saml2_login'), {'next': nexturl}) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) self.assertIn('RelayState', params) self.assertEqual(params['RelayState'][0], nexturl) def test_login_several_idps(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=[ 'idp1.example.com', 'idp2.example.com', 'idp3.example.com' ], metadata_file='remote_metadata_three_idps.xml', ) response = self.client.get(reverse('saml2_login')) # a WAYF page should be displayed self.assertContains(response, 'Where are you from?', status_code=200) for i in range(1, 4): link = '/login/?idp=https://idp%d.example.com/simplesaml/saml2/idp/metadata.php&next=/' self.assertContains(response, link % i) # click on the second idp response = self.client.get( reverse('saml2_login'), { 'idp': 'https://idp2.example.com/simplesaml/saml2/idp/metadata.php', 'next': '/', }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp2.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) self.assertIn('RelayState', params) saml_request = params['SAMLRequest'][0] self.assertIn('AuthnRequest xmlns', decode_base64_and_inflate(saml_request).decode('utf-8')) def test_assertion_consumer_service(self): # Get initial number of users initial_user_count = User.objects.count() settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = '/another-view/' self.add_outstanding_query(session_id, came_from) # this will create a user saml_response = auth_response(session_id, 'student') _url = reverse('saml2_acs') response = self.client.post( _url, { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.path, came_from) self.assertEqual(User.objects.count(), initial_user_count + 1) user_id = self.client.session[SESSION_KEY] user = User.objects.get(id=user_id) self.assertEqual(user.username, 'student') # let's create another user and log in with that one new_user = User.objects.create(username='******', password='******') # session_id = "a1111111111111111111111111111111" client = Client() response = client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) came_from = '' # bad, let's see if we can deal with this saml_response = auth_response(session_id, 'teacher') self.add_outstanding_query(session_id, '/') response = client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) # as the RelayState is empty we have redirect to LOGIN_REDIRECT_URL self.assertEqual(url.path, settings.LOGIN_REDIRECT_URL) self.assertEqual(force_text(new_user.id), client.session[SESSION_KEY]) def test_assertion_consumer_service_already_logged_in_allowed(self): self.client.force_login( User.objects.create(username='******', password='******')) settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN = True came_from = '/dummy-url/' response = self.client.get( reverse('saml2_login') + f'?next={came_from}') self.assertEqual(response.status_code, 302) url = urlparse(response['Location']) self.assertEqual(url.path, came_from) def test_assertion_consumer_service_already_logged_in_error(self): self.client.force_login( User.objects.create(username='******', password='******')) settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN = False came_from = '/dummy-url/' response = self.client.get( reverse('saml2_login') + f'?next={came_from}') self.assertEqual(response.status_code, 200) self.assertInHTML( "<p>You are already logged in and you are trying to go to the login page again.</p>", response.content.decode()) def test_assertion_consumer_service_no_session(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = '/another-view/' self.add_outstanding_query(session_id, came_from) # Authentication is confirmed. saml_response = auth_response(session_id, 'student') response = self.client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.path, came_from) # Session should no longer be in outstanding queries. saml_response = auth_response(session_id, 'student') response = self.client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 403) def test_missing_param_to_assertion_consumer_service_request(self): # Send request without SAML2Response parameter response = self.client.post(reverse('saml2_acs')) # Assert that view responded with "Bad Request" error self.assertEqual(response.status_code, 400) def test_bad_request_method_to_assertion_consumer_service(self): # Send request with non-POST method. response = self.client.get(reverse('saml2_acs')) # Assert that view responded with method not allowed status self.assertEqual(response.status_code, 405) def do_login(self): """Auxiliary method used in several tests (mainly logout tests)""" self.init_cookies() response = self.client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = '/another-view/' self.add_outstanding_query(session_id, came_from) saml_response = auth_response(session_id, 'student') # this will create a user response = self.client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) subject_id = get_subject_id_from_saml2(saml_response) self.assertEqual(response.status_code, 302) return subject_id def test_echo_view_no_saml_session(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.do_login() request = RequestFactory().get('/bar/foo') request.COOKIES = self.client.cookies request.user = User.objects.last() middleware = SamlSessionMiddleware() middleware.process_request(request) response = EchoAttributesView.as_view()(request) self.assertEqual(response.status_code, 200) self.assertEqual( response.content.decode(), 'No active SAML identity found. Are you sure you have logged in via SAML?' ) def test_echo_view_success(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.do_login() request = RequestFactory().get('/') request.user = User.objects.last() middleware = SamlSessionMiddleware() middleware.process_request(request) saml_session_name = getattr(settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session') getattr( request, saml_session_name )['_saml2_subject_id'] = '1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03' getattr(request, saml_session_name).save() response = EchoAttributesView.as_view()(request) self.assertEqual(response.status_code, 200) self.assertIn('<h1>SAML attributes</h1>', response.content.decode(), 'Echo page not rendered') def test_logout(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.do_login() response = self.client.get(reverse('saml2_logout')) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SingleLogoutService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) saml_request = params['SAMLRequest'][0] self.assertIn('LogoutRequest xmlns', decode_base64_and_inflate(saml_request).decode('utf-8'), 'Not a valid LogoutRequest') def test_logout_service_local(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.do_login() response = self.client.get(reverse('saml2_logout')) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SingleLogoutService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) saml_request = params['SAMLRequest'][0] self.assertIn('LogoutRequest xmlns', decode_base64_and_inflate(saml_request).decode('utf-8'), 'Not a valid LogoutRequest') # now simulate a logout response sent by the idp expected_request = """<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="XXXXXXXXXXXXXXXXXXXXXX" Version="2.0" Destination="https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php" Reason=""><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://sp.example.com/saml2/metadata/</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03</saml:NameID><samlp:SessionIndex>a0123456789abcdef0123456789abcdef</samlp:SessionIndex></samlp:LogoutRequest>""" request_id = re.findall(r' ID="(.*?)" ', expected_request)[0] instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') saml_response = """<?xml version='1.0' encoding='UTF-8'?> <samlp:LogoutResponse xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://sp.example.com/saml2/ls/" ID="a140848e7ce2bce834d7264ecdde0151" InResponseTo="%s" IssueInstant="%s" Version="2.0"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" /></samlp:Status></samlp:LogoutResponse>""" % ( request_id, instant) response = self.client.get( reverse('saml2_ls'), { 'SAMLResponse': deflate_and_base64_encode(saml_response), }) self.assertContains(response, "Logged out", status_code=200) self.assertListEqual(list(self.client.session.keys()), []) def test_logout_service_global(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) subject_id = self.do_login() # now simulate a global logout process initiated by another SP subject_id = views._get_subject_id(self.saml_session) instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') saml_request = """<?xml version='1.0' encoding='UTF-8'?> <samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_9961abbaae6d06d251226cb25e38bf8f468036e57e" Version="2.0" IssueInstant="%s" Destination="http://sp.example.com/saml2/ls/"><saml:Issuer>https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">%s</saml:NameID><samlp:SessionIndex>_1837687b7bc9faad85839dbeb319627889f3021757</samlp:SessionIndex></samlp:LogoutRequest>""" % ( instant, subject_id) response = self.client.get( reverse('saml2_ls'), { 'SAMLRequest': deflate_and_base64_encode(saml_request), }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SingleLogoutService.php') params = parse_qs(url.query) self.assertIn('SAMLResponse', params) saml_response = params['SAMLResponse'][0] self.assertIn('Response xmlns', decode_base64_and_inflate(saml_response).decode('utf-8'), 'Not a valid Response') def test_incomplete_logout(self): settings.SAML_CONFIG = conf.create_conf(sp_host='sp.example.com', idp_hosts=['idp.example.com']) # don't do a login # now simulate a global logout process initiated by another SP instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') saml_request = '<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_9961abbaae6d06d251226cb25e38bf8f468036e57e" Version="2.0" IssueInstant="%s" Destination="http://sp.example.com/saml2/ls/"><saml:Issuer>https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">%s</saml:NameID><samlp:SessionIndex>_1837687b7bc9faad85839dbeb319627889f3021757</samlp:SessionIndex></samlp:LogoutRequest>' % ( instant, 'invalid-subject-id') response = self.client.get( reverse('saml2_ls'), { 'SAMLRequest': deflate_and_base64_encode(saml_request), }) self.assertContains(response, 'Logout error', status_code=403) def test_finish_logout_renders_error_template(self): request = RequestFactory().get('/bar/foo') response = finish_logout(request, None) self.assertContains(response, "<h1>Logout error</h1>", status_code=200) def test_sigalg_not_passed_when_not_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) with mock.patch( 'djangosaml2.views.Saml2Client.prepare_for_authenticate', return_value=('session_id', { 'url': 'fake' }), ) as prepare_for_auth_mock: self.client.get(reverse('saml2_login')) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args self.assertNotIn('sigalg', kwargs) def test_sigalg_passed_when_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) settings.SAML_CONFIG['service']['sp']['authn_requests_signed'] = True with mock.patch( 'djangosaml2.views.Saml2Client.prepare_for_authenticate', return_value=('session_id', { 'url': 'fake' }), ) as prepare_for_auth_mock: self.client.get(reverse('saml2_login')) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args self.assertIn('sigalg', kwargs)
def spid_login(request, config_loader_path=None, wayf_template='wayf.html', authorization_error_template='djangosaml2/auth_error.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http POST protocol binding. """ logger.debug('SPID Login process started') next_url = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not next_url: logger.warning('The next parameter exists but is empty') next_url = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not validate_referral_url(request, next_url): next_url = settings.LOGIN_REDIRECT_URL if request.user.is_authenticated: redirect_authenticated_user = getattr(settings, 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True) if redirect_authenticated_user: return HttpResponseRedirect(next_url) else: # pragma: no cover logger.debug('User is already logged in') return render(request, authorization_error_template, { 'came_from': next_url}) # this works only if request came from wayf selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render(request, wayf_template, { 'available_idps': idps.items(), 'next_url': next_url } ) else: # otherwise is the first one _msg = 'Unable to know which IdP to use' try: selected_idp = selected_idp or list(idps.keys())[0] except TypeError as e: # pragma: no cover logger.error(f'{_msg}: {e}') return HttpResponseError(_msg) except IndexError as e: # pragma: no cover logger.error(f'{_msg}: {e}') return HttpResponseNotFound(_msg) binding = settings.SPID_DEFAULT_BINDING logger.debug(f'Trying binding {binding} for IDP {selected_idp}') # ensure our selected binding is supported by the IDP supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf) if binding not in supported_bindings: _msg = ( f"Requested: {binding} but the selected " f"IDP [{selected_idp}] doesn't support " f"{BINDING_HTTP_POST} or {BINDING_HTTP_REDIRECT}. " f"Check if IdP Metadata is correctly loaded and updated." ) logger.error(_msg) raise UnsupportedBinding(_msg) # SPID things here try: login_response = spid_sp_authn_request(conf, selected_idp, binding, settings.SPID_NAMEID_FORMAT, settings.SPID_AUTH_CONTEXT, settings.SPID_SIG_ALG, settings.SPID_DIG_ALG, next_url ) except UnknownSystemEntity as e: # pragma: no cover _msg = f'Unknown IDP Entity ID: {selected_idp}' logger.error(f'{_msg}: {e}') return HttpResponseNotFound(_msg) session_id = login_response['session_id'] http_response = login_response['http_response'] # success, so save the session ID and return our response logger.debug(f'Saving session-id {session_id} in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.saml_session) oq_cache.set(session_id, next_url) if binding == saml2.BINDING_HTTP_POST: return HttpResponse(http_response['data']) elif binding == saml2.BINDING_HTTP_REDIRECT: headers = dict(login_response['http_response']['headers']) return HttpResponseRedirect(headers['Location'])
class SAML2Tests(TestCase): urls = 'djangosaml2.tests.urls' def setUp(self): if hasattr(settings, 'SAML_ATTRIBUTE_MAPPING'): self.actual_attribute_mapping = settings.SAML_ATTRIBUTE_MAPPING del settings.SAML_ATTRIBUTE_MAPPING if hasattr(settings, 'SAML_CONFIG_LOADER'): self.actual_conf_loader = settings.SAML_CONFIG_LOADER del settings.SAML_CONFIG_LOADER def tearDown(self): if hasattr(self, 'actual_attribute_mapping'): settings.SAML_ATTRIBUTE_MAPPING = self.actual_attribute_mapping if hasattr(self, 'actual_conf_loader'): settings.SAML_CONFIG_LOADER = self.actual_conf_loader def assertSAMLRequestsEquals(self, real_xml, expected_xmls): def remove_variable_attributes(xml_string): xml_string = re.sub(r' ID=".*?" ', ' ', xml_string) xml_string = re.sub(r' IssueInstant=".*?" ', ' ', xml_string) xml_string = re.sub(r'<saml:NameID(.*)>.*</saml:NameID>', r'<saml:NameID\1></saml:NameID>', xml_string) return xml_string self.assertEqual(remove_variable_attributes(real_xml), remove_variable_attributes(expected_xmls)) def init_cookies(self): self.client.cookies[settings.SESSION_COOKIE_NAME] = 'testing' def add_outstanding_query(self, session_id, came_from): settings.SESSION_ENGINE = 'django.contrib.sessions.backends.db' engine = import_module(settings.SESSION_ENGINE) self.saml_session = engine.SessionStore() self.saml_session.save() self.oq_cache = OutstandingQueriesCache(self.saml_session) self.oq_cache.set(session_id \ if isinstance(session_id, str) else session_id.decode(), came_from) self.saml_session.save() self.client.cookies[ settings.SESSION_COOKIE_NAME] = self.saml_session.session_key def render_template(self, text): return Template(text).render(Context()) def b64_for_post(self, xml_text, encoding='utf-8'): return base64.b64encode(xml_text.encode(encoding)).decode('ascii') def test_unsigned_post_authn_request(self): """ Test that unsigned authentication requests via POST binding does not error. https://github.com/knaperek/djangosaml2/issues/168 """ settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_post_binding.xml', authn_requests_signed=False) response = self.client.get(reverse('saml2_login')) self.assertEqual(response.status_code, 200) # Using POST-binding returns a page with form containing the SAMLRequest response_parser = SAMLPostFormParser() response_parser.feed(response.content.decode('utf-8')) saml_request = response_parser.saml_request_value self.assertIsNotNone(saml_request) if 'AuthnRequest xmlns' not in base64.b64decode(saml_request).decode( 'utf-8'): raise Exception( 'test_unsigned_post_authn_request: Not a valid AuthnRequest') def test_login_evil_redirect(self): """ Make sure that if we give an URL other than our own host as the next parameter, it is replaced with the default LOGIN_REDIRECT_URL. """ # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get( reverse('saml2_login') + '?next=http://evil.com') url = urlparse(response['Location']) params = parse_qs(url.query) self.assertEqual(params['RelayState'], [ settings.LOGIN_REDIRECT_URL, ]) def test_login_one_idp(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login')) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) self.assertIn('RelayState', params) saml_request = params['SAMLRequest'][0] if 'AuthnRequest xmlns' not in decode_base64_and_inflate( saml_request).decode('utf-8'): raise Exception('Not a valid AuthnRequest') # if we set a next arg in the login view, it is preserverd # in the RelayState argument nexturl = '/another-view/' response = self.client.get(reverse('saml2_login'), {'next': nexturl}) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) self.assertIn('RelayState', params) self.assertEqual(params['RelayState'][0], nexturl) def test_login_several_idps(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=[ 'idp1.example.com', 'idp2.example.com', 'idp3.example.com' ], metadata_file='remote_metadata_three_idps.xml', ) response = self.client.get(reverse('saml2_login')) # a WAYF page should be displayed self.assertContains(response, 'Where are you from?', status_code=200) for i in range(1, 4): link = '/login/?idp=https://idp%d.example.com/simplesaml/saml2/idp/metadata.php&next=/' self.assertContains(response, link % i) # click on the second idp response = self.client.get( reverse('saml2_login'), { 'idp': 'https://idp2.example.com/simplesaml/saml2/idp/metadata.php', 'next': '/', }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp2.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) self.assertIn('RelayState', params) saml_request = params['SAMLRequest'][0] if 'AuthnRequest xmlns' not in decode_base64_and_inflate( saml_request).decode('utf-8'): raise Exception('Not a valid AuthnRequest') def test_assertion_consumer_service(self): # Get initial number of users initial_user_count = User.objects.count() settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = '/another-view/' self.add_outstanding_query(session_id, came_from) # this will create a user saml_response = auth_response(session_id, 'student') _url = reverse('saml2_acs') response = self.client.post( _url, { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.path, came_from) self.assertEqual(User.objects.count(), initial_user_count + 1) user_id = self.client.session[SESSION_KEY] user = User.objects.get(id=user_id) self.assertEqual(user.username, 'student') # let's create another user and log in with that one new_user = User.objects.create(username='******', password='******') # session_id = "a1111111111111111111111111111111" client = Client() response = client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) came_from = '' # bad, let's see if we can deal with this saml_response = auth_response(session_id, 'teacher') self.add_outstanding_query(session_id, '/') response = client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) # as the RelayState is empty we have redirect to LOGIN_REDIRECT_URL self.assertEqual(url.path, settings.LOGIN_REDIRECT_URL) self.assertEqual(force_text(new_user.id), client.session[SESSION_KEY]) def test_assertion_consumer_service_no_session(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) response = self.client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = '/another-view/' self.add_outstanding_query(session_id, came_from) # Authentication is confirmed. saml_response = auth_response(session_id, 'student') response = self.client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.path, came_from) # Session should no longer be in outstanding queries. saml_response = auth_response(session_id, 'student') response = self.client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) self.assertEqual(response.status_code, 403) def test_missing_param_to_assertion_consumer_service_request(self): # Send request without SAML2Response parameter response = self.client.post(reverse('saml2_acs')) # Assert that view responded with "Bad Request" error self.assertEqual(response.status_code, 400) def test_bad_request_method_to_assertion_consumer_service(self): # Send request with non-POST method. response = self.client.get(reverse('saml2_acs')) # Assert that view responded with method not allowed status self.assertEqual(response.status_code, 405) def do_login(self): """Auxiliary method used in several tests (mainly logout tests)""" self.init_cookies() response = self.client.get(reverse('saml2_login')) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = '/another-view/' self.add_outstanding_query(session_id, came_from) saml_response = auth_response(session_id, 'student') # this will create a user response = self.client.post( reverse('saml2_acs'), { 'SAMLResponse': self.b64_for_post(saml_response), 'RelayState': came_from, }) subject_id = get_subject_id_from_saml2(saml_response) self.assertEqual(response.status_code, 302) return subject_id @skip( "This is a known issue caused by pysaml2. Needs more investigation. Fixes are welcome." ) def test_logout(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.do_login() response = self.client.get(reverse('saml2_logout')) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SingleLogoutService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) saml_request = params['SAMLRequest'][0] if 'LogoutRequest xmlns' not in decode_base64_and_inflate( saml_request).decode('utf-8'): raise Exception('Not a valid LogoutRequest') def test_logout_service_local(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) self.do_login() response = self.client.get(reverse('saml2_logout')) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SingleLogoutService.php') params = parse_qs(url.query) self.assertIn('SAMLRequest', params) saml_request = params['SAMLRequest'][0] if 'LogoutRequest xmlns' not in decode_base64_and_inflate( saml_request).decode('utf-8'): raise Exception('Not a valid LogoutRequest') # now simulate a logout response sent by the idp expected_request = """<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="XXXXXXXXXXXXXXXXXXXXXX" Version="2.0" Destination="https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php" Reason=""><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://sp.example.com/saml2/metadata/</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03</saml:NameID><samlp:SessionIndex>a0123456789abcdef0123456789abcdef</samlp:SessionIndex></samlp:LogoutRequest>""" request_id = re.findall(r' ID="(.*?)" ', expected_request)[0] instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') saml_response = """<?xml version='1.0' encoding='UTF-8'?> <samlp:LogoutResponse xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://sp.example.com/saml2/ls/" ID="a140848e7ce2bce834d7264ecdde0151" InResponseTo="%s" IssueInstant="%s" Version="2.0"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" /></samlp:Status></samlp:LogoutResponse>""" % ( request_id, instant) response = self.client.get( reverse('saml2_ls'), { 'SAMLResponse': deflate_and_base64_encode(saml_response), }) self.assertContains(response, "Logged out", status_code=200) self.assertListEqual(list(self.client.session.keys()), []) def test_logout_service_global(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) subject_id = self.do_login() # now simulate a global logout process initiated by another SP subject_id = views._get_subject_id(self.saml_session) instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') saml_request = """<?xml version='1.0' encoding='UTF-8'?> <samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_9961abbaae6d06d251226cb25e38bf8f468036e57e" Version="2.0" IssueInstant="%s" Destination="http://sp.example.com/saml2/ls/"><saml:Issuer>https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">%s</saml:NameID><samlp:SessionIndex>_1837687b7bc9faad85839dbeb319627889f3021757</samlp:SessionIndex></samlp:LogoutRequest>""" % ( instant, subject_id) response = self.client.get( reverse('saml2_ls'), { 'SAMLRequest': deflate_and_base64_encode(saml_request), }) self.assertEqual(response.status_code, 302) location = response['Location'] url = urlparse(location) self.assertEqual(url.hostname, 'idp.example.com') self.assertEqual(url.path, '/simplesaml/saml2/idp/SingleLogoutService.php') params = parse_qs(url.query) self.assertIn('SAMLResponse', params) saml_response = params['SAMLResponse'][0] if 'Response xmlns' not in decode_base64_and_inflate( saml_response).decode('utf-8'): raise Exception('Not a valid Response') def test_incomplete_logout(self): settings.SAML_CONFIG = conf.create_conf(sp_host='sp.example.com', idp_hosts=['idp.example.com']) # don't do a login # now simulate a global logout process initiated by another SP instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') saml_request = '<samlp:LogoutRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_9961abbaae6d06d251226cb25e38bf8f468036e57e" Version="2.0" IssueInstant="%s" Destination="http://sp.example.com/saml2/ls/"><saml:Issuer>https://idp.example.com/simplesaml/saml2/idp/metadata.php</saml:Issuer><saml:NameID SPNameQualifier="http://sp.example.com/saml2/metadata/" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">%s</saml:NameID><samlp:SessionIndex>_1837687b7bc9faad85839dbeb319627889f3021757</samlp:SessionIndex></samlp:LogoutRequest>' % ( instant, 'invalid-subject-id') response = self.client.get( reverse('saml2_ls'), { 'SAMLRequest': deflate_and_base64_encode(saml_request), }) self.assertContains(response, 'Logout error', status_code=403) def test_finish_logout_renders_error_template(self): request = RequestFactory().get('/bar/foo') response = finish_logout(request, None) self.assertContains(response, "<h1>Logout error</h1>", status_code=200) def _test_metadata(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) valid_until = datetime.datetime.utcnow() + datetime.timedelta(hours=24) valid_until = valid_until.strftime("%Y-%m-%dT%H:%M:%SZ") expected_metadata = """<?xml version='1.0' encoding='UTF-8'?> <md:EntityDescriptor entityID="http://sp.example.com/saml2/metadata/" validUntil="%s" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"><md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>MIIDPjCCAiYCCQCkHjPQlll+mzANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJF UzEQMA4GA1UECBMHU2V2aWxsYTEbMBkGA1UEChMSWWFjbyBTaXN0ZW1hcyBTLkwu MRAwDgYDVQQHEwdTZXZpbGxhMREwDwYDVQQDEwh0aWNvdGljbzAeFw0wOTEyMDQx OTQzNTJaFw0xMDEyMDQxOTQzNTJaMGExCzAJBgNVBAYTAkVTMRAwDgYDVQQIEwdT ZXZpbGxhMRswGQYDVQQKExJZYWNvIFNpc3RlbWFzIFMuTC4xEDAOBgNVBAcTB1Nl dmlsbGExETAPBgNVBAMTCHRpY290aWNvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEA7rMOMOaIZ/YYD5hYS6Hpjpovcu4k8gaIY+om9zCxLV5F8BLEfkxo Pk9IA3cRQNRxf7AXCFxEOH3nKy56AIi1gU7X6fCT30JBT8NQlYdgOVMLlR+tjy1b YV07tDa9U8gzjTyKQHgVwH0436+rmSPnacGj3fMwfySTMhtmrJmax0bIa8EB+gY1 77DBtvf8dIZIXLlGMQFloZeUspvHOrgNoEA9xU4E9AanGnV9HeV37zv3mLDUOQLx 4tk9sMQmylCpij7WZmcOV07DyJ/cEmnvHSalBTcyIgkcwlhmjtSgfCy6o5zuWxYd T9ia80SZbWzn8N6B0q+nq23+Oee9H0lvcwIDAQABMA0GCSqGSIb3DQEBBQUAA4IB AQCQBhKOqucJZAqGHx4ybDXNzpPethszonLNVg5deISSpWagy55KlGCi5laio/xq hHRx18eTzeCeLHQYvTQxw0IjZOezJ1X30DD9lEqPr6C+IrmZc6bn/pF76xsvdaRS gduNQPT1B25SV2HrEmbf8wafSlRARmBsyUHh860TqX7yFVjhYIAUF/El9rLca51j ljCIqqvT+klPdjQoZwODWPFHgute2oNRmoIcMjSnoy1+mxOC2Q/j7kcD8/etulg2 XDxB3zD81gfdtT8VBFP+G4UrBa+5zFk6fT6U8a7ZqVsyH+rCXAdCyVlEC4Y5fZri ID4zT0FcZASGuthM56rRJJSx </ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://sp.example.com/saml2/ls/" /><md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://sp.example.com/saml2/acs/" index="1" /><md:AttributeConsumingService index="1"><md:ServiceName xml:lang="en">Test SP</md:ServiceName><md:RequestedAttribute FriendlyName="uid" Name="urn:oid:0.9.2342.19200300.100.1.1" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="true" /><md:RequestedAttribute FriendlyName="eduPersonAffiliation" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" isRequired="false" /></md:AttributeConsumingService></md:SPSSODescriptor><md:Organization><md:OrganizationName xml:lang="es">Ejemplo S.A.</md:OrganizationName><md:OrganizationName xml:lang="en">Example Inc.</md:OrganizationName><md:OrganizationDisplayName xml:lang="es">Ejemplo</md:OrganizationDisplayName><md:OrganizationDisplayName xml:lang="en">Example</md:OrganizationDisplayName><md:OrganizationURL xml:lang="es">http://www.example.es</md:OrganizationURL><md:OrganizationURL xml:lang="en">http://www.example.com</md:OrganizationURL></md:Organization><md:ContactPerson contactType="technical"><md:Company>Example Inc.</md:Company><md:GivenName>Technical givenname</md:GivenName><md:SurName>Technical surname</md:SurName><md:EmailAddress>[email protected]</md:EmailAddress></md:ContactPerson><md:ContactPerson contactType="administrative"><md:Company>Example Inc.</md:Company><md:GivenName>Administrative givenname</md:GivenName><md:SurName>Administrative surname</md:SurName><md:EmailAddress>[email protected]</md:EmailAddress></md:ContactPerson></md:EntityDescriptor>""" expected_metadata = expected_metadata % valid_until response = self.client.get('/metadata/') self.assertEqual(response['Content-type'], 'text/xml; charset=utf8') self.assertEqual(response.status_code, 200) self.assertEqual(response.content, expected_metadata) def test_post_authenticated_signal(self): self.called = [] def signal_handler(sender, instance, session_info, request, **kwargs): self.called.append({ 'sender': sender, 'instance': instance, 'request': request, 'session_info': session_info }) post_authenticated.connect(signal_handler, dispatch_uid='test_signal') self.do_login() # make sure the handler is only called once self.assertEqual(len(self.called), 1) # test 'sender', this should be User.__class__ self.assertEqual(self.called[0]['sender'], get_user_model(), 'post_authenticated signal sender is not a User') # test 'instance', this should be User self.assertIsInstance( self.called[0]['instance'], get_user_model(), 'post_authenticated signal did not send a User instance') # test the request self.assertIsInstance( self.called[0]['request'], HttpRequest, 'post_authenticated signal did not send a request') # test the session_info self.assertIsInstance( self.called[0]['session_info'], dict, 'post_authenticated signal did not send a session_info dict') post_authenticated.disconnect(dispatch_uid='test_signal') def test_pre_user_save_signal(self): self.called = [] def signal_handler(sender, instance, attributes, user_modified, **kwargs): self.called.append({ 'sender': sender, 'instance': instance, 'attributes': attributes, 'user_modified': user_modified }) pre_user_save.connect(signal_handler, dispatch_uid='test_signal') self.do_login() # make sure the handler is only called once self.assertEqual(len(self.called), 1) # test 'sender', this should be User.__class__ self.assertEqual(self.called[0]['sender'], get_user_model(), 'pre_user_save signal sender is not a User') # test 'instance', this should be User self.assertIsInstance( self.called[0]['instance'], get_user_model(), 'pre_user_save signal did not send a User instance') # test the attributes self.assertIsInstance(self.called[0]['attributes'], dict, 'pre_user_save signal did not send attributes') # test the user_modified self.assertIsInstance( self.called[0]['user_modified'], bool, 'pre_user_save signal did not send a user_modified bool') pre_user_save.disconnect(dispatch_uid='test_signal') def test_idplist_templatetag(self): settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=[ 'idp1.example.com', 'idp2.example.com', 'idp3.example.com' ], metadata_file='remote_metadata_three_idps.xml', ) rendered = self.render_template('{% load idplist %}' '{% idplist as idps %}' '{% for url, name in idps.items %}' '{{ url }} - {{ name }}; ' '{% endfor %}') # the idplist is unordered, so convert the result into a set. rendered = set(rendered.split('; ')) expected = set([ u'https://idp1.example.com/simplesaml/saml2/idp/metadata.php - idp1.example.com IdP', u'https://idp2.example.com/simplesaml/saml2/idp/metadata.php - idp2.example.com IdP', u'https://idp3.example.com/simplesaml/saml2/idp/metadata.php - idp3.example.com IdP', u'', ]) self.assertEqual(rendered, expected) def test_sigalg_not_passed_when_not_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) with mock.patch( 'djangosaml2.views.Saml2Client.prepare_for_authenticate', return_value=('session_id', { 'url': 'fake' }), ) as prepare_for_auth_mock: self.client.get(reverse('saml2_login')) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args self.assertNotIn('sigalg', kwargs) def test_sigalg_passed_when_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host='sp.example.com', idp_hosts=['idp.example.com'], metadata_file='remote_metadata_one_idp.xml', ) settings.SAML_CONFIG['service']['sp']['authn_requests_signed'] = True with mock.patch( 'djangosaml2.views.Saml2Client.prepare_for_authenticate', return_value=('session_id', { 'url': 'fake' }), ) as prepare_for_auth_mock: self.client.get(reverse('saml2_login')) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args self.assertIn('sigalg', kwargs)
location = result[1] # fix up the redirect url for endpoints that have ? in the link split_location = location.split("?SAMLRequest=") if split_location and "?" in split_location[0]: logger.debug("Redirect URL already has query string, " + "transforming ?SAMLRequest=") location = location.replace("?SAMLRequest=", "&SAMLRequest=") split_location = location.split("?RelayState=") if split_location and "?" in split_location[0]: logger.debug("Redirect URL already has query string, " + "transforming ?RelayState=") location = location.replace("?RelayState=", "&RelayState=") logger.debug("Saving the session_id in the OutstandingQueries cache") oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) logger.debug("Redirecting the user to the IdP") logger.debug("Redirecting to %s" % location) return HttpResponseRedirect(location) @require_POST @csrf_exempt def assertion_consumer_service(request, config_loader_path=None, attribute_mapping=None, create_unknown_user=None): """SAML Authorization Response endpoint The IdP will send its response to this view, which will process it with pysaml2 help and log the user in using the custom Authorization backend djangosaml2.backends.Saml2Backend that should be
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html', post_binding_form_template='djangosaml2/post_binding_form.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. * post_binding_form_template - path to a template containing HTML form with hidden input elements, used to send the SAML message data when HTTP POST binding is being used. You can customize this template to include custom branding and/or text explaining the automatic redirection process. Please see the example template in templates/djangosaml2/example_post_binding_form.html If set to None or nonexistent template, default form from the saml2 library will be rendered. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # Ensure the user-originating redirection url is safe. if not is_safe_url_compat(url=came_from, allowed_hosts={request.get_host()}): came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if callable_bool(request.user.is_authenticated): redirect_authenticated_user = getattr(settings, 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True) if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render(request, authorization_error_template, { 'came_from': came_from, }) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render(request, wayf_template, { 'available_idps': idps.items(), 'came_from': came_from, }) # choose a binding to try first sign_requests = getattr(conf, '_sp_authn_requests_signed', False) binding = BINDING_HTTP_POST if sign_requests else BINDING_HTTP_REDIRECT logger.debug('Trying binding %s for IDP %s', binding, selected_idp) # ensure our selected binding is supported by the IDP supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf) if binding not in supported_bindings: logger.debug('Binding %s not in IDP %s supported bindings: %s', binding, selected_idp, supported_bindings) if binding == BINDING_HTTP_POST: logger.warning('IDP %s does not support %s, trying %s', selected_idp, binding, BINDING_HTTP_REDIRECT) binding = BINDING_HTTP_REDIRECT else: logger.warning('IDP %s does not support %s, trying %s', selected_idp, binding, BINDING_HTTP_POST) binding = BINDING_HTTP_POST # if switched binding still not supported, give up if binding not in supported_bindings: raise UnsupportedBinding('IDP %s does not support %s or %s', selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT) client = Saml2Client(conf) http_response = None logger.debug('Redirecting user to the IdP via %s binding.', binding) if binding == BINDING_HTTP_REDIRECT: try: # do not sign the xml itself, instead use the sigalg to # generate the signature as a URL param sig_alg_option_map = {'sha1': SIG_RSA_SHA1, 'sha256': SIG_RSA_SHA256} sig_alg_option = getattr(conf, '_sp_authn_requests_signed_alg', 'sha1') sigalg = sig_alg_option_map[sig_alg_option] if sign_requests else None nsprefix = get_namespace_prefixes() session_id, result = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=binding, sign=False, sigalg=sigalg, nsprefix=nsprefix) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) else: http_response = HttpResponseRedirect(get_location(result)) elif binding == BINDING_HTTP_POST: if post_binding_form_template: # get request XML to build our own html based on the template try: location = client.sso_location(selected_idp, binding) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) session_id, request_xml = client.create_authn_request( location, binding=binding) try: if PY3: saml_request = base64.b64encode(binary_type(request_xml, 'UTF-8')) else: saml_request = base64.b64encode(binary_type(request_xml)) http_response = render(request, post_binding_form_template, { 'target_url': location, 'params': { 'SAMLRequest': saml_request, 'RelayState': came_from, }, }) except TemplateDoesNotExist: pass if not http_response: # use the html provided by pysaml2 if no template was specified or it didn't exist try: session_id, result = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=binding) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(text_type(e)) else: http_response = HttpResponse(result['data']) else: raise UnsupportedBinding('Unsupported binding: %s', binding) # success, so save the session ID and return our response logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) return http_response
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if not request.user.is_anonymous(): try: redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN except AttributeError: redirect_authenticated_user = True if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render_to_response(authorization_error_template, { 'came_from': came_from, }, context_instance=RequestContext(request)) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) # is a embedded wayf needed? idps = available_idps(conf) if selected_idp is None and len(idps) > 1: logger.debug('A discovery process is needed') return render_to_response(wayf_template, { 'available_idps': idps.items(), 'came_from': came_from, }, context_instance=RequestContext(request)) client = Saml2Client(conf) try: (session_id, result) = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=BINDING_HTTP_REDIRECT, ) except TypeError as e: logger.error('Unable to know which IdP to use') return HttpResponse(unicode(e)) logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, came_from) logger.debug('Redirecting the user to the IdP') return HttpResponseRedirect(get_location(result))
def post(self, request): if not self.request.user.is_anonymous: error_message = _('This endpoint is for anonymous users only.') return JsonResponse({'error_message': error_message}, status_code=400) serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) idp = serializer.validated_data.get('idp') conf = get_config(request=request) # ensure our selected binding is supported by the IDP supported_bindings = utils.get_idp_sso_supported_bindings(idp, config=conf) default_binding = settings.WALDUR_AUTH_SAML2.get('DEFAULT_BINDING') if default_binding in supported_bindings: binding = default_binding elif BINDING_HTTP_POST in supported_bindings: binding = BINDING_HTTP_POST elif BINDING_HTTP_REDIRECT in supported_bindings: binding = BINDING_HTTP_REDIRECT else: error_message = _('Identity provider does not support available bindings.') return JsonResponse({'error_message': error_message}, status_code=400) client = Saml2Client(conf) kwargs = {} sign_requests = getattr(conf, '_sp_authn_requests_signed', False) if sign_requests: signature_algorithm = settings.WALDUR_AUTH_SAML2.get('signature_algorithm') or SIG_RSA_SHA1 digest_algorithm = settings.WALDUR_AUTH_SAML2.get('digest_algorithm') or DIGEST_SHA1 kwargs['sign'] = True kwargs['sigalg'] = signature_algorithm kwargs['sign_alg'] = signature_algorithm kwargs['digest_alg'] = digest_algorithm nameid_format = settings.WALDUR_AUTH_SAML2.get('nameid_format') if nameid_format or nameid_format == "": # "" is a valid setting in pysaml2 kwargs['nameid_format'] = nameid_format if binding == BINDING_HTTP_REDIRECT: session_id, result = client.prepare_for_authenticate( entityid=idp, binding=binding, **kwargs) data = { 'binding': 'redirect', 'url': get_location(result), } elif binding == BINDING_HTTP_POST: try: location = client.sso_location(idp, binding) except TypeError: error_message = _('Invalid identity provider specified.') return JsonResponse({'error_message': error_message}, status_code=400) session_id, request_xml = client.create_authn_request(location, binding=binding, **kwargs) data = { 'binding': 'post', 'url': location, 'request': base64.b64encode(request_xml.encode('UTF-8')), } # save session_id oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(session_id, '') return JsonResponse(data)
def login(request, config_loader_path=None, wayf_template='djangosaml2/wayf.html', authorization_error_template='djangosaml2/auth_error.html'): """SAML Authorization Request initiator This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. It uses the SAML 2.0 Http Redirect protocol binding. """ logger.debug('Login process started') came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL) if not came_from: logger.warning('The next parameter exists but is empty') came_from = settings.LOGIN_REDIRECT_URL # if the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. # We can only make one thing here and that is configurable with the # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting # is True (default value) we will redirect him to the came_from view. # Otherwise, we will show an (configurable) authorization error. if not request.user.is_anonymous(): try: redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN except AttributeError: redirect_authenticated_user = True if redirect_authenticated_user: return HttpResponseRedirect(came_from) else: logger.debug('User is already logged in') return render_to_response(authorization_error_template, { 'came_from': came_from, }, context_instance=RequestContext(request)) selected_idp = request.GET.get('idp', None) conf = get_config(config_loader_path, request) client = Saml2Client(conf) try: sid, http_args = client.prepare_for_authenticate( entityid=selected_idp, relay_state=came_from, binding=BINDING_HTTP_REDIRECT, ) except TypeError as e: logger.error('Unable to know which IdP to use') raise e assert isinstance(sid, str) assert len(http_args) == 4 assert http_args["headers"][0][0] == "Location" assert http_args["data"] == [] location = http_args["headers"][0][1] # fix up the redirect url for endpoints that have ? in the link split_location = location.split('?SAMLRequest=') if split_location and '?' in split_location[0]: logger.debug( 'Redirect URL already has query string, ' + 'transforming ?SAMLRequest=') location = location.replace('?SAMLRequest=', '&SAMLRequest=') split_location = location.split('?RelayState=') if split_location and '?' in split_location[0]: logger.debug( 'Redirect URL already has query string, ' + 'transforming ?RelayState=') location = location.replace('?RelayState=', '&RelayState=') logger.debug('Saving the session_id in the OutstandingQueries cache') oq_cache = OutstandingQueriesCache(request.session) oq_cache.set(sid, came_from) logger.debug('Redirecting the user to the IdP') logger.debug('Redirecting to %s' % location) return HttpResponseRedirect(location)