From b2fb35452adbd70ef9d5df2534217af0b5e0b276 Mon Sep 17 00:00:00 2001 From: Cole Date: Sun, 25 Aug 2019 15:35:40 -0400 Subject: [PATCH] adding csrf check form functionality --- docs/options.rst | 8 +++++ docs/tokens_in_cookies.rst | 36 ++++++++++++++++++++ flask_jwt_extended/config.py | 12 +++++++ flask_jwt_extended/jwt_manager.py | 3 ++ flask_jwt_extended/view_decorators.py | 6 +++- tests/test_cookies.py | 48 +++++++++++++++++++++++++-- 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/docs/options.rst b/docs/options.rst index 6697b0e2..b72fa480 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -157,6 +157,14 @@ These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies and Only applicable if ``JWT_CSRF_IN_COOKIES`` is ``True`` ``JWT_REFRESH_CSRF_COOKIE_PATH`` Path of the CSRF refresh cookie. Defaults to ``'/'``. Only applicable if ``JWT_CSRF_IN_COOKIES`` is ``True`` +``JWT_CSRF_CHECK_FORM`` When no CSRF token can be found in the header, check the form data. Defaults to + ``False``. +``JWT_ACCESS_CSRF_FIELD_NAME`` Name of the form field that should contain the CSRF double submit value for access + tokens when no header is present. Only applicable if ``JWT_CSRF_CHECK_FORM`` is + ``True``. Defaults to ``'csrf_token'``. +``JWT_REFRESH_CSRF_FIELD_NAME`` Name of the form field that should contain the CSRF double submit value for refresh + tokens when no header is present. Only applicable if ``JWT_CSRF_CHECK_FORM`` is + ``True``. Defaults to ``'csrf_token'``. ================================= ========================================= diff --git a/docs/tokens_in_cookies.rst b/docs/tokens_in_cookies.rst index 0a2dbb5b..1bebb79e 100644 --- a/docs/tokens_in_cookies.rst +++ b/docs/tokens_in_cookies.rst @@ -78,3 +78,39 @@ to the caller, like such: set_access_cookies(resp, access_token) set_refresh_cookies(resp, refresh_token) return resp, 200 + + +Typically JWT is used with API servers using JSON payloads, often via AJAX. However you may have an endpoint that +receives POST requests directly from an HTML form. Without AJAX, you can't set the CSRF headers to pass your token to +the server. In this scenario you can send the token in a hidden form field. To accomplish this, first configure JWT to +check the form for CSRF tokens. Now it's not necessary to send the csrf in a separate cookie, you can render it +directly into your HTML template: + + +.. code-block:: python + + app.config['JWT_CSRF_CHECK_FORM'] = True + + ... + + @app.route('/protected', methods=['GET', 'POST']) + @jwt_optional + def protected(): + if request.method == "GET": + return render_template( + "form.html", csrf_token=(get_raw_jwt() or {}).get("csrf") + ) + else: + # handle POST request + current_user = get_jwt_identity() + + +In the HTML template, pass the token back to the server via a hidden input. + +.. code-block:: html + +
+ ... + + +
diff --git a/flask_jwt_extended/config.py b/flask_jwt_extended/config.py index 0f49c2d8..aa60c3a8 100644 --- a/flask_jwt_extended/config.py +++ b/flask_jwt_extended/config.py @@ -184,6 +184,18 @@ def refresh_csrf_header_name(self): return self._get_depreciated_csrf_header_name() or \ current_app.config['JWT_REFRESH_CSRF_HEADER_NAME'] + @property + def csrf_check_form(self): + return current_app.config['JWT_CSRF_CHECK_FORM'] + + @property + def access_csrf_field_name(self): + return current_app.config['JWT_ACCESS_CSRF_FIELD_NAME'] + + @property + def refresh_csrf_field_name(self): + return current_app.config['JWT_REFRESH_CSRF_FIELD_NAME'] + @property def access_expires(self): delta = current_app.config['JWT_ACCESS_TOKEN_EXPIRES'] diff --git a/flask_jwt_extended/jwt_manager.py b/flask_jwt_extended/jwt_manager.py index 96d28a53..ab488a0f 100644 --- a/flask_jwt_extended/jwt_manager.py +++ b/flask_jwt_extended/jwt_manager.py @@ -196,6 +196,9 @@ def _set_default_configuration_options(app): app.config.setdefault('JWT_REFRESH_CSRF_COOKIE_NAME', 'csrf_refresh_token') app.config.setdefault('JWT_ACCESS_CSRF_COOKIE_PATH', '/') app.config.setdefault('JWT_REFRESH_CSRF_COOKIE_PATH', '/') + app.config.setdefault('JWT_CSRF_CHECK_FORM', False) + app.config.setdefault('JWT_ACCESS_CSRF_FIELD_NAME', 'csrf_token') + app.config.setdefault('JWT_REFRESH_CSRF_FIELD_NAME', 'csrf_token') # How long an a token will live before they expire. app.config.setdefault('JWT_ACCESS_TOKEN_EXPIRES', datetime.timedelta(minutes=15)) diff --git a/flask_jwt_extended/view_decorators.py b/flask_jwt_extended/view_decorators.py index f95a47e2..5f8df774 100644 --- a/flask_jwt_extended/view_decorators.py +++ b/flask_jwt_extended/view_decorators.py @@ -198,9 +198,11 @@ def _decode_jwt_from_cookies(request_type): if request_type == 'access': cookie_key = config.access_cookie_name csrf_header_key = config.access_csrf_header_name + csrf_field_key = config.access_csrf_field_name else: cookie_key = config.refresh_cookie_name csrf_header_key = config.refresh_csrf_header_name + csrf_field_key = config.refresh_csrf_field_name encoded_token = request.cookies.get(cookie_key) if not encoded_token: @@ -208,8 +210,10 @@ def _decode_jwt_from_cookies(request_type): if config.csrf_protect and request.method in config.csrf_request_methods: csrf_value = request.headers.get(csrf_header_key, None) + if not csrf_value and config.csrf_check_form: + csrf_value = request.form.get(csrf_field_key, None) if not csrf_value: - raise CSRFError("Missing CSRF token in headers") + raise CSRFError("Missing CSRF token") else: csrf_value = None diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 62aa7bdc..cad67b31 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -139,7 +139,7 @@ def test_default_access_csrf_protection(app, options): # Test you cannot post without the additional csrf protection response = test_client.post(post_url) assert response.status_code == 401 - assert response.get_json() == {'msg': 'Missing CSRF token in headers'} + assert response.get_json() == {'msg': 'Missing CSRF token'} # Test that you can post with the csrf double submit value csrf_headers = {'X-CSRF-TOKEN': csrf_token} @@ -201,6 +201,48 @@ def test_csrf_with_custom_header_names(app, options): assert response.get_json() == {'foo': 'bar'} +@pytest.mark.parametrize("options", [ + ('/refresh_token', 'csrf_refresh_token', '/post_refresh_protected'), + ('/access_token', 'csrf_access_token', '/post_protected') +]) +def test_csrf_with_default_form_field(app, options): + app.config['JWT_CSRF_CHECK_FORM'] = True + test_client = app.test_client() + auth_url, csrf_cookie_name, post_url = options + + # Get the jwt cookies and csrf double submit tokens + response = test_client.get(auth_url) + csrf_token = _get_cookie_from_response(response, csrf_cookie_name)[csrf_cookie_name] + + # Test that you can post with the csrf double submit value + csrf_data = {'csrf_token': csrf_token} + response = test_client.post(post_url, data=csrf_data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} + + +@pytest.mark.parametrize("options", [ + ('/refresh_token', 'csrf_refresh_token', '/post_refresh_protected'), + ('/access_token', 'csrf_access_token', '/post_protected') +]) +def test_csrf_with_custom_form_field(app, options): + app.config['JWT_CSRF_CHECK_FORM'] = True + app.config['JWT_ACCESS_CSRF_FIELD_NAME'] = 'FOO' + app.config['JWT_REFRESH_CSRF_FIELD_NAME'] = 'FOO' + test_client = app.test_client() + auth_url, csrf_cookie_name, post_url = options + + # Get the jwt cookies and csrf double submit tokens + response = test_client.get(auth_url) + csrf_token = _get_cookie_from_response(response, csrf_cookie_name)[csrf_cookie_name] + + # Test that you can post with the csrf double submit value + csrf_data = {'FOO': csrf_token} + response = test_client.post(post_url, data=csrf_data) + assert response.status_code == 200 + assert response.get_json() == {'foo': 'bar'} + + @pytest.mark.parametrize("options", [ ('/refresh_token', 'csrf_refresh_token', '/refresh_protected', '/post_refresh_protected'), # nopep8 ('/access_token', 'csrf_access_token', '/protected', '/post_protected') @@ -222,7 +264,7 @@ def test_custom_csrf_methods(app, options): # Insure GET requests now fail without csrf response = test_client.get(get_url) assert response.status_code == 401 - assert response.get_json() == {'msg': 'Missing CSRF token in headers'} + assert response.get_json() == {'msg': 'Missing CSRF token'} # Insure GET requests now succeed with csrf csrf_headers = {'X-CSRF-TOKEN': csrf_token} @@ -430,4 +472,4 @@ def test_jwt_optional_with_csrf_enabled(app): csrf_token = csrf_cookie['csrf_access_token'] response = test_client.post('/optional_post_protected') assert response.status_code == 401 - assert response.get_json() == {'msg': 'Missing CSRF token in headers'} + assert response.get_json() == {'msg': 'Missing CSRF token'}