Skip to content

Latest commit

 

History

History
613 lines (407 loc) · 21.6 KB

README.md

File metadata and controls

613 lines (407 loc) · 21.6 KB

Build Status Build Status Coverage Status PyPI version GitHub license Documentation Status Pyversions

Welcome to djwto!

djwto ("jot two") is an alternative library offering support for JWT based authentication on top of the Django framework. Its main features are:

  • Authentication either through a Bearer token or Cookies.
  • Access token can be divided into two parts where one part is not encoded and can be used by the client (hence the lib name).
  • CSRF protection by default.
  • Customizable. Add your own code when you see fit.
  • Full Auth Layer: protect your views by requiring the JWT tokens to be present in the income request. Also available for the permissions layer.

Documentation

Complete documentation is also available at ReadTheDocs.

Installation

Install it through pip directly:

    pip install djwto

Then make it available in your INSTALLED_APPS:

    INSTALLED_APPS = [
        'django.contrib.auth',
        'django.contrib.contenttypes',
        ...
        'djwto'
    ]

And for using its defaults urls, add it to your urls.py project file:

  from django.contrib import admin
  from django.urls import path, include


  urlpatterns = [
    path('', include('djwto.urls')),
  ]

Requirements

  • Python (3.7, 3.8, 3.9)
  • Django 3+

Overview

Contents:

djwto offers 3 main ways to process the JWT tokens, which is defined by the settings DJWTO_MODE. Despite of the mode running, the tokens are always returned as acccess and refresh.

The first is intended to be short-lived and used more often whereas the second lives longer and is only sent on a specific path as defined by the setting DJWTO_REFRESH_COOKIE_PATH. Its purpose, as the name implies, is to refresh and create a new access token.

Here's an overview of each mode available:

JSON

In this setting the tokens are returned as a direct JSON response to the client. Here's an example using the requests library, running on a simple demo Django project:

  import requests


  sess = requests.Session()
  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})

  r.json()
  {'refresh': 'eyJ0eXAiO.eyJpc3MiOiJ.QXq8sbgIEgT', 'access': 'eyJ0eXAiOi.eyJpc3MiOiJ.TtSnWdrhWuX'}

In order to make further requests to the backend the client would have to grab the tokens and add them to the AUTHORIZATION header with the Bearer pattern.

ONE-COOKIE

In this mode, the tokens are returned in Cookies. Here's an example:

  sess = requests.Session()
  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})

  sess.cookies
  <RequestsCookieJar[Cookie(name='csrftoken', value='DB6kR7o'), Cookie(name='jwt_access', value='eyJ0.eyJpc.kJsR'), Cookie(name='jwt_refresh', value='eyJ0e.eyJ.wWr')]>

Notice we have:

  • csrftoken
  • jwt_access
  • jwt_refresh

The first must be sent on a header on views protected by CSRF and the last two are the ones used for the auth layer.

TWO-COOKIES

Similar to ONE-COOKIE but this time the access token is divided in two pieces. Here's an example:

  import base64


  sess = requests.Session()
  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})

  sess.cookies
  <RequestsCookieJar[Cookie(name='csrftoken', value='N1vJ9D'), Cookie(name='jwt_access_payload', value='eyJhdWQiO.ZXJuYW1lIj.FsaWN'), Cookie(name='jwt_access_token', value='eyJ0eXAi.OiJKV1QiLC.JhbGciOiJIU'), Cookie(name='jwt_refresh', value='eyJ0eXA.iOiJKV1Qi.LCJhbGc')]>

  base64.b64decode(sess.cookies['jwt_access_payload'])
  b'{"aud": "aud", "exp": "2021-06-18T02:32:55.144", "iat": "2021-06-17T18:12:55.144", "iss": "iss", "jti": "0b2d199d-f233-4203-bdab-693c03bca505", "refresh_iat": 1623953575, "sub": "sub", "type": "access", "user": {"id": 1, "perms": [], "username": "alice"}}'

Now we have the following cookies:

  • csrftoken
  • jwt_access_payload
  • jwt_access_token
  • jwt_refresh

Notice the access token now have two components: payload and the token itself. The payload is the original payload encoded in base64 which can be used by the client receiving those cookies. The other part though is protected from javascript access and should only be used by the backend.

Settings

Here's an overview of all settings available for djwto:

DJWTO_ISS_CLAIM: Optional[str] = getattr(settings, 'DJWTO_ISS_CLAIM', None)
DJWTO_SUB_CLAIM: Optional[str] = getattr(settings, 'DJWTO_SUB_CLAIM', None)
DJWTO_AUD_CLAIM: Optional[Union[List[str], str]] = getattr(settings, 'DJWTO_AUD_CLAIM', None)

