diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index a8d6d5f0f..8beb9c1eb 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -112,12 +112,32 @@ export function refreshUserToken() { }; } +export function otpVerify(code, mfa_token) { + return (dispatch, getState) => { + const app = getState().getIn(['auth', 'app']); + return api(getState, 'app').post('/oauth/mfa/challenge', { + client_id: app.get('client_id'), + client_secret: app.get('client_secret'), + mfa_token: mfa_token, + code: code, + challenge_type: 'totp', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + }).then(response => { + dispatch(authLoggedIn(response.data)); + }); + }; +} + export function logIn(username, password) { return (dispatch, getState) => { return dispatch(createAppAndToken()).then(() => { return dispatch(createUserToken(username, password)); }).catch(error => { - dispatch(showAlert('Login failed.', 'Invalid username or password.')); + if (error.response.data.error === 'mfa_required') { + throw error; + } else { + dispatch(showAlert('Login failed.', 'Invalid username or password.')); + } throw error; }); }; diff --git a/app/soapbox/actions/mfa.js b/app/soapbox/actions/mfa.js new file mode 100644 index 000000000..0a8a706eb --- /dev/null +++ b/app/soapbox/actions/mfa.js @@ -0,0 +1,180 @@ +import api from '../api'; + +export const TOTP_SETTINGS_FETCH_REQUEST = 'TOTP_SETTINGS_FETCH_REQUEST'; +export const TOTP_SETTINGS_FETCH_SUCCESS = 'TOTP_SETTINGS_FETCH_SUCCESS'; +export const TOTP_SETTINGS_FETCH_FAIL = 'TOTP_SETTINGS_FETCH_FAIL'; + +export const BACKUP_CODES_FETCH_REQUEST = 'BACKUP_CODES_FETCH_REQUEST'; +export const BACKUP_CODES_FETCH_SUCCESS = 'BACKUP_CODES_FETCH_SUCCESS'; +export const BACKUP_CODES_FETCH_FAIL = 'BACKUP_CODES_FETCH_FAIL'; + +export const TOTP_SETUP_FETCH_REQUEST = 'TOTP_SETUP_FETCH_REQUEST'; +export const TOTP_SETUP_FETCH_SUCCESS = 'TOTP_SETUP_FETCH_SUCCESS'; +export const TOTP_SETUP_FETCH_FAIL = 'TOTP_SETUP_FETCH_FAIL'; + +export const CONFIRM_TOTP_REQUEST = 'CONFIRM_TOTP_REQUEST'; +export const CONFIRM_TOTP_SUCCESS = 'CONFIRM_TOTP_SUCCESS'; +export const CONFIRM_TOTP_FAIL = 'CONFIRM_TOTP_FAIL'; + +export const DISABLE_TOTP_REQUEST = 'DISABLE_TOTP_REQUEST'; +export const DISABLE_TOTP_SUCCESS = 'DISABLE_TOTP_SUCCESS'; +export const DISABLE_TOTP_FAIL = 'DISABLE_TOTP_FAIL'; + +export function fetchUserMfaSettings() { + return (dispatch, getState) => { + dispatch({ type: TOTP_SETTINGS_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa').then(response => { + dispatch({ type: TOTP_SETTINGS_FETCH_SUCCESS, totpEnabled: response.data.totp }); + return response; + }).catch(error => { + dispatch({ type: TOTP_SETTINGS_FETCH_FAIL }); + }); + }; +} + +export function fetchUserMfaSettingsRequest() { + return { + type: TOTP_SETTINGS_FETCH_REQUEST, + }; +}; + +export function fetchUserMfaSettingsSuccess() { + return { + type: TOTP_SETTINGS_FETCH_SUCCESS, + }; +}; + +export function fetchUserMfaSettingsFail() { + return { + type: TOTP_SETTINGS_FETCH_FAIL, + }; +}; + +export function fetchBackupCodes() { + return (dispatch, getState) => { + dispatch({ type: BACKUP_CODES_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(response => { + dispatch({ type: BACKUP_CODES_FETCH_SUCCESS, backup_codes: response.data }); + return response; + }).catch(error => { + dispatch({ type: BACKUP_CODES_FETCH_FAIL }); + }); + }; +} + +export function fetchBackupCodesRequest() { + return { + type: BACKUP_CODES_FETCH_REQUEST, + }; +}; + +export function fetchBackupCodesSuccess(backup_codes, response) { + return { + type: BACKUP_CODES_FETCH_SUCCESS, + backup_codes: response.data, + }; +}; + +export function fetchBackupCodesFail(error) { + return { + type: BACKUP_CODES_FETCH_FAIL, + error, + }; +}; + +export function fetchToptSetup() { + return (dispatch, getState) => { + dispatch({ type: TOTP_SETUP_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/setup/totp').then(response => { + dispatch({ type: TOTP_SETUP_FETCH_SUCCESS, totp_setup: response.data }); + return response; + }).catch(error => { + dispatch({ type: TOTP_SETUP_FETCH_FAIL }); + }); + }; +} + +export function fetchToptSetupRequest() { + return { + type: TOTP_SETUP_FETCH_REQUEST, + }; +}; + +export function fetchToptSetupSuccess(totp_setup, response) { + return { + type: TOTP_SETUP_FETCH_SUCCESS, + totp_setup: response.data, + }; +}; + +export function fetchToptSetupFail(error) { + return { + type: TOTP_SETUP_FETCH_FAIL, + error, + }; +}; + +export function confirmToptSetup(code, password) { + return (dispatch, getState) => { + dispatch({ type: CONFIRM_TOTP_REQUEST, code }); + return api(getState).post('/api/pleroma/accounts/mfa/confirm/totp', { + code, + password, + }).then(response => { + dispatch({ type: CONFIRM_TOTP_SUCCESS }); + return response; + }).catch(error => { + dispatch({ type: CONFIRM_TOTP_FAIL }); + }); + }; +} + +export function confirmToptRequest() { + return { + type: CONFIRM_TOTP_REQUEST, + }; +}; + +export function confirmToptSuccess(backup_codes, response) { + return { + type: CONFIRM_TOTP_SUCCESS, + }; +}; + +export function confirmToptFail(error) { + return { + type: CONFIRM_TOTP_FAIL, + error, + }; +}; + +export function disableToptSetup(password) { + return (dispatch, getState) => { + dispatch({ type: DISABLE_TOTP_REQUEST }); + return api(getState).delete('/api/pleroma/accounts/mfa/totp', { data: { password } }).then(response => { + dispatch({ type: DISABLE_TOTP_SUCCESS }); + return response; + }).catch(error => { + dispatch({ type: DISABLE_TOTP_FAIL }); + }); + }; +} + +export function disableToptRequest() { + return { + type: DISABLE_TOTP_REQUEST, + }; +}; + +export function disableToptSuccess(backup_codes, response) { + return { + type: DISABLE_TOTP_SUCCESS, + }; +}; + +export function disableToptFail(error) { + return { + type: DISABLE_TOTP_FAIL, + error, + }; +}; diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 0c2f35a8b..a482b519c 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -23,6 +23,7 @@ const defaultSettings = ImmutableMap({ themeMode: 'light', locale: navigator.language.split(/[-_]/)[0] || 'en', explanationBox: true, + otpEnabled: false, systemFont: false, dyslexicFont: false, diff --git a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap index 6c34feba7..646a96d49 100644 --- a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap +++ b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap @@ -3,11 +3,8 @@ exports[` renders correctly 1`] = `
-
+
diff --git a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap index 35adf1d29..69885fe7b 100644 --- a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap +++ b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders correctly 1`] = ` +exports[` renders correctly on load 1`] = ` renders correctly 1`] = ` `; -exports[` renders correctly 2`] = `null`; +exports[` renders correctly on load 2`] = `null`; diff --git a/app/soapbox/features/auth_login/components/__tests__/login_page-test.js b/app/soapbox/features/auth_login/components/__tests__/login_page-test.js index f0e3d7901..27393c6c3 100644 --- a/app/soapbox/features/auth_login/components/__tests__/login_page-test.js +++ b/app/soapbox/features/auth_login/components/__tests__/login_page-test.js @@ -2,9 +2,16 @@ import React from 'react'; import LoginPage from '../login_page'; import { createComponent, mockStore } from 'soapbox/test_helpers'; import { Map as ImmutableMap } from 'immutable'; +// import { __stub as stubApi } from 'soapbox/api'; +// import { logIn } from 'soapbox/actions/auth'; describe('', () => { - it('renders correctly', () => { + beforeEach(() => { + const store = mockStore(ImmutableMap({})); + return store; + }); + + it('renders correctly on load', () => { expect(createComponent( ).toJSON()).toMatchSnapshot(); @@ -12,7 +19,38 @@ describe('', () => { const store = mockStore(ImmutableMap({ me: '1234' })); expect(createComponent( , - { store }, + { store } ).toJSON()).toMatchSnapshot(); }); + + // it('renders the OTP form when logIn returns with mfa_required', () => { + // + // stubApi(mock => { + // mock.onPost('/api/v1/apps').reply(200, { + // data: { + // client_id:'12345', client_secret:'12345', id:'111', name:'SoapboxFE', redirect_uri:'urn:ietf:wg:oauth:2.0:oob', website:null, vapid_key:'12345', + // }, + // }); + // mock.onPost('/oauth/token').reply(403, { + // error:'mfa_required', mfa_token:'12345', supported_challenge_types:'totp', + // }); + // }); + // + // const app = new Map(); + // app.set('app', { client_id: '12345', client_secret:'12345' }); + // const store = mockStore(ImmutableMap({ + // auth: { app }, + // })); + // const loginPage = createComponent(, { store }); + // + // return loginPage.handleSubmit().then(() => { + // const wrapper = loginPage.toJSON(); + // expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({ + // type: 'h1', + // props: { className: 'otp-login' }, + // children: [ 'OTP Login' ], + // })); + // }); + // + // }); }); diff --git a/app/soapbox/features/auth_login/components/__tests__/otp_auth_form-test.js b/app/soapbox/features/auth_login/components/__tests__/otp_auth_form-test.js new file mode 100644 index 000000000..799a5e819 --- /dev/null +++ b/app/soapbox/features/auth_login/components/__tests__/otp_auth_form-test.js @@ -0,0 +1,29 @@ +import React from 'react'; +import OtpAuthForm from '../otp_auth_form'; +import { createComponent, mockStore } from 'soapbox/test_helpers'; +import { Map as ImmutableMap } from 'immutable'; + +describe('', () => { + it('renders correctly', () => { + + const store = mockStore(ImmutableMap({ mfa_auth_needed: true })); + + const wrapper = createComponent( + , + { store } + ).toJSON(); + + expect(wrapper).toEqual(expect.objectContaining({ + type: 'form', + })); + + expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({ + type: 'h1', + props: { className: 'otp-login' }, + children: [ 'OTP Login' ], + })); + + }); +}); diff --git a/app/soapbox/features/auth_login/components/login_form.js b/app/soapbox/features/auth_login/components/login_form.js index 1b669063b..0a964f449 100644 --- a/app/soapbox/features/auth_login/components/login_form.js +++ b/app/soapbox/features/auth_login/components/login_form.js @@ -3,8 +3,6 @@ import { connect } from 'react-redux'; import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { Link } from 'react-router-dom'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { logIn } from 'soapbox/actions/auth'; -import { fetchMe } from 'soapbox/actions/me'; const messages = defineMessages({ username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' }, @@ -15,34 +13,12 @@ export default @connect() @injectIntl class LoginForm extends ImmutablePureComponent { - state = { - isLoading: false, - } - - getFormData = (form) => { - return Object.fromEntries( - Array.from(form).map(i => [i.name, i.value]) - ); - } - - handleSubmit = (event) => { - const { dispatch } = this.props; - const { username, password } = this.getFormData(event.target); - dispatch(logIn(username, password)).then(() => { - return dispatch(fetchMe()); - }).catch(error => { - this.setState({ isLoading: false }); - }); - this.setState({ isLoading: true }); - event.preventDefault(); - } - render() { - const { intl } = this.props; + const { intl, isLoading, handleSubmit } = this.props; return ( -
-
+ +
diff --git a/app/soapbox/features/auth_login/components/login_page.js b/app/soapbox/features/auth_login/components/login_page.js index 2de7daa8f..934ce12ea 100644 --- a/app/soapbox/features/auth_login/components/login_page.js +++ b/app/soapbox/features/auth_login/components/login_page.js @@ -3,19 +3,57 @@ import { connect } from 'react-redux'; import { Redirect } from 'react-router-dom'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LoginForm from './login_form'; +import OtpAuthForm from './otp_auth_form'; +import { logIn } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; const mapStateToProps = state => ({ me: state.get('me'), + isLoading: false, }); export default @connect(mapStateToProps) class LoginPage extends ImmutablePureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + state = { + mfa_auth_needed: false, + mfa_token: '', + } + + handleSubmit = (event) => { + const { dispatch } = this.props; + const { username, password } = this.getFormData(event.target); + dispatch(logIn(username, password)).then(() => { + return dispatch(fetchMe()); + }).catch(error => { + if (error.response.data.error === 'mfa_required') { + this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token }); + } + this.setState({ isLoading: false }); + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + render() { - const { me } = this.props; + const { me, isLoading } = this.props; + const { mfa_auth_needed, mfa_token } = this.state; if (me) return ; - return ; + if (mfa_auth_needed) return ; + + return ; } } diff --git a/app/soapbox/features/auth_login/components/otp_auth_form.js b/app/soapbox/features/auth_login/components/otp_auth_form.js new file mode 100644 index 000000000..21655f492 --- /dev/null +++ b/app/soapbox/features/auth_login/components/otp_auth_form.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { otpVerify } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; +import { SimpleInput } from 'soapbox/features/forms'; +import PropTypes from 'prop-types'; + +const messages = defineMessages({ + otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' }, + otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' }, +}); + +export default @connect() +@injectIntl +class OtpAuthForm extends ImmutablePureComponent { + + state = { + isLoading: false, + code_error: '', + } + + static propTypes = { + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + mfa_token: PropTypes.string, + }; + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + handleSubmit = (event) => { + const { dispatch, mfa_token } = this.props; + const { code } = this.getFormData(event.target); + dispatch(otpVerify(code, mfa_token)).then(() => { + this.setState({ code_error: false }); + return dispatch(fetchMe()); + }).catch(error => { + this.setState({ isLoading: false }); + if (error.response.data.error === 'Invalid code') { + this.setState({ code_error: true }); + } + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + + render() { + const { intl } = this.props; + const { code_error } = this.state; + + return ( + +
+
+
+

+ +

+
+
+ +
+
+
+ { code_error && +
+ +
+ } +
+ +
+ + ); + } + +} diff --git a/app/soapbox/features/public_layout/components/header.js b/app/soapbox/features/public_layout/components/header.js index 9f018cc3c..70c20dcaa 100644 --- a/app/soapbox/features/public_layout/components/header.js +++ b/app/soapbox/features/public_layout/components/header.js @@ -6,25 +6,85 @@ import { Link } from 'react-router-dom'; import LoginForm from 'soapbox/features/auth_login/components/login_form'; import SiteLogo from './site_logo'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; +import { logIn } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; +import PropTypes from 'prop-types'; +import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form'; +import IconButton from 'soapbox/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); const mapStateToProps = state => ({ me: state.get('me'), instance: state.get('instance'), + isLoading: false, }); export default @connect(mapStateToProps) +@injectIntl class Header extends ImmutablePureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + static contextTypes = { + router: PropTypes.object, + }; + + handleSubmit = (event) => { + const { dispatch } = this.props; + const { username, password } = this.getFormData(event.target); + dispatch(logIn(username, password)).then(() => { + return dispatch(fetchMe()); + }).catch(error => { + if (error.response.data.error === 'mfa_required') { + this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token }); + } + this.setState({ isLoading: false }); + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + + onClickClose = (event) => { + this.setState({ mfa_auth_needed: false, mfa_token: '' }); + } + static propTypes = { me: SoapboxPropTypes.me, instance: ImmutablePropTypes.map, } + state = { + mfa_auth_needed: false, + mfa_token: '', + } + render() { - const { me, instance } = this.props; + const { me, instance, isLoading, intl } = this.props; + const { mfa_auth_needed, mfa_token } = this.state; return (