diff --git a/app/mobile/src/api/addAccountMFA.js b/app/mobile/src/api/addAccountMFA.js new file mode 100644 index 00000000..5d3a91c7 --- /dev/null +++ b/app/mobile/src/api/addAccountMFA.js @@ -0,0 +1,11 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function addAccountMFA(server, token) { + const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); + const protocol = insecure ? 'http' : 'https'; + + const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}`, { method: 'POST' }) + checkResponse(mfa); + return mfa.json(); +} + diff --git a/app/mobile/src/api/removeAccountMFA.js b/app/mobile/src/api/removeAccountMFA.js new file mode 100644 index 00000000..96ffe0ed --- /dev/null +++ b/app/mobile/src/api/removeAccountMFA.js @@ -0,0 +1,10 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function removeAccountMFA(server, token) { + const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); + const protocol = insecure ? 'http' : 'https'; + + const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}`, { method: 'DELETE' }); + checkResponse(mfa); +} + diff --git a/app/mobile/src/api/setAccountMFA.js b/app/mobile/src/api/setAccountMFA.js new file mode 100644 index 00000000..8fef1e8e --- /dev/null +++ b/app/mobile/src/api/setAccountMFA.js @@ -0,0 +1,10 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function setAccountMFA(server, token, code) { + const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); + const protocol = insecure ? 'http' : 'https'; + + const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}&code=${code}`, { method: 'PUT' }) + checkResponse(mfa); +} + diff --git a/app/mobile/src/context/useAccountContext.hook.js b/app/mobile/src/context/useAccountContext.hook.js index 73529f48..d87d1991 100644 --- a/app/mobile/src/context/useAccountContext.hook.js +++ b/app/mobile/src/context/useAccountContext.hook.js @@ -5,6 +5,9 @@ import { setAccountSearchable } from 'api/setAccountSearchable'; import { setAccountNotifications } from 'api/setAccountNotifications'; import { getAccountStatus } from 'api/getAccountStatus'; import { setAccountLogin } from 'api/setAccountLogin'; +import { addAccountMFA } from 'api/addAccountMFA'; +import { setAccountMFA } from 'api/setAccountMFA'; +import { removeAccountMFA } from 'api/removeAccountMFA'; export function useAccountContext() { const [state, setState] = useState({ @@ -75,6 +78,19 @@ export function useAccountContext() { const { server, token } = access.current || {}; await setAccountSearchable(server, token, flag); }, + enableMFA: async () => { + const { server, token } = access.current || {}; + const secret = await addAccountMFA(server, token); + return secret; + }, + disableMFA: async () => { + const { server, token } = access.current || {}; + await removeAccountMFA(server, token); + }, + confirmMFA: async (code) => { + const { server, token } = access.current || {}; + await setAccountMFA(server, token, code); + }, setAccountSeal: async (seal, key) => { const { guid, server, token } = access.current || {}; await setAccountSeal(server, token, seal); diff --git a/app/mobile/src/session/settings/Settings.jsx b/app/mobile/src/session/settings/Settings.jsx index 5497995b..fe77fee9 100644 --- a/app/mobile/src/session/settings/Settings.jsx +++ b/app/mobile/src/session/settings/Settings.jsx @@ -1,14 +1,17 @@ import { useState } from 'react'; -import { Linking, ActivityIndicator, FlatList, KeyboardAvoidingView, Modal, ScrollView, View, Switch, Text, TextInput, TouchableOpacity, Alert } from 'react-native'; +import { Linking, ActivityIndicator, FlatList, Image, KeyboardAvoidingView, Modal, ScrollView, View, Switch, Text, TextInput, TouchableOpacity, Alert } from 'react-native'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { useNavigate } from 'react-router-dom'; import { styles } from './Settings.styled'; import { useSettings } from './useSettings.hook'; +import AntIcon from 'react-native-vector-icons/AntDesign'; import MatIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import Colors from 'constants/Colors'; import { BlurView } from "@react-native-community/blur"; import { InputField } from 'utils/InputField'; import { Logo } from 'utils/Logo'; +import { InputCode } from 'utils/InputCode'; +import Clipboard from '@react-native-clipboard/clipboard'; export function Settings({ drawer }) { @@ -99,6 +102,28 @@ export function Settings({ drawer }) { } } + const toggleMFA = async () => { + if (!busy) { + try { + setBusy(true); + if (state.mfaEnabled) { + await actions.disableMFA(); + } + else { + await actions.enableMFA(); + } + } + catch (err) { + console.log(err); + Alert.alert( + state.strings.error, + state.strings.tryAgain, + ); + } + setBusy(false); + } + } + const deleteAccount = async () => { if (!busy) { try { @@ -199,6 +224,20 @@ export function Settings({ drawer }) { { state.strings.logout } + + + + + + + + { state.strings.mfaTitle } + + + + + @@ -354,6 +393,19 @@ export function Settings({ drawer }) { + + + + + + + { state.strings.mfaTitle } + + + + + @@ -833,6 +885,60 @@ export function Settings({ drawer }) { + + + + + + { state.strings.mfaTitle } + { state.strings.mfaSteps } + { state.mfaImage && ( + + )} + { !state.mfaImage && !state.mfaText && ( + + )} + { state.mfaText && ( + Clipboard.setString(state.mfaText)}> + { state.mfaText } + + + )} + + + { state.mfaError == '401' && ( + { state.strings.mfaError } + )} + { state.mfaError == '429' && ( + { state.strings.mfaDisabled } + )} + + + + { state.strings.cancel } + + { state.mfaCode != '' && ( + + { state.strings.mfaConfirm } + + )} + { state.mfaCode == '' && ( + + { state.strings.mfaConfirm } + + )} + + + + + + ); } diff --git a/app/mobile/src/session/settings/Settings.styled.js b/app/mobile/src/session/settings/Settings.styled.js index 5656b72b..886b9616 100644 --- a/app/mobile/src/session/settings/Settings.styled.js +++ b/app/mobile/src/session/settings/Settings.styled.js @@ -15,6 +15,112 @@ export const styles = StyleSheet.create({ flexDirection: 'row', padding: 8, }, + mfaOverlay: { + width: '100%', + height: '100%', + }, + mfaSecret: { + display: 'flex', + flexDirection: 'row', + gap: 4, + }, + mfaText: { + fontSize: 11, + color: Colors.mainText, + }, + mfaIcon: { + fontSize: 14, + color: Colors.primaryButton, + }, + mfaError: { + width: '100%', + height: 24, + display: 'flex', + alignItems: 'center', + }, + mfaErrorLabel: { + color: Colors.dangerText, + }, + mfaControl: { + height: 32, + display: 'flex', + flexDirection: 'row', + width: '100%', + justifyContent: 'flex-end', + gap: 16, + }, + mfaCancel: { + width: 72, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.cancelButton, + borderRadius: 4, + }, + mfaCancelLabel: { + color: Colors.cancelButtonText, + }, + mfaConfirm: { + width: 72, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.primaryButton, + borderRadius: 4, + }, + mfaConfirmLabel: { + color: Colors.primaryButtonText, + }, + mfaDisabled: { + width: 72, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.disabledButton, + borderRadius: 4, + }, + mfaDisabledLabel: { + color: Colors.disabledButtonText, + }, + mfaBase: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center' + }, + mfaContainer: { + backgroundColor: Colors.modalBase, + borderColor: Colors.modalBorder, + borderWidth: 1, + width: '80%', + maxWidth: 400, + display: 'flex', + gap: 8, + alignItems: 'center', + borderRadius: 8, + padding: 16, + }, + mfaTitle: { + fontSize: 20, + color: Colors.descriptionText, + paddingBottom: 8, + }, + mfaDescription: { + fontSize: 14, + color: Colors.descriptionText, + textAlign: 'center', + }, + mfaCode: { + width: 400, + borderWidth: 1, + borderColor: '#333333', + width: '100%', + opacity: 0, + }, content: { width: '100%', height: '100%', diff --git a/app/mobile/src/session/settings/useSettings.hook.js b/app/mobile/src/session/settings/useSettings.hook.js index c701ebda..fbe2f091 100644 --- a/app/mobile/src/session/settings/useSettings.hook.js +++ b/app/mobile/src/session/settings/useSettings.hook.js @@ -56,6 +56,14 @@ export function useSettings() { contacts: [], topics: [], messages: [], + + mfaModal: false, + mfaEnabled: false, + mfaSecret: false, + mfaError: null, + mfaCode: '', + mfaText: null, + mfaImage: null, }); const updateState = (value) => { @@ -69,11 +77,11 @@ export function useSettings() { }, [profile.state]); useEffect(() => { - const { seal, sealable, pushEnabled } = account.state.status; + const { seal, sealable, pushEnabled, mfaEnabled } = account.state.status; const sealKey = account.state.sealKey; const sealEnabled = seal?.publicKey != null; const sealUnlocked = seal?.publicKey === sealKey?.public && sealKey?.private && sealKey?.public; - updateState({ sealable, seal, sealKey, sealEnabled, sealUnlocked, pushEnabled }); + updateState({ sealable, seal, sealKey, sealEnabled, sealUnlocked, pushEnabled, mfaEnabled }); }, [account.state]); const setCardItem = (item) => { @@ -375,6 +383,37 @@ export function useSettings() { updateState({ messages: state.messages.filter(item => item.channelId !== channelId || item.topicId !== topicId) }); } }, + enableMFA: async () => { + updateState({ mfaModal: true, mfaSecret: false, mfaCode: '' }); + const mfa = await account.actions.enableMFA(); + updateState({ mfaImage: mfa.secretImage, mfaText: mfa.secretText }); + }, + disableMFA: async () => { + updateState({ mfaEnabled: false }); + try { + await account.actions.disableMFA(); + } + catch (err) { + updateState({ mfaEnabled: true }); + throw err; + } + }, + confirmMFA: async () => { + try { + updateState({ mfaEnabled: true }); + await account.actions.confirmMFA(state.mfaCode); + updateState({ mfaModal: false }); + } + catch (err) { + updateState({ mfaEnabled: false, mfaError: err.message}); + } + }, + dismissMFA: () => { + updateState({ mfaModal: false }); + }, + setCode: (mfaCode) => { + updateState({ mfaCode }); + }, }; return { state, actions }; diff --git a/net/web/src/context/useAccountContext.hook.js b/net/web/src/context/useAccountContext.hook.js index 4ea4a0d3..d6d71741 100644 --- a/net/web/src/context/useAccountContext.hook.js +++ b/net/web/src/context/useAccountContext.hook.js @@ -77,15 +77,12 @@ export function useAccountContext() { }, enableMFA: async () => { const secret = await addAccountMFA(access.current); - console.log("SECRET ", secret); return secret; }, disableMFA: async () => { await removeAccountMFA(access.current); }, confirmMFA: async (code) => { -console.log("CONFIRMING: ", code); - await setAccountMFA(access.current, code); }, setSeal: async (seal, sealKey) => {