From 5a16fe26ae60f123b1a63ae4179fe4625f49c46f Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Tue, 22 Aug 2017 21:39:08 +0300 Subject: [PATCH] #305: add mfa step during auth --- .eslintignore | 1 + .eslintrc.json | 2 +- .flowconfig | 1 + flow-typed/Promise.js | 29 ++++ src/components/auth/PanelTransition.js | 8 +- src/components/auth/README.md | 8 +- src/components/auth/actions.js | 149 ++++++++++++++---- .../auth/forgotPassword/ForgotPasswordBody.js | 12 +- .../auth/forgotPassword/forgotPassword.scss | 8 - src/components/auth/mfa/Mfa.intl.json | 4 + src/components/auth/mfa/Mfa.js | 15 ++ src/components/auth/mfa/MfaBody.js | 39 +++++ src/components/auth/mfa/mfa.scss | 6 + src/components/auth/reducer.js | 55 +++++-- src/components/auth/reducer.test.js | 6 +- src/components/ui/Panel.js | 55 +++++-- src/components/ui/panel.scss | 7 + src/pages/auth/AuthPage.js | 3 + src/services/api/authentication.js | 54 +++++-- src/services/api/authentication.test.js | 2 + src/services/authFlow/AbstractState.js | 10 +- .../authFlow/AuthFlow.functional.test.js | 16 +- src/services/authFlow/AuthFlow.js | 59 ++++--- src/services/authFlow/AuthFlow.test.js | 4 +- src/services/authFlow/LoginState.js | 6 +- src/services/authFlow/LoginState.test.js | 8 +- src/services/authFlow/MfaState.js | 49 ++++++ src/services/authFlow/MfaState.test.js | 102 ++++++++++++ src/services/authFlow/PasswordState.js | 40 +++-- src/services/authFlow/PasswordState.test.js | 12 +- 30 files changed, 624 insertions(+), 146 deletions(-) create mode 100644 .eslintignore create mode 100644 flow-typed/Promise.js create mode 100644 src/components/auth/mfa/Mfa.intl.json create mode 100644 src/components/auth/mfa/Mfa.js create mode 100644 src/components/auth/mfa/MfaBody.js create mode 100644 src/components/auth/mfa/mfa.scss create mode 100644 src/services/authFlow/MfaState.js create mode 100644 src/services/authFlow/MfaState.test.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..c6417d9 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +flow-typed diff --git a/.eslintrc.json b/.eslintrc.json index 09533f5..3cc1af9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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", diff --git a/.flowconfig b/.flowconfig index ac9d339..9cc9c98 100644 --- a/.flowconfig +++ b/.flowconfig @@ -4,6 +4,7 @@ [include] [libs] +./flow-typed [options] module.system.node.resolve_dirname=node_modules diff --git a/flow-typed/Promise.js b/flow-typed/Promise.js new file mode 100644 index 0000000..4bf09fe --- /dev/null +++ b/flow-typed/Promise.js @@ -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) => void, + reject: (error: any) => void + ) => mixed): void; + + then( + onFulfill?: (value: R) => Promise | U, + onReject?: (error: any) => Promise | U + ): Promise; + + catch( + onReject?: (error: any) => Promise | U + ): Promise; + + static resolve(object: Promise | T): Promise; + static reject(error?: any): Promise; + static all>(promises: T): Promise<$TupleMap>; + static race | T>(promises: Array): Promise; + + finally( + onSettled?: ?(value: any) => Promise | T + ): Promise; +} diff --git a/src/components/auth/PanelTransition.js b/src/components/auth/PanelTransition.js index eac640f..875c4bb 100644 --- a/src/components/auth/PanelTransition.js +++ b/src/components/auth/PanelTransition.js @@ -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 }; diff --git a/src/components/auth/README.md b/src/components/auth/README.md index 7a8142a..2b04be9 100644 --- a/src/components/auth/README.md +++ b/src/components/auth/README.md @@ -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 diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index 4a5b821..40892a3 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -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) { 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; diff --git a/src/components/auth/forgotPassword/ForgotPasswordBody.js b/src/components/auth/forgotPassword/ForgotPasswordBody.js index d5592e2..2419ce6 100644 --- a/src/components/auth/forgotPassword/ForgotPasswordBody.js +++ b/src/components/auth/forgotPassword/ForgotPasswordBody.js @@ -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 {
{this.renderErrors()} -
- -
+ {isLoginEditShown ? (
@@ -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 = () => { diff --git a/src/components/auth/forgotPassword/forgotPassword.scss b/src/components/auth/forgotPassword/forgotPassword.scss index 96f8313..b2d112b 100644 --- a/src/components/auth/forgotPassword/forgotPassword.scss +++ b/src/components/auth/forgotPassword/forgotPassword.scss @@ -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'; } diff --git a/src/components/auth/mfa/Mfa.intl.json b/src/components/auth/mfa/Mfa.intl.json new file mode 100644 index 0000000..b19e7e8 --- /dev/null +++ b/src/components/auth/mfa/Mfa.intl.json @@ -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" +} diff --git a/src/components/auth/mfa/Mfa.js b/src/components/auth/mfa/Mfa.js new file mode 100644 index 0000000..71b0351 --- /dev/null +++ b/src/components/auth/mfa/Mfa.js @@ -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 + } +}); diff --git a/src/components/auth/mfa/MfaBody.js b/src/components/auth/mfa/MfaBody.js new file mode 100644 index 0000000..267aeaa --- /dev/null +++ b/src/components/auth/mfa/MfaBody.js @@ -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 ( +
+ {this.renderErrors()} + + + +

+ +

+ + +
+ ); + } +} diff --git a/src/components/auth/mfa/mfa.scss b/src/components/auth/mfa/mfa.scss new file mode 100644 index 0000000..8edb590 --- /dev/null +++ b/src/components/auth/mfa/mfa.scss @@ -0,0 +1,6 @@ +.descriptionText { + font-size: 15px; + line-height: 1.4; + padding-bottom: 8px; + color: #aaa; +} diff --git a/src/components/auth/reducer.js b/src/components/auth/reducer.js index 28d470a..468865f 100644 --- a/src/components/auth/reducer.js +++ b/src/components/auth/reducer.js @@ -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; +} diff --git a/src/components/auth/reducer.test.js b/src/components/auth/reducer.test.js index 0cb40ab..4961712 100644 --- a/src/components/auth/reducer.test.js +++ b/src/components/auth/reducer.test.js @@ -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 }); }); diff --git a/src/components/ui/Panel.js b/src/components/ui/Panel.js index a3e155e..5c32ad5 100644 --- a/src/components/ui/Panel.js +++ b/src/components/ui/Panel.js @@ -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 (
{props.children} @@ -44,7 +51,9 @@ export function PanelHeader(props) { ); } -export function PanelBody(props) { +export function PanelBody(props: { + children: * +}) { return (
{props.children} @@ -52,7 +61,9 @@ export function PanelBody(props) { ); } -export function PanelFooter(props) { +export function PanelFooter(props: { + children: * +}) { return (
{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 ( -
+
{close} {children}
); } - 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 ( +
+ +
+ ); +} diff --git a/src/components/ui/panel.scss b/src/components/ui/panel.scss index 3ac3b37..96fc12c 100644 --- a/src/components/ui/panel.scss +++ b/src/components/ui/panel.scss @@ -129,3 +129,10 @@ $bodyTopBottomPadding: 15px; cursor: pointer; } + +.panelIcon { + color: #ccc; + font-size: 100px; + line-height: 1; + margin-bottom: 15px; +} diff --git a/src/pages/auth/AuthPage.js b/src/pages/auth/AuthPage.js index c04fc8d..524a37c 100644 --- a/src/pages/auth/AuthPage.js +++ b/src/pages/auth/AuthPage.js @@ -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 {
+ @@ -72,6 +74,7 @@ class AuthPage extends Component { function renderPanelTransition(factory) { const {Title, Body, Footer, Links} = factory(); + return (props) => ( { + 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 })); } diff --git a/src/services/api/authentication.test.js b/src/services/api/authentication.test.js index 3e5bc65..f359347 100644 --- a/src/services/api/authentication.test.js +++ b/src/services/api/authentication.test.js @@ -15,6 +15,8 @@ describe('authentication api', () => { beforeEach(() => { sinon.stub(request, 'post').named('request.post'); + + request.post.returns(Promise.resolve()); }); afterEach(() => { diff --git a/src/services/authFlow/AbstractState.js b/src/services/authFlow/AbstractState.js index b5427f0..da78cae 100644 --- a/src/services/authFlow/AbstractState.js +++ b/src/services/authFlow/AbstractState.js @@ -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 {} } diff --git a/src/services/authFlow/AuthFlow.functional.test.js b/src/services/authFlow/AuthFlow.functional.test.js index 71129b7..fff7cfc 100644 --- a/src/services/authFlow/AuthFlow.functional.test.js +++ b/src/services/authFlow/AuthFlow.functional.test.js @@ -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', () => { diff --git a/src/services/authFlow/AuthFlow.js b/src/services/authFlow/AuthFlow.js index 554261d..e7533e4 100644 --- a/src/services/authFlow/AuthFlow.js +++ b/src/services/authFlow/AuthFlow.js @@ -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': diff --git a/src/services/authFlow/AuthFlow.test.js b/src/services/authFlow/AuthFlow.test.js index 4883045..741a504 100644 --- a/src/services/authFlow/AuthFlow.test.js +++ b/src/services/authFlow/AuthFlow.test.js @@ -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', () => { diff --git a/src/services/authFlow/LoginState.js b/src/services/authFlow/LoginState.js index 3cfdce6..98e5239 100644 --- a/src/services/authFlow/LoginState.js +++ b/src/services/authFlow/LoginState.js @@ -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'); diff --git a/src/services/authFlow/LoginState.test.js b/src/services/authFlow/LoginState.test.js index aeef849..c57fa2b 100644 --- a/src/services/authFlow/LoginState.test.js +++ b/src/services/authFlow/LoginState.test.js @@ -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); diff --git a/src/services/authFlow/MfaState.js b/src/services/authFlow/MfaState.js new file mode 100644 index 0000000..2746fab --- /dev/null +++ b/src/services/authFlow/MfaState.js @@ -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()); + } +} diff --git a/src/services/authFlow/MfaState.test.js b/src/services/authFlow/MfaState.test.js new file mode 100644 index 0000000..e20ec60 --- /dev/null +++ b/src/services/authFlow/MfaState.test.js @@ -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); + }); + }); +}); diff --git a/src/services/authFlow/PasswordState.js b/src/services/authFlow/PasswordState.js index 3c1988d..cf56626 100644 --- a/src/services/authFlow/PasswordState.js +++ b/src/services/authFlow/PasswordState.js @@ -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()); } diff --git a/src/services/authFlow/PasswordState.test.js b/src/services/authFlow/PasswordState.test.js index ad1b906..a73b54a 100644 --- a/src/services/authFlow/PasswordState.test.js +++ b/src/services/authFlow/PasswordState.test.js @@ -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 + } } });