base.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. # This Source Code Form is subject to the terms of the Mozilla Public
  2. # License, v. 2.0. If a copy of the MPL was not distributed with this
  3. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
  4. import logging
  5. from datetime import datetime
  6. from django.conf import settings
  7. from django.core.exceptions import ImproperlyConfigured
  8. from django.utils import six
  9. from django.utils.encoding import python_2_unicode_compatible
  10. import requests
  11. from django_browserid.compat import pybrowserid_found
  12. from django_browserid.util import same_origin
  13. logger = logging.getLogger(__name__)
  14. @python_2_unicode_compatible
  15. class BrowserIDException(Exception):
  16. """Raised when there is an issue verifying an assertion."""
  17. def __init__(self, exc):
  18. #: Original exception that caused this to be raised.
  19. self.exc = exc
  20. def __str__(self):
  21. return six.text_type(self.exc)
  22. def sanity_checks(request):
  23. """
  24. Small checks for common errors.
  25. Checks are normally only enabled if DEBUG is True. You can
  26. explicitly disable the checks using the
  27. BROWSERID_DISABLE_SANITY_CHECKS.
  28. :returns:
  29. True if the checks were run, False if they were skipped.
  30. """
  31. if getattr(settings, 'BROWSERID_DISABLE_SANITY_CHECKS', not settings.DEBUG):
  32. return False # Return value helps us test if the checks ran.
  33. # SESSION_COOKIE_SECURE should be False in development unless you can
  34. # use https.
  35. if settings.SESSION_COOKIE_SECURE and not request.is_secure():
  36. logger.warning('SESSION_COOKIE_SECURE is currently set to True, '
  37. 'which may cause issues with django_browserid '
  38. 'login during local development. Consider setting '
  39. 'it to False.')
  40. # If you're using django-csp, you should include persona.
  41. if 'csp.middleware.CSPMiddleware' in settings.MIDDLEWARE_CLASSES:
  42. persona = 'https://login.persona.org'
  43. in_default = persona in getattr(settings, 'CSP_DEFAULT_SRC', ())
  44. in_script = persona in getattr(settings, 'CSP_SCRIPT_SRC', ())
  45. in_frame = persona in getattr(settings, 'CSP_FRAME_SRC', ())
  46. if (not in_script or not in_frame) and not in_default:
  47. logger.warning('django-csp detected, but %s was not found in '
  48. 'your CSP policies. Consider adding it to '
  49. 'CSP_SCRIPT_SRC and CSP_FRAME_SRC',
  50. persona)
  51. return True
  52. def get_audience(request):
  53. """
  54. Determine the audience to use for verification from the given request.
  55. Relies on the BROWSERID_AUDIENCES setting, which is an explicit list of acceptable
  56. audiences for your site.
  57. :returns:
  58. The first audience in BROWSERID_AUDIENCES that has the same origin as the request's
  59. URL.
  60. :raises:
  61. :class:`django.core.exceptions.ImproperlyConfigured`: If BROWSERID_AUDIENCES isn't
  62. defined, or if no matching audience could be found.
  63. """
  64. protocol = 'https' if request.is_secure() else 'http'
  65. host = '{0}://{1}'.format(protocol, request.get_host())
  66. try:
  67. audiences = settings.BROWSERID_AUDIENCES
  68. if not audiences and settings.DEBUG:
  69. return host
  70. except AttributeError:
  71. if settings.DEBUG:
  72. return host
  73. raise ImproperlyConfigured('Required setting BROWSERID_AUDIENCES not found!')
  74. for audience in audiences:
  75. if same_origin(host, audience):
  76. return audience
  77. # No audience found? We must not be configured properly, otherwise why are we getting this
  78. # request?
  79. raise ImproperlyConfigured('No audience could be found in BROWSERID_AUDIENCES for host `{0}`.'
  80. .format(host))
  81. @python_2_unicode_compatible
  82. class VerificationResult(object):
  83. """
  84. Result of an attempt to verify an assertion.
  85. VerificationResult objects can be treated as booleans to test if the verification succeeded or
  86. not.
  87. The fields returned by the remote verification service, such as ``email`` or ``issuer``, are
  88. available as attributes if they were included in the response. For example, a failure result
  89. will raise an AttributeError if you try to access the ``email`` attribute.
  90. """
  91. def __init__(self, response):
  92. """
  93. :param response:
  94. Dictionary of the response from the remote verification service.
  95. """
  96. self._response = response
  97. def __getattr__(self, name):
  98. if name in self._response:
  99. return self._response[name]
  100. else:
  101. raise AttributeError
  102. @property
  103. def expires(self):
  104. """The expiration date of the assertion as a naive :class:`datetime.datetime` in UTC."""
  105. try:
  106. return datetime.utcfromtimestamp(int(self._response['expires']) / 1000.0)
  107. except KeyError:
  108. raise AttributeError
  109. except ValueError:
  110. timestamp = self._response['expires']
  111. logger.warning('Could not parse expires timestamp: `{0}`'.format(timestamp))
  112. return timestamp
  113. def __nonzero__(self):
  114. return self._response.get('status') == 'okay'
  115. def __bool__(self):
  116. return self.__nonzero__()
  117. def __str__(self):
  118. result = six.u('Success') if self else six.u('Failure')
  119. email = getattr(self, 'email', None)
  120. email_string = six.u(' email={0}').format(email) if email else six.u('')
  121. return six.u('<VerificationResult {0}{1}>').format(result, email_string)
  122. class RemoteVerifier(object):
  123. """
  124. Verifies BrowserID assertions using a remote verification service.
  125. By default, this uses the Mozilla Persona service for remote verification.
  126. """
  127. verification_service_url = 'https://verifier.login.persona.org/verify'
  128. requests_parameters = {
  129. 'timeout': 5
  130. }
  131. def verify(self, assertion, audience, **kwargs):
  132. """
  133. Verify an assertion using a remote verification service.
  134. :param assertion:
  135. BrowserID assertion to verify.
  136. :param audience:
  137. The protocol, hostname and port of your website. Used to confirm that the assertion was
  138. meant for your site and not for another site.
  139. :param kwargs:
  140. Extra keyword arguments are passed on to requests.post to allow customization.
  141. :returns:
  142. :class:`.VerificationResult`
  143. :raises:
  144. :class:`.BrowserIDException`: Error connecting to the remote verification service, or
  145. error parsing the response received from the service.
  146. """
  147. parameters = dict(self.requests_parameters, **{
  148. 'data': {
  149. 'assertion': assertion,
  150. 'audience': audience,
  151. }
  152. })
  153. parameters['data'].update(kwargs)
  154. try:
  155. response = requests.post(self.verification_service_url, **parameters)
  156. except requests.exceptions.RequestException as err:
  157. raise BrowserIDException(err)
  158. try:
  159. return VerificationResult(response.json())
  160. except (ValueError, TypeError) as err:
  161. # If the returned JSON is invalid, log a warning and return a failure result.
  162. logger.warning('Failed to parse remote verifier response: `{0}`'
  163. .format(response.content))
  164. return VerificationResult({
  165. 'status': 'failure',
  166. 'reason': 'Could not parse verifier response: {0}'.format(err)
  167. })
  168. class MockVerifier(object):
  169. """Mock-verifies BrowserID assertions."""
  170. def __init__(self, email, **kwargs):
  171. """
  172. :param email:
  173. Email address to include in successful verification result. If None, verify will return
  174. a failure result.
  175. :param kwargs:
  176. Extra keyword arguments are used to update successful verification results. This allows
  177. for mocking attributes on the result, such as the issuer.
  178. """
  179. self.email = email
  180. self.result_attributes = kwargs
  181. def verify(self, assertion, audience, **kwargs):
  182. """
  183. Mock-verify an assertion. The return value is determined by the parameters given to the
  184. constructor.
  185. """
  186. if not self.email:
  187. return VerificationResult({
  188. 'status': 'failure',
  189. 'reason': 'No email given to MockVerifier.'
  190. })
  191. else:
  192. result = {
  193. 'status': 'okay',
  194. 'audience': audience,
  195. 'email': self.email,
  196. 'issuer': 'mockissuer.example.com:443',
  197. 'expires': '1311377222765'
  198. }
  199. result.update(self.result_attributes)
  200. return VerificationResult(result)
  201. if pybrowserid_found:
  202. from browserid.errors import Error as PyBrowserIDError
  203. from browserid.verifiers.local import LocalVerifier as PyBrowserIDLocalVerifier
  204. class LocalVerifier(object):
  205. """
  206. Verifies BrowserID assertions locally instead of using the remote
  207. verification service.
  208. """
  209. def __init__(self, *args, **kwargs):
  210. super(LocalVerifier, self).__init__(*args, **kwargs)
  211. self.pybid_verifier = PyBrowserIDLocalVerifier()
  212. def verify(self, assertion, audience, **kwargs):
  213. """
  214. Verify an assertion locally.
  215. :param assertion:
  216. BrowserID assertion to verify.
  217. :param audience:
  218. The protocol, hostname and port of your website. Used to confirm that the assertion was
  219. meant for your site and not for another site.
  220. :returns:
  221. :class:`.VerificationResult`
  222. """
  223. try:
  224. result = self.pybid_verifier.verify(assertion, audience)
  225. except PyBrowserIDError as error:
  226. return VerificationResult({
  227. 'status': 'failure',
  228. 'reason': error
  229. })
  230. return VerificationResult(result)
  231. else:
  232. # If someone tries to use LocalVerifier, let's show a helpful error
  233. # instead of just raising an ImportError.
  234. class LocalVerifier(object):
  235. def __init__(self, *args, **kwargs):
  236. raise RuntimeError('You\'re attempting to use local assertion verification without '
  237. 'PyBrowserID installed. Please install PyBrowserID in order to '
  238. 'enable local assertion verification.')