mirror of
https://github.com/balzack/databag.git
synced 2025-04-21 17:15:16 +00:00
Merge branch 'main' into fdroid
This commit is contained in:
commit
67db682c10
@ -5,6 +5,8 @@ import { useAdmin } from './useAdmin.hook';
|
||||
import Colors from 'constants/Colors';
|
||||
import MatIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { tos } from 'constants/TermsOfService';
|
||||
import { BlurView } from "@react-native-community/blur";
|
||||
import { InputCode } from 'utils/InputCode';
|
||||
|
||||
export function Admin() {
|
||||
|
||||
@ -94,6 +96,47 @@ export function Admin() {
|
||||
</TouchableOpacity>
|
||||
</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" />
|
||||
<View style={styles.mfaBase}>
|
||||
<View style={styles.mfaContainer}>
|
||||
<Text style={styles.mfaTitle}>{ state.strings.mfaTitle }</Text>
|
||||
<Text style={styles.mfaDescription}>{ state.strings.mfaEnter }</Text>
|
||||
<InputCode style={{ width: '100%' }} onChangeText={actions.setCode} />
|
||||
<View style={styles.mfaError}>
|
||||
{ state.mfaError == '403' && (
|
||||
<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.access}>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
@ -15,6 +15,98 @@ export const styles = StyleSheet.create({
|
||||
space: {
|
||||
width: 32,
|
||||
},
|
||||
mfaOverlay: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
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,
|
||||
},
|
||||
mfaCode: {
|
||||
width: 400,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333333',
|
||||
width: '100%',
|
||||
opacity: 0,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { AppContext } from 'context/AppContext';
|
||||
import { getNodeStatus } from 'api/getNodeStatus';
|
||||
import { setNodeStatus } from 'api/setNodeStatus';
|
||||
import { setNodeAccess } from 'api/setNodeAccess';
|
||||
import { getNodeConfig } from 'api/getNodeConfig';
|
||||
import { getLanguageStrings } from 'constants/Strings';
|
||||
|
||||
@ -22,6 +23,10 @@ export function useAdmin() {
|
||||
version: null,
|
||||
agree: false,
|
||||
showTerms: false,
|
||||
|
||||
mfaModal: false,
|
||||
mfaCode: '',
|
||||
mfaError: null,
|
||||
});
|
||||
|
||||
const updateState = (value) => {
|
||||
@ -80,22 +85,47 @@ 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);
|
||||
}
|
||||
const config = await getNodeConfig(node, token);
|
||||
updateState({ server: node, busy: false });
|
||||
navigate('/dashboard', { state: { config, server: node, token }});
|
||||
await setNodeStatus(node, state.token);
|
||||
}
|
||||
try {
|
||||
const session = await setNodeAccess(node, state.token, state.mfaCode);
|
||||
updateState({ server: node, busy: false });
|
||||
navigate('/dashboard', { state: { server: node, token: session, mfa: true }});
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message == '405' || err.message == '403' || err.message == '429') {
|
||||
updateState({ mfaModal: true, mfaError: err.message });
|
||||
}
|
||||
else {
|
||||
try {
|
||||
await getNodeConfig(node, state.token);
|
||||
updateState({ server: node, busy: false });
|
||||
navigate('/dashboard', { state: { server: node, token: state.token, mfa: false }});
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err.message);
|
||||
updateState({ busy: false, showAlert: true });
|
||||
throw new Error('login failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
updateState({ busy: false });
|
||||
throw new Error("access failed");
|
||||
}
|
||||
updateState({ busy: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
setCode: (mfaCode) => {
|
||||
updateState({ mfaCode });
|
||||
},
|
||||
dismissMFA: () => {
|
||||
updateState({ mfaModal: false });
|
||||
},
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
|
11
app/mobile/src/api/addAdminMFAuth.js
Normal file
11
app/mobile/src/api/addAdminMFAuth.js
Normal 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();
|
||||
}
|
||||
|
11
app/mobile/src/api/getAdminMFAuth.js
Normal file
11
app/mobile/src/api/getAdminMFAuth.js
Normal 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();
|
||||
}
|
||||
|
10
app/mobile/src/api/removeAdminMFAuth.js
Normal file
10
app/mobile/src/api/removeAdminMFAuth.js
Normal 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);
|
||||
}
|
||||
|
10
app/mobile/src/api/setAdminMFAuth.js
Normal file
10
app/mobile/src/api/setAdminMFAuth.js
Normal 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);
|
||||
}
|
||||
|
12
app/mobile/src/api/setNodeAccess.js
Normal file
12
app/mobile/src/api/setNodeAccess.js
Normal 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()
|
||||
}
|
||||
|
@ -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: 'Вы уверены, что хотите отключить двухфакторную аутентификацию?',
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -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';
|
||||
@ -8,12 +8,50 @@ import { useLocation } from 'react-router-dom';
|
||||
import { useDashboard } from './useDashboard.hook';
|
||||
import { Logo } from 'utils/Logo';
|
||||
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, mfa } = location.state;
|
||||
const { state, actions } = useDashboard(server, token, mfa);
|
||||
|
||||
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 {
|
||||
@ -23,8 +61,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -36,8 +74,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -49,8 +87,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -66,8 +104,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -82,6 +120,16 @@ export function Dashboard(props) {
|
||||
<TouchableOpacity onPress={actions.showEditConfig}>
|
||||
<AntIcon style={styles.icon} name={'setting'} size={20} />
|
||||
</TouchableOpacity>
|
||||
{ !state.mfaEnabled && mfa && (
|
||||
<TouchableOpacity onPress={enableMFA}>
|
||||
<MatIcon style={styles.icon} name={'shield-lock-open-outline'} size={20} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{ state.mfaEnabled && mfa && (
|
||||
<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>
|
||||
@ -330,6 +378,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>
|
||||
)
|
||||
}
|
||||
|
@ -307,4 +307,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,
|
||||
},
|
||||
});
|
||||
|
@ -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, mfa) {
|
||||
|
||||
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 = mfa ? await getAdminMFAuth(server, token) : false;
|
||||
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 };
|
||||
|
151
doc/api.oa3
151
doc/api.oa3
@ -86,7 +86,140 @@ paths:
|
||||
description: permission denied
|
||||
'500':
|
||||
description: internal server error
|
||||
|
||||
|
||||
/admin/access:
|
||||
put:
|
||||
tags:
|
||||
- admin
|
||||
description: Acquire new session token for admin endpoints
|
||||
operationId: set-admin-session
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: access token
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: code
|
||||
in: query
|
||||
description: totp code
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: generated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
'401':
|
||||
description: invalid token
|
||||
'405':
|
||||
description: totp code required but not set
|
||||
'429':
|
||||
description: temporarily locked due to too many failures
|
||||
'500':
|
||||
description: internal server error
|
||||
|
||||
/admin/mfauth:
|
||||
get:
|
||||
tags:
|
||||
- admin
|
||||
description: check if multi-factor authentication enabled
|
||||
operationId: get-admin-mfa
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: session token
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
'401':
|
||||
description: permission denied
|
||||
'500':
|
||||
description: internal server error
|
||||
post:
|
||||
tags:
|
||||
- admin
|
||||
description: Enable multi-factor authentication
|
||||
operationId: add-admin-mfa
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: session token
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
'401':
|
||||
description: permission denied
|
||||
'500':
|
||||
description: internal server error
|
||||
put:
|
||||
tags:
|
||||
- admin
|
||||
description: Confirm multi-factor authentication
|
||||
operationId: confirm-admin-mfa
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: session token
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: code
|
||||
in: query
|
||||
description: totp code generated from secret
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: success
|
||||
'401':
|
||||
description: permission denied
|
||||
'403':
|
||||
description: totp code not correct
|
||||
'405':
|
||||
description: totp code required but not set
|
||||
'429':
|
||||
description: temporarily locked due to too many failures
|
||||
'500':
|
||||
description: internal server error
|
||||
delete:
|
||||
tags:
|
||||
- admin
|
||||
description: Disable multi-factor authentication
|
||||
operationId: remove-admin-mfa
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: session token
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: success
|
||||
'401':
|
||||
description: permission denied
|
||||
'500':
|
||||
description: internal server error
|
||||
|
||||
/admin/config:
|
||||
get:
|
||||
tags:
|
||||
@ -96,7 +229,7 @@ paths:
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -119,7 +252,7 @@ paths:
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -146,7 +279,7 @@ paths:
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -173,7 +306,7 @@ paths:
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -204,7 +337,7 @@ paths:
|
||||
type: string
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -238,7 +371,7 @@ paths:
|
||||
type: string
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -261,7 +394,7 @@ paths:
|
||||
parameters:
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@ -298,7 +431,7 @@ paths:
|
||||
type: string
|
||||
- name: token
|
||||
in: query
|
||||
description: token for admin access
|
||||
description: session token for admin access
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
73
net/server/internal/api_addAdminMFAuth.go
Normal file
73
net/server/internal/api_addAdminMFAuth.go
Normal file
@ -0,0 +1,73 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"image/png"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"databag/internal/store"
|
||||
"encoding/base64"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
//AddAdminMFAuth enables multi-factor auth on the given account
|
||||
func AddAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: APPMFAIssuer,
|
||||
AccountName: "admin",
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA256,
|
||||
})
|
||||
|
||||
err = store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
// upsert mfa enabled
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: true}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
|
||||
// upsert mfa confirmed
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: false}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
|
||||
// upsert mfa secret
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFASecret, StrValue: key.Secret()}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
img, err := key.Image(200, 200)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
png.Encode(&buf, img)
|
||||
enc := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
WriteResponse(w, MFASecret{ Image: "data:image/png;base64," + enc, Text: key.Secret() })
|
||||
}
|
@ -22,7 +22,7 @@ func AddMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: APPMFAIssuer,
|
||||
AccountName: account.GUID,
|
||||
AccountName: account.Handle,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA256,
|
||||
})
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
//AddNodeAccount generate a new token to be used for account creation
|
||||
func AddNodeAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ func AddNodeAccountAccess(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
20
net/server/internal/api_getAdminMFAuth.go
Normal file
20
net/server/internal/api_getAdminMFAuth.go
Normal file
@ -0,0 +1,20 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//GetAdminMFAuth checks if mfa enabled for admin
|
||||
func GetAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
enabled := getBoolConfigValue(CNFMFAEnabled, false);
|
||||
confirmed := getBoolConfigValue(CNFMFAConfirmed, false);
|
||||
|
||||
WriteResponse(w, enabled && confirmed)
|
||||
}
|
@ -23,7 +23,7 @@ func GetNodeAccountImage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
//GetNodeAccounts retrieves profiles of hosted accounts for the admin
|
||||
func GetNodeAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
func GetNodeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
40
net/server/internal/api_removeAdminMFAuth.go
Normal file
40
net/server/internal/api_removeAdminMFAuth.go
Normal file
@ -0,0 +1,40 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"databag/internal/store"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
//Disable multi-factor auth for admin
|
||||
func RemoveAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: false}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: false}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
WriteResponse(w, nil)
|
||||
}
|
@ -21,7 +21,7 @@ func RemoveNodeAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
103
net/server/internal/api_setAdminAccess.go
Normal file
103
net/server/internal/api_setAdminAccess.go
Normal file
@ -0,0 +1,103 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"github.com/theckman/go-securerandom"
|
||||
"databag/internal/store"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/http"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
//SetAdminAccess begins a session for admin access
|
||||
func SetAdminAccess(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check mfa
|
||||
curTime := time.Now().Unix()
|
||||
failedTime := getNumConfigValue(CNFMFAFailedTime, 0);
|
||||
failedCount := getNumConfigValue(CNFMFAFailedCount, 0);
|
||||
mfaEnabled := getBoolConfigValue(CNFMFAEnabled, false);
|
||||
mfaConfirmed := getBoolConfigValue(CNFMFAConfirmed, false);
|
||||
if mfaEnabled && mfaConfirmed {
|
||||
if failedTime + APPMFAFailPeriod > curTime && failedCount > APPMFAFailCount {
|
||||
ErrResponse(w, http.StatusTooManyRequests, errors.New("temporarily locked"))
|
||||
return;
|
||||
}
|
||||
|
||||
code := r.FormValue("code")
|
||||
if code == "" {
|
||||
ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp code required"))
|
||||
return;
|
||||
}
|
||||
|
||||
secret := getStrConfigValue(CNFMFASecret, "");
|
||||
opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256}
|
||||
if valid, _ := totp.ValidateCustom(code, secret, time.Now(), opts); !valid {
|
||||
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if failedTime + APPMFAFailPeriod > curTime {
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
} else {
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAFailedTime, NumValue: curTime}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
LogMsg("failed to increment fail count");
|
||||
}
|
||||
|
||||
ErrResponse(w, http.StatusForbidden, errors.New("invalid code"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// gernate app token
|
||||
data, err := securerandom.Bytes(APPTokenSize)
|
||||
if err != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
access := hex.EncodeToString(data)
|
||||
|
||||
err = store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// upsert mfa enabled
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFAdminSession, StrValue: access}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
WriteResponse(w, access)
|
||||
}
|
92
net/server/internal/api_setAdminMFAuth.go
Normal file
92
net/server/internal/api_setAdminMFAuth.go
Normal file
@ -0,0 +1,92 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"databag/internal/store"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/http"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
//SetMultiFactorAuth
|
||||
func SetAdminMFAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !getBoolConfigValue(CNFMFAEnabled, false) {
|
||||
ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp not enabled"))
|
||||
return;
|
||||
}
|
||||
code := r.FormValue("code")
|
||||
if code == "" {
|
||||
ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp code required"))
|
||||
return;
|
||||
}
|
||||
|
||||
curTime := time.Now().Unix()
|
||||
failedTime := getNumConfigValue(CNFMFAFailedTime, 0);
|
||||
failedCount := getNumConfigValue(CNFMFAFailedCount, 0);
|
||||
if failedTime + APPMFAFailPeriod > curTime && failedCount > APPMFAFailCount {
|
||||
ErrResponse(w, http.StatusTooManyRequests, errors.New("temporarily locked"))
|
||||
return;
|
||||
}
|
||||
|
||||
secret := getStrConfigValue(CNFMFASecret, "");
|
||||
opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256}
|
||||
if valid, _ := totp.ValidateCustom(code, secret, time.Now(), opts); !valid {
|
||||
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if failedTime + APPMFAFailPeriod > curTime {
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
} else {
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAFailedTime, NumValue: curTime}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"num_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
LogMsg("failed to increment fail count");
|
||||
}
|
||||
|
||||
ErrResponse(w, http.StatusUnauthorized, errors.New("invalid code"))
|
||||
return
|
||||
}
|
||||
|
||||
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// upsert mfa confirmed
|
||||
if res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "config_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"bool_value"}),
|
||||
}).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: true}).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
WriteResponse(w, nil)
|
||||
}
|
@ -18,7 +18,7 @@ func SetNodeAccountStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
func SetNodeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// validate login
|
||||
if code, err := ParamAdminToken(r); err != nil {
|
||||
if code, err := ParamSessionToken(r); err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
@ -95,6 +95,30 @@ func ParamAdminToken(r *http.Request) (int, error) {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
//ParamSessionToken compares session token with token query param
|
||||
func ParamSessionToken(r *http.Request) (int, error) {
|
||||
|
||||
// parse authentication token
|
||||
token := r.FormValue("token")
|
||||
if token == "" {
|
||||
return http.StatusUnauthorized, errors.New("token not set")
|
||||
}
|
||||
|
||||
// nothing to do if not configured
|
||||
if !getBoolConfigValue(CNFConfigured, false) {
|
||||
return http.StatusUnauthorized, errors.New("node not configured")
|
||||
}
|
||||
|
||||
// compare password
|
||||
value := getStrConfigValue(CNFAdminSession, "")
|
||||
if value != token {
|
||||
return http.StatusUnauthorized, errors.New("invalid session token")
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
|
||||
//GetSessionDetail retrieves account detail specified by agent query param
|
||||
func GetSessionDetail(r *http.Request) (*store.Session, int, error) {
|
||||
|
||||
|
@ -63,6 +63,23 @@ const CNFIceUsername = "ice_username"
|
||||
//CNFIceUrl specifies the ice candidate url
|
||||
const CNFIcePassword = "ice_password"
|
||||
|
||||
//CNFMFAFailedTime start of mfa failure window
|
||||
const CNFMFAFailedTime = "mfa_failed_time"
|
||||
|
||||
//CNFMFAFailedCount number of failures in window
|
||||
const CNFMFAFailedCount = "mfa_failed_count"
|
||||
|
||||
//CNFMFARequired specified if mfa enabled for admin
|
||||
const CNFMFAEnabled = "mfa_enabled"
|
||||
|
||||
//CNFMFAConfirmed specified if mfa has been confirmed for admin
|
||||
const CNFMFAConfirmed = "mfa_confirmed"
|
||||
|
||||
//CNFMFASecret specified the mfa secret
|
||||
const CNFMFASecret = "mfa_secret"
|
||||
|
||||
//CNFAdminSession sepcifies the admin session token
|
||||
const CNFAdminSession = "admin_session"
|
||||
|
||||
func getStrConfigValue(configID string, empty string) string {
|
||||
var config store.Config
|
||||
|
@ -53,16 +53,24 @@ func TestMain(m *testing.M) {
|
||||
panic("failed to configure account limit")
|
||||
}
|
||||
|
||||
// admin login
|
||||
r, w, _ = NewRequest("PUT", "/admin/access?token=pass", nil);
|
||||
SetAdminAccess(w, r)
|
||||
var session string
|
||||
if ReadResponse(w, &session) != nil {
|
||||
panic("failed to login as admin")
|
||||
}
|
||||
|
||||
// config server
|
||||
config := NodeConfig{Domain: "databag.coredb.org", AccountStorage: 4096, KeyType: "RSA2048"}
|
||||
r, w, _ = NewRequest("PUT", "/admin/config?token=pass", &config)
|
||||
r, w, _ = NewRequest("PUT", "/admin/config?token=" + session, &config)
|
||||
SetNodeConfig(w, r)
|
||||
if ReadResponse(w, nil) != nil {
|
||||
panic("failed to set config")
|
||||
}
|
||||
|
||||
// check config
|
||||
r, w, _ = NewRequest("GET", "/admin/config?token=pass", nil)
|
||||
r, w, _ = NewRequest("GET", "/admin/config?token=" + session, nil)
|
||||
GetNodeConfig(w, r)
|
||||
var check NodeConfig
|
||||
if ReadResponse(w, &check) != nil {
|
||||
|
@ -279,6 +279,41 @@ var endpoints = routes{
|
||||
ImportAccount,
|
||||
},
|
||||
|
||||
route{
|
||||
"SetAdminAccess",
|
||||
strings.ToUpper("Put"),
|
||||
"/admin/access",
|
||||
SetAdminAccess,
|
||||
},
|
||||
|
||||
route{
|
||||
"GetAdminMFAuth",
|
||||
strings.ToUpper("Get"),
|
||||
"/admin/mfauth",
|
||||
GetAdminMFAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"AddAdminMFAuth",
|
||||
strings.ToUpper("Post"),
|
||||
"/admin/mfauth",
|
||||
AddAdminMFAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"SetAdminMFAuth",
|
||||
strings.ToUpper("Put"),
|
||||
"/admin/mfauth",
|
||||
SetAdminMFAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"RemoveAdminMFAuth",
|
||||
strings.ToUpper("Delete"),
|
||||
"/admin/mfauth",
|
||||
RemoveAdminMFAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"RemoveNodeAccount",
|
||||
strings.ToUpper("Delete"),
|
||||
|
@ -618,8 +618,16 @@ func addTestAccount(username string) (guid string, token string, err error) {
|
||||
var profile Profile
|
||||
var login = username + ":pass"
|
||||
|
||||
// admin login
|
||||
r, w, _ = NewRequest("PUT", "/admin/access?token=pass", nil);
|
||||
SetAdminAccess(w, r)
|
||||
var session string
|
||||
if ReadResponse(w, &session) != nil {
|
||||
panic("failed to login as admin")
|
||||
}
|
||||
|
||||
// get account token
|
||||
if r, w, err = NewRequest("POST", "/admin/accounts?token=pass", nil); err != nil {
|
||||
if r, w, err = NewRequest("POST", "/admin/accounts?token=" + session, nil); err != nil {
|
||||
return
|
||||
}
|
||||
AddNodeAccount(w, r)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Button, Modal, Form, Input } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { AdminWrapper } from './Admin.styled';
|
||||
import { AdminWrapper, MFAModal } from './Admin.styled';
|
||||
import { useAdmin } from './useAdmin.hook';
|
||||
|
||||
export function Admin() {
|
||||
@ -55,6 +55,26 @@ export function Admin() {
|
||||
|
||||
</Form>
|
||||
</div>
|
||||
<Modal centerd closable={false} footer={null} visible={state.mfaModal} destroyOnClose={true} bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} onCancel={actions.dismissMFA}>
|
||||
<MFAModal>
|
||||
<div className="title">{state.strings.mfaTitle}</div>
|
||||
<div className="description">{state.strings.mfaEnter}</div>
|
||||
<Input.OTP onChange={actions.setCode} />
|
||||
<div className="alert">
|
||||
{ state.mfaError === '403' && (
|
||||
<span>{state.strings.mfaError}</span>
|
||||
)}
|
||||
{ state.mfaError === '429' && (
|
||||
<span>{state.strings.mfaDisabled}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="controls">
|
||||
<Button key="back" onClick={actions.dismissMFA}>{state.strings.cancel}</Button>
|
||||
<Button key="save" type="primary" className={state.mfaCode ? 'saveEnabled' : 'saveDisabled'} onClick={login}
|
||||
disabled={!state.mfaCode} loading={state.busy}>{state.strings.login}</Button>
|
||||
</div>
|
||||
</MFAModal>
|
||||
</Modal>
|
||||
</AdminWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -72,3 +72,53 @@ export const AdminWrapper = styled.div`
|
||||
`;
|
||||
|
||||
|
||||
export const MFAModal = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.0rem;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.code {
|
||||
padding-top: 4px;
|
||||
border-bottom: 1px solid ${props => props.theme.sectionBorder};
|
||||
}
|
||||
|
||||
.alert {
|
||||
height: 24px;
|
||||
color: ${props => props.theme.alertText};
|
||||
}
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
|
||||
.saveDisabled {
|
||||
background-color: ${props => props.theme.disabledArea};
|
||||
|
||||
button {
|
||||
color: ${props => props.theme.idleText};
|
||||
}
|
||||
}
|
||||
|
||||
.saveEnabled {
|
||||
background-color: ${props => props.theme.enabledArea};
|
||||
|
||||
button {
|
||||
color: ${props => props.theme.activeText};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -2,7 +2,7 @@ import { useContext, useState, useEffect } from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getNodeStatus } from 'api/getNodeStatus';
|
||||
import { setNodeStatus } from 'api/setNodeStatus';
|
||||
import { getNodeConfig } from 'api/getNodeConfig';
|
||||
import { setNodeAccess } from 'api/setNodeAccess';
|
||||
import { AppContext } from 'context/AppContext';
|
||||
import { SettingsContext } from 'context/SettingsContext';
|
||||
|
||||
@ -15,6 +15,9 @@ export function useAdmin() {
|
||||
busy: false,
|
||||
strings: {},
|
||||
menuStyle: {},
|
||||
mfaModal: false,
|
||||
mfaCode: null,
|
||||
mfaError: null,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@ -52,9 +55,22 @@ export function useAdmin() {
|
||||
if (state.unclaimed === true) {
|
||||
await setNodeStatus(state.password);
|
||||
}
|
||||
await getNodeConfig(state.password);
|
||||
try {
|
||||
const session = await setNodeAccess(state.password, state.mfaCode);
|
||||
app.actions.setAdmin(session);
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err?.message;
|
||||
if (msg === '405' || msg === '403' || msg === '429') {
|
||||
updateState({ busy: false, mfaModal: true, mfaError: msg });
|
||||
}
|
||||
else {
|
||||
console.log(err);
|
||||
updateState({ busy: false })
|
||||
throw new Error('login failed: check your username and password');
|
||||
}
|
||||
}
|
||||
updateState({ busy: false });
|
||||
app.actions.setAdmin(state.password);
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
@ -63,6 +79,12 @@ export function useAdmin() {
|
||||
}
|
||||
}
|
||||
},
|
||||
setCode: (mfaCode) => {
|
||||
updateState({ mfaCode });
|
||||
},
|
||||
dismissMFA: () => {
|
||||
updateState({ mfaModal: false, mfaCode: null });
|
||||
},
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -93,7 +93,6 @@ console.log(state.mfaError);
|
||||
<Button key="save" type="primary" className={state.mfaCode ? 'saveEnabled' : 'saveDisabled'} onClick={login}
|
||||
disabled={!state.mfaCode} loading={state.busy}>{state.strings.login}</Button>
|
||||
</div>
|
||||
|
||||
</MFAModal>
|
||||
</Modal>
|
||||
</LoginWrapper>
|
||||
|
8
net/web/src/api/addAdminMFAuth.js
Normal file
8
net/web/src/api/addAdminMFAuth.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function addAdminMFAuth(token) {
|
||||
const mfa = await fetchWithTimeout(`/admin/mfauth?token=${token}`, { method: 'POST' })
|
||||
checkResponse(mfa);
|
||||
return mfa.json();
|
||||
}
|
||||
|
8
net/web/src/api/getAdminMFAuth.js
Normal file
8
net/web/src/api/getAdminMFAuth.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function getAdminMFAuth(token) {
|
||||
const mfa = await fetchWithTimeout(`/admin/mfauth?token=${encodeURIComponent(token)}`, { method: 'GET' });
|
||||
checkResponse(mfa);
|
||||
return await mfa.json();
|
||||
}
|
||||
|
7
net/web/src/api/removeAdminMFAuth.js
Normal file
7
net/web/src/api/removeAdminMFAuth.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function removeAdminMFAuth(token) {
|
||||
const mfa = await fetchWithTimeout(`/admin/mfauth?token=${token}`, { method: 'DELETE' })
|
||||
checkResponse(mfa);
|
||||
}
|
||||
|
7
net/web/src/api/setAdminMFAuth.js
Normal file
7
net/web/src/api/setAdminMFAuth.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function setAdminMFAuth(token, code) {
|
||||
const mfa = await fetchWithTimeout(`/admin/mfauth?token=${token}&code=${code}`, { method: 'PUT' })
|
||||
checkResponse(mfa);
|
||||
}
|
||||
|
9
net/web/src/api/setNodeAccess.js
Normal file
9
net/web/src/api/setNodeAccess.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function setNodeAccess(token, code) {
|
||||
const mfa = code ? `&code=${code}` : '';
|
||||
const access = await fetchWithTimeout(`/admin/access?token=${encodeURIComponent(token)}${mfa}`, { method: 'PUT' });
|
||||
checkResponse(access);
|
||||
return access.json()
|
||||
}
|
||||
|
@ -195,6 +195,13 @@ export const en = {
|
||||
mfaDisabled: 'verification temporarily disabled',
|
||||
mfaConfirm: 'Confirm',
|
||||
mfaEnter: 'Enter your verification code',
|
||||
|
||||
enableMultifactor: 'Enable multi-factor authentication',
|
||||
disableMultifactor: 'Disable multi-factor authentication',
|
||||
|
||||
disable: 'Disable',
|
||||
confirmDisable: 'Disabling Multi-Factor Authentication',
|
||||
disablePrompt: 'Are you sure you want to disable multi-factor authentication',
|
||||
};
|
||||
|
||||
export const fr = {
|
||||
@ -395,6 +402,13 @@ export const fr = {
|
||||
mfaError: 'erreur de code de vérification',
|
||||
mfaDisabled: 'vérification temporairement désactivée',
|
||||
mfaConfirm: 'Confirmer',
|
||||
|
||||
enableMultifactor: 'Activer l\'authentification multifacteur',
|
||||
disableMultifactor: 'Désactiver l\'authentification multifacteur',
|
||||
|
||||
disable: 'Désactiver',
|
||||
confirmDisable: 'Désactivation de l\'authentification multi-facteurs',
|
||||
disablePrompt: 'Êtes-vous sûr de vouloir désactiver l\'authentification multi-facteurs',
|
||||
};
|
||||
|
||||
export const sp = {
|
||||
@ -594,6 +608,13 @@ export const sp = {
|
||||
mfaError: 'error de código de verificación',
|
||||
mfaDisabled: 'verificación temporalmente deshabilitada',
|
||||
mfaConfirm: 'Confirmar',
|
||||
|
||||
enableMultifactor: 'Habilitar la autenticación multifactor',
|
||||
disableMultifactor: 'Deshabilitar la autenticación multifactor',
|
||||
|
||||
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?',
|
||||
};
|
||||
|
||||
export const pt = {
|
||||
@ -793,6 +814,13 @@ export const pt = {
|
||||
mfaError: 'erro de código de verificação',
|
||||
mfaDisabled: 'verificação temporariamente desativada',
|
||||
mfaConfirm: 'Confirmar',
|
||||
|
||||
enableMultifactor: 'Habilitar autenticação multifator',
|
||||
disableMultifactor: 'Desativar autenticação multifator',
|
||||
|
||||
disable: 'Desativar',
|
||||
confirmDisable: 'Desativando Autenticação de Dois Fatores',
|
||||
disablePrompt: 'Tem certeza de que deseja desativar a autenticação de dois fatores?',
|
||||
};
|
||||
|
||||
export const de = {
|
||||
@ -992,6 +1020,13 @@ export const de = {
|
||||
mfaError: 'Verifizierungscodefehler',
|
||||
mfaDisabled: 'Verifizierung vorübergehend deaktiviert',
|
||||
mfaConfirm: 'Bestätigen',
|
||||
|
||||
enableMultifactor: 'Aktivieren Sie die Multi-Faktor-Authentifizierung',
|
||||
disableMultifactor: 'Deaktivieren Sie die Multi-Faktor-Authentifizierung',
|
||||
|
||||
disable: 'Deaktivieren',
|
||||
confirmDisable: 'Deaktivierung der Zwei-Faktor-Authentifizierung',
|
||||
disablePrompt: 'Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten?',
|
||||
};
|
||||
|
||||
export const ru = {
|
||||
@ -1191,4 +1226,11 @@ export const ru = {
|
||||
mfaError: 'ошибка проверочного кода',
|
||||
mfaDisabled: 'проверка временно отключена',
|
||||
mfaConfirm: 'Подтвердить',
|
||||
|
||||
enableMultifactor: 'Включить многофакторную аутентификацию',
|
||||
disableMultifactor: 'Отключить многофакторную аутентификацию',
|
||||
|
||||
disable: 'Отключить',
|
||||
confirmDisable: 'Отключение двухфакторной аутентификации',
|
||||
disablePrompt: 'Вы уверены, что хотите отключить двухфакторную аутентификацию?',
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AlertIcon, DashboardWrapper, SettingsButton, AddButton, SettingsLayout, CreateLayout } from './Dashboard.styled';
|
||||
import { AlertIcon, MFAModal, DashboardWrapper, SettingsButton, AddButton, SettingsLayout, CreateLayout } from './Dashboard.styled';
|
||||
import { Tooltip, Switch, Select, Button, Space, Modal, Input, InputNumber, List } from 'antd';
|
||||
import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutlined, ReloadOutlined, LockOutlined, UnlockOutlined } from '@ant-design/icons';
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { useDashboard } from './useDashboard.hook';
|
||||
import { AccountItem } from './accountItem/AccountItem';
|
||||
@ -8,6 +8,7 @@ import { CopyButton } from '../copyButton/CopyButton';
|
||||
|
||||
export function Dashboard() {
|
||||
|
||||
const [ modal, modalContext ] = Modal.useModal();
|
||||
const { state, actions } = useDashboard();
|
||||
|
||||
const onClipboard = async (value) => {
|
||||
@ -18,9 +19,67 @@ export function Dashboard() {
|
||||
return window.location.origin + '/#/create?add=' + state.createToken;
|
||||
};
|
||||
|
||||
const confirmDisableMFA = () => {
|
||||
modal.confirm({
|
||||
title: <span style={state.menuStyle}>{state.strings.confirmDisable}</span>,
|
||||
content: <span style={state.menuStyle}>{state.strings.disablePrompt}</span>,
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
|
||||
okText: state.strings.disable,
|
||||
cancelText: state.strings.cancel,
|
||||
onOk() {
|
||||
disableMFA();
|
||||
},
|
||||
onCancel() {},
|
||||
});
|
||||
}
|
||||
|
||||
const disableMFA = async () => {
|
||||
try {
|
||||
await actions.disableMFA();
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
modal.error({
|
||||
title: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
|
||||
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
|
||||
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const enableMFA = async () => {
|
||||
try {
|
||||
await actions.enableMFA();
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
modal.error({
|
||||
title: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
|
||||
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
|
||||
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const confirmMFA = async () => {
|
||||
try {
|
||||
await actions.confirmMFA();
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
modal.error({
|
||||
title: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
|
||||
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
|
||||
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={state.colors}>
|
||||
<DashboardWrapper>
|
||||
{ modalContext }
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<div className="label">{ state.strings.accounts }</div>
|
||||
@ -34,6 +93,18 @@ export function Dashboard() {
|
||||
<SettingsButton type="text" size="small" icon={<SettingOutlined />}
|
||||
onClick={() => actions.setShowSettings(true)}></SettingsButton>
|
||||
</div>
|
||||
{ (state.mfAuthSet && state.mfaAuthEnabled) && (
|
||||
<div className="settings">
|
||||
<SettingsButton type="text" size="small" icon={<UnlockOutlined />}
|
||||
onClick={confirmDisableMFA}></SettingsButton>
|
||||
</div>
|
||||
)}
|
||||
{ (state.mfAuthSet && !state.mfaAuthEnabled) && (
|
||||
<div className="settings">
|
||||
<SettingsButton type="text" size="small" icon={<LockOutlined />}
|
||||
onClick={enableMFA}></SettingsButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings">
|
||||
<SettingsButton type="text" size="small" icon={<LogoutOutlined />}
|
||||
onClick={() => actions.logout()}></SettingsButton>
|
||||
@ -63,6 +134,22 @@ export function Dashboard() {
|
||||
onClick={() => actions.setShowSettings(true)}></SettingsButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{ (state.mfAuthSet && state.mfaAuthEnabled) && (
|
||||
<div className="settings">
|
||||
<Tooltip placement="topRight" title={state.strings.disableMultifactor}>
|
||||
<SettingsButton type="text" size="small" icon={<LockOutlined />}
|
||||
onClick={confirmDisableMFA}></SettingsButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{ (state.mfAuthSet && !state.mfaAuthEnabled) && (
|
||||
<div className="settings">
|
||||
<Tooltip placement="topRight" title={state.strings.enableMultifactor}>
|
||||
<SettingsButton type="text" size="small" icon={<UnlockOutlined />}
|
||||
onClick={enableMFA}></SettingsButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings">
|
||||
<Tooltip placement="topRight" title={state.strings.logout}>
|
||||
<SettingsButton type="text" size="small" icon={<LogoutOutlined />}
|
||||
@ -218,6 +305,32 @@ export function Dashboard() {
|
||||
</div>
|
||||
</CreateLayout>
|
||||
</Modal>
|
||||
<Modal bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} closable={false} visible={state.mfaModal} centered width="fitContent"
|
||||
destroyOnClose={true} footer={null} onCancel={actions.dismissMFA}>
|
||||
<MFAModal>
|
||||
<div className="title">{state.strings.mfaTitle}</div>
|
||||
<div className="description">{state.strings.mfaSteps}</div>
|
||||
<img src={state.mfaImage} alt="QRCode" />
|
||||
<div className="secret">
|
||||
<div className="label">{ state.mfaText }</div>
|
||||
<CopyButton onCopy={async () => await navigator.clipboard.writeText(state.mfaText)} />
|
||||
</div>
|
||||
<Input.OTP onChange={actions.setCode} />
|
||||
<div className="alert">
|
||||
{ state.mfaError === '401' && (
|
||||
<span>{state.strings.mfaError}</span>
|
||||
)}
|
||||
{ state.mfaError === '429' && (
|
||||
<span>{state.strings.mfaDisabled}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="controls">
|
||||
<Button key="back" onClick={actions.dismissMFA}>{state.strings.cancel}</Button>
|
||||
<Button key="save" type="primary" className={state.mfaCode ? 'saveEnabled' : 'saveDisabled'} onClick={confirmMFA}
|
||||
disabled={!state.mfaCode} loading={state.busy}>{state.strings.mfaConfirm}</Button>
|
||||
</div>
|
||||
</MFAModal>
|
||||
</Modal>
|
||||
</DashboardWrapper>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
@ -163,3 +163,72 @@ export const CreateLayout = styled.div`
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const MFAModal = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-aling: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.0rem;
|
||||
padding-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.secret {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.code {
|
||||
padding-top: 4px;
|
||||
border-bottom: 1px solid ${props => props.theme.sectionBorder};
|
||||
}
|
||||
|
||||
.codeLabel {
|
||||
padding-top: 4px;
|
||||
font-size: 0.9.rem;
|
||||
color: ${props => props.theme.mainText};
|
||||
}
|
||||
|
||||
.alert {
|
||||
height: 24px;
|
||||
color: ${props => props.theme.alertText};
|
||||
}
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
|
||||
.saveDisabled {
|
||||
background-color: ${props => props.theme.disabledArea};
|
||||
|
||||
button {
|
||||
color: ${props => props.theme.idleText};
|
||||
}
|
||||
}
|
||||
|
||||
.saveEnabled {
|
||||
background-color: ${props => props.theme.enabledArea};
|
||||
|
||||
button {
|
||||
color: ${props => props.theme.activeText};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -8,7 +8,12 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { AppContext } from 'context/AppContext';
|
||||
import { SettingsContext } from 'context/SettingsContext';
|
||||
|
||||
export function useDashboard() {
|
||||
import { getAdminMFAuth } from 'api/getAdminMFAuth';
|
||||
import { addAdminMFAuth } from 'api/addAdminMFAuth';
|
||||
import { setAdminMFAuth } from 'api/setAdminMFAuth';
|
||||
import { removeAdminMFAuth } from 'api/removeAdminMFAuth';
|
||||
|
||||
export function useDashboard(token) {
|
||||
|
||||
const [state, setState] = useState({
|
||||
domain: "",
|
||||
@ -38,6 +43,14 @@ export function useDashboard() {
|
||||
colors: {},
|
||||
menuStyle: {},
|
||||
strings: {},
|
||||
|
||||
mfaModal: false,
|
||||
mfAuthSet: false,
|
||||
mfAuthEnabled: false,
|
||||
mfAuthSecretText: null,
|
||||
mfAuthSecretImage: null,
|
||||
mfaAuthError: null,
|
||||
mfaCode: '',
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@ -140,6 +153,30 @@ export function useDashboard() {
|
||||
await syncConfig();
|
||||
await syncAccounts();
|
||||
},
|
||||
setCode: async (code) => {
|
||||
updateState({ mfaCode: code });
|
||||
},
|
||||
enableMFA: async () => {
|
||||
const mfa = await addAdminMFAuth(app.state.adminToken);
|
||||
updateState({ mfaModal: true, mfaError: false, mfaText: mfa.secretText, mfaImage: mfa.secretImage, mfaCode: '' });
|
||||
},
|
||||
disableMFA: async () => {
|
||||
await removeAdminMFAuth(app.state.adminToken);
|
||||
updateState({ mfaAuthEnabled: false });
|
||||
},
|
||||
confirmMFA: async () => {
|
||||
try {
|
||||
await setAdminMFAuth(app.state.adminToken, state.mfaCode);
|
||||
updateState({ mfaAuthEnabled: true, mfaModal: false });
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err?.message;
|
||||
updateState({ mfaError: msg });
|
||||
}
|
||||
},
|
||||
dismissMFA: async () => {
|
||||
updateState({ mfaModal: false });
|
||||
},
|
||||
setSettings: async () => {
|
||||
if (!state.busy) {
|
||||
updateState({ busy: true });
|
||||
@ -161,10 +198,11 @@ export function useDashboard() {
|
||||
|
||||
const syncConfig = async () => {
|
||||
try {
|
||||
const enabled = await getAdminMFAuth(app.state.adminToken);
|
||||
const config = await getNodeConfig(app.state.adminToken);
|
||||
const { accountStorage, domain, keyType, pushSupported, transformSupported, allowUnsealed, enableImage, enableAudio, enableVideo, enableBinary, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit } = config;
|
||||
const storage = Math.ceil(accountStorage / 1073741824);
|
||||
updateState({ configError: false, domain, accountStorage: storage, keyType, enableImage, enableAudio, enableVideo, enableBinary, pushSupported, transformSupported, allowUnsealed, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit });
|
||||
updateState({ mfAuthSet: true, mfaAuthEnabled: enabled, configError: false, domain, accountStorage: storage, keyType, enableImage, enableAudio, enableVideo, enableBinary, pushSupported, transformSupported, allowUnsealed, enableIce, iceUrl, iceUsername, icePassword, enableOpenAccess, openAccessLimit });
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
|
Loading…
x
Reference in New Issue
Block a user