views.py 22 KB

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