From 71b2e0548260c3581fc336bb985282d342e21529 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 10 Jun 2020 23:03:28 +0300 Subject: [PATCH 1/5] Add split cookies --- sanic_jwt/authentication.py | 14 ++++----- sanic_jwt/configuration.py | 3 ++ sanic_jwt/responses.py | 13 ++++++-- tests/test_endpoints_cookies.py | 54 +++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/sanic_jwt/authentication.py b/sanic_jwt/authentication.py index 3960390..fec355e 100644 --- a/sanic_jwt/authentication.py +++ b/sanic_jwt/authentication.py @@ -7,12 +7,8 @@ import jwt from . import exceptions, utils -from .exceptions import ( - InvalidCustomClaimError, - InvalidVerification, - InvalidVerificationError, - SanicJWTException -) +from .exceptions import (InvalidCustomClaimError, InvalidVerification, + InvalidVerificationError, SanicJWTException) logger = logging.getLogger(__name__) claim_label = {"iss": "issuer", "iat": "iat", "nbf": "nbf", "aud": "audience"} @@ -274,7 +270,11 @@ def _get_token_from_cookies(self, request, refresh_token): else: cookie_token_name_key = "cookie_access_token_name" cookie_token_name = getattr(self.config, cookie_token_name_key) - return request.cookies.get(cookie_token_name(), None) + token = request.cookies.get(cookie_token_name(), None) + if not refresh_token and self.config.cookie_split() and token: + signature_name = self.config.cookie_split_signature_name() + token += "." + request.cookies.get(signature_name, '') + return token def _get_token_from_headers(self, request, refresh_token): """ diff --git a/sanic_jwt/configuration.py b/sanic_jwt/configuration.py index 11760fb..b7be3c3 100644 --- a/sanic_jwt/configuration.py +++ b/sanic_jwt/configuration.py @@ -23,6 +23,8 @@ "cookie_path": "/", "cookie_refresh_token_name": "refresh_token", "cookie_set": False, + "cookie_split": False, + "cookie_split_signature_name": "access_token_signature", "cookie_strict": True, "debug": False, "do_protection": True, @@ -55,6 +57,7 @@ aliases = { "cookie_access_token_name": "cookie_token_name", "secret": "public_key", + "cookie_split": "split_cookie", } ignore_keys = ( diff --git a/sanic_jwt/responses.py b/sanic_jwt/responses.py index 7c5ec1c..cbcd0e5 100644 --- a/sanic_jwt/responses.py +++ b/sanic_jwt/responses.py @@ -3,9 +3,9 @@ from .base import BaseDerivative -def _set_cookie(response, key, value, config): +def _set_cookie(response, key, value, config, force_httponly=None): response.cookies[key] = value - response.cookies[key]["httponly"] = config.cookie_httponly() + response.cookies[key]["httponly"] = config.cookie_httponly() if force_httponly is None else force_httponly response.cookies[key]["path"] = config.cookie_path() domain = config.cookie_domain() @@ -30,7 +30,14 @@ def get_token_response( if config.cookie_set(): key = config.cookie_access_token_name() - _set_cookie(response, key, access_token, config) + + if config.cookie_split(): + signature_name = config.cookie_split_signature_name() + header_payload, signature = access_token.rsplit('.', maxsplit=1) + _set_cookie(response, key, header_payload, config, force_httponly=False) + _set_cookie(response, signature_name, signature, config, force_httponly=True) + else: + _set_cookie(response, key, access_token, config) if refresh_token and config.refresh_token_enabled(): key = config.cookie_refresh_token_name() diff --git a/tests/test_endpoints_cookies.py b/tests/test_endpoints_cookies.py index aa0a1eb..e1d49d7 100644 --- a/tests/test_endpoints_cookies.py +++ b/tests/test_endpoints_cookies.py @@ -1,11 +1,11 @@ import binascii import os +import jwt import pytest + from sanic import Sanic from sanic.response import json - -import jwt from sanic_jwt import Initialize, protected @@ -425,3 +425,53 @@ def test_config_with_cookie_path(users, authenticate): cookie = response.raw_cookies.get("access_token") assert cookie.path == path + +def test_with_split_cookie(app): + sanic_app, sanicjwt = app + sanicjwt.config.cookie_set.update(True) + sanicjwt.config.cookie_split.update(True) + key = sanicjwt.config.cookie_access_token_name() + sig_key = sanicjwt.config.cookie_split_signature_name() + + _, response = sanic_app.test_client.post( + "/auth", + json={"username": "user1", "password": "abcxyz"}, + raw_cookies=True, + ) + token_cookie = response.raw_cookies.get(key) + signature_cookie = response.raw_cookies.get(sig_key) + + assert token_cookie + assert signature_cookie + + raw_token_cookie, raw_signature_cookie = [value.decode(response.headers.encoding) for key, value in response.headers.raw if key.lower() == b'set-cookie'] + + assert raw_token_cookie + assert raw_signature_cookie + + assert token_cookie.value.count('.') == 1 + assert signature_cookie.value.count('.') == 0 + assert "HttpOnly" not in raw_token_cookie + assert "HttpOnly" in raw_signature_cookie + + access_token = ".".join([token_cookie.value, signature_cookie.value]) + + payload_cookie = jwt.decode( + access_token, + sanicjwt.config.secret(), + algorithms=sanicjwt.config.algorithm(), + ) + + assert isinstance(payload_cookie, dict) + assert sanicjwt.config.user_id() in payload_cookie + + _, response = sanic_app.test_client.get( + "/auth/verify", + cookies={ + sanicjwt.config.cookie_access_token_name(): token_cookie.value, + sanicjwt.config.cookie_split_signature_name(): signature_cookie.value, + }, + ) + + assert response.status == 200 + assert response.json.get("valid") == True From 47399fc6199fa314d9ae9dc584f62c8c6f315348 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 10 Jun 2020 23:03:39 +0300 Subject: [PATCH 2/5] Add split cookies --- example/basic_with_split_cookies.py | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 example/basic_with_split_cookies.py diff --git a/example/basic_with_split_cookies.py b/example/basic_with_split_cookies.py new file mode 100644 index 0000000..df61fa9 --- /dev/null +++ b/example/basic_with_split_cookies.py @@ -0,0 +1,56 @@ +""" +This is taken from "Simple Usage" page in the docs: +http://sanic-jwt.readthedocs.io/en/latest/pages/simpleusage.html +""" + +from sanic import Sanic +from sanic_jwt import exceptions, initialize + + +class User: + def __init__(self, id, username, password): + self.user_id = id + self.username = username + self.password = password + + def __repr__(self): + return "User(id='{}')".format(self.user_id) + + def to_dict(self): + return {"user_id": self.user_id, "username": self.username} + + +users = [User(1, "user1", "abcxyz"), User(2, "user2", "abcxyz")] + +username_table = {u.username: u for u in users} +userid_table = {u.user_id: u for u in users} + + +async def authenticate(request, *args, **kwargs): + username = request.json.get("username", None) + password = request.json.get("password", None) + + if not username or not password: + raise exceptions.AuthenticationFailed("Missing username or password.") + + user = username_table.get(username, None) + if user is None: + raise exceptions.AuthenticationFailed("User not found.") + + if password != user.password: + raise exceptions.AuthenticationFailed("Password is incorrect.") + + return user + + +app = Sanic() +initialize( + app, + authenticate=authenticate, + cookie_set=True, + cookie_split=True, +) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8888) From a21f7d356c6395434a8fa7ca8aaba6d8b974e775 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 11 Jun 2020 00:48:19 +0300 Subject: [PATCH 3/5] Add more cookie options --- example/basic_with_user_secrets.py | 1 + example/inline_tokens_and_verification.py | 8 ++- sanic_jwt/authentication.py | 25 ++++++--- sanic_jwt/configuration.py | 3 ++ sanic_jwt/decorators.py | 4 +- sanic_jwt/endpoints.py | 4 +- sanic_jwt/exceptions.py | 4 +- sanic_jwt/responses.py | 34 +++++++++--- tests/conftest.py | 1 + tests/test_async_options.py | 2 +- tests/test_claims.py | 1 + tests/test_complete_authentication.py | 2 +- tests/test_custom_claims.py | 2 +- tests/test_decorators.py | 4 +- tests/test_decorators_override_config.py | 2 +- tests/test_endpoints_auth.py | 3 +- tests/test_endpoints_basic.py | 3 +- tests/test_endpoints_cbv.py | 2 +- tests/test_endpoints_cookies.py | 65 +++++++++++++++++++++-- tests/test_endpoints_query_string.py | 2 +- tests/test_extend_payload.py | 2 +- tests/test_initialize.py | 10 +++- tests/test_me_invalid.py | 2 +- tests/test_user_secret.py | 6 +-- 24 files changed, 145 insertions(+), 47 deletions(-) diff --git a/example/basic_with_user_secrets.py b/example/basic_with_user_secrets.py index f1e726f..f749af3 100644 --- a/example/basic_with_user_secrets.py +++ b/example/basic_with_user_secrets.py @@ -48,6 +48,7 @@ async def retrieve_user_secret(user_id): print(f"{user_id=}") return f"user_id|{user_id}" + app = Sanic(__name__) Initialize( app, diff --git a/example/inline_tokens_and_verification.py b/example/inline_tokens_and_verification.py index 38aa867..931fddf 100644 --- a/example/inline_tokens_and_verification.py +++ b/example/inline_tokens_and_verification.py @@ -36,7 +36,9 @@ async def run(): payload = await app.auth.verify_token(token, return_payload=True) try: - is_verified = await app.auth.verify_token(token, custom_claims=[UserIsPrime]) + is_verified = await app.auth.verify_token( + token, custom_claims=[UserIsPrime] + ) except exceptions.InvalidCustomClaimError: is_verified = False finally: @@ -50,7 +52,9 @@ async def run(): payload = await app.auth.verify_token(token, return_payload=True) try: - is_verified = await app.auth.verify_token(token, custom_claims=[UserIsPrime]) + is_verified = await app.auth.verify_token( + token, custom_claims=[UserIsPrime] + ) except exceptions.InvalidCustomClaimError: is_verified = False finally: diff --git a/sanic_jwt/authentication.py b/sanic_jwt/authentication.py index fec355e..a9a2a59 100644 --- a/sanic_jwt/authentication.py +++ b/sanic_jwt/authentication.py @@ -7,8 +7,12 @@ import jwt from . import exceptions, utils -from .exceptions import (InvalidCustomClaimError, InvalidVerification, - InvalidVerificationError, SanicJWTException) +from .exceptions import ( + InvalidCustomClaimError, + InvalidVerification, + InvalidVerificationError, + SanicJWTException +) logger = logging.getLogger(__name__) claim_label = {"iss": "issuer", "iat": "iat", "nbf": "nbf", "aud": "audience"} @@ -125,7 +129,9 @@ async def retrieve_user(self, *args, **kwargs): class Authentication(BaseAuthentication): - async def _check_authentication(self, request, request_args, request_kwargs): + async def _check_authentication( + self, request, request_args, request_kwargs + ): """ Checks a request object to determine if that request contains a valid, and authenticated JWT. @@ -247,13 +253,14 @@ async def _get_secret(self, token=None, payload=None, encode=False): if self.config.user_secret_enabled(): if not payload: algorithm = self._get_algorithm() - payload = jwt.decode(token, verify=False, - algorithms=[algorithm]) + payload = jwt.decode( + token, verify=False, algorithms=[algorithm] + ) user_id = payload.get("user_id") return await utils.call( self.retrieve_user_secret, user_id=user_id, - encode=self._is_asymmetric and encode + encode=self._is_asymmetric and encode, ) if self._is_asymmetric and encode: @@ -273,7 +280,7 @@ def _get_token_from_cookies(self, request, refresh_token): token = request.cookies.get(cookie_token_name(), None) if not refresh_token and self.config.cookie_split() and token: signature_name = self.config.cookie_split_signature_name() - token += "." + request.cookies.get(signature_name, '') + token += "." + request.cookies.get(signature_name, "") return token def _get_token_from_headers(self, request, refresh_token): @@ -516,7 +523,9 @@ async def is_authenticated(self, request): async def retrieve_refresh_token_from_request(self, request): return await self._get_refresh_token(request) - async def verify_token(self, token, return_payload=False, custom_claims=None): + async def verify_token( + self, token, return_payload=False, custom_claims=None + ): """ Perform an inline verification of a token. """ diff --git a/sanic_jwt/configuration.py b/sanic_jwt/configuration.py index b7be3c3..4e1607e 100644 --- a/sanic_jwt/configuration.py +++ b/sanic_jwt/configuration.py @@ -19,9 +19,12 @@ "claim_nbf_delta": 0, "cookie_access_token_name": "access_token", "cookie_domain": "", + "cookie_expires": None, "cookie_httponly": True, + "cookie_max_age": 0, "cookie_path": "/", "cookie_refresh_token_name": "refresh_token", + "cookie_secure": False, "cookie_set": False, "cookie_split": False, "cookie_split_signature_name": "access_token_signature", diff --git a/sanic_jwt/decorators.py b/sanic_jwt/decorators.py index 449a66d..0bb290d 100644 --- a/sanic_jwt/decorators.py +++ b/sanic_jwt/decorators.py @@ -231,7 +231,9 @@ async def decorated_function(request, *args, **kwargs): f, request, *args, **kwargs ) # noqa - payload = await instance.auth.extract_payload(request, verify=False) + payload = await instance.auth.extract_payload( + request, verify=False + ) user = await utils.call( instance.auth.retrieve_user, request, payload ) diff --git a/sanic_jwt/endpoints.py b/sanic_jwt/endpoints.py index 2022826..889e006 100644 --- a/sanic_jwt/endpoints.py +++ b/sanic_jwt/endpoints.py @@ -147,7 +147,9 @@ async def post(self, request, *args, **kwargs): # TODO: # - Add more exceptions - payload = await self.instance.auth.extract_payload(request, verify=False) + payload = await self.instance.auth.extract_payload( + request, verify=False + ) try: user = await utils.call( diff --git a/sanic_jwt/exceptions.py b/sanic_jwt/exceptions.py index f897460..01e7436 100644 --- a/sanic_jwt/exceptions.py +++ b/sanic_jwt/exceptions.py @@ -117,9 +117,7 @@ class UserSecretNotImplemented(SanicJWTException): status_code = 500 def __init__( - self, - message="User secrets have not been enabled.", - **kwargs + self, message="User secrets have not been enabled.", **kwargs ): super().__init__(message, **kwargs) diff --git a/sanic_jwt/responses.py b/sanic_jwt/responses.py index cbcd0e5..1993b2b 100644 --- a/sanic_jwt/responses.py +++ b/sanic_jwt/responses.py @@ -2,15 +2,25 @@ from .base import BaseDerivative +COOKIE_OPTIONS = ( + ("domain", "cookie_domain"), + ("expires", "cookie_expires"), + ("max-age", "cookie_max_age"), + ("secure", "cookie_secure"), +) + def _set_cookie(response, key, value, config, force_httponly=None): response.cookies[key] = value - response.cookies[key]["httponly"] = config.cookie_httponly() if force_httponly is None else force_httponly + response.cookies[key]["httponly"] = ( + config.cookie_httponly() if force_httponly is None else force_httponly + ) response.cookies[key]["path"] = config.cookie_path() - domain = config.cookie_domain() - if domain: - response.cookies[key]["domain"] = domain + for item, option in COOKIE_OPTIONS: + value = getattr(config, option)() + if value: + response.cookies[key][item] = value class Responses(BaseDerivative): @@ -33,9 +43,19 @@ def get_token_response( if config.cookie_split(): signature_name = config.cookie_split_signature_name() - header_payload, signature = access_token.rsplit('.', maxsplit=1) - _set_cookie(response, key, header_payload, config, force_httponly=False) - _set_cookie(response, signature_name, signature, config, force_httponly=True) + header_payload, signature = access_token.rsplit( + ".", maxsplit=1 + ) + _set_cookie( + response, key, header_payload, config, force_httponly=False + ) + _set_cookie( + response, + signature_name, + signature, + config, + force_httponly=True, + ) else: _set_cookie(response, key, access_token, config) diff --git a/tests/conftest.py b/tests/conftest.py index f574a50..9fcc7f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,6 +73,7 @@ async def retrieve_user(request, payload, *args, **kwargs): def retrieve_user_secret(): async def retrieve_user_secret(user_id, **kwargs): return f"foobar<{user_id}>" + yield retrieve_user_secret diff --git a/tests/test_async_options.py b/tests/test_async_options.py index 694651e..d78f321 100644 --- a/tests/test_async_options.py +++ b/tests/test_async_options.py @@ -4,12 +4,12 @@ """ +import jwt import pytest from sanic import Blueprint, Sanic from sanic.response import text from sanic.views import HTTPMethodView -import jwt from sanic_jwt import Authentication, initialize, protected ALL_METHODS = ["GET", "OPTIONS"] diff --git a/tests/test_claims.py b/tests/test_claims.py index 0d6b287..6d814e5 100644 --- a/tests/test_claims.py +++ b/tests/test_claims.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import jwt + from freezegun import freeze_time diff --git a/tests/test_complete_authentication.py b/tests/test_complete_authentication.py index 4667f5f..888875e 100644 --- a/tests/test_complete_authentication.py +++ b/tests/test_complete_authentication.py @@ -1,10 +1,10 @@ from datetime import datetime, timedelta +import jwt import pytest from sanic import Sanic from sanic.response import json -import jwt from freezegun import freeze_time from sanic_jwt import Authentication, exceptions, Initialize, protected diff --git a/tests/test_custom_claims.py b/tests/test_custom_claims.py index 33837d9..77ce516 100644 --- a/tests/test_custom_claims.py +++ b/tests/test_custom_claims.py @@ -1,7 +1,7 @@ +import jwt import pytest from sanic import Sanic -import jwt from sanic_jwt import Claim, exceptions, Initialize diff --git a/tests/test_decorators.py b/tests/test_decorators.py index d42dc1a..158b769 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,6 +1,6 @@ from sanic import Sanic from sanic.blueprints import Blueprint -from sanic.response import json, text, html +from sanic.response import html, json, text from sanic_jwt import Initialize from sanic_jwt.decorators import inject_user, protected, scoped @@ -233,7 +233,7 @@ async def my_protected_static(request): request, response = sanic_app.test_client.get("/protected/static") assert response.status == 200 - assert response.body == b'Home' + assert response.body == b"Home" assert response.history assert response.history[0].status_code == 302 diff --git a/tests/test_decorators_override_config.py b/tests/test_decorators_override_config.py index 6ca2949..fcc767b 100644 --- a/tests/test_decorators_override_config.py +++ b/tests/test_decorators_override_config.py @@ -1,10 +1,10 @@ from datetime import datetime, timedelta +import jwt from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json -import jwt from freezegun import freeze_time from sanic_jwt import Initialize from sanic_jwt.decorators import protected diff --git a/tests/test_endpoints_auth.py b/tests/test_endpoints_auth.py index e213d70..4df965e 100644 --- a/tests/test_endpoints_auth.py +++ b/tests/test_endpoints_auth.py @@ -1,6 +1,5 @@ -import pytest - import jwt +import pytest @pytest.fixture diff --git a/tests/test_endpoints_basic.py b/tests/test_endpoints_basic.py index 2813ac0..bc90424 100644 --- a/tests/test_endpoints_basic.py +++ b/tests/test_endpoints_basic.py @@ -1,6 +1,5 @@ -import pytest - import jwt +import pytest def test_unprotected(app): diff --git a/tests/test_endpoints_cbv.py b/tests/test_endpoints_cbv.py index f5df42b..c27c7a3 100644 --- a/tests/test_endpoints_cbv.py +++ b/tests/test_endpoints_cbv.py @@ -1,8 +1,8 @@ +import jwt from sanic import Sanic from sanic.response import json from sanic.views import HTTPMethodView -import jwt from sanic_jwt import exceptions, Initialize from sanic_jwt.decorators import protected diff --git a/tests/test_endpoints_cookies.py b/tests/test_endpoints_cookies.py index e1d49d7..7461b8e 100644 --- a/tests/test_endpoints_cookies.py +++ b/tests/test_endpoints_cookies.py @@ -1,11 +1,12 @@ import binascii import os +from datetime import datetime import jwt import pytest - from sanic import Sanic from sanic.response import json + from sanic_jwt import Initialize, protected @@ -426,6 +427,7 @@ def test_config_with_cookie_path(users, authenticate): cookie = response.raw_cookies.get("access_token") assert cookie.path == path + def test_with_split_cookie(app): sanic_app, sanicjwt = app sanicjwt.config.cookie_set.update(True) @@ -444,13 +446,17 @@ def test_with_split_cookie(app): assert token_cookie assert signature_cookie - raw_token_cookie, raw_signature_cookie = [value.decode(response.headers.encoding) for key, value in response.headers.raw if key.lower() == b'set-cookie'] - + raw_token_cookie, raw_signature_cookie = [ + value.decode(response.headers.encoding) + for key, value in response.headers.raw + if key.lower() == b"set-cookie" + ] + assert raw_token_cookie assert raw_signature_cookie - assert token_cookie.value.count('.') == 1 - assert signature_cookie.value.count('.') == 0 + assert token_cookie.value.count(".") == 1 + assert signature_cookie.value.count(".") == 0 assert "HttpOnly" not in raw_token_cookie assert "HttpOnly" in raw_signature_cookie @@ -475,3 +481,52 @@ def test_with_split_cookie(app): assert response.status == 200 assert response.json.get("valid") == True + + +def test_with_cookie_normal(app): + sanic_app, sanicjwt = app + sanicjwt.config.cookie_set.update(True) + + _, response = sanic_app.test_client.post( + "/auth", + json={"username": "user1", "password": "abcxyz"}, + raw_cookies=True, + ) + + raw_token_cookie = [ + value.decode(response.headers.encoding) + for key, value in response.headers.raw + if key.lower() == b"set-cookie" + ][0] + + assert raw_token_cookie + assert "httponly" in raw_token_cookie.lower() + assert "expired" not in raw_token_cookie.lower() + assert "secure" not in raw_token_cookie.lower() + assert "max-age" not in raw_token_cookie.lower() + + +def test_with_cookie_config(app): + sanic_app, sanicjwt = app + sanicjwt.config.cookie_set.update(True) + sanicjwt.config.cookie_httponly.update(False) + sanicjwt.config.cookie_expires.update(datetime(2100, 1, 1)) + sanicjwt.config.cookie_secure.update(True) + sanicjwt.config.cookie_max_age.update(10) + + _, response = sanic_app.test_client.post( + "/auth", + json={"username": "user1", "password": "abcxyz"}, + raw_cookies=True, + ) + + raw_token_cookie = [ + value.decode(response.headers.encoding) + for key, value in response.headers.raw + if key.lower() == b"set-cookie" + ][0] + assert raw_token_cookie + assert "httponly" not in raw_token_cookie.lower() + assert "expires=fri, 01-jan-2100 00:00:00 gmt" in raw_token_cookie.lower() + assert "secure" in raw_token_cookie.lower() + assert "max-age=10" in raw_token_cookie.lower() diff --git a/tests/test_endpoints_query_string.py b/tests/test_endpoints_query_string.py index 71a2c55..756430a 100644 --- a/tests/test_endpoints_query_string.py +++ b/tests/test_endpoints_query_string.py @@ -1,11 +1,11 @@ import binascii import os +import jwt import pytest from sanic import Sanic from sanic.response import json -import jwt from sanic_jwt import Initialize, protected diff --git a/tests/test_extend_payload.py b/tests/test_extend_payload.py index 9946554..24ddb42 100644 --- a/tests/test_extend_payload.py +++ b/tests/test_extend_payload.py @@ -1,6 +1,6 @@ +import jwt from sanic import Sanic -import jwt from sanic_jwt import Authentication, exceptions, Initialize # import pytest diff --git a/tests/test_initialize.py b/tests/test_initialize.py index 7619576..618ab73 100644 --- a/tests/test_initialize.py +++ b/tests/test_initialize.py @@ -19,7 +19,10 @@ def test_store_refresh_token_ommitted(): with pytest.raises(exceptions.RefreshTokenNotImplemented): Initialize( - app, authenticate=lambda: True, refresh_token_enabled=True, retrieve_refresh_token=lambda: True + app, + authenticate=lambda: True, + refresh_token_enabled=True, + retrieve_refresh_token=lambda: True, ) @@ -28,7 +31,10 @@ def test_retrieve_refresh_token_ommitted(): with pytest.raises(exceptions.RefreshTokenNotImplemented): initialize( - app, authenticate=lambda: True, refresh_token_enabled=True, store_refresh_token=lambda: True + app, + authenticate=lambda: True, + refresh_token_enabled=True, + store_refresh_token=lambda: True, ) diff --git a/tests/test_me_invalid.py b/tests/test_me_invalid.py index 4e2f70c..75702e0 100644 --- a/tests/test_me_invalid.py +++ b/tests/test_me_invalid.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta +import jwt import pytest from sanic.response import json -import jwt from freezegun import freeze_time from sanic_jwt.decorators import protected diff --git a/tests/test_user_secret.py b/tests/test_user_secret.py index 983d7c0..aee4608 100644 --- a/tests/test_user_secret.py +++ b/tests/test_user_secret.py @@ -1,7 +1,7 @@ +import jwt import pytest from sanic import Sanic -import jwt from sanic_jwt import exceptions, Initialize @@ -9,9 +9,7 @@ def test_secret_not_enabled(): app = Sanic(__name__) with pytest.raises(exceptions.UserSecretNotImplemented): sanicjwt = Initialize( - app, - authenticate=lambda: {}, - user_secret_enabled=True, + app, authenticate=lambda: {}, user_secret_enabled=True, ) From b6362e52a0159267fb23e718acd2e04ac6d8126c Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 7 Jul 2020 01:09:08 +0300 Subject: [PATCH 4/5] Docs for new cookie stuff and fix tests --- docs/source/pages/configuration.rst | 40 +++++++++++++++++++++++++++++ docs/source/pages/protected.rst | 34 ++++++++++++++++++++++++ tests/test_endpoints_basic.py | 14 ++++------ tests/test_endpoints_cbv.py | 24 ++++++----------- tests/test_exceptions.py | 7 ++--- 5 files changed, 89 insertions(+), 30 deletions(-) diff --git a/docs/source/pages/configuration.rst b/docs/source/pages/configuration.rst index bce8bbd..df834e9 100644 --- a/docs/source/pages/configuration.rst +++ b/docs/source/pages/configuration.rst @@ -240,6 +240,14 @@ Settings | **Default**: ``''`` | +----------------- +``cookie_expires`` +----------------- + +| **Purpose**: If set, it will add an ``expires`` field to cookies. Should be a Python ``datetime`` object, and therefore should be set in Python code and not via environment variables. +| **Default**: ``None`` +| + ------------------- ``cookie_httponly`` ------------------- @@ -248,6 +256,14 @@ Settings | **Default**: ``True`` | +----------------- +``cookie_max_age`` +----------------- + +| **Purpose**: Should be a number. If it is greater than 0, then it will add a ``max-age`` field to the cookies. The number is expressed in seconds. +| **Default**: ``0`` +| + ----------------- ``cookie_path`` ----------------- @@ -264,6 +280,14 @@ Settings | **Default**: ``'refresh_token'`` | +----------------- +``cookie_secure`` +----------------- + +| **Purpose**: Adds a ``secure`` field to cookies. This should be used in production, but is disabled by default because it might lead to unintended frustrations in development. +| **Default**: ``False`` +| + -------------- ``cookie_set`` -------------- @@ -272,6 +296,22 @@ Settings | **Default**: ``False`` | +-------------- +``cookie_split`` +-------------- + +| **Purpose**: If ``True``, will enable split cookies (see :doc:`protecting routes with cookies ` for more details). Overrides ``cookie_httponly``. +| **Default**: ``False`` +| + +-------------- +``cookie_split_signature_name`` +-------------- + +| **Purpose**: The name of the cookie to be set for storing the signature part of the access token if using cookie based authentication with ``cookie_split`` turned on. +| **Default**: ``access_token_signature`` +| + ----------------- ``cookie_strict`` ----------------- diff --git a/docs/source/pages/protected.rst b/docs/source/pages/protected.rst index 36908b2..c68f76f 100644 --- a/docs/source/pages/protected.rst +++ b/docs/source/pages/protected.rst @@ -143,6 +143,40 @@ Now, Sanic JWT will reject any request that does not have a valid access token i If you are using cookies to pass JWTs, then it is recommended that you do **not** disable ``cookie_httponly``. Doing so means that any javascript running on the client can access the token. Bad news. +**Cookie splitting, and suggested best practices** + +Sanic JWT comes with the ability to split the access token into two cookies. The reason would be to allow cookies to both (1) be secured from XSS, and (2) allow for browser clients to have access to the token and it's payload. + +.. note:: + + This is initially disabled, and is an opt-in feature. However, if your intent is to use Sanic JWT with a browser based application, and you want to have access to the payload on the client, then it is **HIGHLY** suggested that you use this method, and not Header tokens. + +To use split cookies, you can enable it as follows: + +.. code-block:: python + + Initialize( + app, + cookie_set=True, + cookie_split=True, + cookie_access_token_name='token-header-payload',) + +This will split the cookie in two parts: + +1. ``header.payload`` +2. ``signature`` + +The first part will **not** have ``HttpOnly`` set, but the signature part will. This keeps your token safe from being used since it cannot be verified by the backend. But, the payload can be accessible from JavaScript. + +.. code-block:: javascript + + import jwtDecode from 'jwt-decode' + + const payload = jwtDecode(getCookieValue('token-header-payload')) + +.. note:: + + Setting this will override the ``cookie_httponly`` configuration for the access token. Also, the above example sets ``cookie_access_token_name``, but it is not necessary. This is just to show that ``cookie_access_token_name`` will control the name of the ``header.payload`` cookie. To change the name of the ``signature`` cookie, use ``cookie_split_signature_name``. ~~~~~~~~~~~~~~~~~~~ Query String Tokens diff --git a/tests/test_endpoints_basic.py b/tests/test_endpoints_basic.py index bc90424..9d7815f 100644 --- a/tests/test_endpoints_basic.py +++ b/tests/test_endpoints_basic.py @@ -28,7 +28,7 @@ def test_auth_invalid_method(app): sanic_app, _ = app _, response = sanic_app.test_client.get("/auth") assert response.status == 405 - assert b"Error: Method GET not allowed for URL /auth" in response.body + assert b"Method GET not allowed for URL /auth" in response.body def test_auth_proper_credentials(app): @@ -37,9 +37,7 @@ def test_auth_proper_credentials(app): "/auth", json={"username": "user1", "password": "abcxyz"} ) - access_token = response.json.get( - sanic_jwt.config.access_token_name(), None - ) + access_token = response.json.get(sanic_jwt.config.access_token_name(), None) payload = jwt.decode( access_token, sanic_jwt.config.secret(), @@ -53,8 +51,7 @@ def test_auth_proper_credentials(app): assert "exp" in payload _, response = sanic_app.test_client.get( - "/protected", - headers={"Authorization": "Bearer {}".format(access_token)}, + "/protected", headers={"Authorization": "Bearer {}".format(access_token)}, ) assert response.status == 200 @@ -105,7 +102,7 @@ def test_auth_refresh_not_found(app): sanic_app, _ = app _, response = sanic_app.test_client.post("/auth/refresh") assert response.status == 404 # since refresh_token_enabled is False - assert b"Error: Requested URL /auth/refresh not found" in response.body + assert b"Requested URL /auth/refresh not found" in response.body def test_auth_refresh_not_enabled(app_with_refresh_token): @@ -123,8 +120,7 @@ def test_auth_refresh_not_enabled(app_with_refresh_token): assert "Authorization header not present." in response.json.get("reasons") _, response = sanic_app.test_client.post( - "/auth/refresh", - headers={"Authorization": "Bearer {}".format(access_token)}, + "/auth/refresh", headers={"Authorization": "Bearer {}".format(access_token)}, ) message = "Refresh tokens have not been enabled properly." "Perhaps you forgot to initialize with a retrieve_user handler?" diff --git a/tests/test_endpoints_cbv.py b/tests/test_endpoints_cbv.py index c27c7a3..3fa8d3f 100644 --- a/tests/test_endpoints_cbv.py +++ b/tests/test_endpoints_cbv.py @@ -1,9 +1,9 @@ import jwt + from sanic import Sanic from sanic.response import json from sanic.views import HTTPMethodView - -from sanic_jwt import exceptions, Initialize +from sanic_jwt import Initialize, exceptions from sanic_jwt.decorators import protected @@ -80,9 +80,7 @@ def test_protected(self): _, response = sanic_app.test_client.get("/protected") assert response.status == 401 assert response.json.get("exception") == "Unauthorized" - assert "Authorization header not present." in response.json.get( - "reasons" - ) + assert "Authorization header not present." in response.json.get("reasons") def test_partially_protected(self): _, response = sanic_app.test_client.get("/partially") @@ -91,23 +89,19 @@ def test_partially_protected(self): _, response = sanic_app.test_client.patch("/partially") assert response.status == 401 assert response.json.get("exception") == "Unauthorized" - assert "Authorization header not present." in response.json.get( - "reasons" - ) + assert "Authorization header not present." in response.json.get("reasons") def test_auth_invalid_method(self): _, response = sanic_app.test_client.get("/auth") assert response.status == 405 - assert b"Error: Method GET not allowed for URL /auth" in response.body + assert b"Method GET not allowed for URL /auth" in response.body def test_auth_proper_credentials(self): _, response = sanic_app.test_client.post( "/auth", json={"username": "user1", "password": "abcxyz"} ) - access_token = response.json.get( - sanic_jwt.config.access_token_name(), None - ) + access_token = response.json.get(sanic_jwt.config.access_token_name(), None) payload = jwt.decode( access_token, sanic_jwt.config.secret(), @@ -121,13 +115,11 @@ def test_auth_proper_credentials(self): assert "exp" in payload _, response = sanic_app.test_client.get( - "/protected", - headers={"Authorization": "Bearer {}".format(access_token)}, + "/protected", headers={"Authorization": "Bearer {}".format(access_token)}, ) assert response.status == 200 _, response = sanic_app.test_client.patch( - "/partially", - headers={"Authorization": "Bearer {}".format(access_token)}, + "/partially", headers={"Authorization": "Bearer {}".format(access_token)}, ) assert response.status == 200 diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 922cd71..ace84c0 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,5 +1,4 @@ from sanic.exceptions import abort - from sanic_jwt.decorators import protected @@ -15,13 +14,11 @@ async def test(request): "/auth", json={"username": "user1", "password": "abcxyz"} ) - access_token = response.json.get( - sanic_jwt.config.access_token_name(), None - ) + access_token = response.json.get(sanic_jwt.config.access_token_name(), None) _, response = sanic_app.test_client.get( "/abort", headers={"Authorization": "Bearer {}".format(access_token)} ) assert response.status == 400 - assert response.body == b"Error: Aborted request" + assert response.body == b"Aborted request" From e7005371558a32ddb1022b8af899f1d8c0dc8dd8 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 7 Jul 2020 01:11:11 +0300 Subject: [PATCH 5/5] Fix failing test --- tests/test_exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index ace84c0..24664c5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -21,4 +21,4 @@ async def test(request): ) assert response.status == 400 - assert response.body == b"Aborted request" + assert b"Aborted request" in response.body