DJWTO_IAT_CLAIM: bool = getattr(settings, 'DJWTO_IAT_CLAIM', True)
DJWTO_JTI_CLAIM: bool = getattr(settings, 'DJWTO_JTI_CLAIM', True)

DJWTO_ALLOW_REFRESH_UPDATE: bool = getattr(settings, 'DJWTO_ALLOW_REFRESH_UPDATE', True)

DJWTO_ACCESS_TOKEN_LIFETIME = getattr(settings, 'DJWTO_ACCESS_TOKEN_LIFETIME', timedelta(minutes=5))
DJWTO_REFRESH_TOKEN_LIFETIME = getattr(settings, 'DJWTO_REFRESH_TOKEN_LIFETIME', timedelta(days=1))
DJWTO_NBF_LIFETIME: Optional[timedelta] = getattr(settings, 'DJWTO_NBF_LIFETIME', timedelta(minutes=0))

DJWTO_SIGNING_KEY: str = getattr(settings, 'DJWTO_SIGNING_KEY', os.environ['DJWTO_SIGNING_KEY'])

# Only set if Algorithm uses asymetrical signing.
DJWTO_VERIFYING_KEY: Optional[str] = getattr(settings, 'DJWTO_VERIFYING_KEY', None)

DJWTO_ALGORITHM: str = getattr(settings, 'DJWTO_ALGORITHM', 'HS256')

DJWTO_MODE: Literal['JSON', 'ONE-COOKIE', 'TWO-COOKIES'] = getattr(settings, 'DJWTO_MODE', 'JSON')

DJWTO_REFRESH_COOKIE_PATH: str = getattr(settings, 'DJWTO_REFRESH_COOKIE_PATH', 'api/token/refresh')

DJWTO_SAME_SITE: str = getattr(settings, 'DJWTO_SAME_SITE', 'Lax')
DJWTO_DOMAIN: Optional[str] = getattr(settings, 'DJWTO_DOMAIN', None)

DJWTO_CSRF: bool = getattr(settings, 'DJWTO_CSRF', True)

For a thorough view of each please refer to the official docs.

Endpoints

Here's an overview of each endpoint offered by djwto and how to use them. For each endpoint, if something goes wrong then a key error should be available in the response and its value should be an explanation for the event.

/login/

Simply POST to this endpoint with data containing the user username and their input password, the return response, if valid, should contain the newly created JWTs for the client.

The return type depends on which mode djwto is running; here's an example on DJWTO_MODE=JSON mode making use of the requests library:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally

  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})

  r.json()
  {'refresh': 'eyJ0eXAiO.eyJpc3MiOiJ.QXq8sbgIEgT', 'access': 'eyJ0eXAiOi.eyJpc3MiOiJ.TtSnWdrhWuX'}

/validate/

Sometimes the frontend may want to confirm whether a given token is still valid or not; that's the purpose of this endpoint. Supposing that the login process has already taken place, here's an example of access token validation on mode JSON:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally
  sess.headers.update({'AUTHORIZATION': f'Bearer {r.json()["access"]}'})

  r = sess.post('https://localhost:8001/validate_access/')

  print(r.json())
  {'msg': 'Token is valid'}

Here's an example for TWO-COOKIES:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally
  sess.post('https://localhost:8001/login/',
            data={'username': 'alice', 'password': 'pass'})
  sess.headers.update({'X-CSRFToken': sess.cookies['csrftoken']})

  r = sess.post('https://localhost:8001/validate_access/',
                headers={'REFERER': 'https://localhost:8001'})

Notice that the CSRF token is being sent in the header as X-CSRFToken and also there's a REFERER value indicating from where the client is coming (this is mandatory as by Django's built-in csrf techniques protection).

In order to validate the refresh, here's an example:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally
  sess.post('https://localhost:8001/login/',
            data={'username': 'alice', 'password': 'pass'})
  sess.headers.update({'X-CSRFToken': sess.cookies['csrftoken']})

  r = sess.post('https://localhost:8001/api/token/refresh/validate_refresh/',
                headers={'REFERER': 'https://localhost:8001'},
                data={'jwt_type': 'refresh'})

Notice that the POST must send the data jwt_type='refresh' to specify for the backend which token to use.

/refresh_access/

The access token is designed to be short-lived, that is, it grants access for clients for a brief period of time before it goes expired. The reasoning is that after it expires the API has a chance to validate whether the client can continue receiving new tokens or not (so in case the client logged out or was blacklisted for some reason they'd lose access thereafter).

When the token expires, a new one can be obtained by posting the refresh token to this endpoint. Here's an example for JSON mode:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally
  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})
  sess.headers.update({'AUTHORIZATION': f'Bearer {r.json()["refresh"]}'})

  r = sess.post('https://localhost:8001/api/token/refresh/refresh_access/',
                headers={'REFERER': 'https://localhost:8001'})

  print(r.json())
  {"refresh": ..., "access": ...}

