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 4a3b2bd..da86249 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..d80cb29 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -56,22 +56,11 @@ export function logout() { export function fetchUserData() { return (dispatch) => 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)); + }); } export function changePassword({ @@ -94,14 +83,138 @@ export function changePassword({ ; } +let middlewareAdded = false; +export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth + return (dispatch, getState) => { + if (!middlewareAdded) { + request.addMiddleware(tokenCheckMiddleware(dispatch, getState)); + request.addMiddleware(tokenApplyMiddleware(dispatch, getState)); + middlewareAdded = true; + } -export function authenticate(token) { - if (!token || token.split('.').length !== 3) { - throw new Error('Invalid token'); - } + refreshToken = refreshToken || getState().user.refreshToken; + dispatch(updateUser({ + token, + refreshToken + })); - return (dispatch) => { - request.setAuthToken(token); - return dispatch(fetchUserData()); + return dispatch(fetchUserData()).then((resp) => { + dispatch(updateUser({ + 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('.'); + + 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 new file mode 100644 index 0000000..bea1019 --- /dev/null +++ b/src/services/api/authentication.js @@ -0,0 +1,21 @@ +import request from 'services/request'; + +export default { + login({ + login = '', + password = '', + rememberMe = false + }) { + return request.post( + '/api/authentication/login', + {login, password, rememberMe} + ); + }, + + refreshToken(refresh_token) { + return request.post( + '/api/authentication/refresh-token', + {refresh_token} + ); + } +}; 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/src/services/request.js b/src/services/request.js index 81099a2..308b225 100644 --- a/src/services/request.js +++ b/src/services/request.js @@ -1,3 +1,73 @@ +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;}); +const handleResponseSuccess = (resp) => Promise[resp.success || typeof resp.success === 'undefined' ? 'resolve' : 'reject'](resp); + +function doFetch(url, options = {}) { + // NOTE: we are wrapping fetch, because it is returning + // Promise instance that can not be pollyfilled with Promise.prototype.finally + + options.headers = options.headers || {}; + options.headers.Accept = 'application/json'; + + 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) + ); +} + function convertQueryValue(value) { if (typeof value === 'undefined') { return ''; @@ -25,64 +95,3 @@ function buildQuery(data = {}) { .join('&') ; } - -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 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 getDefaultHeaders = () => { - const header = {Accept: 'application/json'}; - - if (authToken) { - header.Authorization = `Bearer ${authToken}`; - } - - return header; -}; - -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) { - if (typeof data === 'object') { - const separator = url.indexOf('?') === -1 ? '?' : '&'; - url += separator + buildQuery(data); - } - - return doFetch(url, { - headers: getDefaultHeaders() - }) - .then(checkStatus) - .then(toJSON, rejectWithJSON) - .then(handleResponse) - ; - }, - - buildQuery, - - setAuthToken(tkn) { - authToken = tkn; - } -}; 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}); }); };