views.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # Copyright (C) 2010-2013 Yaco Sistemas (http://www.yaco.es)
  2. # Copyright (C) 2009 Lorenzo Gil Sanchez <lorenzo.gil.sanchez@gmail.com>
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import base64
  16. import logging
  17. try:
  18. from xml.etree import ElementTree
  19. except ImportError:
  20. from elementtree import ElementTree
  21. from django.conf import settings
  22. from django.contrib import auth
  23. from django.contrib.auth.decorators import login_required
  24. from django.contrib.auth.views import logout as django_logout
  25. from django.core.exceptions import PermissionDenied, SuspiciousOperation
  26. from django.http import Http404, HttpResponse
  27. from django.http import HttpResponseRedirect # 30x
  28. from django.http import HttpResponseBadRequest, HttpResponseForbidden # 40x
  29. from django.http import HttpResponseServerError # 50x
  30. from django.views.decorators.http import require_POST
  31. from django.shortcuts import render
  32. from django.template import TemplateDoesNotExist
  33. from django.utils.http import is_safe_url
  34. from django.utils.six import text_type, binary_type
  35. from django.views.decorators.csrf import csrf_exempt
  36. from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
  37. from saml2.client import Saml2Client
  38. from saml2.metadata import entity_descriptor
  39. from saml2.ident import code, decode
  40. from saml2.sigver import MissingKey
  41. from saml2.s_utils import UnsupportedBinding
  42. from saml2.response import StatusError, StatusAuthnFailed, SignatureError, StatusRequestDenied
  43. from saml2.validate import ResponseLifetimeExceed, ToEarly
  44. from saml2.xmldsig import SIG_RSA_SHA1, SIG_RSA_SHA256 # support for SHA1 is required by spec
  45. from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
  46. from djangosaml2.cache import StateCache
  47. from djangosaml2.conf import get_config
  48. from djangosaml2.signals import post_authenticated
  49. from djangosaml2.utils import fail_acs_response, get_custom_setting, available_idps, get_location, get_idp_sso_supported_bindings
  50. logger = logging.getLogger('djangosaml2')
  51. def _set_subject_id(session, subject_id):
  52. session['_saml2_subject_id'] = code(subject_id)
  53. def _get_subject_id(session):
  54. try:
  55. return decode(session['_saml2_subject_id'])
  56. except KeyError:
  57. return None
  58. def login(request,
  59. config_loader_path=None,
  60. wayf_template='djangosaml2/wayf.html',
  61. authorization_error_template='djangosaml2/auth_error.html',
  62. post_binding_form_template='djangosaml2/post_binding_form.html'):
  63. """SAML Authorization Request initiator
  64. This view initiates the SAML2 Authorization handshake
  65. using the pysaml2 library to create the AuthnRequest.
  66. It uses the SAML 2.0 Http Redirect protocol binding.
  67. * post_binding_form_template - path to a template containing HTML form with
  68. hidden input elements, used to send the SAML message data when HTTP POST
  69. binding is being used. You can customize this template to include custom
  70. branding and/or text explaining the automatic redirection process. Please
  71. see the example template in
  72. templates/djangosaml2/example_post_binding_form.html
  73. If set to None or nonexistent template, default form from the saml2 library
  74. will be rendered.
  75. """
  76. logger.debug('Login process started')
  77. came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL)
  78. if not came_from:
  79. logger.warning('The next parameter exists but is empty')
  80. came_from = settings.LOGIN_REDIRECT_URL
  81. # Ensure the user-originating redirection url is safe.
  82. if not is_safe_url(url=came_from, host=request.get_host()):
  83. came_from = settings.LOGIN_REDIRECT_URL
  84. # if the user is already authenticated that maybe because of two reasons:
  85. # A) He has this URL in two browser windows and in the other one he
  86. # has already initiated the authenticated session.
  87. # B) He comes from a view that (incorrectly) send him here because
  88. # he does not have enough permissions. That view should have shown
  89. # an authorization error in the first place.
  90. # We can only make one thing here and that is configurable with the
  91. # SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting. If that setting
  92. # is True (default value) we will redirect him to the came_from view.
  93. # Otherwise, we will show an (configurable) authorization error.
  94. if not request.user.is_anonymous():
  95. try:
  96. redirect_authenticated_user = settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN
  97. except AttributeError:
  98. redirect_authenticated_user = True
  99. if redirect_authenticated_user:
  100. return HttpResponseRedirect(came_from)
  101. else:
  102. logger.debug('User is already logged in')
  103. return render(request, authorization_error_template, {
  104. 'came_from': came_from,
  105. })
  106. selected_idp = request.GET.get('idp', None)
  107. conf = get_config(config_loader_path, request)
  108. # is a embedded wayf needed?
  109. idps = available_idps(conf)
  110. if selected_idp is None and len(idps) > 1:
  111. logger.debug('A discovery process is needed')
  112. return render(request, wayf_template, {
  113. 'available_idps': idps.items(),
  114. 'came_from': came_from,
  115. })
  116. # choose a binding to try first
  117. sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
  118. binding = BINDING_HTTP_POST if sign_requests else BINDING_HTTP_REDIRECT
  119. logger.debug('Trying binding %s for IDP %s', binding, selected_idp)
  120. # ensure our selected binding is supported by the IDP
  121. supported_bindings = get_idp_sso_supported_bindings(selected_idp, config=conf)
  122. if binding not in supported_bindings:
  123. logger.debug('Binding %s not in IDP %s supported bindings: %s',
  124. binding, selected_idp, supported_bindings)
  125. if binding == BINDING_HTTP_POST:
  126. logger.warning('IDP %s does not support %s, trying %s',
  127. selected_idp, binding, BINDING_HTTP_REDIRECT)
  128. binding = BINDING_HTTP_REDIRECT
  129. else:
  130. logger.warning('IDP %s does not support %s, trying %s',
  131. selected_idp, binding, BINDING_HTTP_POST)
  132. binding = BINDING_HTTP_POST
  133. # if switched binding still not supported, give up
  134. if binding not in supported_bindings:
  135. raise UnsupportedBinding('IDP %s does not support %s or %s',
  136. selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT)
  137. client = Saml2Client(conf)
  138. http_response = None
  139. logger.debug('Redirecting user to the IdP via %s binding.', binding)
  140. if binding == BINDING_HTTP_REDIRECT:
  141. try:
  142. # do not sign the xml itself, instead use the sigalg to
  143. # generate the signature as a URL param
  144. sig_alg_option_map = {'sha1': SIG_RSA_SHA1,
  145. 'sha256': SIG_RSA_SHA256}
  146. sig_alg_option = getattr(conf, '_sp_authn_requests_signed_alg', 'sha1')
  147. sigalg = sig_alg_option_map[sig_alg_option] if sign_requests else None
  148. session_id, result = client.prepare_for_authenticate(
  149. entityid=selected_idp, relay_state=came_from,
  150. binding=binding, sign=False, sigalg=sigalg)
  151. except TypeError as e:
  152. logger.error('Unable to know which IdP to use')
  153. return HttpResponse(text_type(e))
  154. else:
  155. http_response = HttpResponseRedirect(get_location(result))
  156. elif binding == BINDING_HTTP_POST:
  157. if post_binding_form_template:
  158. # get request XML to build our own html based on the template
  159. try:
  160. location = client.sso_location(selected_idp, binding)
  161. except TypeError as e:
  162. logger.error('Unable to know which IdP to use')
  163. return HttpResponse(text_type(e))
  164. session_id, request_xml = client.create_authn_request(
  165. location,
  166. binding=binding)
  167. try:
  168. http_response = render(request, post_binding_form_template, {
  169. 'target_url': location,
  170. 'params': {
  171. 'SAMLRequest': base64.b64encode(binary_type(request_xml)),
  172. 'RelayState': came_from,
  173. },
  174. })
  175. # upgrade change: binary_type(request_xml, 'UTF-8') to binary_type(request_xml)
  176. except TemplateDoesNotExist:
  177. pass
  178. if not http_response:
  179. # use the html provided by pysaml2 if no template was specified or it didn't exist
  180. try:
  181. session_id, result = client.prepare_for_authenticate(
  182. entityid=selected_idp, relay_state=came_from,
  183. binding=binding)
  184. except TypeError as e:
  185. logger.error('Unable to know which IdP to use')
  186. return HttpResponse(text_type(e))
  187. else:
  188. http_response = HttpResponse(result['data'])
  189. else:
  190. raise UnsupportedBinding('Unsupported binding: %s', binding)
  191. # success, so save the session ID and return our response
  192. logger.debug('Saving the session_id in the OutstandingQueries cache')
  193. oq_cache = OutstandingQueriesCache(request.session)
  194. oq_cache.set(session_id, came_from)
  195. return http_response
  196. @require_POST
  197. @csrf_exempt
  198. def assertion_consumer_service(request,
  199. config_loader_path=None,
  200. attribute_mapping=None,
  201. create_unknown_user=None):
  202. """SAML Authorization Response endpoint
  203. The IdP will send its response to this view, which
  204. will process it with pysaml2 help and log the user
  205. in using the custom Authorization backend
  206. djangosaml2.backends.Saml2Backend that should be
  207. enabled in the settings.py
  208. """
  209. attribute_mapping = attribute_mapping or get_custom_setting('SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
  210. create_unknown_user = create_unknown_user or get_custom_setting('SAML_CREATE_UNKNOWN_USER', True)
  211. conf = get_config(config_loader_path, request)
  212. try:
  213. xmlstr = request.POST['SAMLResponse']
  214. except KeyError:
  215. logger.warning('Missing "SAMLResponse" parameter in POST data.')
  216. raise SuspiciousOperation
  217. client = Saml2Client(conf, identity_cache=IdentityCache(request.session))
  218. oq_cache = OutstandingQueriesCache(request.session)
  219. outstanding_queries = oq_cache.outstanding_queries()
  220. try:
  221. response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
  222. except (StatusError, ToEarly):
  223. logger.exception("Error processing SAML Assertion.")
  224. return fail_acs_response(request)
  225. except ResponseLifetimeExceed:
  226. logger.info("SAML Assertion is no longer valid. Possibly caused by network delay or replay attack.", exc_info=True)
  227. return fail_acs_response(request)
  228. except SignatureError:
  229. logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
  230. return fail_acs_response(request)
  231. except StatusAuthnFailed:
  232. logger.info("Authentication denied for user by IdP.", exc_info=True)
  233. return fail_acs_response(request)
  234. except StatusRequestDenied:
  235. logger.warning("Authentication interrupted at IdP.", exc_info=True)
  236. return fail_acs_response(request)
  237. except MissingKey:
  238. logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
  239. return fail_acs_response(request)
  240. if response is None:
  241. logger.warning("Invalid SAML Assertion received (unknown error).")
  242. return fail_acs_response(request, status=400, exc_class=SuspiciousOperation)
  243. session_id = response.session_id()
  244. oq_cache.delete(session_id)
  245. # authenticate the remote user
  246. session_info = response.session_info()
  247. if callable(attribute_mapping):
  248. attribute_mapping = attribute_mapping()
  249. if callable(create_unknown_user):
  250. create_unknown_user = create_unknown_user()
  251. logger.debug('Trying to authenticate the user. Session info: %s', session_info)
  252. user = auth.authenticate(request=request,
  253. session_info=session_info,
  254. attribute_mapping=attribute_mapping,
  255. create_unknown_user=create_unknown_user)
  256. if user is None:
  257. logger.warning("Could not authenticate user received in SAML Assertion. Session info: %s", session_info)
  258. raise PermissionDenied
  259. auth.login(request, user)
  260. _set_subject_id(request.session, session_info['name_id'])
  261. logger.debug("User %s authenticated via SSO.", user)
  262. logger.debug('Sending the post_authenticated signal')
  263. post_authenticated.send_robust(sender=user, session_info=session_info)
  264. # redirect the user to the view where he came from
  265. default_relay_state = get_custom_setting('ACS_DEFAULT_REDIRECT_URL',
  266. settings.LOGIN_REDIRECT_URL)
  267. relay_state = request.POST.get('RelayState', default_relay_state)
  268. if not relay_state:
  269. logger.warning('The RelayState parameter exists but is empty')
  270. relay_state = default_relay_state
  271. if not is_safe_url(url=relay_state, host=request.get_host()):
  272. relay_state = settings.LOGIN_REDIRECT_URL
  273. logger.debug('Redirecting to the RelayState: %s', relay_state)
  274. return HttpResponseRedirect(relay_state)
  275. @login_required
  276. def echo_attributes(request,
  277. config_loader_path=None,
  278. template='djangosaml2/echo_attributes.html'):
  279. """Example view that echo the SAML attributes of an user"""
  280. state = StateCache(request.session)
  281. conf = get_config(config_loader_path, request)
  282. client = Saml2Client(conf, state_cache=state,
  283. identity_cache=IdentityCache(request.session))
  284. subject_id = _get_subject_id(request.session)
  285. try:
  286. identity = client.users.get_identity(subject_id,
  287. check_not_on_or_after=False)
  288. except AttributeError:
  289. return HttpResponse("No active SAML identity found. Are you sure you have logged in via SAML?")
  290. return render(request, template, {'attributes': identity[0]})
  291. @login_required
  292. def logout(request, config_loader_path=None):
  293. """SAML Logout Request initiator
  294. This view initiates the SAML2 Logout request
  295. using the pysaml2 library to create the LogoutRequest.
  296. """
  297. state = StateCache(request.session)
  298. conf = get_config(config_loader_path, request)
  299. client = Saml2Client(conf, state_cache=state,
  300. identity_cache=IdentityCache(request.session))
  301. subject_id = _get_subject_id(request.session)
  302. if subject_id is None:
  303. logger.warning(
  304. 'The session does not contain the subject id for user %s',
  305. request.user)
  306. result = client.global_logout(subject_id)
  307. state.sync()
  308. if not result:
  309. logger.error("Looks like the user %s is not logged in any IdP/AA", subject_id)
  310. return HttpResponseBadRequest("You are not logged in any IdP/AA")
  311. if len(result) > 1:
  312. logger.error('Sorry, I do not know how to logout from several sources. I will logout just from the first one')
  313. for entityid, logout_info in result.items():
  314. if isinstance(logout_info, tuple):
  315. binding, http_info = logout_info
  316. if binding == BINDING_HTTP_POST:
  317. logger.debug('Returning form to the IdP to continue the logout process')
  318. body = ''.join(http_info['data'])
  319. return HttpResponse(body)
  320. elif binding == BINDING_HTTP_REDIRECT:
  321. logger.debug('Redirecting to the IdP to continue the logout process')
  322. return HttpResponseRedirect(get_location(http_info))
  323. else:
  324. logger.error('Unknown binding: %s', binding)
  325. return HttpResponseServerError('Failed to log out')
  326. else:
  327. # We must have had a soap logout
  328. return finish_logout(request, logout_info)
  329. logger.error('Could not logout because there only the HTTP_REDIRECT is supported')
  330. return HttpResponseServerError('Logout Binding not supported')
  331. def logout_service(request, *args, **kwargs):
  332. return do_logout_service(request, request.GET, BINDING_HTTP_REDIRECT, *args, **kwargs)
  333. @csrf_exempt
  334. def logout_service_post(request, *args, **kwargs):
  335. return do_logout_service(request, request.POST, BINDING_HTTP_POST, *args, **kwargs)
  336. def do_logout_service(request, data, binding, config_loader_path=None, next_page=None,
  337. logout_error_template='djangosaml2/logout_error.html'):
  338. """SAML Logout Response endpoint
  339. The IdP will send the logout response to this view,
  340. which will process it with pysaml2 help and log the user
  341. out.
  342. Note that the IdP can request a logout even when
  343. we didn't initiate the process as a single logout
  344. request started by another SP.
  345. """
  346. logger.debug('Logout service started')
  347. conf = get_config(config_loader_path, request)
  348. state = StateCache(request.session)
  349. client = Saml2Client(conf, state_cache=state,
  350. identity_cache=IdentityCache(request.session))
  351. if 'SAMLResponse' in data: # we started the logout
  352. logger.debug('Receiving a logout response from the IdP')
  353. response = client.parse_logout_request_response(data['SAMLResponse'], binding)
  354. state.sync()
  355. return finish_logout(request, response, next_page=next_page)
  356. elif 'SAMLRequest' in data: # logout started by the IdP
  357. logger.debug('Receiving a logout request from the IdP')
  358. subject_id = _get_subject_id(request.session)
  359. if subject_id is None:
  360. logger.warning(
  361. 'The session does not contain the subject id for user %s. Performing local logout',
  362. request.user)
  363. auth.logout(request)
  364. return render(request, logout_error_template, {})
  365. else:
  366. http_info = client.handle_logout_request(
  367. data['SAMLRequest'],
  368. subject_id,
  369. binding,
  370. relay_state=data.get('RelayState', ''))
  371. state.sync()
  372. auth.logout(request)
  373. return HttpResponseRedirect(get_location(http_info))
  374. else:
  375. logger.error('No SAMLResponse or SAMLRequest parameter found')
  376. raise Http404('No SAMLResponse or SAMLRequest parameter found')
  377. def finish_logout(request, response, next_page=None):
  378. if response and response.status_ok():
  379. if next_page is None and hasattr(settings, 'LOGOUT_REDIRECT_URL'):
  380. next_page = settings.LOGOUT_REDIRECT_URL
  381. logger.debug('Performing django_logout with a next_page of %s',
  382. next_page)
  383. return django_logout(request, next_page=next_page)
  384. else:
  385. logger.error('Unknown error during the logout')
  386. return render(request, "djangosaml2/logout_error.html", {})
  387. def metadata(request, config_loader_path=None, valid_for=None):
  388. """Returns an XML with the SAML 2.0 metadata for this
  389. SP as configured in the settings.py file.
  390. """
  391. conf = get_config(config_loader_path, request)
  392. metadata = entity_descriptor(conf)
  393. return HttpResponse(content=text_type(metadata).encode('utf-8'),
  394. content_type="text/xml; charset=utf8")
  395. def register_namespace_prefixes():
  396. from saml2 import md, saml, samlp
  397. try:
  398. from saml2 import xmlenc
  399. from saml2 import xmldsig
  400. except ImportError:
  401. import xmlenc
  402. import xmldsig
  403. prefixes = (('saml', saml.NAMESPACE),
  404. ('samlp', samlp.NAMESPACE),
  405. ('md', md.NAMESPACE),
  406. ('ds', xmldsig.NAMESPACE),
  407. ('xenc', xmlenc.NAMESPACE))
  408. if hasattr(ElementTree, 'register_namespace'):
  409. for prefix, namespace in prefixes:
  410. ElementTree.register_namespace(prefix, namespace)
  411. else:
  412. for prefix, namespace in prefixes:
  413. ElementTree._namespace_map[namespace] = prefix
  414. register_namespace_prefixes()