#305: add mfa step during auth

This commit is contained in:
SleepWalker 2017-08-22 21:39:08 +03:00
parent d0a356050f
commit 5a16fe26ae
30 changed files with 624 additions and 146 deletions

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
flow-typed

View File

@ -213,7 +213,7 @@
"react/no-direct-mutation-state": "warn",
"react/require-render-return": "warn",
"react/no-is-mounted": "warn",
"react/no-multi-comp": "warn",
"react/no-multi-comp": "off",
"react/no-string-refs": "warn",
"react/no-unknown-property": "warn",
"react/prefer-es6-class": "warn",

View File

@ -4,6 +4,7 @@
[include]
[libs]
./flow-typed
[options]
module.system.node.resolve_dirname=node_modules

29
flow-typed/Promise.js vendored Normal file
View File

@ -0,0 +1,29 @@
/**
* This is a copypasted declaration from
* https://github.com/facebook/flow/blob/master/lib/core.js
* with addition of finally method
*/
declare class Promise<+R> {
constructor(callback: (
resolve: (result: Promise<R> | R) => void,
reject: (error: any) => void
) => mixed): void;
then<U>(
onFulfill?: (value: R) => Promise<U> | U,
onReject?: (error: any) => Promise<U> | U
): Promise<U>;
catch<U>(
onReject?: (error: any) => Promise<U> | U
): Promise<R | U>;
static resolve<T>(object: Promise<T> | T): Promise<T>;
static reject<T>(error?: any): Promise<T>;
static all<Elem, T:Iterable<Elem>>(promises: T): Promise<$TupleMap<T, typeof $await>>;
static race<T, Elem: Promise<T> | T>(promises: Array<Elem>): Promise<T>;
finally<T>(
onSettled?: ?(value: any) => Promise<T> | T
): Promise<T>;
}

View File

