From 2fd5e5cd35fe71483850103fe1d8e3f7206578e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 7 Jan 2022 14:26:19 -0600 Subject: [PATCH] Refactor MFA setup, fixes #792 --- app/soapbox/actions/mfa.js | 186 +++++----------------- app/soapbox/actions/settings.js | 1 - app/soapbox/features/security/index.js | 22 ++- app/soapbox/features/security/mfa_form.js | 90 ++++++----- app/soapbox/reducers/security.js | 33 ++++ app/styles/components/mfa_form.scss | 9 -- 6 files changed, 135 insertions(+), 206 deletions(-) diff --git a/app/soapbox/actions/mfa.js b/app/soapbox/actions/mfa.js index 7cde55b84..171446053 100644 --- a/app/soapbox/actions/mfa.js +++ b/app/soapbox/actions/mfa.js @@ -1,180 +1,80 @@ 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 MFA_FETCH_REQUEST = 'MFA_FETCH_REQUEST'; +export const MFA_FETCH_SUCCESS = 'MFA_FETCH_SUCCESS'; +export const MFA_FETCH_FAIL = 'MFA_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 MFA_BACKUP_CODES_FETCH_REQUEST = 'MFA_BACKUP_CODES_FETCH_REQUEST'; +export const MFA_BACKUP_CODES_FETCH_SUCCESS = 'MFA_BACKUP_CODES_FETCH_SUCCESS'; +export const MFA_BACKUP_CODES_FETCH_FAIL = 'MFA_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 MFA_SETUP_REQUEST = 'MFA_SETUP_REQUEST'; +export const MFA_SETUP_SUCCESS = 'MFA_SETUP_SUCCESS'; +export const MFA_SETUP_FAIL = 'MFA_SETUP_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 MFA_CONFIRM_REQUEST = 'MFA_CONFIRM_REQUEST'; +export const MFA_CONFIRM_SUCCESS = 'MFA_CONFIRM_SUCCESS'; +export const MFA_CONFIRM_FAIL = 'MFA_CONFIRM_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 const MFA_DISABLE_REQUEST = 'MFA_DISABLE_REQUEST'; +export const MFA_DISABLE_SUCCESS = 'MFA_DISABLE_SUCCESS'; +export const MFA_DISABLE_FAIL = 'MFA_DISABLE_FAIL'; -export function fetchUserMfaSettings() { +export function fetchMfa() { 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; + dispatch({ type: MFA_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa').then(({ data }) => { + dispatch({ type: MFA_FETCH_SUCCESS, data }); }).catch(error => { - dispatch({ type: TOTP_SETTINGS_FETCH_FAIL }); + dispatch({ type: MFA_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; + dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(({ data }) => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data }); + return data; }).catch(error => { - dispatch({ type: BACKUP_CODES_FETCH_FAIL }); + dispatch({ type: MFA_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() { +export function setupMfa(method) { 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; + dispatch({ type: MFA_SETUP_REQUEST, method }); + return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then(({ data }) => { + dispatch({ type: MFA_SETUP_SUCCESS, data }); + return data; }).catch(error => { - dispatch({ type: TOTP_SETUP_FETCH_FAIL }); + dispatch({ type: MFA_SETUP_FAIL }); + throw error; }); }; } -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) { +export function confirmMfa(method, 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; + const params = { code, password }; + dispatch({ type: MFA_CONFIRM_REQUEST, method, code }); + return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then(() => { + dispatch({ type: MFA_CONFIRM_SUCCESS, method, code }); }).catch(error => { - dispatch({ type: CONFIRM_TOTP_FAIL }); + dispatch({ type: MFA_CONFIRM_FAIL, method, code, error }); }); }; } -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) { +export function disableMfa(method, 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; + dispatch({ type: MFA_DISABLE_REQUEST, method }); + return api(getState).delete(`/api/pleroma/accounts/mfa/${method}`, { data: { password } }).then(response => { + dispatch({ type: MFA_DISABLE_SUCCESS, method }); }).catch(error => { - dispatch({ type: DISABLE_TOTP_FAIL }); + dispatch({ type: MFA_DISABLE_FAIL, method }); }); }; } - -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 1bfaef7a2..18b0636f5 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -30,7 +30,6 @@ export const defaultSettings = ImmutableMap({ locale: navigator.language.split(/[-_]/)[0] || 'en', showExplanationBox: true, explanationBox: true, - otpEnabled: false, autoloadTimelines: true, autoloadMore: true, diff --git a/app/soapbox/features/security/index.js b/app/soapbox/features/security/index.js index e19b1f0b7..22ab8b729 100644 --- a/app/soapbox/features/security/index.js +++ b/app/soapbox/features/security/index.js @@ -18,9 +18,9 @@ import { deleteAccount, } from 'soapbox/actions/security'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; -import { fetchUserMfaSettings } from '../../actions/mfa'; +import { fetchMfa } from '../../actions/mfa'; import snackbar from 'soapbox/actions/snackbar'; -import { changeSetting, getSettings } from 'soapbox/actions/settings'; +import { getSettings } from 'soapbox/actions/settings'; /* Security settings page for user account @@ -64,6 +64,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ settings: getSettings(state), tokens: state.getIn(['security', 'tokens']), + mfa: state.getIn(['security', 'mfa']), }); export default @connect(mapStateToProps) @@ -242,33 +243,30 @@ class ChangePasswordForm extends ImmutablePureComponent { @injectIntl class SetUpMfa extends ImmutablePureComponent { - constructor(props) { - super(props); - this.props.dispatch(fetchUserMfaSettings()).then(response => { - this.props.dispatch(changeSetting(['otpEnabled'], response.data.settings.enabled)); - }).catch(e => e); - } - static contextTypes = { router: PropTypes.object, }; static propTypes = { intl: PropTypes.object.isRequired, - settings: ImmutablePropTypes.map.isRequired, + mfa: ImmutablePropTypes.map.isRequired, }; handleMfaClick = e => { this.context.router.history.push('../auth/mfa'); } + componentDidMount() { + this.props.dispatch(fetchMfa()); + } + render() { - const { intl, settings } = this.props; + const { intl, mfa } = this.props; return (