/update_refresh/

At some scenarios it may be interesting for the JWT auth process to also be able to update the refresh token. This may occur for instance in an eCommerce environment when the customer is finishing the purchase process and may get blocked due expired token (which is highly undesirable). In order to allow this feature to be available, set settings.DJWTO_ALLOW_REFRESH_UPDATE to True. Here's an example for JSON mode:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally
  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})
  sess.headers.update({'AUTHORIZATION': f'Bearer {r.json()["refresh"]}'})

  r = sess.post('https://localhost:8001/api/token/refresh/update_refresh/')
  print(r.json())
  {"refresh": '...', "access": '...'}

/logout/

When logging a user out, if JTI is available then the tokens will be blacklisted. In either case, the tokens are deleted (both access and refresh). The request path must contain settings.DJWTO_REFRESH_COOKIE_PATH. Here's an example for JSON mode:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally
  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})
  sess.headers.update({'AUTHORIZATION': f'Bearer {r.json()["refresh"]}'})

  r = sess.post('https://localhost:8001/api/token/refresh/logout/')
  print(r.content)
  b'{"msg": "Token successfully blacklisted."}'

For either ONE-COOKIE or TWO-COOKIES:

  import requests


  sess = requests.Session()
  sess.verify = False  # For testing locally

  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})
  sess.headers.update({'X-CSRFToken': sess.cookies['csrftoken']})
  r = sess.post('https://localhost:8001/api/token/refresh/logout/',
                headers={'REFERER': 'https://localhost:8001'},
                data={'jwt_type': 'refresh'})

  print(r.content)
  b'{"msg": "Token successfully blacklisted."}'

  r = sess.delete('https://localhost:8001/api/token/refresh/logout/',
                  headers={'REFERER': 'https://localhost:8001'},
                  data={'jwt_type': 'refresh'})

  print(r.content)
  b'{"msg": "Tokens successfully deleted."}'

Notice that the verb DELETE is also available with removes the cookies from the response. This option only works on for the cookie-based settings.

If after blacklisting a token a request is sent for updating either access or refresh, the process should fail:

  r = sess.post('https://localhost:8001/api/token/refresh/update_refresh/',
                headers={'REFERER':'https://localhost:8001'},
                data={'jwt_type': 'refresh'})

  print(r.content)
  b'{"error": "Can\'t update refresh token."}'

  r = sess.post('https://localhost:8001/api/token/refresh/refresh_access/',
                headers={'REFERER': 'https://localhost:8001'},
                data={'jwt_type': 'refresh'})

  print(r.content)
  b'{"error": "Can\'t update access token."}'

Signals

djwto offers by default a set of signals that you can use in your projects for tracking down when certain events take place. In order to demonstrate how it works, consider a regular Django project created with an app called testapp.

Here's a list of all available signals offered by the package:

/jwt_logged_in/

The signal is triggered when a successfull loggin happens. Here's how to connect it:

  def ready(self):
      import djwto.signals as signals

      def handler(sender, **kwargs):
          print('sender: ', sender)
          print('kwargs: ', kwargs)

      signals.jwt_logged_in.connect(
          handler,
          sender='GetTokensView'
      )

On each successfull login the new function handler will process with appropriate input arguments.

/jwt_login_fail/

The signal is triggered when a loggin fails by any reason. Here's how to connect it:

  def ready(self):
      import djwto.signals as signals

      def handler(sender, **kwargs):
          print('sender: ', sender)
          print('kwargs: ', kwargs)

      signals.jwt_login_fail.connect(handler, sender='GetTokensView')

/jwt_blacklisted/

The signal is sent when a token is successfully blacklisted:

  def ready(self):
      import djwto.signals as signals

      def handler(sender, **kwargs):
          print('sender: ', sender)
          print('kwargs: ', kwargs)

      signals.jwt_blacklisted.connect(handler, sender='BlackListTokenView')

/jwt_token_validated/

The signal is sent each time a validation request is processed:

  def ready(self):
      import djwto.signals as signals

      def handler(sender, **kwargs):
          print('sender: ', sender)
          print('kwargs: ', kwargs)

      signals.jwt_token_validated.connect(handler, sender='ValidateTokensView')

/jwt_access_refreshed/

The signal is sent when the access token is successfully refreshed:

  def ready(self):
      import djwto.signals as signals

      def handler(sender, **kwargs):
          print('sender: ', sender)
          print('kwargs: ', kwargs)

      signals.jwt_access_refreshed.connect(handler, sender='RefreshAccessView')

/jwt_refresh_updated/