@ -1,9 +1,11 @@
import React, { Component, PropTypes } from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { TransitionMotion, spring } from 'react-motion';
import { Panel, PanelBody, PanelFooter, PanelHeader } from 'components/ui/Panel';
import { getLogin } from 'components/auth/reducer';
import { Form } from 'components/ui/form';
import MeasureHeight from 'components/MeasureHeight';
import { helpLinks as helpLinksStyles } from 'components/auth/helpLinks.scss';
@ -30,7 +32,7 @@ const changeContextSpringConfig = {stiffness: 500, damping: 20, precision: 0.5};
* (e.g. the panel with lower index will slide from left side, and with greater from right side)
*/
const contexts = [
['login', 'password', 'forgotPassword', 'recoverPassword'],
['login', 'password', 'mfa', 'forgotPassword', 'recoverPassword'],
['register', 'activation', 'resendActivation'],
['acceptRules'],
['chooseAccount', 'permissions']
@ -459,7 +461,7 @@ class PanelTransition extends Component {
}
export default connect((state) => {
const {login} = state.auth;
const login = getLogin(state);
let user = {
...state.user
};

View File

@ -4,14 +4,10 @@ To add new panel you need to:
* create panel component at `components/auth/[panelId]`
* add new context in `components/auth/PanelTransition`
* connect component to `routes`
* connect component to router in `pages/auth/AuthPage`
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
* add new actions to `components/auth/actions` and api endpoints to `services/api`
* whatever else you need
Commit id with example: f4d315c
# TODO
This flow must be simplified
Commit id with example implementation: f4d315c

View File

@ -1,3 +1,4 @@
// @flow
import { browserHistory } from 'services/history';
import logger from 'services/logger';
@ -23,7 +24,7 @@ export { authenticate, logoutAll as logout } from 'components/accounts/actions';
*
* @return {object} - action definition
*/
export function goBack(fallbackUrl = null) {
export function goBack(fallbackUrl?: ?string = null) {
if (history.canGoBack()) {
browserHistory.goBack();
} else if (fallbackUrl) {
@ -35,7 +36,7 @@ export function goBack(fallbackUrl = null) {
};
}
export function redirect(url) {
export function redirect(url: string) {
loader.show();
return () => new Promise(() => {
@ -45,14 +46,25 @@ export function redirect(url) {
});
}
export function login({login = '', password = '', rememberMe = false}) {
export function login({
login = '',
password = '',
totp,
rememberMe = false
}: {
login: string,
password?: string,
totp?: string,
rememberMe?: bool
}) {
const PASSWORD_REQUIRED = 'error.password_required';
const LOGIN_REQUIRED = 'error.login_required';
const ACTIVATION_REQUIRED = 'error.account_not_activated';
const TOTP_REQUIRED = 'error.totp_required';
return wrapInLoader((dispatch) =>
authentication.login(
{login, password, rememberMe}
{login, password, totp, rememberMe}
)
.then(authHandler(dispatch))
.catch((resp) => {
@ -61,6 +73,12 @@ export function login({login = '', password = '', rememberMe = false}) {
return dispatch(setLogin(login));
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation());
} else if (resp.errors.totp === TOTP_REQUIRED) {
return dispatch(requestTotp({
login,
password,
rememberMe
}));
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
logger.warn('No login on password panel');
@ -83,6 +101,9 @@ export function acceptRules() {
export function forgotPassword({
login = '',
captcha = ''
}: {
login: string,
captcha: string
}) {
return wrapInLoader((dispatch, getState) =>
authentication.forgotPassword({login, captcha})
@ -97,6 +118,10 @@ export function recoverPassword({
key = '',
newPassword = '',
newRePassword = ''
}: {
key: string,
newPassword: string,
newRePassword: string
}) {
return wrapInLoader((dispatch) =>
authentication.recoverPassword({key, newPassword, newRePassword})
@ -112,6 +137,13 @@ export function register({
rePassword = '',
captcha = '',
rulesAgreement = false
}: {
email: string,
username: string,
password: string,
rePassword: string,
captcha: string,
rulesAgreement: bool
}) {
return wrapInLoader((dispatch, getState) =>
signup.register({
@ -134,7 +166,7 @@ export function register({
);
}
export function activate({key = ''}) {
export function activate({key = ''}: {key: string}) {
return wrapInLoader((dispatch) =>
signup.activate({key})
.then(authHandler(dispatch))
@ -142,7 +174,13 @@ export function activate({key = ''}) {
);
}
export function resendActivation({email = '', captcha}) {
export function resendActivation({
email = '',
captcha
}: {
email: string,
captcha: string
}) {
return wrapInLoader((dispatch) =>
signup.resendActivation({email, captcha})
.then((resp) => {
@ -160,16 +198,43 @@ export function contactUs() {
return createPopup(ContactForm);
}
export const SET_LOGIN = 'auth:setLogin';
export function setLogin(login) {
export const SET_CREDENTIALS = 'auth:setCredentials';
/**
* Sets login in credentials state
*
* Resets the state, when `null` is passed
*
* @param {string|null} login
*
* @return {object}
*/
export function setLogin(login: ?string) {
return {
type: SET_LOGIN,
payload: login
type: SET_CREDENTIALS,
payload: login ? {
login
} : null
};
}
function requestTotp({login, password, rememberMe}: {
login: string,
password: string,
rememberMe: bool
}) {
return {
type: SET_CREDENTIALS,
payload: {
login,
password,
rememberMe,
isTotpRequired: true
}
};
}
export const SET_SWITCHER = 'auth:setAccountSwitcher';
export function setAccountSwitcher(isOn) {
export function setAccountSwitcher(isOn: bool) {
return {
type: SET_SWITCHER,
payload: isOn
@ -177,7 +242,7 @@ export function setAccountSwitcher(isOn) {
}
export const ERROR = 'auth:error';
export function setErrors(errors) {
export function setErrors(errors: ?{[key: string]: string}) {
return {
type: ERROR,
payload: errors,
@ -213,7 +278,16 @@ const KNOWN_SCOPES = [
*
* @return {Promise}
*/
export function oAuthValidate(oauthData) {
export function oAuthValidate(oauthData: {
clientId: string,
redirectUrl: string,
responseType: string,
description: string,
scope: string,
prompt: 'none'|'consent'|'select_account',
loginHint?: string,
state?: string
}) {
// TODO: move to oAuth actions?
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
return wrapInLoader((dispatch) =>
@ -255,7 +329,7 @@ export function oAuthValidate(oauthData) {
*
* @return {Promise}
*/
export function oAuthComplete(params = {}) {
export function oAuthComplete(params: {accept?: bool} = {}) {
return wrapInLoader((dispatch, getState) =>
oauth.complete(getState().auth.oauth, params)
.then((resp) => {
@ -297,7 +371,15 @@ function handleOauthParamsValidation(resp = {}) {
}
export const SET_CLIENT = 'set_client';
export function setClient({id, name, description}) {
export function setClient({
id,
name,
description
}: {
id: string,
name: string,
description: string
}) {
return {
type: SET_CLIENT,
payload: {id, name, description}
@ -305,7 +387,7 @@ export function setClient({id, name, description}) {
}
export function resetOAuth() {
return (dispatch) => {
return (dispatch: (Function|Object) => void) => {
localStorage.removeItem('oauthData');
dispatch(setOAuthRequest({}));
};
@ -317,14 +399,22 @@ export function resetOAuth() {
* @return {function}
*/
export function resetAuth() {
return (dispatch) => {
return (dispatch: (Function|Object) => void) => {
dispatch(setLogin(null));
dispatch(resetOAuth({}));
dispatch(resetOAuth());
};
}
export const SET_OAUTH = 'set_oauth';
export function setOAuthRequest(oauth) {
export function setOAuthRequest(oauth: {
client_id?: string,
redirect_uri?: string,
response_type?: string,
scope?: string,
prompt?: string,
loginHint?: string,
state?: string
}) {
return {
type: SET_OAUTH,
payload: {
@ -340,7 +430,11 @@ export function setOAuthRequest(oauth) {
}
export const SET_OAUTH_RESULT = 'set_oauth_result';
export function setOAuthCode(oauth) {
export function setOAuthCode(oauth: {
success: bool,
code: string,
displayCode: bool
}) {
return {
type: SET_OAUTH_RESULT,
payload: {
@ -359,7 +453,7 @@ export function requirePermissionsAccept() {
}
export const SET_SCOPES = 'set_scopes';
export function setScopes(scopes) {
export function setScopes(scopes: Array<string>) {
if (!(scopes instanceof Array)) {
throw new Error('Scopes must be array');
}
@ -372,7 +466,7 @@ export function setScopes(scopes) {
export const SET_LOADING_STATE = 'set_loading_state';
export function setLoadingState(isLoading) {
export function setLoadingState(isLoading: bool) {
return {
type: SET_LOADING_STATE,
payload: isLoading
@ -380,7 +474,7 @@ export function setLoadingState(isLoading) {
}
function wrapInLoader(fn) {
return (dispatch, getState) => {
return (dispatch: (Function|Object) => void, getState: Object) => {
dispatch(setLoadingState(true));
const endLoading = () => dispatch(setLoadingState(false));
@ -414,14 +508,15 @@ function authHandler(dispatch) {
});
}
function validationErrorsHandler(dispatch, repeatUrl) {
function validationErrorsHandler(dispatch: (Function|Object) => void, repeatUrl?: string) {
return (resp) => {
if (resp.errors) {
const firstError = Object.keys(resp.errors)[0];
const error = {
type: resp.errors[firstError],
payload: {
isGuest: true
isGuest: true,
repeatUrl: ''
}
};
@ -432,9 +527,7 @@ function validationErrorsHandler(dispatch, repeatUrl) {
if (['error.key_not_exists', 'error.key_expire'].includes(error.type) && repeatUrl) {
// TODO: this should be formatted on backend
Object.assign(error.payload, {
repeatUrl
});
error.payload.repeatUrl = repeatUrl;
}
resp.errors[firstError] = error;

View File

@ -3,7 +3,8 @@ import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input, Captcha } from 'components/ui/form';
import icons from 'components/ui/icons.scss';
import { getLogin } from 'components/auth/reducer';
import { PanelIcon } from 'components/ui/Panel';
import BaseAuthBody from 'components/auth/BaseAuthBody';
import styles from './forgotPassword.scss';
@ -28,9 +29,7 @@ export default class ForgotPasswordBody extends BaseAuthBody {
<div>
{this.renderErrors()}
<div className={styles.bigIcon}>
<span className={icons.lock} />
</div>
<PanelIcon icon="lock" />
{isLoginEditShown ? (
<div>
@ -73,9 +72,10 @@ export default class ForgotPasswordBody extends BaseAuthBody {
}
getLogin() {
const { user, auth } = this.context;
const login = getLogin(this.context);
const { user } = this.context;
return auth.login || user.username || user.email || '';
return login || user.username || user.email || '';
}
onClickEdit = () => {

View File

@ -7,14 +7,6 @@
color: #aaa;
}
// TODO: вынести иконки такого типа в какую-то внешнюю структуру?
.bigIcon {
color: #ccc;
font-size: 100px;
line-height: 1;
margin-bottom: 15px;
}
.login {
composes: email from 'components/auth/password/password.scss';
}

View File

@ -0,0 +1,4 @@
{
"enterTotp": "Enter code",
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
}

View File

@ -0,0 +1,15 @@
// @flow
import factory from 'components/auth/factory';
import Body from './MfaBody';
import messages from './Mfa.intl.json';
import passwordMessages from '../password/Password.intl.json';
export default factory({
title: messages.enterTotp,
body: Body,
footer: {
color: 'green',
label: passwordMessages.signInButton
}
});

View File

@ -0,0 +1,39 @@
// @flow
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { PanelIcon } from 'components/ui/Panel';
import { Input } from 'components/ui/form';
import BaseAuthBody from 'components/auth/BaseAuthBody';
import styles from './mfa.scss';
import messages from './Mfa.intl.json';
export default class MfaBody extends BaseAuthBody {
static panelId = 'mfa';
static hasGoBack = true;
autoFocusField = 'totp';
render() {
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
<p className={styles.descriptionText}>
<Message {...messages.description} />
</p>
<Input {...this.bindField('totp')}
icon="key"
required
placeholder={messages.enterTotp}
autoComplete="off"
/>
</div>
);
}
}

View File

@ -0,0 +1,6 @@
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}

View File

@ -8,12 +8,12 @@ import {
SET_SCOPES,
SET_LOADING_STATE,
REQUIRE_PERMISSIONS_ACCEPT,
SET_LOGIN,
SET_CREDENTIALS,
SET_SWITCHER
} from './actions';
export default combineReducers({
login,
credentials,
error,
isLoading,
isSwitcherEnabled,
@ -39,21 +39,31 @@ function error(
}
}
function login(
state = null,
{type, payload = null}
) {
switch (type) {
case SET_LOGIN:
if (payload !== null && typeof payload !== 'string') {
throw new Error('Expected payload with login string or null');
}
return payload;
default:
return state;
function credentials(
state = {},
{type, payload}: {
type: string,
payload: ?{
login?: string,
password?: string,
rememberMe?: bool,
isTotpRequired?: bool
}
}
) {
if (type === SET_CREDENTIALS) {
if (payload
&& typeof payload === 'object'
) {
return {
...payload
};
}
return {};
}
return state;
}
function isSwitcherEnabled(
@ -150,3 +160,16 @@ function scopes(
return state;
}
}
export function getLogin(state: Object): ?string {
return state.auth.credentials.login || null;
}
export function getCredentials(state: Object): {
login?: string,
password?: string,
rememberMe?: bool,
isTotpRequired?: bool
} {
return state.auth.credentials;
}

View File

@ -2,16 +2,16 @@ import expect from 'unexpected';
import auth from 'components/auth/reducer';
import {
setLogin, SET_LOGIN,
setLogin, SET_CREDENTIALS,
setAccountSwitcher, SET_SWITCHER
} from 'components/auth/actions';
describe('components/auth/reducer', () => {
describe(SET_LOGIN, () => {
describe(SET_CREDENTIALS, () => {
it('should set login', () => {
const expectedLogin = 'foo';
expect(auth(undefined, setLogin(expectedLogin)), 'to satisfy', {
expect(auth(undefined, setLogin(expectedLogin)).credentials, 'to satisfy', {
login: expectedLogin
});
});

View File

@ -1,4 +1,5 @@
import React, { Component, PropTypes } from 'react';
// @flow
import React, { Component } from 'react';
import classNames from 'classnames';
@ -7,8 +8,12 @@ import { omit } from 'functions';
import styles from './panel.scss';
import icons from './icons.scss';
export function Panel(props) {
var { title, icon } = props;
export function Panel(props: {
title: string,
icon: string,
children: *
}) {
let { title, icon } = props;
if (icon) {
icon = (
@ -36,7 +41,9 @@ export function Panel(props) {
);
}
export function PanelHeader(props) {
export function PanelHeader(props: {
children: *
}) {
return (
<div className={styles.header} {...props}>
{props.children}
@ -44,7 +51,9 @@ export function PanelHeader(props) {
);
}
export function PanelBody(props) {
export function PanelBody(props: {
children: *
}) {
return (
<div className={styles.body} {...props}>
{props.children}
@ -52,7 +61,9 @@ export function PanelBody(props) {
);
}
export function PanelFooter(props) {
export function PanelFooter(props: {
children: *
}) {
return (
<div className={styles.footer} {...props}>
{props.children}
@ -61,11 +72,16 @@ export function PanelFooter(props) {
}
export class PanelBodyHeader extends Component {
static displayName = 'PanelBodyHeader';
props: {
type: 'default'|'error',
onClose: Function,
children: *
};
static propTypes = {
type: PropTypes.oneOf(['default', 'error']),
onClose: PropTypes.func
state: {
isClosed: bool
} = {
isClosed: false
};
render() {
@ -79,18 +95,23 @@ export class PanelBodyHeader extends Component {
}
const className = classNames(styles[`${type}BodyHeader`], {
[styles.isClosed]: this.state && this.state.isClosed
[styles.isClosed]: this.state.isClosed
});
const extraProps = omit(this.props, [
'type',
'onClose'
]);
return (
<div className={className} {...omit(this.props, Object.keys(PanelBodyHeader.propTypes))}>
<div className={className} {...extraProps}>
{close}
{children}
</div>
);
}
onClose = (event) => {
onClose = (event: MouseEvent) => {
event.preventDefault();
this.setState({isClosed: true});
@ -98,3 +119,11 @@ export class PanelBodyHeader extends Component {
this.props.onClose();
};
}
export function PanelIcon({icon}: {icon: string}) {
return (
<div className={styles.panelIcon}>
<span className={icons[icon]} />
</div>
);
}

View File

@ -129,3 +129,10 @@ $bodyTopBottomPadding: 15px;
cursor: pointer;
}
.panelIcon {
color: #ccc;
font-size: 100px;
line-height: 1;
margin-bottom: 15px;
}

View File

@ -16,6 +16,7 @@ import Password from 'components/auth/password/Password';
import AcceptRules from 'components/auth/acceptRules/AcceptRules';
import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword';
import RecoverPassword from 'components/auth/recoverPassword/RecoverPassword';
import Mfa from 'components/auth/mfa/Mfa';
import Finish from 'components/auth/finish/Finish';
import styles from './auth.scss';
@ -46,6 +47,7 @@ class AuthPage extends Component {
<div className={styles.content}>
<Switch>
<Route path="/login" render={renderPanelTransition(Login)} />
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
<Route path="/password" render={renderPanelTransition(Password)} />
<Route path="/register" render={renderPanelTransition(Register)} />
<Route path="/activation/:key?" render={renderPanelTransition(Activation)} />
@ -72,6 +74,7 @@ class AuthPage extends Component {
function renderPanelTransition(factory) {
const {Title, Body, Footer, Links} = factory();
return (props) => (
<PanelTransition
key="panel-transition"

View File

@ -1,35 +1,54 @@
// @flow
import request from 'services/request';
import accounts from 'services/api/accounts';
const authentication = {
login({
login = '',
password = '',
login,
password,
totp,
rememberMe = false
}: {
login: string,
password?: string,
totp?: string,
rememberMe: bool
}) {
return request.post(
'/api/authentication/login',
{login, password, rememberMe},
{login, password, token: totp, rememberMe},
{token: null}
);
).catch((resp) => {
if (resp && resp.errors && resp.errors.token) {
resp.errors.totp = resp.errors.token.replace('token', 'totp');
delete resp.errors.token;
}
return Promise.reject(resp);
});
},
/**
* @param {object} options
* @param {object} [options.token] - an optional token to overwrite headers
* @param {string} [options.token] - an optional token to overwrite headers
* in middleware and disable token auto-refresh
*
* @return {Promise}
*/
logout(options = {}) {
logout(options: {
token?: string
} = {}) {
return request.post('/api/authentication/logout', {}, {
token: options.token
});
},
forgotPassword({
login = '',
captcha = ''
login,
captcha
}: {
login: string,
captcha: string
}) {
return request.post(
'/api/authentication/forgot-password',
@ -39,9 +58,13 @@ const authentication = {
},
recoverPassword({
key = '',
newPassword = '',
newRePassword = ''
key,
newPassword,
newRePassword
}: {
key: string,
newPassword: string,
newRePassword: string
}) {
return request.post(
'/api/authentication/recover-password',
@ -61,7 +84,10 @@ const authentication = {
* if it was refreshed. As a side effect the response
* will have a `user` field with current user data
*/
validateToken({token, refreshToken}) {
validateToken({token, refreshToken}: {
token: string,
refreshToken: string
}) {
return new Promise((resolve) => {
if (typeof token !== 'string') {
throw new Error('token must be a string');
@ -91,12 +117,12 @@ const authentication = {
*
* @return {Promise} - resolves to {token}
*/
requestToken(refreshToken) {
requestToken(refreshToken: string): Promise<{token: string}> {
return request.post(
'/api/authentication/refresh-token',
{refresh_token: refreshToken}, // eslint-disable-line
{token: null}
).then((resp) => ({
).then((resp: {access_token: string}) => ({
token: resp.access_token
}));
}

View File

@ -15,6 +15,8 @@ describe('authentication api', () => {
beforeEach(() => {
sinon.stub(request, 'post').named('request.post');
request.post.returns(Promise.resolve());
});
afterEach(() => {

View File

@ -3,11 +3,11 @@
import type { AuthContext } from 'services/authFlow';
export default class AbstractState {
resolve(context: AuthContext, payload: Object) {}
goBack(context: AuthContext) {
resolve(context: AuthContext, payload: Object): void {}
goBack(context: AuthContext): void {
throw new Error('There is no way back');
}
reject(context: AuthContext, payload: Object) {}
enter(context: AuthContext) {}
leave(context: AuthContext) {}
reject(context: AuthContext, payload: Object): void {}
enter(context: AuthContext): void {}
leave(context: AuthContext): void {}
}

View File

@ -45,12 +45,16 @@ describe('AuthFlow.functional', () => {
describe('guest', () => {
beforeEach(() => {
state.user = {
isGuest: true
};
state.auth = {
login: null
};
Object.assign(state, {
user: {
isGuest: true,
},
auth: {
credentials: {
login: null
}
}
});
});
it('should redirect guest / -> /login', () => {

View File

@ -1,3 +1,4 @@
// @flow
import { browserHistory } from 'services/history';
import logger from 'services/logger';
@ -13,18 +14,35 @@ import CompleteState from './CompleteState';
import ResendActivationState from './ResendActivationState';
import type AbstractState from './AbstractState';
export type AuthContext = {
run: (actionId: string, payload: Object) => any,
setState: (newState: AbstractState) => void,
getRequest: () => {
path: string,
query: URLSearchParams,
params: Object
}
type Request = {
path: string,
query: URLSearchParams,
params: Object
};
export interface AuthContext {
run(actionId: string, payload: *): *;
setState(newState: AbstractState): Promise<*>|void;
getState(): Object;
navigate(route: string): void;
getRequest(): Request;
}
export default class AuthFlow {
constructor(actions) {
export default class AuthFlow implements AuthContext {
actions: {[key: string]: Function};
state: AbstractState;
prevState: AbstractState;
/**
* A callback from router, that allows to replace (perform redirect) route
* during route transition
*/
replace: ?(string) => void;
onReady: Function;
navigate: Function;
currentRequest: Request;
dispatch: (action: Object) => void;
getState: () => Object;
constructor(actions: {[key: string]: Function}) {
if (typeof actions !== 'object') {
throw new Error('AuthFlow requires an actions object');
}
@ -36,16 +54,18 @@ export default class AuthFlow {
}
}
setStore(store) {
setStore(store: *) {
/**
* @param {string} route
* @param {object} options
* @param {object} options.replace
*/
this.navigate = (route, options = {}) => {
this.navigate = (route: string, options: {replace?: bool} = {}) => {
if (this.getRequest().path !== route) {
this.currentRequest = {
path: route
path: route,
params: {},
query: new URLSearchParams()
};
if (this.replace) {
@ -62,11 +82,11 @@ export default class AuthFlow {
this.dispatch = store.dispatch.bind(store);
}
resolve(payload = {}) {
resolve(payload: Object = {}) {
this.state.resolve(this, payload);
}
reject(payload = {}) {
reject(payload: Object = {}) {
this.state.reject(this, payload);
}
@ -74,15 +94,15 @@ export default class AuthFlow {
this.state.goBack(this);
}
run(actionId, payload) {
run(actionId: string, payload: Object): Promise<*> {
if (!this.actions[actionId]) {
throw new Error(`Action ${actionId} does not exists`);
}
return this.dispatch(this.actions[actionId](payload));
return Promise.resolve(this.dispatch(this.actions[actionId](payload)));
}
setState(state) {
setState(state: AbstractState) {
if (!state) {
throw new Error('State is required');
}
@ -128,7 +148,7 @@ export default class AuthFlow {
* @param {function} [callback = function() {}] - an optional callback function to be called, when state will be stabilized
* (state's enter function's promise resolved)
*/
handleRequest(request, replace, callback = function() {}) {
handleRequest(request: Request, replace: Function, callback: Function = function() {}) {
const {path} = request;
this.replace = replace;
this.onReady = callback;
@ -165,6 +185,7 @@ export default class AuthFlow {
case '/':
case '/login':
case '/password':
case '/mfa':
case '/accept-rules':
case '/oauth/permissions':
case '/oauth/finish':

View File

@ -202,11 +202,11 @@ describe('AuthFlow', () => {
expect(actions.test, 'to have a call satisfying', ['arg']);
});
it('should return action dispatch result', () => {
it('should resolve to action dispatch result', () => {
const expected = 'dispatch called';
store.dispatch.returns(expected);
expect(flow.run('test'), 'to be', expected);
expect(flow.run('test'), 'to be fulfilled with', expected);
});
it('throws when running unexisted action', () => {

View File

@ -1,4 +1,5 @@
import logger from 'services/logger';
import { getLogin } from 'components/auth/reducer';
import AbstractState from './AbstractState';
import PasswordState from './PasswordState';
@ -6,13 +7,14 @@ import RegisterState from './RegisterState';
export default class LoginState extends AbstractState {
enter(context) {
const {auth, user} = context.getState();
const login = getLogin(context.getState());
const {user} = context.getState();
const isUserAddsSecondAccount = !user.isGuest
&& /login|password/.test(context.getRequest().path); // TODO: improve me
// TODO: it may not allow user to leave password state till he click back or enters password
if (auth.login) {
if (login) {
context.setState(new PasswordState());
} else if (user.isGuest || isUserAddsSecondAccount) {
context.navigate('/login');

View File

@ -27,7 +27,9 @@ describe('LoginState', () => {
it('should navigate to /login', () => {
context.getState.returns({
user: {isGuest: true},
auth: {login: null}
auth: {
credentials: {login: null}
}
});
expectNavigate(mock, '/login');
@ -38,7 +40,9 @@ describe('LoginState', () => {
it('should transition to password if login was set', () => {
context.getState.returns({
user: {isGuest: true},
auth: {login: 'foo'}
auth: {
credentials: {login: 'foo'}
}
});
expectState(mock, PasswordState);

View File

@ -0,0 +1,49 @@
// @flow
import logger from 'services/logger';
import { getCredentials } from 'components/auth/reducer';
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
import PasswordState from './PasswordState';
import type { AuthContext } from './AuthFlow';
export default class MfaState extends AbstractState {
enter(context: AuthContext) {
const {
login,
password,
isTotpRequired
} = getCredentials(context.getState());
if (login && password && isTotpRequired) {
context.navigate('/mfa');
} else {
context.setState(new CompleteState());
}
}
resolve(context: AuthContext, {totp}: {totp: string}) {
const {
login,
password,
rememberMe
} = getCredentials(context.getState());
context.run('login', {
totp,
password,
rememberMe,
login
})
.then(() => context.setState(new CompleteState()))
.catch((err = {}) =>
err.errors || logger.warn('Error logging in', err)
);
}
goBack(context: AuthContext) {
context.setState(new PasswordState());
}
}

View File

@ -0,0 +1,102 @@
import expect from 'unexpected';
import sinon from 'sinon';
import MfaState from './MfaState';
import CompleteState from 'services/authFlow/CompleteState';
import PasswordState from 'services/authFlow/PasswordState';
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
describe('MfaState', () => {
let state;
let context;
let mock;
beforeEach(() => {
state = new MfaState();
const data = bootstrap();
context = data.context;
mock = data.mock;
});
afterEach(() => {
mock.verify();
});
describe('#enter', () => {
it('should navigate to /mfa', () => {
context.getState.returns({
auth: {
credentials: {
login: 'foo',
password: 'bar',
isTotpRequired: true
}
}
});
expectNavigate(mock, '/mfa');
state.enter(context);
});
it('should transition to complete if no totp required', () => {
context.getState.returns({
auth: {
credentials: {
login: 'foo',
password: 'bar'
}
}
});
expectState(mock, CompleteState);
state.enter(context);
});
});
describe('#resolve', () => {
it('should call login with login and password', () => {
const expectedLogin = 'foo';
const expectedPassword = 'bar';
const expectedTotp = '111222';
const expectedRememberMe = true;
context.getState.returns({
auth: {
credentials: {
login: expectedLogin,
password: expectedPassword,
rememberMe: expectedRememberMe
}
}
});
expectRun(
mock,
'login',
sinon.match({
totp: expectedTotp,
login: expectedLogin,
password: expectedPassword,
rememberMe: expectedRememberMe
})
).returns(Promise.resolve());
expectState(mock, CompleteState);
const payload = {totp: expectedTotp};
return expect(state.resolve(context, payload), 'to be fulfilled');
});
});
describe('#goBack', () => {
it('should transition to login state', () => {
expectState(mock, PasswordState);
state.goBack(context);
});
});
});

View File

@ -1,40 +1,62 @@
// @flow
import logger from 'services/logger';
import { getCredentials } from 'components/auth/reducer';
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
import ForgotPasswordState from './ForgotPasswordState';
import LoginState from './LoginState';
import MfaState from './MfaState';
import type { AuthContext } from './AuthFlow';
export default class PasswordState extends AbstractState {
enter(context) {
const {auth} = context.getState();
enter(context: AuthContext) {
const {login} = getCredentials(context.getState());
if (auth.login) {
if (login) {
context.navigate('/password');
} else {
context.setState(new CompleteState());
}
}
resolve(context, {password, rememberMe}) {
const {auth: {login}} = context.getState();
resolve(
context: AuthContext,
{
password,
rememberMe
}: {
password: string,
rememberMe: bool
}
) {
const {login} = getCredentials(context.getState());
return context.run('login', {
context.run('login', {
password,
rememberMe,
login
})
.then(() => context.setState(new CompleteState()))
.then(() => {
const {isTotpRequired} = getCredentials(context.getState());
if (isTotpRequired) {
return context.setState(new MfaState());
}
return context.setState(new CompleteState());
})
.catch((err = {}) =>
err.errors || logger.warn('Error logging in', err)
);
}
reject(context) {
reject(context: AuthContext) {
context.setState(new ForgotPasswordState());
}
goBack(context) {
goBack(context: AuthContext) {
context.run('setLogin', null);
context.setState(new LoginState());
}

View File

@ -29,7 +29,9 @@ describe('PasswordState', () => {
it('should navigate to /password', () => {
context.getState.returns({
user: {isGuest: true},
auth: {login: 'foo'}
auth: {
credentials: {login: 'foo'}
}
});
expectNavigate(mock, '/password');
@ -40,7 +42,9 @@ describe('PasswordState', () => {
it('should transition to complete if not guest', () => {
context.getState.returns({
user: {isGuest: false},
auth: {login: null}
auth: {
credentials: {login: null}
}
});
expectState(mock, CompleteState);
@ -57,7 +61,9 @@ describe('PasswordState', () => {
context.getState.returns({
auth: {
login: expectedLogin
credentials: {
login: expectedLogin
}
}
});