* Adding MFA support

* specific actions on mfa

* Fix header on new navigation

* review feedback
This commit is contained in:
enahum 2017-01-24 18:08:09 -03:00 committed by Harrison Healey
parent dd073f173e
commit 79902e8ceb
18 changed files with 315 additions and 21 deletions

View file

@ -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({

View file

@ -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 (
<FormattedText
id={error.id}
defaultMessage={error.defaultMessage}
values={error.values}
style={GlobalStyles.errorLabel}
/>
);
}
return (
<Text style={GlobalStyles.errorLabel}>
{this.props.error.message || this.props.error}

View file

@ -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,

View file

@ -63,9 +63,11 @@ class Router extends React.Component {
});
return (
<View style={{flex: 1}}>
<View style={{flex: 1, flexDirection: 'column-reverse'}}>
<View style={{flex: 1}}>
{renderedScenes}
</View>
{title}
{renderedScenes}
</View>
);
};

View file

@ -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'
},

View file

@ -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:

View file

@ -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,

View file

@ -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'
/>
<ErrorText error={this.props.loginRequest.error || this.props.checkMfaRequest.error || this.state.error}/>
<TextInput
ref='loginId'
value={this.props.loginId}
@ -119,10 +186,10 @@ class Login extends Component {
autoCapitalize='none'
underlineColorAndroid='transparent'
returnKeyType='go'
onSubmitEditing={this.signIn.bind(this)}
onSubmitEditing={this.preSignIn}
/>
<Button
onPress={this.signIn.bind(this)}
onPress={this.preSignIn}
containerStyle={GlobalStyles.signupButton}
>
<FormattedText
@ -131,8 +198,6 @@ class Login extends Component {
style={GlobalStyles.signupButtonText}
/>
</Button>
<ErrorText error={this.props.loginRequest.error}/>
<KeyboardAvoidingView style={GlobalStyles.pagePush}/>
</KeyboardAvoidingView>
);
}

View file

@ -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)
};

6
app/scenes/mfa/index.js Normal file
View file

@ -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;

107
app/scenes/mfa/mfa.js Normal file
View file

@ -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 (
<KeyboardAvoidingView
behavior='padding'
style={[GlobalStyles.container, GlobalStyles.signupContainer]}
>
<Image
source={logo}
/>
<FormattedText
style={[GlobalStyles.header, GlobalStyles.label]}
id='login_mfa.enterToken'
defaultMessage="To complete the sign in process, please enter a token from your smartphone's authenticator"
/>
<ErrorText error={this.state.error}/>
<TextInputWithLocalizedPlaceholder
value={this.state.token}
onChangeText={this.handleInput}
onSubmitEditing={this.submit}
style={GlobalStyles.inputBox}
autoCapitalize='none'
autoCorrect={false}
keyboardType='numeric'
placeholder={{id: 'login_mfa.token', defaultMessage: 'MFA Token'}}
returnKeyType='go'
underlineColorAndroid='transparent'
/>
<Button
onPress={this.submit}
loading={false}
containerStyle={GlobalStyles.signupButton}
>
<FormattedText
style={GlobalStyles.signupButtonText}
id='mobile.components.select_server_view.proceed'
defaultMessage='Proceed'
/>
</Button>
</KeyboardAvoidingView>
);
}
}

View file

@ -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);

View file

@ -73,7 +73,6 @@ class SelectServer extends Component {
/>
</Button>
<ErrorText error={this.props.server.error}/>
<KeyboardAvoidingView style={GlobalStyles.pagePush}/>
</KeyboardAvoidingView>
);
}

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,