#365: Redirect user to login page, when token can not be refreshed. Further improvement of auth errors handling

This commit is contained in:
SleepWalker
2017-12-30 21:04:31 +02:00
parent 50d753e006
commit 9afa4be8cb
20 changed files with 519 additions and 313 deletions

View File

@ -21,14 +21,7 @@ export class AccountSwitcher extends Component {
removeAccount: PropTypes.func.isRequired, removeAccount: PropTypes.func.isRequired,
onAfterAction: PropTypes.func, // called after each action performed onAfterAction: PropTypes.func, // called after each action performed
onSwitch: PropTypes.func, // called after switching an account. The active account will be passed as arg onSwitch: PropTypes.func, // called after switching an account. The active account will be passed as arg
accounts: PropTypes.shape({ // TODO: accounts shape accounts: PropTypes.object, // eslint-disable-line
active: PropTypes.shape({
id: PropTypes.number
}),
available: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number
}))
}),
user: userShape, // TODO: remove me, when we will be sure, that accounts.active is always set for user (event after register) user: userShape, // TODO: remove me, when we will be sure, that accounts.active is always set for user (event after register)
skin: PropTypes.oneOf(skins), skin: PropTypes.oneOf(skins),
highlightActiveAccount: PropTypes.bool, // whether active account should be expanded and shown on the top highlightActiveAccount: PropTypes.bool, // whether active account should be expanded and shown on the top

View File

