From 818101b710cbdba2ea110588bf73f8c1143b33d4 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 4 Jun 2016 16:46:39 +0300 Subject: [PATCH 1/4] #126: slightly refactored login and jwt token logic --- src/components/auth/actions.js | 54 +++++++------------ src/components/auth/password/PasswordBody.jsx | 3 +- src/components/user/User.js | 1 + src/components/user/actions.js | 12 ++++- src/services/api/authentication.js | 14 +++++ src/services/authFlow/PasswordState.js | 3 +- tests/services/authFlow/PasswordState.test.js | 6 ++- 7 files changed, 52 insertions(+), 41 deletions(-) create mode 100644 src/services/api/authentication.js diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index 8f280fd..f0c3c70 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -2,6 +2,7 @@ import { routeActions } from 'react-router-redux'; import { updateUser, logout as logoutUser, changePassword as changeUserPassword, authenticate } from 'components/user/actions'; import request from 'services/request'; +import authentication from 'services/api/authentication'; export function login({login = '', password = '', rememberMe = false}) { const PASSWORD_REQUIRED = 'error.password_required'; @@ -9,28 +10,21 @@ export function login({login = '', password = '', rememberMe = false}) { const ACTIVATION_REQUIRED = 'error.account_not_activated'; return wrapInLoader((dispatch) => - request.post( - '/api/authentication/login', + authentication.login( {login, password, rememberMe} ) - .then((resp) => { - dispatch(updateUser({ - isGuest: false, - token: resp.jwt - })); - - return dispatch(authenticate(resp.jwt)); - }) + .then(authHandler(dispatch)) .catch((resp) => { - if (resp.errors.login === ACTIVATION_REQUIRED) { - return dispatch(needActivation()); - } else if (resp.errors.password === PASSWORD_REQUIRED) { - return dispatch(updateUser({ - username: login, - email: login - })); - } else if (resp.errors) { - if (resp.errors.login === LOGIN_REQUIRED && password) { + if (resp.errors) { + if (resp.errors.password === PASSWORD_REQUIRED) { + return dispatch(updateUser({ + username: login, + email: login + })); + } else if (resp.errors.login === ACTIVATION_REQUIRED) { + return dispatch(needActivation()); + } else if (resp.errors.login === LOGIN_REQUIRED && password) { + // return to the first step dispatch(logout()); } } @@ -76,14 +70,7 @@ export function recoverPassword({ '/api/authentication/recover-password', {key, newPassword, newRePassword} ) - .then((resp) => { - dispatch(updateUser({ - isGuest: false, - isActive: true - })); - - return dispatch(authenticate(resp.jwt)); - }) + .then(authHandler(dispatch)) .catch(validationErrorsHandler(dispatch, '/forgot-password')) ); } @@ -118,14 +105,7 @@ export function activate({key = ''}) { '/api/signup/confirm', {key} ) - .then((resp) => { - dispatch(updateUser({ - isGuest: false, - isActive: true - })); - - return dispatch(authenticate(resp.jwt)); - }) + .then(authHandler(dispatch)) .catch(validationErrorsHandler(dispatch, '/resend-activation')) ); } @@ -341,6 +321,10 @@ function needActivation() { }); } +function authHandler(dispatch) { + return (resp) => dispatch(authenticate(resp.access_token, resp.refresh_token)); +} + function validationErrorsHandler(dispatch, repeatUrl) { return (resp) => { if (resp.errors) { diff --git a/src/components/auth/password/PasswordBody.jsx b/src/components/auth/password/PasswordBody.jsx index ea9d80a..cd47753 100644 --- a/src/components/auth/password/PasswordBody.jsx +++ b/src/components/auth/password/PasswordBody.jsx @@ -32,6 +32,7 @@ export default class PasswordBody extends BaseAuthBody { {user.email || user.username} + diff --git a/src/components/user/User.js b/src/components/user/User.js index 8dc4a08..7e9672f 100644 --- a/src/components/user/User.js +++ b/src/components/user/User.js @@ -19,6 +19,7 @@ export default class User { id: null, uuid: null, token: '', + refreshToken: '', username: '', email: '', // will contain user's email or masked email diff --git a/src/components/user/actions.js b/src/components/user/actions.js index 13af82a..05f82ea 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -95,13 +95,21 @@ export function changePassword({ } -export function authenticate(token) { +export function authenticate(token, refreshToken) { if (!token || token.split('.').length !== 3) { throw new Error('Invalid token'); } return (dispatch) => { request.setAuthToken(token); - return dispatch(fetchUserData()); + + return dispatch(fetchUserData()).then((resp) => { + dispatch(updateUser({ + isGuest: false, + token, + refreshToken + })); + return resp; + }); }; } diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js new file mode 100644 index 0000000..90282cd --- /dev/null +++ b/src/services/api/authentication.js @@ -0,0 +1,14 @@ +import request from 'services/request'; + +export default { + login({ + login = '', + password = '', + rememberMe = false + }) { + return request.post( + '/api/authentication/login', + {login, password, rememberMe} + ); + } +}; diff --git a/src/services/authFlow/PasswordState.js b/src/services/authFlow/PasswordState.js index 2bdd112..13e6c22 100644 --- a/src/services/authFlow/PasswordState.js +++ b/src/services/authFlow/PasswordState.js @@ -14,11 +14,12 @@ export default class PasswordState extends AbstractState { } } - resolve(context, {password}) { + resolve(context, {password, rememberMe}) { const {user} = context.getState(); context.run('login', { password, + rememberMe, login: user.email || user.username }) .then(() => context.setState(new CompleteState())); diff --git a/tests/services/authFlow/PasswordState.test.js b/tests/services/authFlow/PasswordState.test.js index 2606012..5a003de 100644 --- a/tests/services/authFlow/PasswordState.test.js +++ b/tests/services/authFlow/PasswordState.test.js @@ -48,6 +48,7 @@ describe('PasswordState', () => { (function() { const expectedLogin = 'login'; const expectedPassword = 'password'; + const expectedRememberMe = true; const testWith = (user) => { it(`should call login with email or username and password. User: ${JSON.stringify(user)}`, () => { @@ -58,11 +59,12 @@ describe('PasswordState', () => { 'login', sinon.match({ login: expectedLogin, - password: expectedPassword + password: expectedPassword, + rememberMe: expectedRememberMe, }) ).returns({then() {}}); - state.resolve(context, {password: expectedPassword}); + state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe}); }); }; From 3bf02f16dcfbcb9c05b388309ada03580fc6c09c Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 4 Jun 2016 17:58:29 +0300 Subject: [PATCH 2/4] #126: added refresh token support on frontend --- src/components/user/actions.js | 79 +++++++++++++++++++++--------- src/services/api/authentication.js | 7 +++ 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/components/user/actions.js b/src/components/user/actions.js index 05f82ea..d7a2578 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -54,24 +54,39 @@ export function logout() { } export function fetchUserData() { - return (dispatch) => + return (dispatch, getState) => accounts.current() - .then((resp) => { - dispatch(updateUser(resp)); + .then((resp) => { + dispatch(updateUser(resp)); - return dispatch(changeLang(resp.lang)); - }); - /* - .catch((resp) => { - { - "name": "Unauthorized", - "message": "You are requesting with an invalid credential.", - "code": 0, - "status": 401, - "type": "yii\\web\\UnauthorizedHttpException" - } - }); - */ + return dispatch(changeLang(resp.lang)); + }) + .catch((resp) => { + /* + { + "name": "Unauthorized", + "message": "You are requesting with an invalid credential.", + "code": 0, + "status": 401, + "type": "yii\\web\\UnauthorizedHttpException" + } + { + "name": "Unauthorized", + "message": "Token expired", + "code": 0, + "status": 401, + "type": "yii\\web\\UnauthorizedHttpException" + } + */ + if (resp && resp.status === 401) { + const {token} = getState().user; + if (resp.message === 'Token expired' && token) { + return dispatch(authenticate(token)); + } + + dispatch(logout()); + } + }); } export function changePassword({ @@ -95,14 +110,20 @@ export function changePassword({ } -export function authenticate(token, refreshToken) { - if (!token || token.split('.').length !== 3) { - throw new Error('Invalid token'); - } +import authentication from 'services/api/authentication'; +export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth + const jwt = getJWTPayload(token); + + return (dispatch, getState) => { + refreshToken = refreshToken || getState().user.refreshToken; + + if (jwt.exp < Date.now() / 1000) { + return authentication.refreshToken(refreshToken) + .then((resp) => dispatch(authenticate(resp.access_token))) + .catch(() => dispatch(logout())); + } - return (dispatch) => { request.setAuthToken(token); - return dispatch(fetchUserData()).then((resp) => { dispatch(updateUser({ isGuest: false, @@ -113,3 +134,17 @@ export function authenticate(token, refreshToken) { }); }; } + +function getJWTPayload(jwt) { + const parts = (jwt || '').split('.'); + + if (parts.length !== 3) { + throw new Error('Invalid jwt token'); + } + + try { + return JSON.parse(atob(parts[1])); + } catch (err) { + throw new Error('Can not decode jwt token'); + } +} diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js index 90282cd..bea1019 100644 --- a/src/services/api/authentication.js +++ b/src/services/api/authentication.js @@ -10,5 +10,12 @@ export default { '/api/authentication/login', {login, password, rememberMe} ); + }, + + refreshToken(refresh_token) { + return request.post( + '/api/authentication/refresh-token', + {refresh_token} + ); } }; From 35d430e13ce4f214052254bbafdf35b24bd9026e Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 4 Jun 2016 19:54:42 +0300 Subject: [PATCH 3/4] #126: middleware feature for request service. Created middlewares for token headers and token refreshing --- src/components/user/actions.js | 152 ++++++++++++++++++++++++--------- src/services/request.js | 68 ++++++++------- 2 files changed, 149 insertions(+), 71 deletions(-) diff --git a/src/components/user/actions.js b/src/components/user/actions.js index d7a2578..d80cb29 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -54,38 +54,12 @@ export function logout() { } export function fetchUserData() { - return (dispatch, getState) => + return (dispatch) => accounts.current() .then((resp) => { dispatch(updateUser(resp)); return dispatch(changeLang(resp.lang)); - }) - .catch((resp) => { - /* - { - "name": "Unauthorized", - "message": "You are requesting with an invalid credential.", - "code": 0, - "status": 401, - "type": "yii\\web\\UnauthorizedHttpException" - } - { - "name": "Unauthorized", - "message": "Token expired", - "code": 0, - "status": 401, - "type": "yii\\web\\UnauthorizedHttpException" - } - */ - if (resp && resp.status === 401) { - const {token} = getState().user; - if (resp.message === 'Token expired' && token) { - return dispatch(authenticate(token)); - } - - dispatch(logout()); - } }); } @@ -109,32 +83,128 @@ export function changePassword({ ; } - -import authentication from 'services/api/authentication'; +let middlewareAdded = false; export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth - const jwt = getJWTPayload(token); - return (dispatch, getState) => { - refreshToken = refreshToken || getState().user.refreshToken; - - if (jwt.exp < Date.now() / 1000) { - return authentication.refreshToken(refreshToken) - .then((resp) => dispatch(authenticate(resp.access_token))) - .catch(() => dispatch(logout())); + if (!middlewareAdded) { + request.addMiddleware(tokenCheckMiddleware(dispatch, getState)); + request.addMiddleware(tokenApplyMiddleware(dispatch, getState)); + middlewareAdded = true; } - request.setAuthToken(token); + refreshToken = refreshToken || getState().user.refreshToken; + dispatch(updateUser({ + token, + refreshToken + })); + return dispatch(fetchUserData()).then((resp) => { dispatch(updateUser({ - isGuest: false, - token, - refreshToken + isGuest: false })); return resp; }); }; } +import authentication from 'services/api/authentication'; +function requestAccessToken(refreshToken, dispatch) { + let promise; + if (refreshToken) { + promise = authentication.refreshToken(refreshToken); + } else { + promise = Promise.reject(); + } + + return promise + .then((resp) => dispatch(updateUser({ + token: resp.access_token + }))) + .catch(() => dispatch(logout())); +} + +/** + * Ensures, that all user's requests have fresh access token + * + * @param {Function} dispatch + * @param {Function} getState + * + * @return {Object} middleware + */ +function tokenCheckMiddleware(dispatch, getState) { + return { + before(data) { + const {isGuest, refreshToken, token} = getState().user; + const isRefreshTokenRequest = data.url.includes('refresh-token'); + + if (isGuest || isRefreshTokenRequest || !token) { + return data; + } + + const SAFETY_FACTOR = 60; // ask new token earlier to overcome time dissynchronization problem + const jwt = getJWTPayload(token); + + if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) { + return requestAccessToken(refreshToken, dispatch).then(() => data); + } + + return data; + }, + + catch(resp, restart) { + /* + { + "name": "Unauthorized", + "message": "You are requesting with an invalid credential.", + "code": 0, + "status": 401, + "type": "yii\\web\\UnauthorizedHttpException" + } + { + "name": "Unauthorized", + "message": "Token expired", + "code": 0, + "status": 401, + "type": "yii\\web\\UnauthorizedHttpException" + } + */ + if (resp && resp.status === 401) { + const {refreshToken} = getState().user; + if (resp.message === 'Token expired' && refreshToken) { + // request token and retry + return requestAccessToken(refreshToken, dispatch).then(restart); + } + + dispatch(logout()); + } + + return Promise.reject(resp); + } + }; +} + +/** + * Applies Bearer header for all requests + * + * @param {Function} dispatch + * @param {Function} getState + * + * @return {Object} middleware + */ +function tokenApplyMiddleware(dispatch, getState) { + return { + before(data) { + const {token} = getState().user; + + if (token) { + data.options.headers.Authorization = `Bearer ${token}`; + } + + return data; + } + }; +} + function getJWTPayload(jwt) { const parts = (jwt || '').split('.'); diff --git a/src/services/request.js b/src/services/request.js index 81099a2..415d5ef 100644 --- a/src/services/request.js +++ b/src/services/request.js @@ -26,43 +26,55 @@ function buildQuery(data = {}) { ; } -function doFetch(...args) { - // NOTE: we are wrapping fetch, because it is returning - // Promise instance that can not be pollyfilled with Promise.prototype.finally - return new Promise((resolve, reject) => fetch(...args).then(resolve, reject)); -} - -let authToken; +const middlewares = []; const checkStatus = (resp) => Promise[resp.status >= 200 && resp.status < 300 ? 'resolve' : 'reject'](resp); const toJSON = (resp) => resp.json(); const rejectWithJSON = (resp) => toJSON(resp).then((resp) => {throw resp;}); -const handleResponse = (resp) => Promise[resp.success || typeof resp.success === 'undefined' ? 'resolve' : 'reject'](resp); +const handleResponseSuccess = (resp) => Promise[resp.success || typeof resp.success === 'undefined' ? 'resolve' : 'reject'](resp); -const getDefaultHeaders = () => { - const header = {Accept: 'application/json'}; +function doFetch(url, options = {}) { + // NOTE: we are wrapping fetch, because it is returning + // Promise instance that can not be pollyfilled with Promise.prototype.finally - if (authToken) { - header.Authorization = `Bearer ${authToken}`; - } + options.headers = options.headers || {}; + options.headers.Accept = 'application/json'; - return header; -}; + return runMiddlewares('before', {url, options}) + .then(({url, options}) => fetch(url, options)) + .then(checkStatus) + .then(toJSON, rejectWithJSON) + .then(handleResponseSuccess) + .then((resp) => runMiddlewares('then', resp)) + .catch((resp) => runMiddlewares('catch', resp, () => doFetch(url, options))) + ; +} + +/** + * @param {string} action - the name of middleware's hook (before|then|catch) + * @param {Object} data - the initial data to pass through middlewares chain + * @param {Function} restart - a function to restart current request (for `catch` hook) + * + * @return {Promise} + */ +function runMiddlewares(action, data, restart) { + return middlewares + .filter((middleware) => middleware[action]) + .reduce( + (promise, middleware) => promise.then((resp) => middleware[action](resp, restart)), + Promise[action === 'catch' ? 'reject' : 'resolve'](data) + ); +} export default { post(url, data) { return doFetch(url, { method: 'POST', headers: { - ...getDefaultHeaders(), 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: buildQuery(data) - }) - .then(checkStatus) - .then(toJSON, rejectWithJSON) - .then(handleResponse) - ; + }); }, get(url, data) { @@ -71,18 +83,14 @@ export default { url += separator + buildQuery(data); } - return doFetch(url, { - headers: getDefaultHeaders() - }) - .then(checkStatus) - .then(toJSON, rejectWithJSON) - .then(handleResponse) - ; + return doFetch(url); }, buildQuery, - setAuthToken(tkn) { - authToken = tkn; + addMiddleware(middleware) { + if (!middlewares.find((mdware) => mdware === middleware)) { + middlewares.push(middleware); + } } }; From 0ef3b00ee2af7541f701cc0c0c4b3b5770faa854 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 4 Jun 2016 20:11:42 +0300 Subject: [PATCH 4/4] Change functions order in request service --- src/services/request.js | 111 ++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/src/services/request.js b/src/services/request.js index 415d5ef..308b225 100644 --- a/src/services/request.js +++ b/src/services/request.js @@ -1,33 +1,35 @@ -function convertQueryValue(value) { - if (typeof value === 'undefined') { - return ''; - } - - if (value === true) { - return 1; - } - - if (value === false) { - return 0; - } - - return value; -} - -function buildQuery(data = {}) { - return Object.keys(data) - .map( - (keyName) => - [keyName, convertQueryValue(data[keyName])] - .map(encodeURIComponent) - .join('=') - ) - .join('&') - ; -} - const middlewares = []; +export default { + post(url, data) { + return doFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' + }, + body: buildQuery(data) + }); + }, + + get(url, data) { + if (typeof data === 'object') { + const separator = url.indexOf('?') === -1 ? '?' : '&'; + url += separator + buildQuery(data); + } + + return doFetch(url); + }, + + buildQuery, + + addMiddleware(middleware) { + if (!middlewares.find((mdware) => mdware === middleware)) { + middlewares.push(middleware); + } + } +}; + + const checkStatus = (resp) => Promise[resp.status >= 200 && resp.status < 300 ? 'resolve' : 'reject'](resp); const toJSON = (resp) => resp.json(); const rejectWithJSON = (resp) => toJSON(resp).then((resp) => {throw resp;}); @@ -66,31 +68,30 @@ function runMiddlewares(action, data, restart) { ); } -export default { - post(url, data) { - return doFetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' - }, - body: buildQuery(data) - }); - }, - - get(url, data) { - if (typeof data === 'object') { - const separator = url.indexOf('?') === -1 ? '?' : '&'; - url += separator + buildQuery(data); - } - - return doFetch(url); - }, - - buildQuery, - - addMiddleware(middleware) { - if (!middlewares.find((mdware) => mdware === middleware)) { - middlewares.push(middleware); - } +function convertQueryValue(value) { + if (typeof value === 'undefined') { + return ''; } -}; + + if (value === true) { + return 1; + } + + if (value === false) { + return 0; + } + + return value; +} + +function buildQuery(data = {}) { + return Object.keys(data) + .map( + (keyName) => + [keyName, convertQueryValue(data[keyName])] + .map(encodeURIComponent) + .join('=') + ) + .join('&') + ; +}