From 35d430e13ce4f214052254bbafdf35b24bd9026e Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 4 Jun 2016 19:54:42 +0300 Subject: [PATCH] #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); + } } };