adding mfa to mobile dashboard

This commit is contained in:
Roland Osborne 2024-05-22 16:01:06 -07:00
parent df8d2806e6
commit f469aff9f6
10 changed files with 368 additions and 30 deletions

View File

@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { AppContext } from 'context/AppContext';
import { getNodeStatus } from 'api/getNodeStatus';
import { setNodeStatus } from 'api/setNodeStatus';
import { getNodeConfig } from 'api/getNodeConfig';
import { setNodeAccess } from 'api/setNodeAccess';
import { getLanguageStrings } from 'constants/Strings';
export function useAdmin() {
@ -22,6 +22,8 @@ export function useAdmin() {
version: null,
agree: false,
showTerms: false,
mfaCode: '',
});
const updateState = (value) => {
@ -80,14 +82,13 @@ export function useAdmin() {
try {
updateState({ busy: true });
const node = state.server.trim();
const token = state.token;
const unclaimed = await getNodeStatus(node);
if (unclaimed) {
await setNodeStatus(node, token);
await setNodeStatus(node, state.token);
}
const config = await getNodeConfig(node, token);
const session = await setNodeAccess(node, state.token, state.mfaCode);
updateState({ server: node, busy: false });
navigate('/dashboard', { state: { config, server: node, token }});
navigate('/dashboard', { state: { server: node, token: session }});
}
catch (err) {
console.log(err);

View File

@ -0,0 +1,11 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function addAdminMFAuth(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}/admin/mfauth?token=${token}`, { method: 'POST' })
checkResponse(mfa);
return mfa.json();
}

View File

@ -0,0 +1,11 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function getAdminMFAuth(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}/admin/mfauth?token=${encodeURIComponent(token)}`, { method: 'GET' });
checkResponse(mfa);
return await mfa.json();
}

View File

@ -0,0 +1,10 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function removeAdminMFAuth(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}/admin/mfauth?token=${token}`, { method: 'DELETE' })
checkResponse(mfa);
}

View File

@ -0,0 +1,10 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function setAdminMFAuth(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}/admin/mfauth?token=${token}&code=${code}`, { method: 'PUT' })
checkResponse(mfa);
}

View File

@ -0,0 +1,12 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function setNodeAccess(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 = code ? `&code=${code}` : '';
const access = await fetchWithTimeout(`${protocol}://${server}/admin/access?token=${encodeURIComponent(token)}${mfa}`, { method: 'PUT' });
checkResponse(access);
return access.json()
}

View File

