3
\$\begingroup\$

I am currently using Django (2.1) to build an API, and I have added djangorestframework-jwt to manage JWT.

Here is the configuration:

JWT_AUTH = {
    'JWT_SECRET_KEY': SECRET_KEY,
    'JWT_VERIFY': True,
    'JWT_VERIFY_EXPIRATION': True,
    'JWT_EXPIRATION_DELTA': datetime.timedelta(days=14),
    'JWT_ALLOW_REFRESH': True,
    'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
    'JWT_AUTH_HEADER_PREFIX': 'Bearer',
}

and the endpoints:

urlpatterns = [
    path('auth/get-token/', obtain_jwt_token),
    path('auth/refresh-token/', refresh_jwt_token),
]

The client is built with ReactJS. I use an axios instance as a client to communicate with the API. This instance is created that way :

import axios from 'axios'
import jwt_decode from 'jwt-decode' // eslint-disable-line
import { signOut } from '../actions/authActions'

const signOutOn401 = (statusCode) => {
  if (statusCode === 401) {
    signOut()
    window.location = '/signin'
  }
}

const client = axios.create({
  baseURL: process.env.API_URL,
  headers: {'Authorization': ''}
})

/*
 * This interceptor is used for:
 * - adding Authorization header if JWT available
 * - refreshing JWT to keep user authenticated
 */
client.interceptors.request.use((config) => {
  if (window.localStorage.getItem('token')) {
    let token = window.localStorage.getItem('token')
    // Calculate time difference in days
    // between now and token expiration date
    const t = ((jwt_decode(token).exp * 1000) - Date.now()) / 1000 / 60 / 60 / 24
    // Refresh the token if the time difference is
    // smaller than 13 days (original token is valid 14 days)
    if (t < 13) {
      axios.post(`${process.env.API_URL}/auth/refresh-token/`, {
        token: token
      })
        .then(({data}) => {
          token = data.token
        })
        .catch((error) => {
          signOutOn401(error.response.status)
          return error
        })
    }
    config.headers['Authorization'] = `Bearer ${token}`
  }

  return config
})

/*
 * This interceptor is used for:
 * - disconnect user if JWT is expired or revoked
 */
client.interceptors.response.use(
  (response) => {
    return response
  },
  (error) => {
    signOutOn401(error.response.status)
    return error
  }
)

export default client

The signout action only clear the session and clean the store :

export const signOut = () => {
  window.localStorage.clear()

  return ({
   type: SIGN_OUT,
    payload: {
      authenticated: false,
      user: {},
      errorMessage: ''
    }
  })
}

Everything looks working fine, I would just like to know if that implementation is correct, and if there is no security flaw :)

\$\endgroup\$
2
  • \$\begingroup\$ "and if there is no security flaw" What's your threat model? \$\endgroup\$
    – Mast
    Aug 30, 2018 at 9:26
  • \$\begingroup\$ Right now I'm just looking for keeping my users authenticated for 2 weeks minimum, and extend that time if they use the website. The API already control that users can inly access their data. \$\endgroup\$
    – Baiello
    Aug 30, 2018 at 19:30

2 Answers 2

1
\$\begingroup\$

UX concern: refresh period

I wonder if you are confusing the access token expiration setting (JWT_EXPIRATION_DELTA) with the refresh token expiration (JWT_REFRESH_EXPIRATION_DELTA). In either case, your t < 13 check should be related to the refresh token expiration, not the access token expiration. 13 seems to be chosen because it is almost 14; hence my comment. At the very least these refreshes should never go beyond your refresh expiration (7 days), or they will fail, which defeats the purpose of providing a refresh token (because the user would always have to authenticate after the expiration).

Security concern: access token expiration

On a slightly similar note, but this one is a security concern -- you typically want the access token to have a much shorter life than the refresh token. Yours is reversed, as the access token (JWT_EXPIRATION_DELTA) is 14 days vs. the refresh token (JWT_REFRESH_EXPIRATION_DELTA) at 7 days. Note the default JWT_EXPIRATION_DELTA for the djangorestframework-jwt library is a much more conservative and standard 5 minutes:

Default is datetime.timedelta(seconds=300)(5 minutes).

Per OAuth 2 spec:

access tokens may have a shorter lifetime and fewer permissions than authorized by the resource owner

Per Auth0 guidance:

Access tokens carry the necessary information to access a resource directly.

...

Access tokens usually have an expiration date and are short-lived.

...

Common implementations allow for direct authorization checks against an access token. That is, when an access token is passed to a server managing a resource, the server can read the information contained in the token and decide itself whether the user is authorized or not (no checks against an authorization server are needed). This is one of the reasons tokens must be signed (using JWS, for instance). On the other hand, refresh tokens usually require a check against the authorization server. This split way of handling authorization checks allows for three things:

  1. Improved access patterns against the authorization server (lower load, faster checks)
  2. Shorter windows of access for leaked access tokens (these expire quickly, reducing the chance of a leaked token allowing access to a protected resource)

...

Security concern: refresh token revocation

