Format: text/x-python
⬇ Download File (13.3 KB)
   1  from datetime import datetime
   2  from datetime import timezone
   3  from functools import wraps
   4  from re import split
   5  from typing import Any
   6  from typing import Optional
   7  from typing import Sequence
   8  from typing import Tuple
   9  from typing import Union
  10  
  11  from flask import current_app
  12  from flask import g
  13  from flask import request
  14  from werkzeug.exceptions import BadRequest
  15  
  16  from flask_jwt_extended.config import config
  17  from flask_jwt_extended.exceptions import CSRFError
  18  from flask_jwt_extended.exceptions import FreshTokenRequired
  19  from flask_jwt_extended.exceptions import InvalidHeaderError
  20  from flask_jwt_extended.exceptions import InvalidQueryParamError
  21  from flask_jwt_extended.exceptions import NoAuthorizationError
  22  from flask_jwt_extended.exceptions import UserLookupError
  23  from flask_jwt_extended.internal_utils import custom_verification_for_token
  24  from flask_jwt_extended.internal_utils import has_user_lookup
  25  from flask_jwt_extended.internal_utils import user_lookup
  26  from flask_jwt_extended.internal_utils import verify_token_not_blocklisted
  27  from flask_jwt_extended.internal_utils import verify_token_type
  28  from flask_jwt_extended.utils import decode_token
  29  from flask_jwt_extended.utils import get_unverified_jwt_headers
  30  
  31  LocationType = Union[str, Sequence, None]
  32  
  33  
  34  def _verify_token_is_fresh(jwt_header: dict, jwt_data: dict) -> None:
  35      fresh = jwt_data["fresh"]
  36      if isinstance(fresh, bool):
  37          if not fresh:
  38              raise FreshTokenRequired("Fresh token required", jwt_header, jwt_data)
  39      else:
  40          now = datetime.timestamp(datetime.now(timezone.utc))
  41          if fresh < now:
  42              raise FreshTokenRequired("Fresh token required", jwt_header, jwt_data)
  43  
  44  
  45  def verify_jwt_in_request(
  46      optional: bool = False,
  47      fresh: bool = False,
  48      refresh: bool = False,
  49      locations: Optional[LocationType] = None,
  50      verify_type: bool = True,
  51      skip_revocation_check: bool = False,
  52  ) -> Optional[Tuple[dict, dict]]:
  53      """
  54      Verify that a valid JWT is present in the request, unless ``optional=True`` in
  55      which case no JWT is also considered valid.
  56  
  57      :param optional:
  58          If ``True``, do not raise an error if no JWT is present in the request.
  59          Defaults to ``False``.
  60  
  61      :param fresh:
  62          If ``True``, require a JWT marked as ``fresh`` in order to be verified.
  63          Defaults to ``False``.
  64  
  65      :param refresh:
  66          If ``True``, requires a refresh JWT to access this endpoint. If ``False``,
  67          requires an access JWT to access this endpoint. Defaults to ``False``
  68  
  69      :param locations:
  70          A location or list of locations to look for the JWT in this request, for
  71          example ``'headers'`` or ``['headers', 'cookies']``. Defaults to ``None``
  72          which indicates that JWTs will be looked for in the locations defined by the
  73          ``JWT_TOKEN_LOCATION`` configuration option.
  74  
  75      :param verify_type:
  76          If ``True``, the token type (access or refresh) will be checked according
  77          to the ``refresh`` argument. If ``False``, type will not be checked and both
  78          access and refresh tokens will be accepted.
  79  
  80      :param skip_revocation_check:
  81          If ``True``, revocation status of the token will be *not* checked. If ``False``,
  82          revocation status of the token will be checked.
  83  
  84      :return:
  85          A tuple containing the jwt_header and the jwt_data if a valid JWT is
  86          present in the request. If ``optional=True`` and no JWT is in the request,
  87          ``None`` will be returned instead. Raise an exception if an invalid JWT
  88          is in the request.
  89      """
  90      if request.method in config.exempt_methods:
  91          return None
  92  
  93      try:
  94          jwt_data, jwt_header, jwt_location = _decode_jwt_from_request(
  95              locations,
  96              fresh,
  97              refresh=refresh,
  98              verify_type=verify_type,
  99              skip_revocation_check=skip_revocation_check,
 100          )
 101  
 102      except NoAuthorizationError:
 103          if not optional:
 104              raise
 105          g._jwt_extended_jwt = {}
 106          g._jwt_extended_jwt_header = {}
 107          g._jwt_extended_jwt_user = {"loaded_user": None}
 108          g._jwt_extended_jwt_location = None
 109          return None
 110  
 111      # Save these at the very end so that they are only saved in the requet
 112      # context if the token is valid and all callbacks succeed
 113      g._jwt_extended_jwt_user = _load_user(jwt_header, jwt_data)
 114      g._jwt_extended_jwt_header = jwt_header
 115      g._jwt_extended_jwt = jwt_data
 116      g._jwt_extended_jwt_location = jwt_location
 117  
 118      return jwt_header, jwt_data
 119  
 120  
 121  def jwt_required(
 122      optional: bool = False,
 123      fresh: bool = False,
 124      refresh: bool = False,
 125      locations: Optional[LocationType] = None,
 126      verify_type: bool = True,
 127      skip_revocation_check: bool = False,
 128  ) -> Any:
 129      """
 130      A decorator to protect a Flask endpoint with JSON Web Tokens.
 131  
 132      Any route decorated with this will require a valid JWT to be present in the
 133      request (unless optional=True, in which case no JWT is also valid) before the
 134      endpoint can be called.
 135  
 136      :param optional:
 137          If ``True``, allow the decorated endpoint to be accessed if no JWT is present in
 138          the request. Defaults to ``False``.
 139  
 140      :param fresh:
 141          If ``True``, require a JWT marked with ``fresh`` to be able to access this
 142          endpoint. Defaults to ``False``.
 143  
 144      :param refresh:
 145          If ``True``, requires a refresh JWT to access this endpoint. If ``False``,
 146          requires an access JWT to access this endpoint. Defaults to ``False``.
 147  
 148      :param locations:
 149          A location or list of locations to look for the JWT in this request, for
 150          example ``'headers'`` or ``['headers', 'cookies']``. Defaults to ``None``
 151          which indicates that JWTs will be looked for in the locations defined by the
 152          ``JWT_TOKEN_LOCATION`` configuration option.
 153  
 154      :param verify_type:
 155          If ``True``, the token type (access or refresh) will be checked according
 156          to the ``refresh`` argument. If ``False``, type will not be checked and both
 157          access and refresh tokens will be accepted.
 158  
 159      :param skip_revocation_check:
 160          If ``True``, revocation status of the token will be *not* checked. If ``False``,
 161          revocation status of the token will be checked.
 162      """
 163  
 164      def wrapper(fn):
 165          @wraps(fn)
 166          def decorator(*args, **kwargs):
 167              verify_jwt_in_request(
 168                  optional, fresh, refresh, locations, verify_type, skip_revocation_check
 169              )
 170              return current_app.ensure_sync(fn)(*args, **kwargs)
 171  
 172          return decorator
 173  
 174      return wrapper
 175  
 176  
 177  def _load_user(jwt_header: dict, jwt_data: dict) -> Optional[dict]:
 178      if not has_user_lookup():
 179          return None
 180  
 181      identity = jwt_data[config.identity_claim_key]
 182      user = user_lookup(jwt_header, jwt_data)
 183      if user is None:
 184          error_msg = "user_lookup returned None for {}".format(identity)
 185          raise UserLookupError(error_msg, jwt_header, jwt_data)
 186      return {"loaded_user": user}
 187  
 188  
 189  def _decode_jwt_from_headers() -> Tuple[str, None]:
 190      header_name = config.header_name
 191      header_type = config.header_type
 192  
 193      # Verify we have the auth header
 194      auth_header = request.headers.get(header_name, "").strip().strip(",")
 195      if not auth_header:
 196          raise NoAuthorizationError(f"Missing {header_name} Header")
 197  
 198      # Make sure the header is in a valid format that we are expecting, ie
 199      # <HeaderName>: <HeaderType(optional)> <JWT>.
 200      #
 201      # Also handle the fact that the header that can be comma delimited, ie
 202      # <HeaderName>: <field> <value>, <field> <value>, etc...
 203      if header_type:
 204          field_values = split(r",\s*", auth_header)
 205          jwt_headers = [s for s in field_values if s.split()[0] == header_type]
 206          if len(jwt_headers) != 1:
 207              msg = (
 208                  f"Missing '{header_type}' type in '{header_name}' header. "
 209                  f"Expected '{header_name}: {header_type} <JWT>'"
 210              )
 211              raise NoAuthorizationError(msg)
 212  
 213          parts = jwt_headers[0].split()
 214          if len(parts) != 2:
 215              msg = (
 216                  f"Bad {header_name} header. "
 217                  f"Expected '{header_name}: {header_type} <JWT>'"
 218              )
 219              raise InvalidHeaderError(msg)
 220  
 221          encoded_token = parts[1]
 222      else:
 223          parts = auth_header.split()
 224          if len(parts) != 1:
 225              msg = f"Bad {header_name} header. Expected '{header_name}: <JWT>'"
 226              raise InvalidHeaderError(msg)
 227  
 228          encoded_token = parts[0]
 229  
 230      return encoded_token, None
 231  
 232  
 233  def _decode_jwt_from_cookies(refresh: bool) -> Tuple[str, Optional[str]]:
 234      if refresh:
 235          cookie_key = config.refresh_cookie_name
 236          csrf_header_key = config.refresh_csrf_header_name
 237          csrf_field_key = config.refresh_csrf_field_name
 238      else:
 239          cookie_key = config.access_cookie_name
 240          csrf_header_key = config.access_csrf_header_name
 241          csrf_field_key = config.access_csrf_field_name
 242  
 243      encoded_token = request.cookies.get(cookie_key)
 244      if not encoded_token:
 245          raise NoAuthorizationError('Missing cookie "{}"'.format(cookie_key))
 246  
 247      if config.csrf_protect and request.method in config.csrf_request_methods:
 248          csrf_value = request.headers.get(csrf_header_key, None)
 249          if not csrf_value and config.csrf_check_form:
 250              csrf_value = request.form.get(csrf_field_key, None)
 251          if not csrf_value:
 252              raise CSRFError("Missing CSRF token")
 253      else:
 254          csrf_value = None
 255  
 256      return encoded_token, csrf_value
 257  
 258  
 259  def _decode_jwt_from_query_string() -> Tuple[str, None]:
 260      param_name = config.query_string_name
 261      prefix = config.query_string_value_prefix
 262  
 263      value = request.args.get(param_name)
 264      if not value:
 265          raise NoAuthorizationError(f"Missing '{param_name}' query paramater")
 266  
 267      if not value.startswith(prefix):
 268          raise InvalidQueryParamError(
 269              f"Invalid value for query parameter '{param_name}'. "
 270              f"Expected the value to start with '{prefix}'"
 271          )
 272  
 273      encoded_token = value[len(prefix) :]  # noqa: E203
 274      return encoded_token, None
 275  
 276  
 277  def _decode_jwt_from_json(refresh: bool) -> Tuple[str, None]:
 278      if not request.is_json:
 279          raise NoAuthorizationError("Invalid content-type. Must be application/json.")
 280  
 281      if refresh:
 282          token_key = config.refresh_json_key
 283      else:
 284          token_key = config.json_key
 285  
 286      try:
 287          encoded_token = request.json and request.json.get(token_key, None)
 288          if not encoded_token:
 289              raise BadRequest()
 290      except BadRequest:
 291          raise NoAuthorizationError(
 292              'Missing "{}" key in json data.'.format(token_key)
 293          ) from None
 294  
 295      return encoded_token, None
 296  
 297  
 298  def _decode_jwt_from_request(
 299      locations: LocationType,
 300      fresh: bool,
 301      refresh: bool = False,
 302      verify_type: bool = True,
 303      skip_revocation_check: bool = False,
 304  ) -> Tuple[dict, dict, str]:
 305      # Figure out what locations to look for the JWT in this request
 306      if isinstance(locations, str):
 307          locations = [locations]
 308  
 309      if not locations:
 310          locations = config.token_location
 311  
 312      # Get the decode functions in the order specified by locations.
 313      # Each entry in this list is a tuple (<location>, <encoded-token-function>)
 314      get_encoded_token_functions = []
 315      for location in locations:
 316          if location == "cookies":
 317              get_encoded_token_functions.append(
 318                  (location, lambda: _decode_jwt_from_cookies(refresh))
 319              )
 320          elif location == "query_string":
 321              get_encoded_token_functions.append(
 322                  (location, _decode_jwt_from_query_string)
 323              )
 324          elif location == "headers":
 325              get_encoded_token_functions.append((location, _decode_jwt_from_headers))
 326          elif location == "json":
 327              get_encoded_token_functions.append(
 328                  (location, lambda: _decode_jwt_from_json(refresh))
 329              )
 330          else:
 331              raise RuntimeError(f"'{location}' is not a valid location")
 332  
 333      # Try to find the token from one of these locations. It only needs to exist
 334      # in one place to be valid (not every location).
 335      errors = []
 336      decoded_token = None
 337      for location, get_encoded_token_function in get_encoded_token_functions:
 338          try:
 339              encoded_token, csrf_token = get_encoded_token_function()
 340              decoded_token = decode_token(encoded_token, csrf_token)
 341              jwt_location = location
 342              jwt_header = get_unverified_jwt_headers(encoded_token)
 343              break
 344          except NoAuthorizationError as e:
 345              errors.append(str(e))
 346  
 347      # Do some work to make a helpful and human readable error message if no
 348      # token was found in any of the expected locations.
 349      if not decoded_token:
 350          if len(locations) > 1:
 351              err_msg = "Missing JWT in {start_locs} or {end_locs} ({details})".format(
 352                  start_locs=", ".join(locations[:-1]),
 353                  end_locs=locations[-1],
 354                  details="; ".join(errors),
 355              )
 356              raise NoAuthorizationError(err_msg)
 357          else:
 358              raise NoAuthorizationError(errors[0])
 359  
 360      # Additional verifications provided by this extension
 361      if verify_type:
 362          verify_token_type(decoded_token, refresh)
 363  
 364      if fresh:
 365          _verify_token_is_fresh(jwt_header, decoded_token)
 366  
 367      if not skip_revocation_check:
 368          verify_token_not_blocklisted(jwt_header, decoded_token)
 369  
 370      custom_verification_for_token(jwt_header, decoded_token)
 371  
 372      return decoded_token, jwt_header, jwt_location
 373