Merge branch 'main' into fdroid

This commit is contained in:
Roland Osborne 2024-05-24 12:56:58 -07:00
commit 67db682c10
45 changed files with 1509 additions and 62 deletions

View File

@ -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>
);
}

View File

@ -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%',

View File

@ -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 };

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';
@ -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>
)
}

View File

@ -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,
},
});

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, 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 };

View File

@ -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

View 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() })
}

View File

@ -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,
})

View File

@ -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
}

View File

@ -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
}

View 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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View 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)
}

View File

@ -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
}

View 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)
}

View 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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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

View File

@ -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 {

View File

@ -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"),

View File

@ -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)

View File

@ -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>
);
};

View File

@ -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};
}
}
}
`

View File

@ -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(() => {

View File

@ -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>

View 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();
}

View 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();
}

View 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);
}

View 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);
}

View 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()
}

View File

@ -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: 'Вы уверены, что хотите отключить двухфакторную аутентификацию?',
};

View File

@ -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>
);

View File

@ -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};
}
}
}
`

View File

@ -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);