If the updating endpoint is available (as by the settings) then when the updating of the refresh successfully happens this signal is sent. Example:

  def ready(self):
      import djwto.signals as signals

      def handler(sender, **kwargs):
          print('sender: ', sender)
          print('kwargs: ', kwargs)

      signals.jwt_refresh_updated.connect(handler, sender='UpdateRefreshView')

Customization

djwto offers the possibility for the client to customize how parts of the code should be processed, replacing the original logic. Let's suppose a regular Django project with an app called testapp.

It's possible to specify customizations for djwto when the app is ready. For instance, if your project requires to also bring the customer's email when the JWT creation is running, here's one way of doing it:

  from django.apps import AppConfig


  class TestappConfig(AppConfig):
      default_auto_field = 'django.db.models.BigAutoField'
      name = 'testapp'

      def ready(self):
          import djwto.tokens as tokens


          def new_process_user(user):
              return {
                  user.USERNAME_FIELD: user.get_username(),
                  'email': user.email,
                  'id': user.pk,
                  'perms': tokens.process_perms(user)
              }

          tokens.process_claims = new_process_user

Running the loggin process for TWO-COOKIES, we get now:

  import requests
  import base64


  sess = requests.Session()
  sess.verify = False  # For testing locally

  r = sess.post('https://localhost:8001/login/',
                data={'username': 'alice', 'password': 'pass'})

  base64.b64decode(sess.cookies['jwt_access_payload'])
  b'{"aud": "aud", "exp": 1624259339, "iat": 1624229339, "iss": "iss", "jti": "900f4f1a-3e0f-4843-9997-9fd8d032684e", "refresh_iat": 1624229339, "sub": "sub", "type": "access", "user": {"email": "[email protected]", "id": 1, "perms": [], "username": "alice"}}'

Now the user key retrieves the user email as well.

Feel free to customize the code as you see fit.

Protecting Views

djwto offers decorators that can be used on views in order to add the authentication layer protection. There are two functions for doing so:

jwt_login_required

djwto offers the decorator jwt_loging_required for guaranteeing a view to only be processed if the required and valid JWT token was sent in the request. Here's an example. Suppose again a regular Django project with the usual testapp with a view defined as:

  import djwto.authentication as auth # type: ignore
  from django.views import View
  from django.utils.decorators import method_decorator
  from django.http.response import HttpResponse


  class ProtectedView(View):
      def dispatch(self, request, *args, **kwargs):
          return super().dispatch(request, *args, **kwargs)

      @method_decorator(auth.jwt_login_required)
      def get(self, request, *args, **kwargs):
          refresh_claims = request.payload
          print(refresh_claims)
          return HttpResponse('worked!')

Notice the decorator auth.jwt_login_required protecting the view. Now let's see what happens if we send a request without the JWT available:

  r = sess.get('https://localhost:8001/testapp/protect/')

  r.content
  b'{"error": "Cookie \\"jwt_access_token\\" cannot be empty."}'

If we properly login:

  r.content
  b'worked!'

jwt_perm_required

djwto also offers the possibility of protecting views with permissions that should be available in the JWT token. Here's an example: decorate a view with the jwt_perm_required function like so:

  class PermsProtectedView(View):
      def dispatch(self, request, *args, **kwargs):
          return super().dispatch(request, *args, **kwargs)

      @method_decorator(auth.jwt_login_required)
      @method_decorator(auth.jwt_perm_required(['perm1']))
      def get(self, request, *args, **kwargs):
          refresh_claims = request.payload
          print(refresh_claims)
          return HttpResponse('perms also worked!')

The function receives a list of permissions as input and only if the input JWT token contains those permissions is that the view will be processed.

Now, sending the request with a regular JWT token returns:

  r = sess.get('https://localhost:8001/testapp/perms_protect/')

  r.content
  b'{"error": "Invalid permissions for jwt token."}'

Suppose now that Alice has the permission perm1 stored in the database. Here's the result then:

  r.content
  b'perms also worked!'

If you run these examples with settings DJWTO_MODE=TWO-COOKIES, you'll be able to see what's inside the returned cookie, like so:

  base64.b64decode(sess.cookies['jwt_access_payload'])
  b'{"aud": "aud", "exp": 1624269024, "iat": 1624239024, "iss": "iss", "jti": "0e9bfcdc-d684-47b5-9677-0cb5e5e88893", "refresh_iat": 1624239024, "sub": "sub", "type": "access", "user": {"email": "[email protected]", "id": 1, "perms": ["perm1"], "username": "alice"}}'

Contributing and Bugs

Contributions are very welcome! If you want to send a PR please consider first discussing your implementation on an issue.

Also, if you find bugs (this is still an alpha project!) please let us know by also opening an issue on the official repository.