Skip to content

Commit

Permalink
Merge pull request #173 from luord/json-lookup
Browse files Browse the repository at this point in the history
Added a fourth lookup within the json body
  • Loading branch information
vimalloc authored Jul 21, 2018
2 parents 131c6f4 + 5adb219 commit e5e37d7
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 8 deletions.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 14 additions & 1 deletion docs/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/tokens_in_json_body.rst
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions examples/jwt_in_json.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 15 additions & 3 deletions flask_jwt_extended/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']
Expand Down Expand Up @@ -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']
Expand Down
4 changes: 4 additions & 0 deletions flask_jwt_extended/jwt_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
23 changes: 23 additions & 0 deletions flask_jwt_extended/view_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand All @@ -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).
Expand Down
13 changes: 11 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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'

Expand Down
115 changes: 115 additions & 0 deletions tests/test_json.py
Original file line number Diff line number Diff line change
@@ -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'}
16 changes: 14 additions & 2 deletions tests/test_multiple_token_locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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')
Expand Down Expand Up @@ -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)')),
Expand Down

0 comments on commit e5e37d7

Please sign in to comment.