{intl.formatMessage(messages.mfaHeader)}

- { settings.get('otpEnabled') === false ? + {!mfa.getIn(['settings', 'totp']) ?

{intl.formatMessage(messages.mfa_setup_hint)} diff --git a/app/soapbox/features/security/mfa_form.js b/app/soapbox/features/security/mfa_form.js index 78525af77..d8a6aa864 100644 --- a/app/soapbox/features/security/mfa_form.js +++ b/app/soapbox/features/security/mfa_form.js @@ -9,7 +9,6 @@ import Column from '../ui/components/column'; import ColumnSubheading from '../ui/components/column_subheading'; import LoadingIndicator from 'soapbox/components/loading_indicator'; import Button from 'soapbox/components/button'; -import { changeSetting, getSettings } from 'soapbox/actions/settings'; import snackbar from 'soapbox/actions/snackbar'; import ShowablePassword from 'soapbox/components/showable_password'; import { @@ -18,11 +17,11 @@ import { TextInput, } from 'soapbox/features/forms'; import { + fetchMfa, fetchBackupCodes, - fetchToptSetup, - confirmToptSetup, - fetchUserMfaSettings, - disableToptSetup, + setupMfa, + confirmMfa, + disableMfa, } from '../../actions/mfa'; /* @@ -44,26 +43,19 @@ const messages = defineMessages({ qrFail: { id: 'security.qr.fail', defaultMessage: 'Failed to fetch setup key' }, codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' }, disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' }, + mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' }, + mfaConfirmSuccess: { id: 'mfa.confirm.success_message', defaultMessage: 'MFA confirmed' }, }); const mapStateToProps = state => ({ backup_codes: state.getIn(['auth', 'backup_codes', 'codes']), - settings: getSettings(state), + mfa: state.getIn(['security', 'mfa']), }); export default @connect(mapStateToProps) @injectIntl class MfaForm extends ImmutablePureComponent { - constructor(props) { - super(props); - this.props.dispatch(fetchUserMfaSettings()).then(response => { - this.props.dispatch(changeSetting(['otpEnabled'], response.data.settings.enabled)); - // this.setState({ otpEnabled: response.data.settings.enabled }); - }).catch(e => e); - this.handleSetupProceedClick = this.handleSetupProceedClick.bind(this); - } - static contextTypes = { router: PropTypes.object, }; @@ -71,7 +63,7 @@ class MfaForm extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, - settings: ImmutablePropTypes.map.isRequired, + mfa: ImmutablePropTypes.map.isRequired, }; state = { @@ -79,20 +71,29 @@ class MfaForm extends ImmutablePureComponent { } handleSetupProceedClick = e => { - e.preventDefault(); this.setState({ displayOtpForm: true }); + e.preventDefault(); + } + + componentDidMount() { + this.props.dispatch(fetchMfa()); } render() { - const { intl, settings } = this.props; + const { intl, mfa } = this.props; const { displayOtpForm } = this.state; return ( - { settings.get('otpEnabled') === true && } - { settings.get('otpEnabled') === false && } - { settings.get('otpEnabled') === false && displayOtpForm && } + {mfa.getIn(['settings', 'totp']) ? ( + + ) : ( + <> + + {displayOtpForm && } + + )} ); } @@ -122,15 +123,17 @@ class DisableOtpForm extends ImmutablePureComponent { } handleOtpDisableClick = e => { - e.preventDefault(); const { password } = this.state; const { dispatch, intl } = this.props; - dispatch(disableToptSetup(password)).then(response => { - this.context.router.history.push('../auth/edit'); - dispatch(changeSetting(['otpEnabled'], false)); + + dispatch(disableMfa('totp', password)).then(() => { + dispatch(snackbar.success(intl.formatMessage(messages.mfaDisableSuccess))); }).catch(error => { dispatch(snackbar.error(intl.formatMessage(messages.disableFail))); }); + + this.context.router.history.push('../auth/edit'); + e.preventDefault(); } render() { @@ -176,8 +179,9 @@ class EnableOtpForm extends ImmutablePureComponent { componentDidMount() { const { dispatch, intl } = this.props; - dispatch(fetchBackupCodes()).then(response => { - this.setState({ backupCodes: response.data.codes }); + + dispatch(fetchBackupCodes()).then(({ codes: backupCodes }) => { + this.setState({ backupCodes }); }).catch(error => { dispatch(snackbar.error(intl.formatMessage(messages.codesFail))); }); @@ -207,26 +211,26 @@ class EnableOtpForm extends ImmutablePureComponent {

- { backupCodes.length ? + {backupCodes.length > 0 ? (
{backupCodes.map((code, i) => (
{code}
))} -
: +
+ ) : ( - } + )}
- { !displayOtpForm && + {!displayOtpForm && (
- } + )}
); @@ -257,8 +261,9 @@ class OtpConfirmForm extends ImmutablePureComponent { componentDidMount() { const { dispatch, intl } = this.props; - dispatch(fetchToptSetup()).then(response => { - this.setState({ qrCodeURI: response.data.provisioning_uri, confirm_key: response.data.key }); + + dispatch(setupMfa('totp')).then(data => { + this.setState({ qrCodeURI: data.provisioning_uri, confirm_key: data.key }); }).catch(error => { dispatch(snackbar.error(intl.formatMessage(messages.qrFail))); }); @@ -269,14 +274,17 @@ class OtpConfirmForm extends ImmutablePureComponent { } handleOtpConfirmClick = e => { - e.preventDefault(); const { code, password } = this.state; const { dispatch, intl } = this.props; - dispatch(confirmToptSetup(code, password)).then(response => { - dispatch(changeSetting(['otpEnabled'], true)); + + dispatch(confirmMfa('totp', code, password)).then(() => { + dispatch(snackbar.success(intl.formatMessage(messages.mfaConfirmSuccess))); }).catch(error => { dispatch(snackbar.error(intl.formatMessage(messages.confirmFail))); }); + + this.context.router.history.push('../auth/edit'); + e.preventDefault(); } render() { diff --git a/app/soapbox/reducers/security.js b/app/soapbox/reducers/security.js index 497f4fb61..e14612bc3 100644 --- a/app/soapbox/reducers/security.js +++ b/app/soapbox/reducers/security.js @@ -2,10 +2,22 @@ import { FETCH_TOKENS_SUCCESS, REVOKE_TOKEN_SUCCESS, } from '../actions/security'; +import { + MFA_FETCH_SUCCESS, + MFA_CONFIRM_SUCCESS, + MFA_DISABLE_REQUEST, + MFA_DISABLE_SUCCESS, + MFA_DISABLE_FAIL, +} from '../actions/mfa'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; const initialState = ImmutableMap({ tokens: ImmutableList(), + mfa: ImmutableMap({ + settings: ImmutableMap({ + totp: false, + }), + }), }); const deleteToken = (state, tokenId) => { @@ -14,12 +26,33 @@ const deleteToken = (state, tokenId) => { }); }; +const importMfa = (state, data) => { + return state.set('mfa', data); +}; + +const enableMfa = (state, method) => { + return state.setIn(['mfa', 'settings', method], true); +}; + +const disableMfa = (state, method) => { + return state.setIn(['mfa', 'settings', method], false); +}; + export default function security(state = initialState, action) { switch(action.type) { case FETCH_TOKENS_SUCCESS: return state.set('tokens', fromJS(action.tokens)); case REVOKE_TOKEN_SUCCESS: return deleteToken(state, action.id); + case MFA_FETCH_SUCCESS: + return importMfa(state, fromJS(action.data)); + case MFA_CONFIRM_SUCCESS: + return enableMfa(state, action.method); + case MFA_DISABLE_REQUEST: + case MFA_DISABLE_SUCCESS: + return disableMfa(state, action.method); + case MFA_DISABLE_FAIL: + return enableMfa(state, action.method); default: return state; } diff --git a/app/styles/components/mfa_form.scss b/app/styles/components/mfa_form.scss index 10ab69fc7..1e45f20c2 100644 --- a/app/styles/components/mfa_form.scss +++ b/app/styles/components/mfa_form.scss @@ -16,11 +16,6 @@ font-weight: 400; } - div { - display: block; - margin: 10px 0; - } - .security-warning { color: var(--primary-text-color); padding: 15px 20px; @@ -44,10 +39,6 @@ .backup_code { margin: 5px auto; } - - .loading-indicator { - position: absolute; - } } .security-settings-panel__setup-otp__buttons {