mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
#365: Redirect user to login page, when token can not be refreshed. Further improvement of auth errors handling
This commit is contained in:
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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', [
|
||||||
|
@ -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
|
||||||
|
@ -1 +1,3 @@
|
|||||||
export AccountSwitcher from './AccountSwitcher';
|
// @flow
|
||||||
|
export { default as AccountSwitcher } from './AccountSwitcher';
|
||||||
|
export type { Account } from './reducer';
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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]
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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) {
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -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());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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: {
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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: {
|
||||||
|
@ -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: '/'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user