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});
});
};