@ -1,10 +1,13 @@
// @flow
import { getJwtPayload } from 'functions';
import { browserHistory } from 'services/history'; import { browserHistory } from 'services/history';
import { sessionStorage } from 'services/localStorage'; import { sessionStorage } from 'services/localStorage';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import { setLogin } from 'components/auth/actions';
import { updateUser, setGuest } from 'components/user/actions'; import { updateUser, setGuest } from 'components/user/actions';
import { setLocale } from 'components/i18n/actions'; import { setLocale } from 'components/i18n/actions';
import { setAccountSwitcher } from 'components/auth/actions'; import { setAccountSwitcher } from 'components/auth/actions';
import { getActiveAccount } from 'components/accounts/reducer';
import logger from 'services/logger'; import logger from 'services/logger';
import { import {
@ -13,19 +16,22 @@ import {
activate, activate,
reset, reset,
updateToken updateToken
} from 'components/accounts/actions/pure-actions'; } from './actions/pure-actions';
import type { Account, State as AccountsState } from './reducer';
type Dispatch = (action: Object) => Promise<*>;
type State = {
accounts: AccountsState,
auth: {
oauth?: {
clientId?: string
},
},
};
export { updateToken }; export { updateToken };
/**
* @typedef {object} Account
* @property {string} id
* @property {string} username
* @property {string} email
* @property {string} token
* @property {string} refreshToken
*/
/** /**
* @param {Account|object} account * @param {Account|object} account
* @param {string} account.token * @param {string} account.token
@ -33,15 +39,25 @@ export { updateToken };
* *
* @return {function} * @return {function}
*/ */
export function authenticate({token, refreshToken}) { export function authenticate(account: Account | {
return (dispatch, getState) => token: string,
refreshToken: ?string,
}) {
const {token, refreshToken} = account;
const email = account.email || null;
return (dispatch: Dispatch, getState: () => State): Promise<Account> =>
authentication.validateToken({token, refreshToken}) authentication.validateToken({token, refreshToken})
.catch((resp = {}) => { .catch((resp = {}) => {
// all the logic to get the valid token was failed, // all the logic to get the valid token was failed,
// we must forget current token, but leave other user's accounts // looks like we have some problems with token
return dispatch(logoutAll()) // lets redirect to login page
.then(() => Promise.reject(resp)); if (typeof email === 'string') {
// TODO: we should somehow try to find email by token
dispatch(relogin(email));
}
return Promise.reject(resp);
}) })
.then(({token, refreshToken, user}) => ({ .then(({token, refreshToken, user}) => ({
user: { user: {
@ -84,6 +100,89 @@ export function authenticate({token, refreshToken}) {
}); });
} }
export function ensureToken() {
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
const {token} = getActiveAccount(getState()) || {};
try {
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time dissynchronization problem
const jwt = getJwtPayload(token);
if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) {
return dispatch(requestNewToken());
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token
});
dispatch(relogin());
return Promise.reject(new Error('Invalid token'));
}
return Promise.resolve();
};
}
export function recoverFromTokenError(error: ?{
status: number,
message: string,
}) {
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
if (error && error.status === 401) {
const activeAccount = getActiveAccount(getState());
if (activeAccount && activeAccount.refreshToken) {
if ([
'Token expired',
'Incorrect token',
'You are requesting with an invalid credential.'
].includes(error.message)) {
// request token and retry
return dispatch(requestNewToken());
}
logger.error('Unknown unauthorized response', {
error
});
}
// user's access token is outdated and we have no refreshToken
// or something unexpected happend
// in both cases we resetting all the user's state
dispatch(relogin());
}
return Promise.reject(error);
};
}
export function requestNewToken() {
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
const {refreshToken} = getActiveAccount(getState()) || {};
if (!refreshToken) {
dispatch(relogin());
return Promise.resolve();
}
return authentication.requestToken(refreshToken)
.then(({ token }) => {
dispatch(updateToken(token));
})
.catch((resp) => {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
dispatch(relogin());
return Promise.reject(resp);
});
};
}
/** /**
* Remove one account from current user's account list * Remove one account from current user's account list
* *
@ -91,9 +190,9 @@ export function authenticate({token, refreshToken}) {
* *
* @return {function} * @return {function}
*/ */
export function revoke(account) { export function revoke(account: Account) {
return (dispatch, getState) => { return (dispatch: Dispatch, getState: () => State): Promise<void> => {
const accountToReplace = getState().accounts.available.find(({id}) => id !== account.id); const accountToReplace: ?Account = getState().accounts.available.find(({id}) => id !== account.id);
if (accountToReplace) { if (accountToReplace) {
return dispatch(authenticate(accountToReplace)) return dispatch(authenticate(accountToReplace))
@ -107,17 +206,34 @@ export function revoke(account) {
}; };
} }
export function relogin(email?: string) {
return (dispatch: Dispatch, getState: () => State) => {
const activeAccount = getActiveAccount(getState());
if (!email && activeAccount) {
email = activeAccount.email;
}
email && dispatch(setLogin(email));
browserHistory.push('/login');
};
}
export function logoutAll() { export function logoutAll() {
return (dispatch, getState) => { return (dispatch: Dispatch, getState: () => State): Promise<void> => {
dispatch(setGuest()); dispatch(setGuest());
const {accounts: {available}} = getState(); const {accounts: {available}} = getState();
available.forEach((account) => authentication.logout(account)); available.forEach((account) =>
authentication.logout(account)
.catch(() => {
// we don't care
})
);
dispatch(reset()); dispatch(reset());
dispatch(relogin());
browserHistory.push('/login');
return Promise.resolve(); return Promise.resolve();
}; };
@ -132,10 +248,11 @@ export function logoutAll() {
* @return {function} * @return {function}
*/ */
export function logoutStrangers() { export function logoutStrangers() {
return (dispatch, getState) => { return (dispatch: Dispatch, getState: () => State): Promise<void> => {
const {accounts: {available, active}} = getState(); const {accounts: {available}} = getState();
const activeAccount = getActiveAccount(getState());
const isStranger = ({refreshToken, id}) => !refreshToken && !sessionStorage.getItem(`stranger${id}`); const isStranger = ({refreshToken, id}: Account) => !refreshToken && !sessionStorage.getItem(`stranger${id}`);
if (available.some(isStranger)) { if (available.some(isStranger)) {
const accountToReplace = available.filter((account) => !isStranger(account))[0]; const accountToReplace = available.filter((account) => !isStranger(account))[0];
@ -147,7 +264,7 @@ export function logoutStrangers() {
authentication.logout(account); authentication.logout(account);
}); });
if (isStranger(active)) { if (activeAccount && isStranger(activeAccount)) {
return dispatch(authenticate(accountToReplace)); return dispatch(authenticate(accountToReplace));
} }
} else { } else {

View File

@ -21,7 +21,7 @@ import {
import { SET_LOCALE } from 'components/i18n/actions'; import { SET_LOCALE } from 'components/i18n/actions';
import { updateUser, setUser } from 'components/user/actions'; import { updateUser, setUser } from 'components/user/actions';
import { setAccountSwitcher } from 'components/auth/actions'; import { setLogin, setAccountSwitcher } from 'components/auth/actions';
const account = { const account = {
id: 1, id: 1,
@ -57,6 +57,10 @@ describe('components/accounts/actions', () => {
}); });
sinon.stub(authentication, 'validateToken').named('authentication.validateToken'); sinon.stub(authentication, 'validateToken').named('authentication.validateToken');
sinon.stub(browserHistory, 'push').named('browserHistory.push');
sinon.stub(authentication, 'logout').named('authentication.logout');
authentication.logout.returns(Promise.resolve());
authentication.validateToken.returns(Promise.resolve({ authentication.validateToken.returns(Promise.resolve({
token: account.token, token: account.token,
refreshToken: account.refreshToken, refreshToken: account.refreshToken,
@ -66,6 +70,8 @@ describe('components/accounts/actions', () => {
afterEach(() => { afterEach(() => {
authentication.validateToken.restore(); authentication.validateToken.restore();
authentication.logout.restore();
browserHistory.push.restore();
}); });
describe('#authenticate()', () => { describe('#authenticate()', () => {
@ -118,14 +124,16 @@ describe('components/accounts/actions', () => {
it('rejects when bad auth data', () => { it('rejects when bad auth data', () => {
authentication.validateToken.returns(Promise.reject({})); authentication.validateToken.returns(Promise.reject({}));
return expect(authenticate(account)(dispatch, getState), 'to be rejected').then(() => { return expect(authenticate(account)(dispatch, getState), 'to be rejected')
expect(dispatch, 'to have a call satisfying', [ .then(() => {
{payload: {isGuest: true}}, expect(dispatch, 'to have a call satisfying', [
]); setLogin(account.email)
expect(dispatch, 'to have a call satisfying', [ ]);
reset()
]); expect(browserHistory.push, 'to have a call satisfying', [
}); '/login'
]);
});
}); });
it('rejects when 5xx without logouting', () => { it('rejects when 5xx without logouting', () => {
@ -182,19 +190,11 @@ describe('components/accounts/actions', () => {
}); });
describe('#revoke()', () => { describe('#revoke()', () => {
beforeEach(() => {
sinon.stub(authentication, 'logout').named('authentication.logout');
});
afterEach(() => {
authentication.logout.restore();
});
describe('when one account available', () => { describe('when one account available', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: account, active: account.id,
available: [account] available: [account]
}, },
user user
@ -220,8 +220,7 @@ describe('components/accounts/actions', () => {
it('should update user state', () => it('should update user state', () =>
revoke(account)(dispatch, getState).then(() => revoke(account)(dispatch, getState).then(() =>
expect(dispatch, 'to have a call satisfying', [ expect(dispatch, 'to have a call satisfying', [
{payload: {isGuest: true}} setUser({isGuest: true})
// updateUser({isGuest: true})
]) ])
// expect(dispatch, 'to have calls satisfying', [ // expect(dispatch, 'to have calls satisfying', [
// [remove(account)], // [remove(account)],
@ -238,7 +237,7 @@ describe('components/accounts/actions', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: account2, active: account2.id,
available: [account, account2] available: [account, account2]
}, },
user user
@ -277,19 +276,11 @@ describe('components/accounts/actions', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: account2, active: account2.id,
available: [account, account2] available: [account, account2]
}, },
user user
}); });
sinon.stub(authentication, 'logout').named('authentication.logout');
sinon.stub(browserHistory, 'push').named('browserHistory.push');
});
afterEach(() => {
authentication.logout.restore();
browserHistory.push.restore();
}); });
it('should call logout api method for each account', () => { it('should call logout api method for each account', () => {
@ -344,17 +335,11 @@ describe('components/accounts/actions', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: foreignAccount, active: foreignAccount.id,
available: [account, foreignAccount, foreignAccount2] available: [account, foreignAccount, foreignAccount2]
}, },
user user
}); });
sinon.stub(authentication, 'logout').named('authentication.logout');
});
afterEach(() => {
authentication.logout.restore();
}); });
it('should remove stranger accounts', () => { it('should remove stranger accounts', () => {
@ -389,7 +374,7 @@ describe('components/accounts/actions', () => {
it('should not activate another account if active account is already not a stranger', () => { it('should not activate another account if active account is already not a stranger', () => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: account, active: account.id,
available: [account, foreignAccount] available: [account, foreignAccount]
}, },
user user
@ -405,7 +390,7 @@ describe('components/accounts/actions', () => {
it('should not dispatch if no strangers', () => { it('should not dispatch if no strangers', () => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: account, active: account.id,
available: [account] available: [account]
}, },
user user
@ -421,7 +406,7 @@ describe('components/accounts/actions', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: foreignAccount, active: foreignAccount.id,
available: [foreignAccount, foreignAccount2] available: [foreignAccount, foreignAccount2]
}, },
user user
@ -431,9 +416,13 @@ describe('components/accounts/actions', () => {
}); });
it('logouts all accounts', () => { it('logouts all accounts', () => {
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount],
[foreignAccount2],
]);
expect(dispatch, 'to have a call satisfying', [ expect(dispatch, 'to have a call satisfying', [
{payload: {isGuest: true}} setUser({isGuest: true})
// updateUser({isGuest: true})
]); ]);
expect(dispatch, 'to have a call satisfying', [ expect(dispatch, 'to have a call satisfying', [

View File

@ -1,3 +1,13 @@
// @flow
import type {
Account,
AddAction,
RemoveAction,
ActivateAction,
UpdateTokenAction,
ResetAction
} from '../reducer';
export const ADD = 'accounts:add'; export const ADD = 'accounts:add';
/** /**
* @api private * @api private
@ -6,7 +16,7 @@ export const ADD = 'accounts:add';
* *
* @return {object} - action definition * @return {object} - action definition
*/ */
export function add(account) { export function add(account: Account): AddAction {
return { return {
type: ADD, type: ADD,
payload: account payload: account
@ -21,7 +31,7 @@ export const REMOVE = 'accounts:remove';
* *
* @return {object} - action definition * @return {object} - action definition
*/ */
export function remove(account) { export function remove(account: Account): RemoveAction {
return { return {
type: REMOVE, type: REMOVE,
payload: account payload: account
@ -36,7 +46,7 @@ export const ACTIVATE = 'accounts:activate';
* *
* @return {object} - action definition * @return {object} - action definition
*/ */
export function activate(account) { export function activate(account: Account): ActivateAction {
return { return {
type: ACTIVATE, type: ACTIVATE,
payload: account payload: account
@ -49,7 +59,7 @@ export const RESET = 'accounts:reset';
* *
* @return {object} - action definition * @return {object} - action definition
*/ */
export function reset() { export function reset(): ResetAction {
return { return {
type: RESET type: RESET
}; };
@ -61,7 +71,7 @@ export const UPDATE_TOKEN = 'accounts:updateToken';
* *
* @return {object} - action definition * @return {object} - action definition
*/ */
export function updateToken(token) { export function updateToken(token: string): UpdateTokenAction {
return { return {
type: UPDATE_TOKEN, type: UPDATE_TOKEN,
payload: token payload: token

View File

@ -1 +1,3 @@
export AccountSwitcher from './AccountSwitcher'; // @flow
export { default as AccountSwitcher } from './AccountSwitcher';
export type { Account } from './reducer';

View File

@ -1,30 +1,52 @@
import { ADD, REMOVE, ACTIVATE, RESET, UPDATE_TOKEN } from './actions/pure-actions'; // @flow
export type Account = {
id: number,
username: string,
email: string,
token: string,
refreshToken: ?string,
};
/** export type State = {
* @typedef {AccountsState} active: ?number,
* @property {Account} active available: Array<Account>,
* @property {Account[]} available };
*/
export type AddAction = { type: 'accounts:add', payload: Account};
export type RemoveAction = { type: 'accounts:remove', payload: Account};
export type ActivateAction = { type: 'accounts:activate', payload: Account};
export type UpdateTokenAction = { type: 'accounts:updateToken', payload: string };
export type ResetAction = { type: 'accounts:reset' };
type Action =
| AddAction
| RemoveAction
| ActivateAction
| UpdateTokenAction
| ResetAction;
export function getActiveAccount(state: { accounts: State }): ?Account {
const activeAccount = state.accounts.active;
// TODO: remove activeAccount.id, when will be sure, that magor part of users have migrated to new state structure
const accountId: number | void = typeof activeAccount === 'number' ? activeAccount : (activeAccount || {}).id;
return state.accounts.available.find((account) => account.id === accountId);
}
/**
* @param {AccountsState} state
* @param {string} options.type
* @param {object} options.payload
*
* @return {AccountsState}
*/
export default function accounts( export default function accounts(
state = { state: State = {
active: null, active: null,
available: [] available: []
}, },
{type, payload = {}} action: Action
) { ): State {
switch (type) { switch (action.type) {
case ADD: case 'accounts:add': {
if (!payload || !payload.id || !payload.token) { if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add'); throw new Error('Invalid or empty payload passed for accounts.add');
} }
const { payload } = action;
state.available = state.available state.available = state.available
.filter((account) => account.id !== payload.id) .filter((account) => account.id !== payload.id)
@ -39,44 +61,70 @@ export default function accounts(
}); });
return state; return state;
}
case ACTIVATE: case 'accounts:activate': {
if (!payload || !payload.id || !payload.token) { if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add'); throw new Error('Invalid or empty payload passed for accounts.add');
} }
const { payload } = action;
return { return {
...state, available: state.available.map((account) => {
active: payload if (account.id === payload.id) {
return {...payload};
}
return {...account};
}),
active: payload.id
};
}
case 'accounts:reset':
return {
active: null,
available: []
}; };
case RESET: case 'accounts:remove': {
return accounts(undefined, {}); if (!action.payload || !action.payload.id) {
case REMOVE:
if (!payload || !payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove'); throw new Error('Invalid or empty payload passed for accounts.remove');
} }
const { payload } = action;
return { return {
...state, ...state,
available: state.available.filter((account) => account.id !== payload.id) available: state.available.filter((account) => account.id !== payload.id)
}; };
}
case UPDATE_TOKEN: case 'accounts:updateToken': {
if (typeof payload !== 'string') { if (typeof action.payload !== 'string') {
throw new Error('payload must be a jwt token'); throw new Error('payload must be a jwt token');
} }
const { payload } = action;
return { return {
...state, ...state,
active: { available: state.available.map((account) => {
...state.active, if (account.id === state.active) {
token: payload return {
} ...account,
token: payload,
};
}
return {...account};
}),
}; };
}
default: default:
(action: empty);
return state; return state;
} }
} }

View File

@ -35,7 +35,7 @@ describe('Accounts reducer', () => {
describe(ACTIVATE, () => { describe(ACTIVATE, () => {
it('sets active account', () => { it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', { expect(accounts(initial, activate(account)), 'to satisfy', {
active: account active: account.id
}); });
}); });
}); });
@ -108,14 +108,14 @@ describe('Accounts reducer', () => {
const newToken = 'newToken'; const newToken = 'newToken';
expect(accounts( expect(accounts(
{active: account, available: [account]}, {active: account.id, available: [account]},
updateToken(newToken) updateToken(newToken)
), 'to satisfy', { ), 'to satisfy', {
active: { active: account.id,
available: [{
...account, ...account,
token: newToken token: newToken
}, }]
available: [account]
}); });
}); });
}); });

View File

@ -20,11 +20,16 @@ export { authenticate, logoutAll as logout } from 'components/accounts/actions';
/** /**
* Reoutes user to the previous page if it is possible * Reoutes user to the previous page if it is possible
* *
* @param {string} fallbackUrl - an url to route user to if goBack is not possible * @param {object} options
* @param {string} options.fallbackUrl - an url to route user to if goBack is not possible
* *
* @return {object} - action definition * @return {object} - action definition
*/ */
export function goBack(fallbackUrl?: ?string = null) { export function goBack(options: {
fallbackUrl?: string
}) {
const { fallbackUrl } = options || {};
if (history.canGoBack()) { if (history.canGoBack()) {
browserHistory.goBack(); browserHistory.goBack();
} else if (fallbackUrl) { } else if (fallbackUrl) {

View File

@ -1,6 +1,6 @@
import { changeLang } from 'components/user/actions'; import { changeLang } from 'components/user/actions';
import { authenticate, logoutStrangers } from 'components/accounts/actions'; import { authenticate, logoutStrangers } from 'components/accounts/actions';
import { getActiveAccount } from 'components/accounts/reducer';
import request from 'services/request'; import request from 'services/request';
import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware'; import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware';
import refreshTokenMiddleware from './middlewares/refreshTokenMiddleware'; import refreshTokenMiddleware from './middlewares/refreshTokenMiddleware';
@ -25,11 +25,11 @@ export function factory(store) {
promise = Promise.resolve() promise = Promise.resolve()
.then(() => store.dispatch(logoutStrangers())) .then(() => store.dispatch(logoutStrangers()))
.then(() => { .then(() => {
const {accounts} = store.getState(); const activeAccount = getActiveAccount(store.getState());
if (accounts.active) { if (activeAccount) {
// authorizing user if it is possible // authorizing user if it is possible
return store.dispatch(authenticate(accounts.active)); return store.dispatch(authenticate(activeAccount));
} }
return Promise.reject(); return Promise.reject();

View File

@ -1,3 +1,6 @@
// @flow
import { getActiveAccount } from 'components/accounts/reducer';
/** /**
* Applies Bearer header for all requests * Applies Bearer header for all requests
* *
@ -9,12 +12,17 @@
* *
* @return {object} - request middleware * @return {object} - request middleware
*/ */
export default function bearerHeaderMiddleware({getState}) { export default function bearerHeaderMiddleware(store: { getState: () => Object }) {
return { return {
before(req) { before<T: {
const {accounts} = getState(); options: {
token?: ?string,
headers: Object,
}
}>(req: T): T {
const activeAccount = getActiveAccount(store.getState());
let {token} = accounts.active || {}; let {token} = activeAccount || {};
if (req.options.token || req.options.token === null) { if (req.options.token || req.options.token === null) {
token = req.options.token; token = req.options.token;

View File

@ -6,8 +6,9 @@ describe('bearerHeaderMiddleware', () => {
const emptyState = { const emptyState = {
user: {}, user: {},
accounts: { accounts: {
active: null active: null,
} available: [],
},
}; };
describe('when token available', () => { describe('when token available', () => {
@ -16,7 +17,11 @@ describe('bearerHeaderMiddleware', () => {
getState: () => ({ getState: () => ({
...emptyState, ...emptyState,
accounts: { accounts: {
active: {token} active: 1,
available: [{
id: 1,
token,
}],
} }
}) })
}); });

View File

@ -1,8 +1,6 @@
import { getJwtPayload } from 'functions'; // @flow
import authentication from 'services/api/authentication'; import { ensureToken, recoverFromTokenError } from 'components/accounts/actions';
import logger from 'services/logger'; import { getActiveAccount } from 'components/accounts/reducer';
import { InternalServerError } from 'services/request';
import { updateToken, logoutAll } from 'components/accounts/actions';
/** /**
* Ensures, that all user's requests have fresh access token * Ensures, that all user's requests have fresh access token
@ -13,75 +11,29 @@ import { updateToken, logoutAll } from 'components/accounts/actions';
* *
* @return {object} - request middleware * @return {object} - request middleware
*/ */
export default function refreshTokenMiddleware({dispatch, getState}) { export default function refreshTokenMiddleware({dispatch, getState}: {dispatch: (Object) => *, getState: () => Object}) {
return { return {
before(req) { before<T: {options: {token?: string}, url: string}>(req: T): Promise<T> {
const {accounts} = getState(); const activeAccount = getActiveAccount(getState());
const disableMiddleware = !!req.options.token || req.options.token === null;
let refreshToken;
let token;
const isRefreshTokenRequest = req.url.includes('refresh-token'); const isRefreshTokenRequest = req.url.includes('refresh-token');
if (accounts.active) { if (!activeAccount || disableMiddleware || isRefreshTokenRequest) {
token = accounts.active.token;
refreshToken = accounts.active.refreshToken;
}
if (!token || req.options.token || isRefreshTokenRequest) {
return Promise.resolve(req); return Promise.resolve(req);
} }
try { return dispatch(ensureToken()).then(() => req);
const SAFETY_FACTOR = 300; // 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(() => req);
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token
});
return dispatch(logoutAll()).then(() => req);
}
return Promise.resolve(req);
}, },
catch(resp, req, restart) { catch(resp: {status: number, message: string}, req: {options: { token?: string}}, restart: () => Promise<mixed>): Promise<*> {
if (resp && resp.status === 401 && !req.options.token) { const disableMiddleware = !!req.options.token || req.options.token === null;
const {accounts} = getState();
const {refreshToken} = accounts.active || {};
if (resp.message === 'Token expired' && refreshToken) { if (disableMiddleware) {
// request token and retry return Promise.reject(resp);
return requestAccessToken(refreshToken, dispatch).then(restart);
}
return dispatch(logoutAll()).then(() => Promise.reject(resp));
} }
return Promise.reject(resp); return dispatch(recoverFromTokenError(resp)).then(restart);
} }
}; };
} }
function requestAccessToken(refreshToken, dispatch) {
if (refreshToken) {
return authentication.requestToken(refreshToken)
.then(({token}) => dispatch(updateToken(token)))
.catch((resp = {}) => {
if (resp instanceof InternalServerError) {
return Promise.reject(resp);
}
return dispatch(logoutAll());
});
}
return dispatch(logoutAll());
}

View File

@ -2,7 +2,7 @@ import expect from 'unexpected';
import sinon from 'sinon'; import sinon from 'sinon';
import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware'; import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware';
import { browserHistory } from 'services/history';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import { InternalServerError } from 'services/request'; import { InternalServerError } from 'services/request';
import { updateToken } from 'components/accounts/actions'; import { updateToken } from 'components/accounts/actions';
@ -17,9 +17,12 @@ describe('refreshTokenMiddleware', () => {
let getState; let getState;
let dispatch; let dispatch;
const email = 'test@email.com';
beforeEach(() => { beforeEach(() => {
sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); sinon.stub(authentication, 'requestToken').named('authentication.requestToken');
sinon.stub(authentication, 'logout').named('authentication.logout'); sinon.stub(authentication, 'logout').named('authentication.logout');
sinon.stub(browserHistory, 'push');
getState = sinon.stub().named('store.getState'); getState = sinon.stub().named('store.getState');
dispatch = sinon.spy((arg) => dispatch = sinon.spy((arg) =>
@ -32,8 +35,22 @@ describe('refreshTokenMiddleware', () => {
afterEach(() => { afterEach(() => {
authentication.requestToken.restore(); authentication.requestToken.restore();
authentication.logout.restore(); authentication.logout.restore();
browserHistory.push.restore();
}); });
function assertRelogin() {
expect(dispatch, 'to have a call satisfying', [
{
type: 'auth:setCredentials',
payload: {login: email}
}
]);
expect(browserHistory.push, 'to have a call satisfying', [
'/login'
]);
}
it('must be till 2100 to test with validToken', () => it('must be till 2100 to test with validToken', () =>
expect(new Date().getFullYear(), 'to be less than', 2100) expect(new Date().getFullYear(), 'to be less than', 2100)
); );
@ -42,6 +59,7 @@ describe('refreshTokenMiddleware', () => {
describe('when token expired', () => { describe('when token expired', () => {
beforeEach(() => { beforeEach(() => {
const account = { const account = {
email,
token: expiredToken, token: expiredToken,
refreshToken refreshToken
}; };
@ -111,8 +129,9 @@ describe('refreshTokenMiddleware', () => {
); );
}); });
it('should logout if token can not be parsed', () => { it('should relogin if token can not be parsed', () => {
const account = { const account = {
email,
token: 'realy bad token', token: 'realy bad token',
refreshToken refreshToken
}; };
@ -126,23 +145,23 @@ describe('refreshTokenMiddleware', () => {
const req = {url: 'foo', options: {}}; const req = {url: 'foo', options: {}};
return expect(middleware.before(req), 'to be fulfilled with', req).then(() => { return expect(middleware.before(req), 'to be rejected with', {
expect(authentication.requestToken, 'was not called'); message: 'Invalid token'
})
.then(() => {
expect(authentication.requestToken, 'was not called');
expect(dispatch, 'to have a call satisfying', [ assertRelogin();
{payload: {isGuest: true}} });
]);
});
}); });
it('should logout if token request failed', () => { it('should relogin if token request failed', () => {
authentication.requestToken.returns(Promise.reject()); authentication.requestToken.returns(Promise.reject());
return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() => return expect(middleware.before({url: 'foo', options: {}}), 'to be rejected')
expect(dispatch, 'to have a call satisfying', [ .then(() =>
{payload: {isGuest: true}} assertRelogin()
]) );
);
}); });
it('should not logout if request failed with 5xx', () => { it('should not logout if request failed with 5xx', () => {
@ -161,12 +180,16 @@ describe('refreshTokenMiddleware', () => {
it('should not be applied if no token', () => { it('should not be applied if no token', () => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: null active: null,
available: [],
}, },
user: {} user: {}
}); });
const data = {url: 'foo'}; const data = {
url: 'foo',
options: {},
};
const resp = middleware.before(data); const resp = middleware.before(data);
return expect(resp, 'to be fulfilled with', data) return expect(resp, 'to be fulfilled with', data)
@ -206,8 +229,13 @@ describe('refreshTokenMiddleware', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
accounts: { accounts: {
active: {refreshToken}, active: 1,
available: [{refreshToken}] available: [{
id: 1,
email,
token: 'old token',
refreshToken,
}]
}, },
user: {} user: {}
}); });
@ -217,37 +245,56 @@ describe('refreshTokenMiddleware', () => {
authentication.requestToken.returns(Promise.resolve({token: validToken})); authentication.requestToken.returns(Promise.resolve({token: validToken}));
}); });
function assertNewTokenRequest() {
expect(authentication.requestToken, 'to have a call satisfying', [
refreshToken
]);
expect(restart, 'was called');
expect(dispatch, 'was called');
}
it('should request new token if expired', () => it('should request new token if expired', () =>
middleware.catch(expiredResponse, {options: {}}, restart).then(() => { expect(
expect(authentication.requestToken, 'to have a call satisfying', [ middleware.catch(expiredResponse, {options: {}}, restart),
refreshToken 'to be fulfilled'
]); ).then(assertNewTokenRequest)
expect(restart, 'was called');
})
); );
it('should logout user if invalid credential', () => it('should request new token if invalid credential', () =>
expect( expect(
middleware.catch(badTokenReponse, {options: {}}, restart), middleware.catch(badTokenReponse, {options: {}}, restart),
'to be rejected' 'to be fulfilled'
).then(() => ).then(assertNewTokenRequest)
expect(dispatch, 'to have a call satisfying', [
{payload: {isGuest: true}}
])
)
); );
it('should logout user if token is incorrect', () => it('should request new token if token is incorrect', () =>
expect( expect(
middleware.catch(incorrectTokenReponse, {options: {}}, restart), middleware.catch(incorrectTokenReponse, {options: {}}, restart),
'to be rejected' 'to be fulfilled'
).then(() => ).then(assertNewTokenRequest)
expect(dispatch, 'to have a call satisfying', [
{payload: {isGuest: true}}
])
)
); );
it('should relogin if no refreshToken', () => {
getState.returns({
accounts: {
active: 1,
available: [{
id: 1,
email,
refreshToken: null,
}]
},
user: {}
});
return expect(
middleware.catch(incorrectTokenReponse, {options: {}}, restart),
'to be rejected'
).then(() => {
assertRelogin();
});
});
it('should pass the request through if options.token specified', () => { it('should pass the request through if options.token specified', () => {
const promise = middleware.catch(expiredResponse, { const promise = middleware.catch(expiredResponse, {
options: { options: {

View File

@ -1,17 +1,17 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { Route, Redirect } from 'react-router-dom'; import { Route, Redirect } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getActiveAccount } from 'components/accounts/reducer';
import type { ComponentType } from 'react';
import type { Account } from 'components/accounts';
import type {User} from 'components/user'; const PrivateRoute = ({account, component: Component, ...rest}: {
component: ComponentType<*>,
const PrivateRoute = ({user, component: Component, ...rest}: { account: ?Account
component: any,
user: User
}) => ( }) => (
<Route {...rest} render={(props: {location: string}) => ( <Route {...rest} render={(props: {location: string}) => (
user.isGuest ? ( !account || !account.token ? (
<Redirect to="/login" /> <Redirect to="/login" />
) : ( ) : (
<Component {...props}/> <Component {...props}/>
@ -20,5 +20,5 @@ const PrivateRoute = ({user, component: Component, ...rest}: {
); );
export default connect((state) => ({ export default connect((state) => ({
user: state.user account: getActiveAccount(state)
}))(PrivateRoute); }))(PrivateRoute);

View File

@ -29,11 +29,11 @@ const authentication = {
* *
* @return {Promise} * @return {Promise}
*/ */
logout(options: { logout(options: ?{
token?: string token: string
} = {}) { }) {
return request.post('/api/authentication/logout', {}, { return request.post('/api/authentication/logout', {}, {
token: options.token token: options && options.token
}); });
}, },
@ -80,7 +80,7 @@ const authentication = {
*/ */
validateToken({token, refreshToken}: { validateToken({token, refreshToken}: {
token: string, token: string,
refreshToken: string refreshToken: ?string
}) { }) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (typeof token !== 'string') { if (typeof token !== 'string') {
@ -91,36 +91,39 @@ const authentication = {
}) })
.then(() => accounts.current({token})) .then(() => accounts.current({token}))
.then((user) => ({token, refreshToken, user})) .then((user) => ({token, refreshToken, user}))
.catch((resp) => { .catch((resp) =>
if (resp instanceof InternalServerError) { this.handleTokenError(resp, refreshToken)
// delegate error recovering to the bsod middleware // TODO: use recursion here
return new Promise(() => {}); .then(({token}) =>
} accounts.current({token})
.then((user) => ({token, refreshToken, user}))
)
);
},
if (['Token expired', 'Incorrect token'].includes(resp.message)) { handleTokenError(resp: Error | { message: string }, refreshToken: ?string): Promise<{
return authentication.requestToken(refreshToken) token: string,
.then(({token}) => }> {
accounts.current({token}) if (resp instanceof InternalServerError) {
.then((user) => ({token, refreshToken, user})) // delegate error recovering to the bsod middleware
) return new Promise(() => {});
.catch((error) => { }
logger.error('Failed refreshing token during token validation', {
error
});
return Promise.reject(error); if (refreshToken) {
}); if ([
} 'Token expired',
'Incorrect token',
'You are requesting with an invalid credential.'
].includes(resp.message)) {
return authentication.requestToken(refreshToken);
}
const errors = resp.errors || {}; logger.error('Unexpected error during token validation', {
if (errors.refresh_token !== 'error.refresh_token_not_exist') { resp
logger.error('Unexpected error during token validation', {
resp
});
}
return Promise.reject(resp);
}); });
}
return Promise.reject(resp);
}, },
/** /**
@ -135,9 +138,21 @@ const authentication = {
'/api/authentication/refresh-token', '/api/authentication/refresh-token',
{refresh_token: refreshToken}, // eslint-disable-line {refresh_token: refreshToken}, // eslint-disable-line
{token: null} {token: null}
).then((resp: {access_token: string}) => ({ )
token: resp.access_token .then((resp: {access_token: string}) => ({
})); token: resp.access_token
}))
.catch((resp) => {
const errors = resp.errors || {};
if (errors.refresh_token !== 'error.refresh_token_not_exist') {
logger.error('Failed refreshing token: unknown error', {
resp
});
}
return Promise.reject(resp);
});
} }
}; };

View File

@ -51,7 +51,7 @@ type ActionId =
| 'setLoadingState'; | 'setLoadingState';
export interface AuthContext { export interface AuthContext {
run(actionId: ActionId, payload?: ?Object): *; run(actionId: ActionId, payload?: mixed): *;
setState(newState: AbstractState): Promise<*> | void; setState(newState: AbstractState): Promise<*> | void;
getState(): Object; getState(): Object;
navigate(route: string): void; navigate(route: string): void;
@ -59,7 +59,7 @@ export interface AuthContext {
} }
export default class AuthFlow implements AuthContext { export default class AuthFlow implements AuthContext {
actions: {[key: string]: Function}; actions: {[key: string]: (mixed) => Object};
state: AbstractState; state: AbstractState;
prevState: AbstractState; prevState: AbstractState;
/** /**
@ -125,12 +125,18 @@ export default class AuthFlow implements AuthContext {
this.state.goBack(this); this.state.goBack(this);
} }
run(actionId: ActionId, payload?: ?Object): Promise<*> { run(actionId: ActionId, payload?: mixed): Promise<any> {
if (!this.actions[actionId]) { const action = this.actions[actionId];
if (!action) {
throw new Error(`Action ${actionId} does not exists`); throw new Error(`Action ${actionId} does not exists`);
} }
return Promise.resolve(this.dispatch(this.actions[actionId](payload))); return Promise.resolve(
this.dispatch(
action(payload)
)
);
} }
setState(state: AbstractState) { setState(state: AbstractState) {

View File

@ -1,3 +1,5 @@
// @flow
import { getActiveAccount } from 'components/accounts/reducer';
import AbstractState from './AbstractState'; import AbstractState from './AbstractState';
import LoginState from './LoginState'; import LoginState from './LoginState';
import PermissionsState from './PermissionsState'; import PermissionsState from './PermissionsState';
@ -5,18 +7,23 @@ import ChooseAccountState from './ChooseAccountState';
import ActivationState from './ActivationState'; import ActivationState from './ActivationState';
import AcceptRulesState from './AcceptRulesState'; import AcceptRulesState from './AcceptRulesState';
import FinishState from './FinishState'; import FinishState from './FinishState';
import type { AuthContext } from './AuthFlow';
const PROMPT_ACCOUNT_CHOOSE = 'select_account'; const PROMPT_ACCOUNT_CHOOSE = 'select_account';
const PROMPT_PERMISSIONS = 'consent'; const PROMPT_PERMISSIONS = 'consent';
export default class CompleteState extends AbstractState { export default class CompleteState extends AbstractState {
constructor(options = {}) { isPermissionsAccepted: bool | void;
super(options);
constructor(options: {
accept?: bool,
} = {}) {
super();
this.isPermissionsAccepted = options.accept; this.isPermissionsAccepted = options.accept;
} }
enter(context) { enter(context: AuthContext) {
const {auth = {}, user} = context.getState(); const {auth = {}, user} = context.getState();
if (user.isGuest) { if (user.isGuest) {
@ -32,7 +39,7 @@ export default class CompleteState extends AbstractState {
} }
} }
processOAuth(context) { processOAuth(context: AuthContext) {
const {auth = {}, accounts} = context.getState(); const {auth = {}, accounts} = context.getState();
let isSwitcherEnabled = auth.isSwitcherEnabled; let isSwitcherEnabled = auth.isSwitcherEnabled;
@ -44,13 +51,14 @@ export default class CompleteState extends AbstractState {
|| account.email === loginHint || account.email === loginHint
|| account.username === loginHint || account.username === loginHint
)[0]; )[0];
const activeAccount = getActiveAccount(context.getState());
if (account) { if (account) {
// disable switching, because we are know the account, user must be authorized with // disable switching, because we are know the account, user must be authorized with
context.run('setAccountSwitcher', false); context.run('setAccountSwitcher', false);
isSwitcherEnabled = false; isSwitcherEnabled = false;
if (account.id !== accounts.active.id) { if (!activeAccount || account.id !== activeAccount.id) {
// lets switch user to an account, that is needed for auth // lets switch user to an account, that is needed for auth
return context.run('authenticate', account) return context.run('authenticate', account)
.then(() => context.setState(new CompleteState())); .then(() => context.setState(new CompleteState()));
@ -75,8 +83,10 @@ export default class CompleteState extends AbstractState {
return; return;
} }
// TODO: it seams that oAuthComplete may be a separate state // TODO: it seems that oAuthComplete may be a separate state
return context.run('oAuthComplete', data).then((resp) => { return context.run('oAuthComplete', data).then((resp: {
redirectUri: string,
}) => {
// TODO: пусть в стейт попадает флаг или тип авторизации // TODO: пусть в стейт попадает флаг или тип авторизации
// вместо волшебства над редирект урлой // вместо волшебства над редирект урлой
if (resp.redirectUri.indexOf('static_page') === 0) { if (resp.redirectUri.indexOf('static_page') === 0) {

View File

@ -166,9 +166,7 @@ describe('CompleteState', () => {
{id: 1}, {id: 1},
{id: 2} {id: 2}
], ],
active: { active: 1
id: 1
}
}, },
auth: { auth: {
isSwitcherEnabled: true, isSwitcherEnabled: true,
@ -195,9 +193,7 @@ describe('CompleteState', () => {
{id: 1}, {id: 1},
{id: 2} {id: 2}
], ],
active: { active: 1
id: 1
}
}, },
auth: { auth: {
isSwitcherEnabled: false, isSwitcherEnabled: false,
@ -224,9 +220,7 @@ describe('CompleteState', () => {
available: [ available: [
{id: 1} {id: 1}
], ],
active: { active: 1
id: 1
}
}, },
auth: { auth: {
isSwitcherEnabled: true, isSwitcherEnabled: true,
@ -252,9 +246,7 @@ describe('CompleteState', () => {
available: [ available: [
{id: 1} {id: 1}
], ],
active: { active: 1
id: 1
}
}, },
auth: { auth: {
isSwitcherEnabled: false, isSwitcherEnabled: false,
@ -416,9 +408,7 @@ describe('CompleteState', () => {
available: [ available: [
account account
], ],
active: { active: 100
id: 100
}
}, },
auth: { auth: {
oauth: { oauth: {
@ -465,7 +455,7 @@ describe('CompleteState', () => {
available: [ available: [
account account
], ],
active: account active: account.id,
}, },
auth: { auth: {
oauth: { oauth: {
@ -497,7 +487,7 @@ describe('CompleteState', () => {
}, },
accounts: { accounts: {
available: [{id: 1}], available: [{id: 1}],
active: {id: 1} active: 1
}, },
auth: { auth: {
oauth: { oauth: {

View File

@ -1,3 +1,4 @@
// @flow
import logger from 'services/logger'; import logger from 'services/logger';
import { getLogin } from 'components/auth/reducer'; import { getLogin } from 'components/auth/reducer';
@ -5,8 +6,10 @@ import AbstractState from './AbstractState';
import PasswordState from './PasswordState'; import PasswordState from './PasswordState';
import RegisterState from './RegisterState'; import RegisterState from './RegisterState';
import type { AuthContext } from './AuthFlow';
export default class LoginState extends AbstractState { export default class LoginState extends AbstractState {
enter(context) { enter(context: AuthContext) {
const login = getLogin(context.getState()); const login = getLogin(context.getState());
const {user} = context.getState(); const {user} = context.getState();
@ -24,7 +27,9 @@ export default class LoginState extends AbstractState {
} }
} }
resolve(context, payload) { resolve(context: AuthContext, payload: {
login: string
}) {
context.run('login', payload) context.run('login', payload)
.then(() => context.setState(new PasswordState())) .then(() => context.setState(new PasswordState()))
.catch((err = {}) => .catch((err = {}) =>
@ -32,11 +37,13 @@ export default class LoginState extends AbstractState {
); );
} }
reject(context) { reject(context: AuthContext) {
context.setState(new RegisterState()); context.setState(new RegisterState());
} }
goBack(context) { goBack(context: AuthContext) {
context.run('goBack', '/'); context.run('goBack', {
fallbackUrl: '/'
});
} }
} }

View File

@ -97,7 +97,9 @@ describe('LoginState', () => {
describe('#goBack', () => { describe('#goBack', () => {
it('should return to previous page', () => { it('should return to previous page', () => {
expectRun(mock, 'goBack', '/'); expectRun(mock, 'goBack', {
fallbackUrl: '/'
});
state.goBack(context); state.goBack(context);
}); });