Mfa (#182)
* Adding MFA support * specific actions on mfa * Fix header on new navigation * review feedback
This commit is contained in:
parent
dd073f173e
commit
79902e8ceb
18 changed files with 315 additions and 21 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
6
app/scenes/mfa/index.js
Normal 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
107
app/scenes/mfa/mfa.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/scenes/mfa/mfa_container.js
Normal file
31
app/scenes/mfa/mfa_container.js
Normal 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);
|
||||
|
|
@ -73,7 +73,6 @@ class SelectServer extends Component {
|
|||
/>
|
||||
</Button>
|
||||
<ErrorText error={this.props.server.error}/>
|
||||
<KeyboardAvoidingView style={GlobalStyles.pagePush}/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue