From 79902e8cebd48eadfa64781c8caaaee6f625df69 Mon Sep 17 00:00:00 2001 From: enahum Date: Tue, 24 Jan 2017 18:08:09 -0300 Subject: [PATCH] Mfa (#182) * Adding MFA support * specific actions on mfa * Fix header on new navigation * review feedback --- app/actions/navigation/index.js | 9 ++ app/components/error_text.js | 13 +++ app/components/formatted_text.js | 6 +- app/navigation/router.js | 6 +- app/navigation/routes.js | 5 + app/reducers/views/login.js | 2 +- app/scenes/index.js | 2 + app/scenes/login/login.js | 79 ++++++++++++++-- app/scenes/login/login_container.js | 20 ++-- app/scenes/mfa/index.js | 6 ++ app/scenes/mfa/mfa.js | 107 ++++++++++++++++++++++ app/scenes/mfa/mfa_container.js | 31 +++++++ app/scenes/select_server/select_server.js | 1 - assets/base/i18n/en.json | 1 + service/actions/users.js | 17 +++- service/client/client.js | 7 ++ service/constants/users.js | 4 + service/reducers/requests/users.js | 20 ++++ 18 files changed, 315 insertions(+), 21 deletions(-) create mode 100644 app/scenes/mfa/index.js create mode 100644 app/scenes/mfa/mfa.js create mode 100644 app/scenes/mfa/mfa_container.js diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index 8f55f3713..93f6c633a 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -29,6 +29,15 @@ export function goToLogin() { }; } +export function goToMfa() { + return async (dispatch, getState) => { + dispatch({ + type: NavigationTypes.NAVIGATION_PUSH, + route: Routes.Mfa + }, getState); + }; +} + export function goToLoadTeam() { return async (dispatch, getState) => { dispatch({ diff --git a/app/components/error_text.js b/app/components/error_text.js index 539b5d4c9..c8fe7c91f 100644 --- a/app/components/error_text.js +++ b/app/components/error_text.js @@ -3,6 +3,7 @@ import React, {Component, PropTypes} from 'react'; import {Text} from 'react-native'; +import FormattedText from 'app/components/formatted_text'; import {GlobalStyles} from 'app/styles'; @@ -18,6 +19,18 @@ export default class ErrorText extends Component { return null; } + if (this.props.error.hasOwnProperty('id')) { + const {error} = this.props; + return ( + + ); + } + return ( {this.props.error.message || this.props.error} diff --git a/app/components/formatted_text.js b/app/components/formatted_text.js index 5bf601fff..32781dfdb 100644 --- a/app/components/formatted_text.js +++ b/app/components/formatted_text.js @@ -11,10 +11,14 @@ class FormattedText extends Component { static propTypes = { intl: intlShape.isRequired, id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string.isRequired, + defaultMessage: PropTypes.string, values: PropTypes.object }; + static defaultProps = { + defaultMessage: '' + }; + render() { const { id, diff --git a/app/navigation/router.js b/app/navigation/router.js index bace732c9..7c5c2d48e 100644 --- a/app/navigation/router.js +++ b/app/navigation/router.js @@ -63,9 +63,11 @@ class Router extends React.Component { }); return ( - + + + {renderedScenes} + {title} - {renderedScenes} ); }; diff --git a/app/navigation/routes.js b/app/navigation/routes.js index d55d717c6..2ea0d45da 100644 --- a/app/navigation/routes.js +++ b/app/navigation/routes.js @@ -28,6 +28,11 @@ export const Routes = { title: {id: 'mobile.routes.login', defaultMessage: 'Login'}, transition: RouteTransitions.Horizontal }, + Mfa: { + key: 'Mfa', + title: {id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'}, + transition: RouteTransitions.Horizontal + }, RightMenuDrawer: { key: 'RightMenuDrawer' }, diff --git a/app/reducers/views/login.js b/app/reducers/views/login.js index 2287fb0d0..7078c75b0 100644 --- a/app/reducers/views/login.js +++ b/app/reducers/views/login.js @@ -8,7 +8,7 @@ import {ViewTypes} from 'app/constants'; function loginId(state = '', action) { switch (action.type) { case ViewTypes.LOGIN_ID_CHANGED: - return action.loginId; + return action.loginId.trim(); case UsersTypes.LOGOUT_SUCCESS: return ''; default: diff --git a/app/scenes/index.js b/app/scenes/index.js index 472ecd989..c18d19676 100644 --- a/app/scenes/index.js +++ b/app/scenes/index.js @@ -6,6 +6,7 @@ import ChannelDrawer from './channel_drawer'; import ChannelInfo from './channel_info'; import LoadTeam from './load_team'; import Login from './login/login_container.js'; +import Mfa from './mfa'; import RightMenuDrawer from './right_menu_drawer'; import Root from './root/root_container.js'; import Search from './search/search_container.js'; @@ -18,6 +19,7 @@ const scenes = { ChannelInfo, LoadTeam, Login, + Mfa, RightMenuDrawer, Root, Search, diff --git a/app/scenes/login/login.js b/app/scenes/login/login.js index 91abd37fb..8c9384f4a 100644 --- a/app/scenes/login/login.js +++ b/app/scenes/login/login.js @@ -18,16 +18,35 @@ import {RequestStatus} from 'service/constants'; class Login extends Component { static propTypes = { intl: intlShape.isRequired, + actions: React.PropTypes.shape({ + handleLoginIdChanged: React.PropTypes.func.isRequired, + handlePasswordChanged: React.PropTypes.func.isRequired, + handleSuccessfulLogin: React.PropTypes.func.isRequired, + checkMfa: React.PropTypes.func.isRequired, + login: React.PropTypes.func.isRequired, + getClientConfig: React.PropTypes.func.isRequired, + getLicenseConfig: React.PropTypes.func.isRequired, + goToMfa: React.PropTypes.func.isRequired, + goToLoadTeam: React.PropTypes.func.isRequired + }).isRequired, config: PropTypes.object.isRequired, license: PropTypes.object.isRequired, - actions: PropTypes.object.isRequired, loginId: PropTypes.string.isRequired, password: PropTypes.string.isRequired, + checkMfaRequest: PropTypes.object.isRequired, loginRequest: PropTypes.object.isRequired, configRequest: PropTypes.object.isRequired, licenseRequest: PropTypes.object.isRequired }; + constructor(props) { + super(props); + + this.state = { + error: null + }; + } + componentWillMount() { this.props.actions.getClientConfig(); this.props.actions.getLicenseConfig(); @@ -39,11 +58,58 @@ class Login extends Component { } } - signIn() { + preSignIn = () => { + this.setState({error: null}); + if (!this.props.loginId) { + // it's slightly weird to be constructing the message ID, but it's a bit nicer than triply nested if statements + let msgId = 'login.no'; + if (this.props.config.EnableSignInWithEmail === 'true') { + msgId += 'Email'; + } + if (this.props.config.EnableSignInWithUsername === 'true') { + msgId += 'Username'; + } + if (this.props.license.IsLicensed === 'true' && this.props.config.EnableLdap === 'true') { + msgId += 'LdapUsername'; + } + + this.setState({ + error: { + id: msgId, + defaultMessage: '', + values: { + ldapUsername: this.props.config.LdapLoginFieldName || + this.props.intl.formatMessage({id: 'login.ldapUsernameLower', defaultMessage: 'AD/LDAP username'}) + } + } + }); + return; + } + + if (!this.props.password) { + this.setState({ + error: {id: 'login.noPassword', defaultMessage: 'Please enter your password'} + }); + return; + } + if (this.props.config.EnableMultifactorAuthentication === 'true') { + this.props.actions.checkMfa(this.props.loginId).then((result) => { + if (result.mfa_required === 'true') { + this.props.actions.goToMfa(); + } else { + this.signIn(); + } + }); + } else { + this.signIn(); + } + }; + + signIn = () => { if (this.props.loginRequest.status !== RequestStatus.STARTED) { this.props.actions.login(this.props.loginId, this.props.password); } - } + }; createLoginPlaceholder() { const {formatMessage} = this.props.intl; @@ -99,6 +165,7 @@ class Login extends Component { id='web.root.signup_info' defaultMessage='All team communication in one place, searchable and accessible anywhere' /> + - - ); } diff --git a/app/scenes/login/login_container.js b/app/scenes/login/login_container.js index bc348dd02..bc95c8747 100644 --- a/app/scenes/login/login_container.js +++ b/app/scenes/login/login_container.js @@ -6,19 +6,23 @@ import {connect} from 'react-redux'; import {getClientConfig, getLicenseConfig} from 'service/actions/general'; import LoginActions from 'app/actions/views/login'; -import {goToLoadTeam} from 'app/actions/navigation'; -import {login} from 'service/actions/users'; +import {goToMfa, goToLoadTeam} from 'app/actions/navigation'; +import {checkMfa, login} from 'service/actions/users'; import Login from './login.js'; function mapStateToProps(state) { + const {config, license} = state.entities.general; + const {config: configRequest, license: licenseRequest} = state.requests.general; + const {checkMfa: checkMfaRequest, login: loginRequest} = state.requests.users; return { ...state.views.login, - loginRequest: state.requests.users.login, - configRequest: state.requests.general.config, - licenseRequest: state.requests.general.license, - config: state.entities.general.config, - license: state.entities.general.license + checkMfaRequest, + loginRequest, + configRequest, + licenseRequest, + config, + license }; } @@ -26,9 +30,11 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ ...LoginActions, + checkMfa, login, getClientConfig, getLicenseConfig, + goToMfa, goToLoadTeam }, dispatch) }; diff --git a/app/scenes/mfa/index.js b/app/scenes/mfa/index.js new file mode 100644 index 000000000..8e1580bf7 --- /dev/null +++ b/app/scenes/mfa/index.js @@ -0,0 +1,6 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import MfaContainer from './mfa_container'; + +export default MfaContainer; diff --git a/app/scenes/mfa/mfa.js b/app/scenes/mfa/mfa.js new file mode 100644 index 000000000..880dac555 --- /dev/null +++ b/app/scenes/mfa/mfa.js @@ -0,0 +1,107 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {Component} from 'react'; +import {Image, KeyboardAvoidingView} from 'react-native'; + +import Button from 'react-native-button'; +import ErrorText from 'app/components/error_text'; +import FormattedText from 'app/components/formatted_text'; +import TextInputWithLocalizedPlaceholder from 'app/components/text_input_with_localized_placeholder'; +import {GlobalStyles} from 'app/styles'; + +import logo from 'assets/images/logo.png'; + +import RequestStatus from 'service/constants/request_status'; + +export default class Mfa extends Component { + static propTypes = { + actions: React.PropTypes.shape({ + goBack: React.PropTypes.func.isRequired, + login: React.PropTypes.func.isRequired + }).isRequired, + loginId: React.PropTypes.string.isRequired, + password: React.PropTypes.string.isRequired, + loginRequest: React.PropTypes.object.isRequired + }; + + constructor(props) { + super(props); + + this.state = { + token: '', + error: null + }; + } + + componentWillReceiveProps(nextProps) { + // In case the login is successful the previous scene (login) will take care of the transition + if (this.props.loginRequest.status === RequestStatus.STARTED && + nextProps.loginRequest.status === RequestStatus.FAILURE) { + this.props.actions.goBack(); + } + } + + handleInput = (token) => { + this.setState({ + token, + error: null + }); + }; + + submit = () => { + if (!this.state.token) { + this.setState({ + error: { + id: 'login_mfa.tokenReq', + defaultMessage: 'Please enter an MFA token' + } + }); + return; + } + + this.props.actions.login(this.props.loginId, this.props.password, this.state.token); + }; + + render() { + return ( + + + + + + + + ); + } +} diff --git a/app/scenes/mfa/mfa_container.js b/app/scenes/mfa/mfa_container.js new file mode 100644 index 000000000..2f7217e95 --- /dev/null +++ b/app/scenes/mfa/mfa_container.js @@ -0,0 +1,31 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {goBack} from 'app/actions/navigation'; +import {login} from 'service/actions/users'; + +import Mfa from './mfa'; + +function mapStateToProps(state) { + const {login: loginRequest} = state.requests.users; + const {loginId, password} = state.views.login; + return { + loginId, + password, + loginRequest + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + goBack, + login + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Mfa); diff --git a/app/scenes/select_server/select_server.js b/app/scenes/select_server/select_server.js index 80a429293..c6662de30 100644 --- a/app/scenes/select_server/select_server.js +++ b/app/scenes/select_server/select_server.js @@ -73,7 +73,6 @@ class SelectServer extends Component { /> - ); } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e1e72238b..41c722199 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1491,6 +1491,7 @@ "mobile.routes.channels": "Channels", "mobile.routes.enterServerUrl": "Enter Server URL", "mobile.routes.login": "Login", + "mobile.routes.mfa": "Multi-factor Authentication", "mobile.routes.postsList": "Posts List", "mobile.routes.selectTeam": "Select Team", "mobile.routes.channelInfo": "Info", diff --git a/service/actions/users.js b/service/actions/users.js index 04e1035fe..29c6111a0 100644 --- a/service/actions/users.js +++ b/service/actions/users.js @@ -7,6 +7,20 @@ import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'service/const import {fetchTeams} from 'service/actions/teams'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; +export function checkMfa(loginId) { + return async (dispatch, getState) => { + dispatch({type: UsersTypes.CHECK_MFA_REQUEST}, getState); + try { + const mfa = await Client.checkMfa(loginId); + dispatch({type: UsersTypes.CHECK_MFA_SUCCESS}, getState); + return mfa; + } catch (error) { + dispatch({type: UsersTypes.CHECK_MFA_FAILURE, error}, getState); + return null; + } + }; +} + export function login(loginId, password, mfaToken = '') { return async (dispatch, getState) => { dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); @@ -61,8 +75,6 @@ export function login(loginId, password, mfaToken = '') { export function loadMe() { return async (dispatch, getState) => { - dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); - let user; dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); try { @@ -288,6 +300,7 @@ export function getAudits(userId) { } export default { + checkMfa, login, logout, getProfiles, diff --git a/service/client/client.js b/service/client/client.js index a1ab2415e..cecfc4c96 100644 --- a/service/client/client.js +++ b/service/client/client.js @@ -194,6 +194,13 @@ export default class Client { ); }; + checkMfa = async (loginId) => { + return this.doFetch( + `${this.getUsersRoute()}/mfa`, + {method: 'post', body: JSON.stringify({login_id: loginId})} + ); + }; + login = async (loginId, password, token = '') => { const body = { login_id: loginId, diff --git a/service/constants/users.js b/service/constants/users.js index 0bf8cbf0b..dd21bcadb 100644 --- a/service/constants/users.js +++ b/service/constants/users.js @@ -44,6 +44,10 @@ const UserTypes = keyMirror({ AUDITS_SUCCESS: null, AUDITS_FAILURE: null, + CHECK_MFA_REQUEST: null, + CHECK_MFA_SUCCESS: null, + CHECK_MFA_FAILURE: null, + RECEIVED_ME: null, RECEIVED_PROFILES: null, RECEIVED_PROFILES_IN_TEAM: null, diff --git a/service/reducers/requests/users.js b/service/reducers/requests/users.js index 8b844984d..7502bba2d 100644 --- a/service/reducers/requests/users.js +++ b/service/reducers/requests/users.js @@ -6,6 +6,25 @@ import {UsersTypes, RequestStatus} from 'service/constants'; import {combineReducers} from 'redux'; +function checkMfa(state = initialRequestState(), action) { + switch (action.type) { + case UsersTypes.CHECK_MFA_REQUEST: + return {...state, status: RequestStatus.STARTED}; + + case UsersTypes.CHECK_MFA_SUCCESS: + return {...state, status: RequestStatus.SUCCESS, error: null}; + + case UsersTypes.CHECK_MFA_FAILURE: + return {...state, status: RequestStatus.FAILURE, error: action.error}; + + case UsersTypes.LOGOUT_SUCCESS: + return {...state, status: RequestStatus.NOT_STARTED, error: null}; + + default: + return state; + } +} + function login(state = initialRequestState(), action) { switch (action.type) { case UsersTypes.LOGIN_REQUEST: @@ -125,6 +144,7 @@ function getAudits(state = initialRequestState(), action) { } export default combineReducers({ + checkMfa, login, logout, getProfiles,