@ -210,6 +210,10 @@ const Strings = [
mfaDisabled: 'verification temporarily disabled',
mfaConfirm: 'Confirm',
mfaEnter: 'Enter your verification code',
disable: 'Disable',
confirmDisable: 'Disabling Multi-Factor Authentication',
disablePrompt: 'Are you sure you want to disable multi-factor authentication',
},
{
languageCode: 'fr',
@ -416,6 +420,10 @@ const Strings = [
mfaError: 'erreur de code de vérification',
mfaDisabled: 'vérification temporairement désactivée',
mfaConfirm: 'Confirmer',
disable: 'Désactiver',
confirmDisable: 'Désactivation de l\'authentification multi-facteurs',
disablePrompt: 'Êtes-vous sûr de vouloir désactiver l\'authentification multi-facteurs',
},
{
languageCode: 'es',
@ -623,6 +631,10 @@ const Strings = [
mfaError: 'error de código de verificación',
mfaDisabled: 'verificación temporalmente deshabilitada',
mfaConfirm: 'Confirmar',
disable: 'Desactivar',
confirmDisable: 'Desactivación de la autenticación de dos factores',
disablePrompt: '¿Estás seguro de que quieres desactivar la autenticación de dos factores?',
},
{
languageCode: 'de',
@ -830,6 +842,10 @@ const Strings = [
mfaError: 'Verifizierungscodefehler',
mfaDisabled: 'Verifizierung vorübergehend deaktiviert',
mfaConfirm: 'Bestätigen',
disable: 'Deaktivieren',
confirmDisable: 'Deaktivierung der Zwei-Faktor-Authentifizierung',
disablePrompt: 'Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten?',
},
{
languageCode: 'pt',
@ -1022,6 +1038,10 @@ const Strings = [
mfaError: 'erro de código de verificação',
mfaDisabled: 'verificação temporariamente desativada',
mfaConfirm: 'Confirmar',
disable: 'Desativar',
confirmDisable: 'Desativando Autenticação de Dois Fatores',
disablePrompt: 'Tem certeza de que deseja desativar a autenticação de dois fatores?',
},
{
languageCode: 'ru',
@ -1212,6 +1232,10 @@ const Strings = [
mfaError: 'ошибка проверочного кода',
mfaDisabled: 'проверка временно отключена',
mfaConfirm: 'Подтвердить',
disable: 'Отключить',
confirmDisable: 'Отключение двухфакторной аутентификации',
disablePrompt: 'Вы уверены, что хотите отключить двухфакторную аутентификацию?',
}
];

View File

@ -1,4 +1,4 @@
import { ScrollView, TextInput, Alert, Switch, TouchableOpacity, View, Text, Modal, FlatList, KeyboardAvoidingView } from 'react-native';
import { ScrollView, Image, TextInput, Alert, Switch, ActivityIndicator, TouchableOpacity, View, Text, Modal, FlatList, KeyboardAvoidingView } from 'react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import AntIcon from 'react-native-vector-icons/AntDesign';
@ -9,12 +9,50 @@ import { useDashboard } from './useDashboard.hook';
import { Logo } from 'utils/Logo';
import { BlurView } from "@react-native-community/blur";
import { InputField } from 'utils/InputField';
import Colors from 'constants/Colors';
import { InputCode } from 'utils/InputCode';
export function Dashboard(props) {
const location = useLocation();
const { config, server, token } = location.state;
const { state, actions } = useDashboard(config, server, token);
const { server, token } = location.state;
const { state, actions } = useDashboard(server, token);
const enableMFA = async () => {
try {
await actions.enableMFA();
}
catch (err) {
Alert.alert(
state.strings.error,
state.strings.tryAgain,
);
}
}
const disableMFA = async () => {
try {
await actions.disableMFA();
}
catch (err) {
Alert.alert(
state.strings.error,
state.strings.tryAgain,
);
}
}
const confirmMFA = async () => {
try {
await actions.confirmMFA();
}
catch (err) {
Alert.alert(
state.strings.error,
state.strings.tryAgain,
);
}
}
const saveConfig = async () => {
try {
@ -24,8 +62,8 @@ export function Dashboard(props) {
catch (err) {
console.log(err);
Alert.alert(
'Failed to Save Settings',
'Please try again.',
state.strings.error,
state.strings.tryAgain,
);
}
}
@ -37,8 +75,8 @@ export function Dashboard(props) {
catch (err) {
console.log(err);
Alert.alert(
'Failed to Generate Access Token',
'Please try again.',
state.strings.error,
state.strings.tryAgain,
);
}
}
@ -50,8 +88,8 @@ export function Dashboard(props) {
catch (err) {
console.log(err);
Alert.alert(
'Failed to Generate Access Token',
'Please try again.',
state.strings.error,
state.strings.tryAgain,
);
}
}
@ -67,8 +105,8 @@ export function Dashboard(props) {
catch (err) {
console.log(err);
Alert.alert(
'Failed to Update Account',
'Please try again.',
state.strings.error,
state.strings.tryAgain,
);
}
}
@ -83,6 +121,16 @@ export function Dashboard(props) {
<TouchableOpacity onPress={actions.showEditConfig}>
<AntIcon style={styles.icon} name={'setting'} size={20} />
</TouchableOpacity>
{ !state.mfaEnabled && (
<TouchableOpacity onPress={enableMFA}>
<MatIcon style={styles.icon} name={'shield-lock-open-outline'} size={20} />
</TouchableOpacity>
)}
{ state.mfaEnabled && (
<TouchableOpacity onPress={disableMFA}>
<MatIcon style={styles.icon} name={'shield-lock-outline'} size={20} />
</TouchableOpacity>
)}
<TouchableOpacity onPress={actions.logout}>
<AntIcon style={styles.icon} name={'logout'} size={20} />
</TouchableOpacity>
@ -340,6 +388,60 @@ export function Dashboard(props) {
</View>
</Modal>
<Modal
animationType="fade"
transparent={true}
visible={state.mfaModal}
supportedOrientations={['portrait', 'landscape']}
onRequestClose={actions.dismissMFA}
>
<View>
<BlurView style={styles.mfaOverlay} blurType={Colors.overlay} blurAmount={2} reducedTransparencyFallbackColor="black" />
<KeyboardAvoidingView style={styles.mfaBase} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.mfaContainer}>
<Text style={styles.mfaTitle}>{ state.strings.mfaTitle }</Text>
<Text style={styles.mfaDescription}>{ state.strings.mfaSteps }</Text>
{ state.mfaImage && (
<Image source={{ uri: state.mfaImage }} style={{ width: 128, height: 128 }} />
)}
{ !state.mfaImage && !state.mfaText && (
<ActivityIndicator style={styles.modalBusy} animating={true} color={Colors.primaryButton} />
)}
{ state.mfaText && (
<TouchableOpacity style={styles.mfaSecret} onPress={() => Clipboard.setString(state.mfaText)}>
<Text style={styles.mfaText}>{ state.mfaText }</Text>
<AntIcon style={styles.mfaIcon} name={'copy1'} size={20} />
</TouchableOpacity>
)}
<InputCode style={{ width: '100%' }} onChangeText={actions.setCode} />
<View style={styles.mfaError}>
{ state.mfaError == '401' && (
<Text style={styles.mfaErrorLabel}>{ state.strings.mfaError }</Text>
)}
{ state.mfaError == '429' && (
<Text style={styles.mfaErrorLabel}>{ state.strings.mfaDisabled }</Text>
)}
</View>
<View style={styles.mfaControl}>
<TouchableOpacity style={styles.mfaCancel} onPress={actions.dismissMFA}>
<Text style={styles.mfaCancelLabel}>{ state.strings.cancel }</Text>
</TouchableOpacity>
{ state.mfaCode != '' && (
<TouchableOpacity style={styles.mfaConfirm} onPress={actions.confirmMFA}>
<Text style={styles.mfaConfirmLabel}>{ state.strings.mfaConfirm }</Text>
</TouchableOpacity>
)}
{ state.mfaCode == '' && (
<View style={styles.mfaDisabled}>
<Text style={styles.mfaDisabledLabel}>{ state.strings.mfaConfirm }</Text>
</View>
)}
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</SafeAreaView>
)
}

View File

@ -302,4 +302,110 @@ export const styles = StyleSheet.create({
marginBottom: 8,
},
},
mfaOverlay: {
width: '100%',
height: '100%',
},
mfaSecret: {
display: 'flex',
flexDirection: 'row',
gap: 4,
},
mfaText: {
fontSize: 11,
color: Colors.labelText,
},
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,
},
});

View File

@ -15,11 +15,15 @@ import { addAccountAccess } from 'api/addAccountAccess';
import { DisplayContext } from 'context/DisplayContext';
import { getLanguageStrings } from 'constants/Strings';
export function useDashboard(config, server, token) {
import { getAdminMFAuth } from 'api/getAdminMFAuth';
import { addAdminMFAuth } from 'api/addAdminMFAuth';
import { setAdminMFAuth } from 'api/setAdminMFAuth';
import { removeAdminMFAuth } from 'api/removeAdminMFAuth';
export function useDashboard(server, token) {
const [state, setState] = useState({
strings: getLanguageStrings(),
config: null,
accounts: [],
editConfig: false,
addUser: false,
@ -40,6 +44,13 @@ export function useDashboard(config, server, token) {
iceUrl: null,
iceUsername: null,
icePassword: null,
mfaModal: false,
mfaImage: null,
mfaText: null,
mfaCode: '',
mfaError: null,
mfaEnabled: false,
});
const navigate = useNavigate();
@ -62,19 +73,24 @@ export function useDashboard(config, server, token) {
return { logo, name, handle, accountId, disabled };
}
const refreshAccounts = async () => {
const accounts = await getNodeAccounts(server, token);
updateState({ accounts: accounts.map(setAccountItem) });
};
useEffect(() => {
const { keyType, accountStorage, domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword } = config;
const syncNode = async () => {
const mfaEnabled = await getAdminMFAuth(server, token);
const config = await getNodeConfig(server, token);
const nodeAccounts = await getNodeAccounts(server, token);
const accounts = nodeAccounts.map(setAccountItem);
const { keyType, accountStorage, domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword } = config || {};
const storage = Math.ceil(accountStorage / 1073741824);
updateState({ keyType, storage: storage.toString(), domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword });
}, [config]);
updateState({ keyType, storage: storage.toString(), domain, enableImage, enableAudio, enableVideo, enableBinary, transformSupported, allowUnsealed, pushSupported, enableIce, iceUrl, iceUsername, icePassword, accounts, mfaEnabled });
}
const refreshAccounts = async () => {
const nodeAccounts = await getNodeAccounts(server, token);
const accounts = nodeAccounts.map(setAccountItem);
updateState({ accounts });
}
useEffect(() => {
refreshAccounts();
syncNode();
}, []);
const actions = {
@ -155,8 +171,8 @@ export function useDashboard(config, server, token) {
},
promptRemove: (accountId) => {
display.actions.showPrompt({
title: 'Delete User',
ok: { label: 'Delete', action: async () => {
title: state.strings.deleteAccount,
ok: { label: state.strings.delete, action: async () => {
await removeAccount(server, token, accountId);
await refreshAccounts();
} , failed: () => {
@ -168,6 +184,41 @@ export function useDashboard(config, server, token) {
cancel: { label: state.strings.cancel },
});
},
enableMFA: async () => {
updateState({ mfaModal: true, mfaImage: null, mfaText: null, mfaCode: '', mfaError: '' });
const mfa = await addAdminMFAuth(server, token);
updateState({ mfaImage: mfa.secretImage, mfaText: mfa.secretText });
},
disableMFA: async () => {
display.actions.showPrompt({
title: state.strings.confirmDisable,
ok: { label: state.strings.disable, action: async () => {
await removeAdminMFAuth(server, token);
updateState({ mfaEnabled: false });
} , failed: () => {
Alert.alert(
state.strings.error,
state.strings.tryAgain,
);
}},
cancel: { label: state.strings.cancel },
});
},
confirmMFA: async () => {
try {
await setAdminMFAuth(server, token, state.mfaCode);
updateState({ mfaEnabled: true, mfaModal: false });
}
catch (err) {
updateState({ mfaError: err.message});
}
},
dismissMFA: () => {
updateState({ mfaModal: false });
},
setCode: (mfaCode) => {
updateState({ mfaCode });
},
};
return { state, actions };