From 3a65ce090f30fe0d7dca69e17f62cfaa6a74f809 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Fri, 4 Oct 2019 14:17:24 +0200 Subject: [PATCH 1/2] [BB-1718] Implement JWT token refreshing --- compose/local/django/Dockerfile | 2 +- config/settings/base.py | 13 +++--- config/urls.py | 2 + frontend/src/actions/auth.js | 42 ++++++----------- frontend/src/actions/sprints.js | 25 +++-------- frontend/src/actions/sustainability.js | 13 ++---- .../components/sprint/SprintActionButtons.js | 10 +---- frontend/src/constants/index.js | 1 + frontend/src/middleware/api.js | 45 +++++++++++++++++++ frontend/src/reducers/auth.js | 15 +++++-- requirements/base.txt | 3 +- sprints/users/jwt.py | 9 ---- 12 files changed, 92 insertions(+), 88 deletions(-) create mode 100644 frontend/src/middleware/api.js delete mode 100644 sprints/users/jwt.py diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile index 72404a6..011b067 100644 --- a/compose/local/django/Dockerfile +++ b/compose/local/django/Dockerfile @@ -4,7 +4,7 @@ ENV PYTHONUNBUFFERED 1 RUN apk update \ # psycopg2 dependencies - && apk add --virtual build-deps gcc python3-dev musl-dev \ + && apk add --virtual build-deps gcc python3-dev musl-dev git \ && apk add postgresql-dev \ # Pillow dependencies && apk add jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev tiff-dev tk-dev tcl-dev \ diff --git a/config/settings/base.py b/config/settings/base.py index b31fb14..510e7e8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -76,6 +76,7 @@ "rest_auth.registration", "drf_yasg", "corsheaders", + 'rest_framework_simplejwt.token_blacklist', ] LOCAL_APPS = [ "sprints.users.apps.UsersConfig", @@ -297,7 +298,7 @@ 'rest_framework.permissions.IsAuthenticated', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', ), @@ -306,11 +307,11 @@ REST_AUTH_SERIALIZERS = { 'USER_DETAILS_SERIALIZER': 'sprints.users.serializers.UserDetailsSerializer', } -JWT_AUTH = { - 'JWT_PAYLOAD_HANDLER': 'sprints.users.jwt.jwt_payload_handler_custom', - 'JWT_EXPIRATION_DELTA': datetime.timedelta(minutes=env.int("DJANGO_JWT_EXPIRATION_MINUTES", 10)), - 'JWT_ALLOW_REFRESH': env.bool("DJANGO_JWT_ALLOW_REFRESH", True), - 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=env.int("DJANGO_JWT_REFRESH_EXPIRATION_DAYS", 7)), +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': datetime.timedelta(hours=env.float("JWT_ACCESS_TOKEN_LIFETIME_HOURS", 3)), + 'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=env.float("JWT_REFRESH_TOKEN_LIFETIME_DAYS", 30)), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, } diff --git a/config/urls.py b/config/urls.py index 95f8c87..0d396c4 100644 --- a/config/urls.py +++ b/config/urls.py @@ -11,6 +11,7 @@ from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions +from rest_framework_simplejwt.views import TokenRefreshView from sprints.users.api import GoogleLogin @@ -59,6 +60,7 @@ urlpatterns += [ path("accounts/", include("allauth.urls")), + url(r'^rest-auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), url(r'^rest-auth/', include('rest_auth.urls')), url(r'^rest-auth/registration/', include('rest_auth.registration.urls')), url(r'^rest-auth/google/$', GoogleLogin.as_view(), name='google_login'), diff --git a/frontend/src/actions/auth.js b/frontend/src/actions/auth.js index 716d8c7..c11704a 100644 --- a/frontend/src/actions/auth.js +++ b/frontend/src/actions/auth.js @@ -1,19 +1,9 @@ import {PATH_GOOGLE, PATH_LOGIN, PATH_LOGOUT, PATH_REGISTER, PATH_USER, PATH_VERIFY_EMAIL} from "../constants"; +import {callApi} from "../middleware/api"; export const loadUser = () => { - return (dispatch, getState) => { - dispatch({type: "USER_LOADING"}); - - const token = getState().auth.token; - - let headers = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `JWT ${token}`; - } - return fetch(PATH_USER, {headers,}) + return (dispatch) => { + return callApi(PATH_USER) .then(res => { if (res.status < 500) { return res.json().then(data => { @@ -37,11 +27,10 @@ export const loadUser = () => { }; export const login = (email, password) => { - return (dispatch, getState) => { - let headers = {"Content-Type": "application/json"}; + return (dispatch) => { let body = JSON.stringify({email, password}); - return fetch(PATH_LOGIN, {headers, body, method: "POST"}) + return callApi(PATH_LOGIN, body) .then(res => { if (res.status < 500) { return res.json().then(data => { @@ -68,11 +57,10 @@ export const login = (email, password) => { }; export const social_login = (access_token) => { - return (dispatch, getState) => { - let headers = {"Content-Type": "application/json"}; + return (dispatch) => { let body = JSON.stringify({access_token}); - return fetch(PATH_GOOGLE, {headers, body, method: "POST"}) + return callApi(PATH_GOOGLE, body) .then(res => { if (res.status < 500) { return res.json().then(data => { @@ -99,11 +87,10 @@ export const social_login = (access_token) => { }; export const register = (email, password1, password2) => { - return (dispatch, getState) => { - let headers = {"Content-Type": "application/json"}; + return (dispatch) => { let body = JSON.stringify({email, password1, password2}); - return fetch(PATH_REGISTER, {headers, body, method: "POST"}) + return callApi(PATH_REGISTER, body) .then(res => { if (res.status < 500) { return res.json().then(data => { @@ -130,10 +117,8 @@ export const register = (email, password1, password2) => { }; export const logout = () => { - return (dispatch, getState) => { - let headers = {"Content-Type": "application/json"}; - - return fetch(PATH_LOGOUT, {headers, body: "", method: "POST"}) + return (dispatch) => { + return callApi(PATH_LOGOUT, "", "POST") .then(res => { if (res.status === 200) { return {status: res.status, data: {}}; @@ -159,11 +144,10 @@ export const logout = () => { }; export const verify_email = (key) => { - return (dispatch, getState) => { - let headers = {"Content-Type": "application/json"}; + return (dispatch) => { let body = JSON.stringify({key}); - return fetch(PATH_VERIFY_EMAIL, {headers, body, method: "POST"}) + return callApi(PATH_VERIFY_EMAIL, body) .then(res => { if (res.status < 500) { return res.json().then(data => { diff --git a/frontend/src/actions/sprints.js b/frontend/src/actions/sprints.js index e041bbc..f432a15 100644 --- a/frontend/src/actions/sprints.js +++ b/frontend/src/actions/sprints.js @@ -1,4 +1,5 @@ import {PARAM_BOARD_ID, PATH_CELLS, PATH_DASHBOARD} from "../constants"; +import {callApi} from "../middleware/api"; const prepareCellIds = (cells) => { const result = {}; @@ -8,18 +9,10 @@ const prepareCellIds = (cells) => { }; export const loadCells = () => { - return (dispatch, getState) => { + return (dispatch) => { dispatch({type: "CELLS_LOADING"}); - let token = getState().auth.token; - let headers = { - "Content-Type": "application/json", - }; - if (token) { - headers["Authorization"] = `JWT ${token}`; - } - - return fetch(PATH_CELLS, {headers,}) + return callApi(PATH_CELLS) .then(response => { if (response.status < 500) { return response.json().then(data => { @@ -46,18 +39,10 @@ export const loadCells = () => { export const loadBoard = (board_id) => { let board_url = `${PATH_DASHBOARD}?${PARAM_BOARD_ID}${board_id}`; - return (dispatch, getState) => { + return (dispatch) => { dispatch({type: "BOARD_LOADING", board_id: board_id}); - let token = getState().auth.token; - let headers = { - "Content-Type": "application/json", - }; - if (token) { - headers["Authorization"] = `JWT ${token}`; - } - - return fetch(board_url, {headers,}) + return callApi(board_url) .then(response => { if (response.status < 500) { return response.json().then(data => { diff --git a/frontend/src/actions/sustainability.js b/frontend/src/actions/sustainability.js index cda3e3d..095cba0 100644 --- a/frontend/src/actions/sustainability.js +++ b/frontend/src/actions/sustainability.js @@ -1,4 +1,5 @@ import {PARAM_FROM, PARAM_TO, PARAM_YEAR, PATH_SUSTAINABILITY_DASHBOARD} from "../constants"; +import {callApi} from "../middleware/api"; const aggregateAccounts = (data) => { let result = {}; @@ -31,18 +32,10 @@ export const loadAccounts = (from, to) => { sustainability_url = `${PATH_SUSTAINABILITY_DASHBOARD}?${PARAM_YEAR}${from}`; } - return (dispatch, getState) => { + return (dispatch) => { dispatch({type: "ACCOUNTS_LOADING"}); - let token = getState().auth.token; - let headers = { - "Content-Type": "application/json", - }; - if (token) { - headers["Authorization"] = `JWT ${token}`; - } - - return fetch(sustainability_url, {headers,}) + return callApi(sustainability_url) .then(response => { if (response.status < 500) { return response.json().then(data => { diff --git a/frontend/src/components/sprint/SprintActionButtons.js b/frontend/src/components/sprint/SprintActionButtons.js index 06a6777..0cc6c4f 100644 --- a/frontend/src/components/sprint/SprintActionButtons.js +++ b/frontend/src/components/sprint/SprintActionButtons.js @@ -1,6 +1,7 @@ import {connect} from "react-redux"; import React, {Component} from 'react'; import {PARAM_BOARD_ID, PATH_COMPLETE_SPRINT, PATH_CREATE_NEXT_SPRINT} from "../../constants"; +import {callApi} from "../../middleware/api"; class SprintActionButton extends Component { @@ -10,16 +11,9 @@ class SprintActionButton extends Component { } this.btn.setAttribute("disabled", "disabled"); - let token = this.props.auth.token; - let headers = { - "Content-Type": "application/json", - }; - if (token) { - headers["Authorization"] = `JWT ${token}`; - } let complete_url = `${this.props.url}?${PARAM_BOARD_ID}${this.props.board_id}`; - fetch(complete_url, {headers, body: "", method: "POST"}) + callApi(complete_url, "", "POST") .then(response => response.json()) }; diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index 746ba6b..c275fa9 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -8,6 +8,7 @@ export const PATH_REGISTER = `${PATH_AUTH}/registration/`; export const PATH_GOOGLE = `${PATH_AUTH}/google/`; export const PATH_LOGOUT = `${PATH_AUTH}/logout/`; export const PATH_VERIFY_EMAIL = `${PATH_AUTH}/registration/verify-email/`; +export const PATH_REFRESH_TOKEN = `${PATH_AUTH}/refresh/`; export const PATH_SPRINT_DASHBOARD = `${PATH_BASE}/dashboard`; export const PATH_CELLS = `${PATH_SPRINT_DASHBOARD}/cells/`; diff --git a/frontend/src/middleware/api.js b/frontend/src/middleware/api.js new file mode 100644 index 0000000..12ac627 --- /dev/null +++ b/frontend/src/middleware/api.js @@ -0,0 +1,45 @@ +import {PATH_REFRESH_TOKEN} from "../constants"; + +const refreshToken = () => { + const refresh = localStorage.getItem("refresh_token"); + const body = JSON.stringify({refresh: refresh}); + + return callApi(PATH_REFRESH_TOKEN, body, "POST", true).then(res => { + if (res.status === 200) { + + + } + return res; + }); +}; + +export const callApi = (endpoint, body = null, method = null, refresh_token = false) => { + method = method ? method : body ? "POST" : "GET"; + let access_token = localStorage.getItem("access_token"); + + let headers = { + "Content-Type": "application/json", + }; + + if (access_token) { + headers.Authorization = `Bearer ${access_token}`; + } + + return fetch(endpoint, {headers, body, method: method}).then(res => { + if (res.status === 401 && !refresh_token) { + return refreshToken().then(ref => { + if (ref.status === 200) { + return ref.json().then(json => { + headers.Authorization = `Bearer ${json.access}`; + localStorage.setItem("access_token", json.access); + localStorage.setItem("refresh_token", json.refresh); + + return fetch(endpoint, {headers, body, method: method}); + }); + } + return res; + }); + } + return res; + }); +}; diff --git a/frontend/src/reducers/auth.js b/frontend/src/reducers/auth.js index 452d317..64ec741 100644 --- a/frontend/src/reducers/auth.js +++ b/frontend/src/reducers/auth.js @@ -1,5 +1,4 @@ const initialState = { - token: localStorage.getItem("token"), isAuthenticated: null, isLoading: true, user: null, @@ -19,7 +18,8 @@ export default function auth(state=initialState, action) { case 'LOGIN_SUCCESSFUL': case 'REGISTRATION_SUCCESSFUL': - localStorage.setItem("token", action.data.token); + localStorage.setItem("access_token", action.data.access_token); + localStorage.setItem("refresh_token", action.data.refresh_token); return {...state, ...action.data, isAuthenticated: true, isLoading: false, errors: null}; case 'EMAIL_VERIFICATION_SUCCESSFUL': @@ -30,8 +30,15 @@ export default function auth(state=initialState, action) { case 'REGISTRATION_FAILED': case 'LOGOUT_SUCCESSFUL': case 'EMAIL_VERIFICATION_FAILED': - localStorage.removeItem("token"); - return {...state, errors: action.data, token: null, user: null, + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + + // Handle non-standard error Object from simpleJWT. + if (action.data && action.data.messages) { + action.data = action.data.messages.map(entry => entry.message); + } + + return {...state, errors: action.data, user: null, isAuthenticated: false, isLoading: false}; default: diff --git a/requirements/base.txt b/requirements/base.txt index fbfc289..368eae7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -20,12 +20,13 @@ django-cors-headers~=3.0.2 # https://github.com/ottoyiu/django-cors-headers # ------------------------------------------------------------------------------ djangorestframework~=3.9.4 # https://github.com/encode/django-rest-framework djangorestframework-jwt~=1.11.0 # https://github.com/GetBlimp/django-rest-framework-jwt +djangorestframework_simplejwt~=4.3.0 # https://github.com/davesque/django-rest-framework-simplejwt coreapi~=2.3.3 # https://github.com/core-api/python-client drf-yasg~=1.15.0 # https://github.com/axnsan12/drf-yasg # REST auth # ------------------------------------------------------------------------------ -django-rest-auth~=0.9.5 # https://github.com/Tivix/django-rest-auth +git+https://github.com/open-craft/django-rest-auth # fork of https://github.com/Tivix/django-rest-auth with simpleJWT support # Jira API diff --git a/sprints/users/jwt.py b/sprints/users/jwt.py deleted file mode 100644 index 39cb9bf..0000000 --- a/sprints/users/jwt.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework_jwt.utils import jwt_payload_handler - - -def jwt_payload_handler_custom(user): - """Include custom data in JWT token.""" - payload = jwt_payload_handler(user) - payload['email'] = user.email - payload['is_staff'] = user.is_staff - return payload From 4926918ba12399e659402fbd54724a4283df8496 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Sat, 5 Oct 2019 16:50:49 +0200 Subject: [PATCH 2/2] [BB-1718] Pin forked `django-rest-auth` dependency --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 368eae7..962f9e0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -26,7 +26,7 @@ drf-yasg~=1.15.0 # https://github.com/axnsan12/drf-yasg # REST auth # ------------------------------------------------------------------------------ -git+https://github.com/open-craft/django-rest-auth # fork of https://github.com/Tivix/django-rest-auth with simpleJWT support +git+https://github.com/open-craft/django-rest-auth.git@0.10.0 # fork of https://github.com/Tivix/django-rest-auth with simpleJWT support # Jira API