It is difficult for me to tell whether that djangorestframework-jwt library will revoke all previous refresh tokens for a given session once a new refresh token is issued for that session. Ideally it should, especially if you are using unauthenticated clients, which it looks like you are (assuming that React app is just a public web app; if it's instead packaged in a native client you may and probably should be authenticating that client). You may want to verify that behavior of the library with your own testing.

If the library does not revoke previous refresh tokens, then you should probably mitigate the risk of having so many outstanding valid refresh tokens per session (~= expiration time divided by refresh period, or currently 7 / 1 = 7 valid tokens!!!) by making the refresh period much closer to the refresh token expiration. So if you keep the refresh expiration at 7 days, then only refreshing at 6 days would make sense. Then you would typically have 1 or at most 2 outstanding valid refresh tokens for a session. However, if given a choice, it is much more secure to have automatically revoked refresh tokens and shorter refresh periods; that way a compromised refresh token is much more likely to be invalid -- and you still wouldn't have to lose the benefit of the authorized user having a long refresh expiration. (The whole point of the long expiration is for situations such as an unattended laptop or mobile browser.)

Per OAuth 2 spec:

The authorization server MUST verify the binding between the refresh token and client identity whenever the client identity can be authenticated. When client authentication is not possible, the authorization server SHOULD deploy other means to detect refresh token abuse.

For example, the authorization server could employ refresh token rotation in which a new refresh token is issued with every access token refresh response. The previous refresh token is invalidated but retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach.

The authorization server MUST ensure that refresh tokens cannot be generated, modified, or guessed to produce valid refresh tokens by unauthorized parties.

\$\endgroup\$
1
  • \$\begingroup\$ Thanks a lot @will-cain for your explanation, it helped me a lot ! You are right, I was confusing the access token and the refresh token, the way djangorestframework-jwt handle it misleads me. I switched to djangorestframework-simplejwt and now everything is working as expected. I'll post my solution as an answer to the question. \$\endgroup\$
    – Baiello
    Sep 6, 2018 at 9:59
0
\$\begingroup\$

API

The djangorestframework-jwt does not support "Refresh Tokens", it only supports refreshing non-expired tokens.

source : https://stackoverflow.com/questions/42547579/django-rest-jwt-refresh

So I replaced it by djangorestframework-simplejwt, using the following configuration:

# settings.py

INSTALLED_APPS = [
    'rest_framework',
    'rest_framework_simplejwt.token_blacklist',
]

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated'
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ]
}

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=14),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUTH_HEADER_TYPES': ('Bearer',),
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

(The rest_framework_simplejwt.token_blacklist app invalidate refreshed tokens until their expiration date)

# urls.py

from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path('auth/token/', TokenObtainPairView.as_view()),
    path('auth/token/refresh/', TokenRefreshView.as_view()),
]

React app

The API client will:

  • send the request without Authorization header if no tokens available
  • refresh both tokens if the access token is expired and the refresh token is not expired
  • add an Authorization header containing the access token (if available and valid) to all request
  • disconnect the user if the access token is expired or invalid

All actions send requests to the API using this client.

import axios from 'axios'
import moment from 'moment'
import jwt_decode from 'jwt-decode'

const client = axios.create({
  baseURL: process.env.API_URL
})

client.interceptors.request.use(async (config) => {
  // Decode available tokens
  let jwta = {}
  let jwtr = {}
  if (window.localStorage.getItem('jwta') && window.localStorage.getItem('jwtr')) {
    jwta = jwt_decode(window.localStorage.getItem('jwta'))
    jwtr = jwt_decode(window.localStorage.getItem('jwtr'))
  }

  // Update tokens if the access token is expired and a valid refresh token is available
  if (jwta.exp < moment().unix() && jwtr.exp > moment().unix()) {
    const response = await axios.post(`${process.env.API_URL}/auth/token/refresh/`, {
      'refresh': window.localStorage.getItem('jwtr')
    })
    window.localStorage.setItem('jwta', response.data.access)
    window.localStorage.setItem('jwtr', response.data.refresh)
    jwta = jwt_decode(response.data.access)
  }

  // Add access token to request
  if (jwta.exp > moment().unix()) {
    config.headers['Authorization'] = `Bearer ${window.localStorage.getItem('jwta')}`
  }

  // Disconnect user if authentication tokens are expired
  // (Added to avoid unnecessary API calls)
  if (jwta.exp < moment().unix() && jwtr.exp < moment().unix()) {
    window.localStorage.clear()
    window.location = '/signin/'
  }

  return config
})

client.interceptors.response.use(
  (response) => {
    return response
  },
  (error) => {
    if (error.response.status === 401) {
      window.localStorage.clear()
      window.location = '/signin/'
    }
    return Promise.reject(error.response)
  }
)

export default client

When the user try to sign in, access and refresh tokens are requested to the API, then persisted in local storage:

export const signIn = (username, password) => async dispatch => {
  try {
    const response = await apiClient.post('/auth/token/', {
      'username': username,
      'password': password
    })

    const userId = jwt_decode(response.data.access).user_id
    window.localStorage.setItem('jwta', response.data.access)
    window.localStorage.setItem('jwtr', response.data.refresh)
    window.localStorage.setItem('user', userId)

    dispatch({
      type: SIGN_IN_SUCCESS,
      payload: {
        authenticated: true,
        user: userId
      }
    })
  } catch (e) {
    window.localStorage.clear()

    const errorMessage = (e.status && e.status === 400)
      ? 'Invalid credentials'
      : 'We encountered an error. Please try again later.'

    dispatch({
      type: SIGN_IN_FAILURE,
      payload: {
        authenticated: false,
        errorMessage: errorMessage
      }
    })
  }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.