views.py 21 KB

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