diff --git a/docs/index.rst b/docs/index.rst index 62dd82a4..3fe40b10 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,4 +24,5 @@ Flask-JWT-Extended's Documentation blacklist_and_token_revoking tokens_in_cookies tokens_in_query_string + tokens_in_json_body api diff --git a/docs/options.rst b/docs/options.rst index 612edd2d..3a3c3824 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -16,7 +16,7 @@ General Options: ================================= ========================================= ``JWT_TOKEN_LOCATION`` Where to look for a JWT when processing a request. The - options are ``'headers'``, ``'cookies'``, or ``'query_string'``. You can pass + options are ``'headers'``, ``'cookies'``, ``'query_string'``, or ``'json'``. You can pass in a list to check more then one location, such as: ``['headers', 'cookies']``. Defaults to ``'headers'`` ``JWT_ACCESS_TOKEN_EXPIRES`` How long an access token should live before it expires. This @@ -101,6 +101,19 @@ These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies. ``JWT_COOKIE_CSRF_PROTECT`` Enable/disable CSRF protection when using cookies. Defaults to ``True``. ================================= ========================================= + +Json Body Options: +~~~~~~~~~~~~~~~~~~~~~ +These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use json data. + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +================================= ========================================= +``JWT_JSON_KEY`` Key to look for in the body of an `application/json` request. Defaults to ``'access_token'`` +``JWT_REFRESH_JSON_KEY`` Key to look for the refresh token in an `application/json` request. Defaults to ``'refresh_token'`` +================================= ========================================= + + Cross Site Request Forgery Options: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies and diff --git a/docs/tokens_in_json_body.rst b/docs/tokens_in_json_body.rst new file mode 100644 index 00000000..5eb1e209 --- /dev/null +++ b/docs/tokens_in_json_body.rst @@ -0,0 +1,13 @@ +JWT in JSON Body +================ + +You can also pass the token as an attribute in the body of an `application/json` request. +However, since the body is meaningless in a `GET` request, this is mostly useful for +protecting routes that only accept `POST`, `PATCH`, or `DELETE` methods. + +That is to say, the `GET` method will become essentially unauthorized in any protected route +if you only use this lookup method. + +If you decide to use JWTs in the request body, here is an example of how it might look: + +.. literalinclude:: ../examples/jwt_in_json.py diff --git a/examples/jwt_in_json.py b/examples/jwt_in_json.py new file mode 100644 index 00000000..0957dedd --- /dev/null +++ b/examples/jwt_in_json.py @@ -0,0 +1,39 @@ +from flask import Flask, jsonify, request + +from flask_jwt_extended import ( + JWTManager, jwt_required, create_access_token, +) + +app = Flask(__name__) + +# IMPORTANT: Body is meaningless in GET requests, so using json +# as the only lookup method means that the GET method will become +# unauthorized in any protected route, as there's no body to look for. + +app.config['JWT_TOKEN_LOCATION'] = ['json'] +app.config['JWT_SECRET_KEY'] = 'super-secret' # Change this! + +jwt = JWTManager(app) + + +@app.route('/login', methods=['POST']) +def login(): + username = request.json.get('username', None) + password = request.json.get('password', None) + if username != 'test' or password != 'test': + return jsonify({"msg": "Bad username or password"}), 401 + + access_token = create_access_token(identity=username) + return jsonify(access_token=access_token) + + +# The default attribute name where the JWT is looked for is `access_token`, +# and can be changed with the JWT_JSON_KEY option. +# Notice how the route is unreachable with GET requests. +@app.route('/protected', methods=['GET', 'POST']) +@jwt_required +def protected(): + return jsonify(foo='bar') + +if __name__ == '__main__': + app.run() diff --git a/flask_jwt_extended/config.py b/flask_jwt_extended/config.py index 0a04b24c..34a51d12 100644 --- a/flask_jwt_extended/config.py +++ b/flask_jwt_extended/config.py @@ -46,11 +46,11 @@ def token_location(self): locations = [locations] if not locations: raise RuntimeError('JWT_TOKEN_LOCATION must contain at least one ' - 'of "headers", "cookies", or "query_string"') + 'of "headers", "cookies", "query_string", or "json"') for location in locations: - if location not in ('headers', 'cookies', 'query_string'): + if location not in ('headers', 'cookies', 'query_string', 'json'): raise RuntimeError('JWT_TOKEN_LOCATION can only contain ' - '"headers", "cookies", or "query_string"') + '"headers", "cookies", "query_string", or "json"') return locations @property @@ -65,6 +65,10 @@ def jwt_in_headers(self): def jwt_in_query_string(self): return 'query_string' in self.token_location + @property + def jwt_in_json(self): + return 'json' in self.token_location + @property def header_name(self): name = current_app.config['JWT_HEADER_NAME'] @@ -112,6 +116,14 @@ def session_cookie(self): def cookie_samesite(self): return current_app.config['JWT_COOKIE_SAMESITE'] + @property + def json_key(self): + return current_app.config['JWT_JSON_KEY'] + + @property + def refresh_json_key(self): + return current_app.config['JWT_REFRESH_JSON_KEY'] + @property def csrf_protect(self): return self.jwt_in_cookies and current_app.config['JWT_COOKIE_CSRF_PROTECT'] diff --git a/flask_jwt_extended/jwt_manager.py b/flask_jwt_extended/jwt_manager.py index d94f8d2f..db03ae8f 100644 --- a/flask_jwt_extended/jwt_manager.py +++ b/flask_jwt_extended/jwt_manager.py @@ -151,6 +151,10 @@ def _set_default_configuration_options(app): app.config.setdefault('JWT_SESSION_COOKIE', True) app.config.setdefault('JWT_COOKIE_SAMESITE', None) + # Option for JWTs when the TOKEN_LOCATION is json + app.config.setdefault('JWT_JSON_KEY', 'access_token') + app.config.setdefault('JWT_REFRESH_JSON_KEY', 'refresh_token') + # Options for using double submit csrf protection app.config.setdefault('JWT_COOKIE_CSRF_PROTECT', True) app.config.setdefault('JWT_CSRF_METHODS', ['POST', 'PUT', 'PATCH', 'DELETE']) diff --git a/flask_jwt_extended/view_decorators.py b/flask_jwt_extended/view_decorators.py index 3505ef54..9bd2535d 100644 --- a/flask_jwt_extended/view_decorators.py +++ b/flask_jwt_extended/view_decorators.py @@ -2,6 +2,8 @@ from datetime import datetime from calendar import timegm +from werkzeug.exceptions import BadRequest + from flask import request try: from flask import _app_ctx_stack as ctx_stack @@ -220,6 +222,25 @@ def _decode_jwt_from_query_string(): return decode_token(encoded_token) +def _decode_jwt_from_json(request_type): + if request.content_type != 'application/json': + raise NoAuthorizationError('Invalid content-type. Must be application/json.') + + if request_type == 'access': + token_key = config.json_key + else: + token_key = config.refresh_json_key + + try: + encoded_token = request.json.get(token_key, None) + if not encoded_token: + raise BadRequest() + except BadRequest: + raise NoAuthorizationError('Missing "{}" key in json data.'.format(token_key)) + + return decode_token(encoded_token) + + def _decode_jwt_from_request(request_type): # All the places we can get a JWT from in this request decode_functions = [] @@ -229,6 +250,8 @@ def _decode_jwt_from_request(request_type): decode_functions.append(_decode_jwt_from_query_string) if config.jwt_in_headers: decode_functions.append(_decode_jwt_from_headers) + if config.jwt_in_json: + decode_functions.append(lambda: _decode_jwt_from_json(request_type)) # Try to find the token from one of these locations. It only needs to exist # in one place to be valid (not every location). diff --git a/tests/test_config.py b/tests/test_config.py index b3152427..24daeb62 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,6 +21,7 @@ def test_default_configs(app): assert config.token_location == ['headers'] assert config.jwt_in_query_string is False assert config.jwt_in_cookies is False + assert config.jwt_in_json is False assert config.jwt_in_headers is True assert config.header_name == 'Authorization' @@ -37,6 +38,9 @@ def test_default_configs(app): assert config.session_cookie is True assert config.cookie_samesite is None + assert config.json_key == 'access_token' + assert config.refresh_json_key == 'refresh_token' + assert config.csrf_protect is False assert config.csrf_request_methods == ['POST', 'PUT', 'PATCH', 'DELETE'] assert config.csrf_in_cookies is True @@ -69,9 +73,11 @@ def test_default_configs(app): def test_override_configs(app): - app.config['JWT_TOKEN_LOCATION'] = ['cookies', 'query_string'] + app.config['JWT_TOKEN_LOCATION'] = ['cookies', 'query_string', 'json'] app.config['JWT_HEADER_NAME'] = 'TestHeader' app.config['JWT_HEADER_TYPE'] = 'TestType' + app.config['JWT_JSON_KEY'] = 'TestKey' + app.config['JWT_REFRESH_JSON_KEY'] = 'TestRefreshKey' app.config['JWT_QUERY_STRING_NAME'] = 'banana' @@ -114,12 +120,15 @@ class CustomJSONEncoder(JSONEncoder): app.json_encoder = CustomJSONEncoder with app.test_request_context(): - assert config.token_location == ['cookies', 'query_string'] + assert config.token_location == ['cookies', 'query_string', 'json'] assert config.jwt_in_query_string is True assert config.jwt_in_cookies is True assert config.jwt_in_headers is False + assert config.jwt_in_json is True assert config.header_name == 'TestHeader' assert config.header_type == 'TestType' + assert config.json_key == 'TestKey' + assert config.refresh_json_key == 'TestRefreshKey' assert config.query_string_name == 'banana' diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 00000000..9ecc5693 --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,115 @@ +import pytest +from flask import Flask, jsonify + +from flask_jwt_extended import JWTManager, jwt_required, jwt_refresh_token_required, create_access_token, create_refresh_token +from tests.utils import get_jwt_manager + + +@pytest.fixture(scope='function') +def app(): + app = Flask(__name__) + app.config['JWT_SECRET_KEY'] = 'foobarbaz' + app.config['JWT_TOKEN_LOCATION'] = 'json' + JWTManager(app) + + @app.route('/protected', methods=['POST']) + @jwt_required + def access_protected(): + return jsonify(foo='bar') + + @app.route('/refresh', methods=['POST']) + @jwt_refresh_token_required + def refresh_protected(): + return jsonify(foo='bar') + + return app + + +def test_content_type(app): + test_client = app.test_client() + + with app.test_request_context(): + access_token = create_access_token('username') + refresh_token = create_refresh_token('username') + + data = {'access_token': access_token} + response = test_client.post('/protected', data=data) + + assert response.status_code == 401 + assert response.get_json() == {'msg': 'Invalid content-type. Must be application/json.'} + + data = {'refresh_token': refresh_token} + response = test_client.post('/refresh', data=data) + + assert response.status_code == 401 + assert response.get_json() == {'msg': 'Invalid content-type. Must be application/json.'} + + +def test_custom_body_key(app): + app.config['JWT_JSON_KEY'] = 'Foo' + app.config['JWT_REFRESH_JSON_KEY'] = 'Bar' + test_client = app.test_client() + + with app.test_request_context(): + access_token = create_access_token('username') + refresh_token = create_refresh_token('username') + + # Ensure 'default' keys no longer work + data = {'access_token': access_token} + response = test_client.post('/protected', json=data) + assert response.status_code == 401 + assert response.get_json() == {'msg': 'Missing "Foo" key in json data.'} + + + data = {'refresh_token': refresh_token} + response = test_client.post('/refresh', json=data) + assert response.status_code == 401 + assert response.get_json() == {'msg': 'Missing "Bar" key in json data.'} + + # Ensure new keys do work + data = {'Foo': access_token} + response = test_client.post('/protected', json=data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} + + data = {'Bar': refresh_token} + response = test_client.post('/refresh', json=data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} + + +def test_missing_keys(app): + test_client = app.test_client() + jwtM = get_jwt_manager(app) + headers = {'content-type': 'application/json'} + + # Ensure 'default' no json response + response = test_client.post('/protected', headers=headers) + assert response.status_code == 401 + assert response.get_json() == {'msg': 'Missing "access_token" key in json data.'} + + # Test custom no json response + @jwtM.unauthorized_loader + def custom_response(err_str): + return jsonify(foo='bar'), 201 + + response = test_client.post('/protected', headers=headers) + assert response.status_code == 201 + assert response.get_json() == {'foo': "bar"} + +def test_defaults(app): + test_client = app.test_client() + + with app.test_request_context(): + access_token = create_access_token('username') + refresh_token = create_refresh_token('username') + + data = {'access_token': access_token} + response = test_client.post('/protected', json=data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} + + data = {'refresh_token': refresh_token} + response = test_client.post('/refresh', json=data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} diff --git a/tests/test_multiple_token_locations.py b/tests/test_multiple_token_locations.py index 9dd7b83b..f55ac785 100644 --- a/tests/test_multiple_token_locations.py +++ b/tests/test_multiple_token_locations.py @@ -10,7 +10,7 @@ def app(): app = Flask(__name__) app.config['JWT_SECRET_KEY'] = 'foobarbaz' - app.config['JWT_TOKEN_LOCATION'] = ['headers', 'cookies', 'query_string'] + app.config['JWT_TOKEN_LOCATION'] = ['headers', 'cookies', 'query_string', 'json'] JWTManager(app) @app.route('/cookie_login', methods=['GET']) @@ -20,7 +20,7 @@ def cookie_login(): set_access_cookies(resp, access_token) return resp - @app.route('/protected', methods=['GET']) + @app.route('/protected', methods=['GET', 'POST']) @jwt_required def access_protected(): return jsonify(foo='bar') @@ -58,6 +58,18 @@ def test_query_string_access(app): assert response.get_json() == {'foo': 'bar'} +def test_json_access(app): + test_client = app.test_client() + + with app.test_request_context(): + access_token = create_access_token('username') + + data = {'access_token': access_token} + response = test_client.post('/protected', json=data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} + + @pytest.mark.parametrize("options", [ (['cookies', 'headers'], ('Missing JWT in cookies or headers (Missing cookie ' '"access_token_cookie"; Missing Authorization Header)')),