Skip to content

Commit

Permalink
Merge pull request #195 from ahopkins/split-cookie
Browse files Browse the repository at this point in the history
Split cookie
  • Loading branch information
ahopkins authored Jul 6, 2020
2 parents 67334a5 + e700537 commit a6fcb7f
Show file tree
Hide file tree
Showing 28 changed files with 343 additions and 70 deletions.
40 changes: 40 additions & 0 deletions docs/source/pages/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
-------------------
Expand All @@ -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``
-----------------
Expand All @@ -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``
--------------
Expand All @@ -272,6 +296,22 @@ Settings
| **Default**: ``False``
|
--------------
``cookie_split``
--------------

| **Purpose**: If ``True``, will enable split cookies (see :doc:`protecting routes with cookies <protected>` 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``
-----------------
Expand Down
34 changes: 34 additions & 0 deletions docs/source/pages/protected.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions example/basic_with_split_cookies.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions example/basic_with_user_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions example/inline_tokens_and_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
21 changes: 15 additions & 6 deletions sanic_jwt/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,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.
Expand Down Expand Up @@ -251,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:
Expand All @@ -274,7 +277,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):
"""
Expand Down Expand Up @@ -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.
"""
Expand Down
6 changes: 6 additions & 0 deletions sanic_jwt/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@
"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",
"cookie_strict": True,
"debug": False,
"do_protection": True,
Expand Down Expand Up @@ -55,6 +60,7 @@
aliases = {
"cookie_access_token_name": "cookie_token_name",
"secret": "public_key",
"cookie_split": "split_cookie",
}

ignore_keys = (
Expand Down
4 changes: 3 additions & 1 deletion sanic_jwt/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
4 changes: 3 additions & 1 deletion sanic_jwt/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 1 addition & 3 deletions sanic_jwt/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
39 changes: 33 additions & 6 deletions sanic_jwt/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

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()
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):
Expand All @@ -30,7 +40,24 @@ 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()
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion tests/test_async_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
1 change: 1 addition & 0 deletions tests/test_claims.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta

import jwt

from freezegun import freeze_time


Expand Down
Loading

0 comments on commit a6fcb7f

Please sign in to comment.