tokens.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. from datetime import timedelta
  2. from uuid import uuid4
  3. from django.conf import settings
  4. from django.utils.translation import gettext_lazy as _
  5. from django.utils.module_loading import import_string
  6. from .exceptions import TokenBackendError, TokenError
  7. from .settings import api_settings
  8. from .token_blacklist.models import BlacklistedToken, OutstandingToken
  9. from .utils import (
  10. aware_utcnow, datetime_from_epoch, datetime_to_epoch, format_lazy,
  11. )
  12. class Token:
  13. """
  14. A class which validates and wraps an existing JWT or can be used to build a
  15. new JWT.
  16. """
  17. token_type = None
  18. lifetime = None
  19. def __init__(self, token=None, verify=True):
  20. """
  21. !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error
  22. message if the given token is invalid, expired, or otherwise not safe
  23. to use.
  24. """
  25. if self.token_type is None or self.lifetime is None:
  26. raise TokenError(_('Cannot create token with no type or lifetime'))
  27. self.token = token
  28. self.current_time = aware_utcnow()
  29. # Set up token
  30. if token is not None:
  31. # An encoded token was provided
  32. token_backend = self.get_token_backend()
  33. # Decode token
  34. try:
  35. self.payload = token_backend.decode(token, verify=verify)
  36. except TokenBackendError:
  37. raise TokenError(_('Token is invalid or expired'))
  38. if verify:
  39. self.verify()
  40. else:
  41. # New token. Skip all the verification steps.
  42. self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}
  43. # Set "exp" claim with default value
  44. self.set_exp(from_time=self.current_time, lifetime=self.lifetime)
  45. # Set "jti" claim
  46. self.set_jti()
  47. def __repr__(self):
  48. return repr(self.payload)
  49. def __getitem__(self, key):
  50. return self.payload[key]
  51. def __setitem__(self, key, value):
  52. self.payload[key] = value
  53. def __delitem__(self, key):
  54. del self.payload[key]
  55. def __contains__(self, key):
  56. return key in self.payload
  57. def get(self, key, default=None):
  58. return self.payload.get(key, default)
  59. def __str__(self):
  60. """
  61. Signs and returns a token as a base64 encoded string.
  62. """
  63. return self.get_token_backend().encode(self.payload)
  64. def verify(self):
  65. """
  66. Performs additional validation steps which were not performed when this
  67. token was decoded. This method is part of the "public" API to indicate
  68. the intention that it may be overridden in subclasses.
  69. """
  70. # According to RFC 7519, the "exp" claim is OPTIONAL
  71. # (https://tools.ietf.org/html/rfc7519#section-4.1.4). As a more
  72. # correct behavior for authorization tokens, we require an "exp"
  73. # claim. We don't want any zombie tokens walking around.
  74. self.check_exp()
  75. # Ensure token id is present
  76. if api_settings.JTI_CLAIM not in self.payload:
  77. raise TokenError(_('Token has no id'))
  78. self.verify_token_type()
  79. def verify_token_type(self):
  80. """
  81. Ensures that the token type claim is present and has the correct value.
  82. """
  83. try:
  84. token_type = self.payload[api_settings.TOKEN_TYPE_CLAIM]
  85. except KeyError:
  86. raise TokenError(_('Token has no type'))
  87. if self.token_type != token_type:
  88. raise TokenError(_('Token has wrong type'))
  89. def set_jti(self):
  90. """
  91. Populates the configured jti claim of a token with a string where there
  92. is a negligible probability that the same string will be chosen at a
  93. later time.
  94. See here:
  95. https://tools.ietf.org/html/rfc7519#section-4.1.7
  96. """
  97. self.payload[api_settings.JTI_CLAIM] = uuid4().hex
  98. def set_exp(self, claim='exp', from_time=None, lifetime=None):
  99. """
  100. Updates the expiration time of a token.
  101. """
  102. if from_time is None:
  103. from_time = self.current_time
  104. if lifetime is None:
  105. lifetime = self.lifetime
  106. self.payload[claim] = datetime_to_epoch(from_time + lifetime)
  107. def check_exp(self, claim='exp', current_time=None):
  108. """
  109. Checks whether a timestamp value in the given claim has passed (since
  110. the given datetime value in `current_time`). Raises a TokenError with
  111. a user-facing error message if so.
  112. """
  113. if current_time is None:
  114. current_time = self.current_time
  115. try:
  116. claim_value = self.payload[claim]
  117. except KeyError:
  118. raise TokenError(format_lazy(_("Token has no '{}' claim"), claim))
  119. claim_time = datetime_from_epoch(claim_value)
  120. if claim_time <= current_time:
  121. raise TokenError(format_lazy(_("Token '{}' claim has expired"), claim))
  122. @classmethod
  123. def for_user(cls, user):
  124. """
  125. Returns an authorization token for the given user that will be provided
  126. after authenticating the user's credentials.
  127. """
  128. user_id = getattr(user, api_settings.USER_ID_FIELD)
  129. if not isinstance(user_id, int):
  130. user_id = str(user_id)
  131. token = cls()
  132. token[api_settings.USER_ID_CLAIM] = user_id
  133. return token
  134. _token_backend = None
  135. def get_token_backend(self):
  136. if self._token_backend is None:
  137. self._token_backend = import_string(
  138. "rest_framework_simplejwt.state.token_backend"
  139. )
  140. return self._token_backend
  141. class BlacklistMixin:
  142. """
  143. If the `rest_framework_simplejwt.token_blacklist` app was configured to be
  144. used, tokens created from `BlacklistMixin` subclasses will insert
  145. themselves into an outstanding token list and also check for their
  146. membership in a token blacklist.
  147. """
  148. if 'rest_framework_simplejwt.token_blacklist' in settings.INSTALLED_APPS:
  149. def verify(self, *args, **kwargs):
  150. self.check_blacklist()
  151. super().verify(*args, **kwargs)
  152. def check_blacklist(self):
  153. """
  154. Checks if this token is present in the token blacklist. Raises
  155. `TokenError` if so.
  156. """
  157. jti = self.payload[api_settings.JTI_CLAIM]
  158. if BlacklistedToken.objects.filter(token__jti=jti).exists():
  159. raise TokenError(_('Token is blacklisted'))
  160. def blacklist(self):
  161. """
  162. Ensures this token is included in the outstanding token list and
  163. adds it to the blacklist.
  164. """
  165. jti = self.payload[api_settings.JTI_CLAIM]
  166. exp = self.payload['exp']
  167. # Ensure outstanding token exists with given jti
  168. token, _ = OutstandingToken.objects.get_or_create(
  169. jti=jti,
  170. defaults={
  171. 'token': str(self),
  172. 'expires_at': datetime_from_epoch(exp),
  173. },
  174. )
  175. return BlacklistedToken.objects.get_or_create(token=token)
  176. @classmethod
  177. def for_user(cls, user):
  178. """
  179. Adds this token to the outstanding token list.
  180. """
  181. token = super().for_user(user)
  182. jti = token[api_settings.JTI_CLAIM]
  183. exp = token['exp']
  184. OutstandingToken.objects.create(
  185. user=user,
  186. jti=jti,
  187. token=str(token),
  188. created_at=token.current_time,
  189. expires_at=datetime_from_epoch(exp),
  190. )
  191. return token
  192. class SlidingToken(BlacklistMixin, Token):
  193. token_type = 'sliding'
  194. lifetime = api_settings.SLIDING_TOKEN_LIFETIME
  195. def __init__(self, *args, **kwargs):
  196. super().__init__(*args, **kwargs)
  197. if self.token is None:
  198. # Set sliding refresh expiration claim if new token
  199. self.set_exp(
  200. api_settings.SLIDING_TOKEN_REFRESH_EXP_CLAIM,
  201. from_time=self.current_time,
  202. lifetime=api_settings.SLIDING_TOKEN_REFRESH_LIFETIME,
  203. )
  204. class RefreshToken(BlacklistMixin, Token):
  205. token_type = 'refresh'
  206. lifetime = api_settings.REFRESH_TOKEN_LIFETIME
  207. no_copy_claims = (
  208. api_settings.TOKEN_TYPE_CLAIM,
  209. 'exp',
  210. # Both of these claims are included even though they may be the same.
  211. # It seems possible that a third party token might have a custom or
  212. # namespaced JTI claim as well as a default "jti" claim. In that case,
  213. # we wouldn't want to copy either one.
  214. api_settings.JTI_CLAIM,
  215. 'jti',
  216. )
  217. @property
  218. def access_token(self):
  219. """
  220. Returns an access token created from this refresh token. Copies all
  221. claims present in this refresh token to the new access token except
  222. those claims listed in the `no_copy_claims` attribute.
  223. """
  224. access = AccessToken()
  225. # Use instantiation time of refresh token as relative timestamp for
  226. # access token "exp" claim. This ensures that both a refresh and
  227. # access token expire relative to the same time if they are created as
  228. # a pair.
  229. access.set_exp(from_time=self.current_time)
  230. no_copy = self.no_copy_claims
  231. for claim, value in self.payload.items():
  232. if claim in no_copy:
  233. continue
  234. access[claim] = value
  235. return access
  236. class AccessToken(Token):
  237. token_type = 'access'
  238. lifetime = api_settings.ACCESS_TOKEN_LIFETIME
  239. class UntypedToken(Token):
  240. token_type = 'untyped'
  241. lifetime = timedelta(seconds=0)
  242. def verify_token_type(self):
  243. """
  244. Untyped tokens do not verify the "token_type" claim. This is useful
  245. when performing general validation of a token's signature and other
  246. properties which do not relate to the token's intended use.
  247. """
  248. pass