mirror of
https://github.com/balzack/databag.git
synced 2025-04-20 08:35:15 +00:00
merging mfa support from main
This commit is contained in:
commit
0cbb8c623a
@ -35,6 +35,7 @@ Databag is designed for efficiency, consuming minimal hosting resources. Notable
|
||||
- Low latency (use of websockets for push events to avoid polling)
|
||||
- Unlimited accounts per node (host for your whole family)
|
||||
- Mobile alerts (push notifications on new contacts, messages, and calls)
|
||||
- Multi-Factor Authentication (integrates with TOTP apps)
|
||||
|
||||
<br>
|
||||
<p align="center">
|
||||
|
@ -5,6 +5,8 @@ import { useLogin } from './useLogin.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 Login() {
|
||||
|
||||
@ -107,6 +109,47 @@ export function Login() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={state.mfaModal}
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={actions.dismissMFA}
|
||||
>
|
||||
<View>
|
||||
<View style={styles.modalContainer}>
|
||||
<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.login}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -22,6 +22,98 @@ export const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
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,
|
||||
},
|
||||
tos: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
@ -18,6 +18,9 @@ export function useLogin() {
|
||||
showPassword: false,
|
||||
agree: false,
|
||||
showTerms: false,
|
||||
mfaModal: false,
|
||||
mfaCode: '',
|
||||
mfaError: null,
|
||||
});
|
||||
|
||||
const updateState = (value) => {
|
||||
@ -74,18 +77,29 @@ export function useLogin() {
|
||||
if (!state.busy) {
|
||||
updateState({ busy: true });
|
||||
try {
|
||||
await app.actions.login(state.login.trim(), state.password);
|
||||
await app.actions.login(state.login.trim(), state.password, state.mfaCode);
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
updateState({ busy: false, showAlert: true });
|
||||
throw new Error('login failed');
|
||||
console.log(err);
|
||||
if (err.message == '405' || err.message == '403' || err.message == '429') {
|
||||
updateState({ mfaModal: true, mfaError: err.message });
|
||||
}
|
||||
else {
|
||||
console.log(err.message);
|
||||
updateState({ busy: false, showAlert: true });
|
||||
throw new Error('login failed');
|
||||
}
|
||||
}
|
||||
updateState({ busy: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
setCode: (mfaCode) => {
|
||||
updateState({ mfaCode });
|
||||
},
|
||||
dismissMFA: () => {
|
||||
updateState({ mfaModal: false });
|
||||
},
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
|
||||
|
11
app/mobile/src/api/addAccountMFA.js
Normal file
11
app/mobile/src/api/addAccountMFA.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function addAccountMFA(server, token) {
|
||||
const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
|
||||
const protocol = insecure ? 'http' : 'https';
|
||||
|
||||
const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}`, { method: 'POST' })
|
||||
checkResponse(mfa);
|
||||
return mfa.json();
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ export function createWebsocket(url) {
|
||||
|
||||
export function checkResponse(response) {
|
||||
if(response.status >= 400 && response.status < 600) {
|
||||
throw new Error(response.url + " failed");
|
||||
console.log(`${response.url} failed [${response?.status}]`);
|
||||
throw new Error(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
|
10
app/mobile/src/api/removeAccountMFA.js
Normal file
10
app/mobile/src/api/removeAccountMFA.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function removeAccountMFA(server, token) {
|
||||
const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
|
||||
const protocol = insecure ? 'http' : 'https';
|
||||
|
||||
const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}`, { method: 'DELETE' });
|
||||
checkResponse(mfa);
|
||||
}
|
||||
|
10
app/mobile/src/api/setAccountMFA.js
Normal file
10
app/mobile/src/api/setAccountMFA.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function setAccountMFA(server, token, code) {
|
||||
const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
|
||||
const protocol = insecure ? 'http' : 'https';
|
||||
|
||||
const mfa = await fetchWithTimeout(`${protocol}://${server}/account/mfauth?agent=${token}&code=${code}`, { method: 'PUT' })
|
||||
checkResponse(mfa);
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
import base64 from 'react-native-base64'
|
||||
|
||||
export async function setLogin(username, server, password, appName, appVersion, platform, deviceToken, notifications) {
|
||||
export async function setLogin(username, server, password, code, appName, appVersion, platform, deviceToken, notifications) {
|
||||
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}` : '';
|
||||
|
||||
let headers = new Headers()
|
||||
headers.append('Authorization', 'Basic ' + base64.encode(username + ":" + password));
|
||||
let login = await fetchWithTimeout(`${protocol}://${server}/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}&deviceToken=${deviceToken}&pushType=up`, { method: 'POST', body: JSON.stringify(notifications), headers: headers })
|
||||
let login = await fetchWithTimeout(`${protocol}://${server}/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}&deviceToken=${deviceToken}&pushType=up${mfa}`, { method: 'POST', body: JSON.stringify(notifications), headers: headers })
|
||||
checkResponse(login)
|
||||
return await login.json()
|
||||
}
|
||||
|
@ -203,6 +203,13 @@ const Strings = [
|
||||
reportMessage: 'Report Message',
|
||||
select: 'Select',
|
||||
selectTopic: 'Select Topic for Sharing',
|
||||
|
||||
mfaTitle: 'Multi-Factor Authentication',
|
||||
mfaSteps: 'Store the SHA256 secret and confirm the verification code',
|
||||
mfaError: 'verification code error',
|
||||
mfaDisabled: 'verification temporarily disabled',
|
||||
mfaConfirm: 'Confirm',
|
||||
mfaEnter: 'Enter your verification code',
|
||||
},
|
||||
{
|
||||
languageCode: 'fr',
|
||||
@ -402,6 +409,13 @@ const Strings = [
|
||||
reportMessage: 'Signaler le Message',
|
||||
select: 'Choisir',
|
||||
selectTopic: 'Choisissez le sujet à partager',
|
||||
|
||||
mfaTitle: 'Authentification Multi-Factor',
|
||||
mfaSteps: 'Enregistrez le secret SHA256 et confirmez le code de vérification',
|
||||
mfaEnter: 'Entrez votre code de vérification',
|
||||
mfaError: 'erreur de code de vérification',
|
||||
mfaDisabled: 'vérification temporairement désactivée',
|
||||
mfaConfirm: 'Confirmer',
|
||||
},
|
||||
{
|
||||
languageCode: 'es',
|
||||
@ -602,6 +616,13 @@ const Strings = [
|
||||
|
||||
select: 'Elegir',
|
||||
selectTopic: 'Elija un tema para compartir',
|
||||
|
||||
mfaTitle: 'Autenticación de Dos Factores',
|
||||
mfaSteps: 'Guarde el secreto SHA256 y confirme el código de verificación',
|
||||
mfaEnter: 'Ingresa tu código de verificación',
|
||||
mfaError: 'error de código de verificación',
|
||||
mfaDisabled: 'verificación temporalmente deshabilitada',
|
||||
mfaConfirm: 'Confirmar',
|
||||
},
|
||||
{
|
||||
languageCode: 'de',
|
||||
@ -802,6 +823,13 @@ const Strings = [
|
||||
|
||||
select: 'Wählen',
|
||||
selectTopic: 'Wählen Sie ein Thema zum Teilen aus',
|
||||
|
||||
mfaTitle: 'Zwei-Faktor-Authentifizierung',
|
||||
mfaSteps: 'Speichern Sie das SHA256-Geheimnis und bestätigen Sie den Bestätigungscode',
|
||||
mfaEnter: 'Geben Sie Ihren Bestätigungs-Code ein',
|
||||
mfaError: 'Verifizierungscodefehler',
|
||||
mfaDisabled: 'Verifizierung vorübergehend deaktiviert',
|
||||
mfaConfirm: 'Bestätigen',
|
||||
},
|
||||
{
|
||||
languageCode: 'pt',
|
||||
@ -987,6 +1015,13 @@ const Strings = [
|
||||
|
||||
select: 'Escolher',
|
||||
selectTopic: 'Escolha o tópico para compartilhar',
|
||||
|
||||
mfaTitle: 'Autenticação de Dois Fatores',
|
||||
mfaSteps: 'Salve o segredo SHA256 e confirme o código de verificação',
|
||||
mfaEnter: 'Digite seu código de verificação',
|
||||
mfaError: 'erro de código de verificação',
|
||||
mfaDisabled: 'verificação temporariamente desativada',
|
||||
mfaConfirm: 'Confirmar',
|
||||
},
|
||||
{
|
||||
languageCode: 'ru',
|
||||
@ -1170,6 +1205,13 @@ const Strings = [
|
||||
|
||||
select: 'выбирать',
|
||||
selectTopic: 'Выберите тему для обмена',
|
||||
|
||||
mfaTitle: 'Двухфакторная аутентификация',
|
||||
mfaSteps: 'Сохраните секрет SHA256 и подтвердите код подтверждения',
|
||||
mfaEnter: 'Введите Ваш верификационный код',
|
||||
mfaError: 'ошибка проверочного кода',
|
||||
mfaDisabled: 'проверка временно отключена',
|
||||
mfaConfirm: 'Подтвердить',
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -5,6 +5,9 @@ import { setAccountSearchable } from 'api/setAccountSearchable';
|
||||
import { setAccountNotifications } from 'api/setAccountNotifications';
|
||||
import { getAccountStatus } from 'api/getAccountStatus';
|
||||
import { setAccountLogin } from 'api/setAccountLogin';
|
||||
import { addAccountMFA } from 'api/addAccountMFA';
|
||||
import { setAccountMFA } from 'api/setAccountMFA';
|
||||
import { removeAccountMFA } from 'api/removeAccountMFA';
|
||||
|
||||
export function useAccountContext() {
|
||||
const [state, setState] = useState({
|
||||
@ -75,6 +78,19 @@ export function useAccountContext() {
|
||||
const { server, token } = access.current || {};
|
||||
await setAccountSearchable(server, token, flag);
|
||||
},
|
||||
enableMFA: async () => {
|
||||
const { server, token } = access.current || {};
|
||||
const secret = await addAccountMFA(server, token);
|
||||
return secret;
|
||||
},
|
||||
disableMFA: async () => {
|
||||
const { server, token } = access.current || {};
|
||||
await removeAccountMFA(server, token);
|
||||
},
|
||||
confirmMFA: async (code) => {
|
||||
const { server, token } = access.current || {};
|
||||
await setAccountMFA(server, token, code);
|
||||
},
|
||||
setAccountSeal: async (seal, key) => {
|
||||
const { guid, server, token } = access.current || {};
|
||||
await setAccountSeal(server, token, seal);
|
||||
|
@ -101,7 +101,7 @@ export function useAppContext() {
|
||||
}
|
||||
updateState({ loggedOut: false });
|
||||
await addAccount(server, username, password, token);
|
||||
const session = await setLogin(username, server, password, getApplicationName(), getVersion(), getDeviceId(), deviceToken.current, notifications)
|
||||
const session = await setLogin(username, server, password, null, getApplicationName(), getVersion(), getDeviceId(), deviceToken.current, notifications)
|
||||
access.current = { loginTimestamp: session.created, server, token: session.appToken, guid: session.guid };
|
||||
await store.actions.setSession(access.current);
|
||||
await setSession();
|
||||
@ -116,13 +116,13 @@ export function useAppContext() {
|
||||
await store.actions.setSession(access.current);
|
||||
await setSession();
|
||||
},
|
||||
login: async (username, password) => {
|
||||
login: async (username, password, code) => {
|
||||
if (!init.current || access.current) {
|
||||
throw new Error('invalid session state');
|
||||
}
|
||||
updateState({ loggedOut: false });
|
||||
const acc = username.includes('/') ? username.split('/') : username.split('@');
|
||||
const session = await setLogin(acc[0], acc[1], password, getApplicationName(), getVersion(), getDeviceId(), deviceToken.current, notifications)
|
||||
const session = await setLogin(acc[0], acc[1], password, code, getApplicationName(), getVersion(), getDeviceId(), deviceToken.current, notifications)
|
||||
access.current = { loginTimestamp: session.created, server: acc[1], token: session.appToken, guid: session.guid };
|
||||
await store.actions.setSession(access.current);
|
||||
await setSession();
|
||||
|
@ -249,7 +249,7 @@ export const styles = StyleSheet.create({
|
||||
},
|
||||
visibleLabel: {
|
||||
fontSize: 16,
|
||||
color: Colors.text,
|
||||
color: Colors.linkText,
|
||||
fontFamily: 'roboto',
|
||||
paddingRight: 8,
|
||||
},
|
||||
|
@ -1,13 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Linking, ActivityIndicator, FlatList, KeyboardAvoidingView, Modal, ScrollView, View, Switch, Text, TextInput, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Linking, ActivityIndicator, FlatList, Image, KeyboardAvoidingView, Modal, ScrollView, View, Switch, Text, TextInput, TouchableOpacity, Alert } from 'react-native';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { styles } from './Settings.styled';
|
||||
import { useSettings } from './useSettings.hook';
|
||||
import AntIcon from 'react-native-vector-icons/AntDesign';
|
||||
import MatIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import Colors from 'constants/Colors';
|
||||
import { InputField } from 'utils/InputField';
|
||||
import { Logo } from 'utils/Logo';
|
||||
import { InputCode } from 'utils/InputCode';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
|
||||
export function Settings({ drawer }) {
|
||||
|
||||
@ -98,6 +101,28 @@ export function Settings({ drawer }) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMFA = async () => {
|
||||
if (!busy) {
|
||||
try {
|
||||
setBusy(true);
|
||||
if (state.mfaEnabled) {
|
||||
await actions.disableMFA();
|
||||
}
|
||||
else {
|
||||
await actions.enableMFA();
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
Alert.alert(
|
||||
state.strings.error,
|
||||
state.strings.tryAgain,
|
||||
);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (!busy) {
|
||||
try {
|
||||
@ -198,6 +223,20 @@ export function Settings({ drawer }) {
|
||||
<Text style={styles.optionLink}>{ state.strings.logout }</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.drawerEntry} activeOpacity={1}>
|
||||
<View style={styles.icon}>
|
||||
<MatIcons name="ticket-confirmation-outline" size={20} color={Colors.text} />
|
||||
</View>
|
||||
<View style={styles.optionControl}>
|
||||
<TouchableOpacity activeOpacity={1} onPress={actions.toggleMFA}>
|
||||
<Text style={styles.optionText}>{ state.strings.mfaTitle }</Text>
|
||||
</TouchableOpacity>
|
||||
<Switch value={state.mfaEnabled} style={Platform.OS==='ios' ? styles.notifications : {}} thumbColor={Colors.sliderGrip} ios_backgroundColor={Colors.idleFill}
|
||||
trackColor={styles.track} onValueChange={toggleMFA} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.drawerEntry} activeOpacity={1} onPress={actions.showLogin}>
|
||||
<View style={styles.icon}>
|
||||
<MatIcons name="login" size={20} color={Colors.text} />
|
||||
@ -353,6 +392,19 @@ export function Settings({ drawer }) {
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.entry} activeOpacity={1}>
|
||||
<View style={styles.icon}>
|
||||
<MatIcons name="ticket-confirmation-outline" size={20} color={Colors.linkText} />
|
||||
</View>
|
||||
<View style={styles.optionControl}>
|
||||
<TouchableOpacity activeOpacity={1} onPress={toggleMFA}>
|
||||
<Text style={styles.optionLink}>{ state.strings.mfaTitle }</Text>
|
||||
</TouchableOpacity>
|
||||
<Switch value={state.mfaEnabled} style={Platform.OS==='ios' ? styles.notifications : {}} thumbColor={Colors.sliderGrip} ios_backgroundColor={Colors.disabledIndicator}
|
||||
trackColor={styles.track} onValueChange={toggleMFA} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.entry} activeOpacity={1} onPress={actions.showLogin}>
|
||||
<View style={styles.icon}>
|
||||
<MatIcons name="login" size={20} color={Colors.linkText} />
|
||||
@ -814,6 +866,59 @@ export function Settings({ drawer }) {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={state.mfaModal}
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={actions.dismissMFA}
|
||||
>
|
||||
<View>
|
||||
<KeyboardAvoidingView style={styles.modalOverlay} 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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -15,6 +15,112 @@ export const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
padding: 8,
|
||||
},
|
||||
mfaOverlay: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mfaSecret: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
mfaText: {
|
||||
fontSize: 11,
|
||||
color: Colors.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,
|
||||
},
|
||||
content: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@ -105,7 +211,7 @@ export const styles = StyleSheet.create({
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
paddingRight: 8,
|
||||
color: Colors.text,
|
||||
color: Colors.linkText,
|
||||
fontFamily: 'Roboto',
|
||||
},
|
||||
optionLink: {
|
||||
|
@ -56,6 +56,13 @@ export function useSettings() {
|
||||
contacts: [],
|
||||
topics: [],
|
||||
messages: [],
|
||||
|
||||
mfaModal: false,
|
||||
mfaEnabled: false,
|
||||
mfaError: null,
|
||||
mfaCode: '',
|
||||
mfaText: null,
|
||||
mfaImage: null,
|
||||
});
|
||||
|
||||
const updateState = (value) => {
|
||||
@ -69,11 +76,11 @@ export function useSettings() {
|
||||
}, [profile.state]);
|
||||
|
||||
useEffect(() => {
|
||||
const { seal, sealable, pushEnabled } = account.state.status;
|
||||
const { seal, sealable, pushEnabled, mfaEnabled } = account.state.status;
|
||||
const sealKey = account.state.sealKey;
|
||||
const sealEnabled = seal?.publicKey != null;
|
||||
const sealUnlocked = seal?.publicKey === sealKey?.public && sealKey?.private && sealKey?.public;
|
||||
updateState({ sealable, seal, sealKey, sealEnabled, sealUnlocked, pushEnabled });
|
||||
updateState({ sealable, seal, sealKey, sealEnabled, sealUnlocked, pushEnabled, mfaEnabled });
|
||||
}, [account.state]);
|
||||
|
||||
const setCardItem = (item) => {
|
||||
@ -375,6 +382,37 @@ export function useSettings() {
|
||||
updateState({ messages: state.messages.filter(item => item.channelId !== channelId || item.topicId !== topicId) });
|
||||
}
|
||||
},
|
||||
enableMFA: async () => {
|
||||
updateState({ mfaModal: true, mfaImage: null, mfaText: null, mfaCode: '' });
|
||||
const mfa = await account.actions.enableMFA();
|
||||
updateState({ mfaImage: mfa.secretImage, mfaText: mfa.secretText });
|
||||
},
|
||||
disableMFA: async () => {
|
||||
updateState({ mfaEnabled: false });
|
||||
try {
|
||||
await account.actions.disableMFA();
|
||||
}
|
||||
catch (err) {
|
||||
updateState({ mfaEnabled: true });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
confirmMFA: async () => {
|
||||
try {
|
||||
updateState({ mfaEnabled: true });
|
||||
await account.actions.confirmMFA(state.mfaCode);
|
||||
updateState({ mfaModal: false });
|
||||
}
|
||||
catch (err) {
|
||||
updateState({ mfaEnabled: false, mfaError: err.message});
|
||||
}
|
||||
},
|
||||
dismissMFA: () => {
|
||||
updateState({ mfaModal: false });
|
||||
},
|
||||
setCode: (mfaCode) => {
|
||||
updateState({ mfaCode });
|
||||
},
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
|
50
app/mobile/src/utils/InputCode.jsx
Normal file
50
app/mobile/src/utils/InputCode.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { TextInput, Text, View, TouchableOpacity } from 'react-native';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
export function InputCode({ onChangeText, style }) {
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const ref = useRef();
|
||||
|
||||
const updateCode = (value) => {
|
||||
if (value.length >= 6) {
|
||||
onChangeText(value.slice(0, 6));
|
||||
if (ref.current) {
|
||||
ref.current.blur();
|
||||
}
|
||||
}
|
||||
else {
|
||||
onChangeText('');
|
||||
}
|
||||
setCode(value.slice(0, 6));
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View style={{ width: '100%', height: 32 }}>
|
||||
<View style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<View style={{ width: 32, height: '100%', borderWidth: 1, borderRadius: 4, backgroundColor: '#dddddd', borderColor: '#aaaaaa', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
<Text style={{ fontSize: 20 }}>{ code.charAt(0) }</Text>
|
||||
</View>
|
||||
<View style={{ width: 32, height: '100%', borderWidth: 1, borderRadius: 4, backgroundColor: '#dddddd', borderColor: '#aaaaaa', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
<Text style={{ fontSize: 20 }}>{ code.charAt(1) }</Text>
|
||||
</View>
|
||||
<View style={{ width: 32, height: '100%', borderWidth: 1, borderRadius: 4, backgroundColor: '#dddddd', borderColor: '#aaaaaa', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
<Text style={{ fontSize: 20 }}>{ code.charAt(2) }</Text>
|
||||
</View>
|
||||
<View style={{ width: 32, height: '100%', borderWidth: 1, borderRadius: 4, backgroundColor: '#dddddd', borderColor: '#aaaaaa', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
<Text style={{ fontSize: 20 }}>{ code.charAt(3) }</Text>
|
||||
</View>
|
||||
<View style={{ width: 32, height: '100%', borderWidth: 1, borderRadius: 4, backgroundColor: '#dddddd', borderColor: '#aaaaaa', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
<Text style={{ fontSize: 20 }}>{ code.charAt(4) }</Text>
|
||||
</View>
|
||||
<View style={{ width: 32, height: '100%', borderWidth: 1, borderRadius: 4, backgroundColor: '#dddddd', borderColor: '#aaaaaa', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
<Text style={{ fontSize: 20 }}>{ code.charAt(5) }</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TextInput style={{ width: '100%', height: '100%', opacity: 0, position: 'absolute', top: 0, left: 0 }} keyboardType={Platform.OS === 'ios' ? 'numeric' : 'number-pad'} onChangeText={updateCode} autoCorrect={false} autoCapitalize="none" maxLength={6} ref={ref} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
90
doc/api.oa3
90
doc/api.oa3
@ -633,7 +633,82 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
|
||||
/account/mfauth:
|
||||
post:
|
||||
tags:
|
||||
- account
|
||||
description: Enable multi-factor authentication
|
||||
operationId: add-mfa
|
||||
parameters:
|
||||
- name: agent
|
||||
in: query
|
||||
description: agent token
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
'401':
|
||||
description: permission denied
|
||||
'500':
|
||||
description: internal server error
|
||||
put:
|
||||
tags:
|
||||
- account
|
||||
description: Confirm multi-factor authentication
|
||||
operationId: confirm-mfa
|
||||
parameters:
|
||||
- name: agent
|
||||
in: query
|
||||
description: agent 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:
|
||||
- account
|
||||
description: Disable multi-factor authentication
|
||||
operationId: remove-mfa
|
||||
parameters:
|
||||
- name: agent
|
||||
in: query
|
||||
description: agent token
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: success
|
||||
'401':
|
||||
description: permission denied
|
||||
'500':
|
||||
description: internal server error
|
||||
|
||||
/account/login:
|
||||
put:
|
||||
tags:
|
||||
@ -849,6 +924,12 @@ paths:
|
||||
security:
|
||||
- basicAuth: []
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
description: totp code
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: appName
|
||||
in: query
|
||||
description: name of connecting app
|
||||
@ -898,8 +979,12 @@ paths:
|
||||
description: invalid token
|
||||
'406':
|
||||
description: app limit reached
|
||||
'405':
|
||||
description: totp code required but not set
|
||||
'410':
|
||||
description: account disabled
|
||||
'429':
|
||||
description: temporarily locked due to too many failures
|
||||
'500':
|
||||
description: internal server error
|
||||
requestBody:
|
||||
@ -3942,6 +4027,7 @@ components:
|
||||
- forwardingAddress
|
||||
- searchable
|
||||
- pushEnabled
|
||||
- multiFactorAuth
|
||||
properties:
|
||||
disabled:
|
||||
type: boolean
|
||||
@ -3965,6 +4051,8 @@ components:
|
||||
$ref: '#/components/schemas/Seal'
|
||||
enableIce:
|
||||
type: boolean
|
||||
multiFactorAuth:
|
||||
type: boolean
|
||||
|
||||
AccountProfile:
|
||||
type: object
|
||||
|
@ -17,6 +17,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
@ -24,6 +25,7 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pquerna/otp v1.4.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
|
@ -1,3 +1,5 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@ -27,10 +29,13 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/theckman/go-securerandom v0.1.1 h1:5KctSyM0D5KKFK+bsypIyLq7yik0CEaI5i2fGcUGcsQ=
|
||||
|
@ -4,8 +4,12 @@ import (
|
||||
"databag/internal/store"
|
||||
"encoding/hex"
|
||||
"github.com/theckman/go-securerandom"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/pquerna/otp"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
//AddAccountApp with access token, attach an app to an account generating agent token
|
||||
@ -17,6 +21,48 @@ func AddAccountApp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
curTime := time.Now().Unix()
|
||||
if account.MFAFailedTime + APPMFAFailPeriod > curTime && account.MFAFailedCount > APPMFAFailCount {
|
||||
ErrResponse(w, http.StatusTooManyRequests, errors.New("temporarily locked"))
|
||||
return;
|
||||
}
|
||||
|
||||
if account.MFAEnabled && account.MFAConfirmed {
|
||||
code := r.FormValue("code")
|
||||
if code == "" {
|
||||
ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp code required"))
|
||||
return;
|
||||
}
|
||||
|
||||
opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256}
|
||||
if valid, _ := totp.ValidateCustom(code, account.MFASecret, time.Now(), opts); !valid {
|
||||
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if account.MFAFailedTime + APPMFAFailPeriod > curTime {
|
||||
account.MFAFailedCount += 1;
|
||||
if res := tx.Model(account).Update("mfa_failed_count", account.MFAFailedCount).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
} else {
|
||||
account.MFAFailedTime = curTime
|
||||
if res := tx.Model(account).Update("mfa_failed_time", account.MFAFailedTime).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
account.MFAFailedCount = 1
|
||||
if res := tx.Model(account).Update("mfa_failed_count", account.MFAFailedCount).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
|
||||
}
|
||||
}
|
||||
|
||||
// parse authentication token
|
||||
appName := r.FormValue("appName")
|
||||
appVersion := r.FormValue("appVersion")
|
||||
|
67
net/server/internal/api_addMultiFactorAuth.go
Normal file
67
net/server/internal/api_addMultiFactorAuth.go
Normal file
@ -0,0 +1,67 @@
|
||||
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"
|
||||
)
|
||||
|
||||
//AddMultiFactorAuth enables multi-factor auth on the given account
|
||||
func AddMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
account, code, err := ParamAgentToken(r, true)
|
||||
if err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: APPMFAIssuer,
|
||||
AccountName: account.GUID,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA256,
|
||||
})
|
||||
|
||||
err = store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
account.MFAConfirmed = false
|
||||
if res := tx.Model(account).Update("mfa_confirmed", account.MFAConfirmed).Error; res != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, res)
|
||||
return res
|
||||
}
|
||||
account.MFAEnabled = true
|
||||
if res := tx.Model(account).Update("mfa_enabled", account.MFAEnabled).Error; res != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, res)
|
||||
return res
|
||||
}
|
||||
account.MFASecret = key.Secret()
|
||||
if res := tx.Model(account).Update("mfa_secret", account.MFASecret).Error; res != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, res)
|
||||
return res
|
||||
}
|
||||
account.AccountRevision += 1;
|
||||
if res := tx.Model(&account).Update("account_revision", account.AccountRevision).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())
|
||||
|
||||
SetStatus(account)
|
||||
WriteResponse(w, MFASecret{ Image: "data:image/png;base64," + enc, Text: account.MFASecret })
|
||||
}
|
@ -35,6 +35,7 @@ func GetAccountStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status.Disabled = account.Disabled
|
||||
status.ForwardingAddress = account.Forward
|
||||
status.Searchable = account.Searchable
|
||||
status.MFAEnabled = account.MFAEnabled && account.MFAConfirmed
|
||||
status.Sealable = true
|
||||
status.EnableIce = getBoolConfigValue(CNFEnableIce, false)
|
||||
status.AllowUnsealed = getBoolConfigValue(CNFAllowUnsealed, false)
|
||||
|
42
net/server/internal/api_removeMultiFactorAuth.go
Normal file
42
net/server/internal/api_removeMultiFactorAuth.go
Normal file
@ -0,0 +1,42 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"databag/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//Disable multi-factor auth on account
|
||||
func RemoveMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
account, code, err := ParamAgentToken(r, true)
|
||||
if err != nil {
|
||||
ErrResponse(w, code, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
account.MFAConfirmed = false
|
||||
if res := tx.Model(account).Update("mfa_confirmed", account.MFAConfirmed).Error; res != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, res)
|
||||
return res
|
||||
}
|
||||
account.MFAEnabled = false
|
||||
if res := tx.Model(account).Update("mfa_enabled", account.MFAEnabled).Error; res != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, res)
|
||||
return res
|
||||
}
|
||||
account.AccountRevision += 1;
|
||||
if res := tx.Model(&account).Update("account_revision", account.AccountRevision).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
SetStatus(account)
|
||||
WriteResponse(w, nil)
|
||||
}
|
85
net/server/internal/api_setMultiFactorAuth.go
Normal file
85
net/server/internal/api_setMultiFactorAuth.go
Normal file
@ -0,0 +1,85 @@
|
||||
package databag
|
||||
|
||||
import (
|
||||
"databag/internal/store"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
//SetMultiFactorAuth
|
||||
func SetMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
account, ret, err := ParamAgentToken(r, true)
|
||||
if err != nil {
|
||||
ErrResponse(w, ret, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !account.MFAEnabled {
|
||||
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()
|
||||
if account.MFAFailedTime + APPMFAFailPeriod > curTime && account.MFAFailedCount > APPMFAFailCount {
|
||||
ErrResponse(w, http.StatusTooManyRequests, errors.New("temporarily locked"))
|
||||
return;
|
||||
}
|
||||
|
||||
opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256}
|
||||
if valid, _ := totp.ValidateCustom(code, account.MFASecret, time.Now(), opts); !valid {
|
||||
err := store.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if account.MFAFailedTime + APPMFAFailPeriod > curTime {
|
||||
account.MFAFailedCount += 1
|
||||
if res := tx.Model(account).Update("mfa_failed_count", account.MFAFailedCount).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
} else {
|
||||
account.MFAFailedTime = curTime
|
||||
if res := tx.Model(account).Update("mfa_failed_time", account.MFAFailedTime).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
account.MFAFailedCount = 1
|
||||
if res := tx.Model(account).Update("mfa_failed_count", account.MFAFailedCount).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 {
|
||||
account.MFAConfirmed = true
|
||||
if res := tx.Model(account).Update("mfa_confirmed", account.MFAConfirmed).Error; res != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, res)
|
||||
return res
|
||||
}
|
||||
account.AccountRevision += 1;
|
||||
if res := tx.Model(&account).Update("account_revision", account.AccountRevision).Error; res != nil {
|
||||
return res
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ErrResponse(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
SetStatus(account)
|
||||
WriteResponse(w, nil)
|
||||
}
|
@ -147,6 +147,15 @@ const APPQueueDefault = ""
|
||||
//APPDefaultPath config for default path to store assets
|
||||
const APPDefaultPath = "/tmp/databag/assets"
|
||||
|
||||
//APPMFAIssuer name servive
|
||||
const APPMFAIssuer = "Databag"
|
||||
|
||||
//APPMFAFailPeriod time window login failures can occur
|
||||
const APPMFAFailPeriod = 300
|
||||
|
||||
//APPMFAFailCount limit of login failures in period
|
||||
const APPMFAFailCount = 4
|
||||
|
||||
//AppCardStatus compares cards status with string
|
||||
func AppCardStatus(status string) bool {
|
||||
if status == APPCardPending {
|
||||
|
@ -35,6 +35,8 @@ type AccountStatus struct {
|
||||
|
||||
Searchable bool `json:"searchable"`
|
||||
|
||||
MFAEnabled bool `json:"mfaEnabled"`
|
||||
|
||||
PushEnabled bool `json:"pushEnabled"`
|
||||
|
||||
Sealable bool `json:"sealable"`
|
||||
@ -51,6 +53,13 @@ type Announce struct {
|
||||
AppToken string `json:"appToken"`
|
||||
}
|
||||
|
||||
//MFASecret values for configuring TOTP
|
||||
type MFASecret struct {
|
||||
Image string `json:"secretImage"`
|
||||
|
||||
Text string `json:"secretText"`
|
||||
}
|
||||
|
||||
//Notification describes type of notifications to receive
|
||||
type Notification struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
|
@ -202,6 +202,27 @@ var endpoints = routes{
|
||||
SetAccountSearchable,
|
||||
},
|
||||
|
||||
route{
|
||||
"AddMultiFactorAuth",
|
||||
strings.ToUpper("Post"),
|
||||
"/account/mfauth",
|
||||
AddMultiFactorAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"SetMultiFactorAuth",
|
||||
strings.ToUpper("Put"),
|
||||
"/account/mfauth",
|
||||
SetMultiFactorAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"RemoveMultiFactorAuth",
|
||||
strings.ToUpper("Delete"),
|
||||
"/account/mfauth",
|
||||
RemoveMultiFactorAuth,
|
||||
},
|
||||
|
||||
route{
|
||||
"AddNodeAccount",
|
||||
strings.ToUpper("Post"),
|
||||
|
@ -81,6 +81,11 @@ type Account struct {
|
||||
Updated int64 `gorm:"autoUpdateTime"`
|
||||
Disabled bool `gorm:"not null;default:false"`
|
||||
Searchable bool `gorm:"not null;default:false"`
|
||||
MFAEnabled bool `gorm:"not null;default:false"`
|
||||
MFAConfirmed bool `gorm:"not null;default:false"`
|
||||
MFASecret string
|
||||
MFAFailedTime int64
|
||||
MFAFailedCount uint
|
||||
Forward string
|
||||
AccountDetail AccountDetail
|
||||
Apps []App
|
||||
|
@ -19,7 +19,7 @@
|
||||
"@charliewilco/gluejar": "^1.0.0",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"antd": "^5.0.4",
|
||||
"antd": "^5.17.2",
|
||||
"axios": "^0.27.2",
|
||||
"base-64": "^1.0.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Button, Modal, Form, Input } from 'antd';
|
||||
import { SettingOutlined, LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { LoginWrapper } from './Login.styled';
|
||||
import { LoginWrapper, MFAModal } from './Login.styled';
|
||||
import { useLogin } from './useLogin.hook';
|
||||
|
||||
export function Login() {
|
||||
@ -27,6 +27,8 @@ export function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(state.mfaError);
|
||||
|
||||
return (
|
||||
<LoginWrapper>
|
||||
{ modalContext }
|
||||
@ -73,6 +75,27 @@ export function Login() {
|
||||
|
||||
</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>
|
||||
</LoginWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -80,4 +80,53 @@ export const LoginWrapper = 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -15,6 +15,9 @@ export function useLogin() {
|
||||
busy: false,
|
||||
strings: {},
|
||||
menuStyle: {},
|
||||
mfaModal: false,
|
||||
mfaCode: null,
|
||||
mfaError: null,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@ -46,12 +49,18 @@ export function useLogin() {
|
||||
if (!state.busy && state.username !== '' && state.password !== '') {
|
||||
updateState({ busy: true })
|
||||
try {
|
||||
await app.actions.login(state.username, state.password)
|
||||
await app.actions.login(state.username, state.password, state.mfaCode)
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
updateState({ busy: false })
|
||||
throw new Error('login failed: check your username and password');
|
||||
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 })
|
||||
}
|
||||
@ -59,6 +68,12 @@ export function useLogin() {
|
||||
onCreate: () => {
|
||||
navigate('/create');
|
||||
},
|
||||
setCode: (mfaCode) => {
|
||||
updateState({ mfaCode });
|
||||
},
|
||||
dismissMFA: () => {
|
||||
updateState({ mfaModal: false, mfaCode: null });
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
8
net/web/src/api/addAccountMFA.js
Normal file
8
net/web/src/api/addAccountMFA.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function addAccountMFA(token) {
|
||||
const mfa = await fetchWithTimeout(`/account/mfauth?agent=${token}`, { method: 'POST' })
|
||||
checkResponse(mfa);
|
||||
return mfa.json();
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ export function createWebsocket(url) {
|
||||
|
||||
export function checkResponse(response) {
|
||||
if(response.status >= 400 && response.status < 600) {
|
||||
throw new Error(response.url + " failed");
|
||||
throw new Error(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
|
7
net/web/src/api/removeAccountMFA.js
Normal file
7
net/web/src/api/removeAccountMFA.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function removeAccountMFA(token) {
|
||||
let res = await fetchWithTimeout(`/account/mfauth?agent=${token}`, { method: 'DELETE' })
|
||||
checkResponse(res);
|
||||
}
|
||||
|
7
net/web/src/api/setAccountMFA.js
Normal file
7
net/web/src/api/setAccountMFA.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
|
||||
export async function setAccountMFA(token, code) {
|
||||
let res = await fetchWithTimeout(`/account/mfauth?agent=${token}&code=${code}`, { method: 'PUT' })
|
||||
checkResponse(res);
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { checkResponse, fetchWithTimeout } from './fetchUtil';
|
||||
var base64 = require('base-64');
|
||||
|
||||
export async function setLogin(username, password, appName, appVersion, userAgent) {
|
||||
export async function setLogin(username, password, code, appName, appVersion, userAgent) {
|
||||
const platform = encodeURIComponent(userAgent);
|
||||
const mfa = code ? `&code=${code}` : '';
|
||||
let headers = new Headers()
|
||||
headers.append('Authorization', 'Basic ' + base64.encode(username + ":" + password));
|
||||
let login = await fetchWithTimeout(`/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}`, { method: 'POST', body: JSON.stringify([]), headers: headers })
|
||||
let login = await fetchWithTimeout(`/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}${mfa}`, { method: 'POST', body: JSON.stringify([]), headers: headers })
|
||||
checkResponse(login)
|
||||
return await login.json()
|
||||
}
|
||||
|
@ -188,6 +188,13 @@ export const en = {
|
||||
confirmRemove: 'Are you sure you want to delete the contact?',
|
||||
message: 'Message',
|
||||
securedMessage: 'Sealed Message',
|
||||
|
||||
mfaTitle: 'Multi-Factor Authentication',
|
||||
mfaSteps: 'Store the SHA256 secret and confirm the verification code',
|
||||
mfaError: 'verification code error',
|
||||
mfaDisabled: 'verification temporarily disabled',
|
||||
mfaConfirm: 'Confirm',
|
||||
mfaEnter: 'Enter your verification code',
|
||||
};
|
||||
|
||||
export const fr = {
|
||||
@ -381,6 +388,13 @@ export const fr = {
|
||||
|
||||
message: 'Message',
|
||||
sealedMessage: 'Message Sécurisé',
|
||||
|
||||
mfaTitle: 'Authentification Multi-Factor',
|
||||
mfaSteps: 'Enregistrez le secret SHA256 et confirmez le code de vérification',
|
||||
mfaEnter: 'Entrez votre code de vérification',
|
||||
mfaError: 'erreur de code de vérification',
|
||||
mfaDisabled: 'vérification temporairement désactivée',
|
||||
mfaConfirm: 'Confirmer',
|
||||
};
|
||||
|
||||
export const sp = {
|
||||
@ -573,6 +587,13 @@ export const sp = {
|
||||
confirmRemove: '¿Estás seguro de que quieres eliminar el contacto?',
|
||||
message: 'Mensaje',
|
||||
sealedMessage: 'Mensaje Seguro',
|
||||
|
||||
mfaTitle: 'Autenticación de Dos Factores',
|
||||
mfaSteps: 'Guarde el secreto SHA256 y confirme el código de verificación',
|
||||
mfaEnter: 'Ingresa tu código de verificación',
|
||||
mfaError: 'error de código de verificación',
|
||||
mfaDisabled: 'verificación temporalmente deshabilitada',
|
||||
mfaConfirm: 'Confirmar',
|
||||
};
|
||||
|
||||
export const pt = {
|
||||
@ -765,6 +786,13 @@ export const pt = {
|
||||
confirmRemove: 'Tem certeza de que deseja remover o contato?',
|
||||
message: 'Mensagem',
|
||||
sealedMessage: 'Mensagem Segura',
|
||||
|
||||
mfaTitle: 'Autenticação de Dois Fatores',
|
||||
mfaSteps: 'Salve o segredo SHA256 e confirme o código de verificação',
|
||||
mfaEnter: 'Digite seu código de verificação',
|
||||
mfaError: 'erro de código de verificação',
|
||||
mfaDisabled: 'verificação temporariamente desativada',
|
||||
mfaConfirm: 'Confirmar',
|
||||
};
|
||||
|
||||
export const de = {
|
||||
@ -957,6 +985,13 @@ export const de = {
|
||||
confirmRemove: 'Sind Sie sicher, dass Sie den Kontakt löschen möchten?',
|
||||
message: 'Nachricht',
|
||||
sealedMessage: 'Gesicherte Nachricht',
|
||||
|
||||
mfaTitle: 'Zwei-Faktor-Authentifizierung',
|
||||
mfaSteps: 'Speichern Sie das SHA256-Geheimnis und bestätigen Sie den Bestätigungscode',
|
||||
mfaEnter: 'Geben Sie Ihren Bestätigungs-Code ein',
|
||||
mfaError: 'Verifizierungscodefehler',
|
||||
mfaDisabled: 'Verifizierung vorübergehend deaktiviert',
|
||||
mfaConfirm: 'Bestätigen',
|
||||
};
|
||||
|
||||
export const ru = {
|
||||
@ -1149,4 +1184,11 @@ export const ru = {
|
||||
confirmRemove: 'Вы уверены, что хотите удалить контакт?',
|
||||
message: 'Cообщение',
|
||||
sealedMessage: 'Защищенное Cообщение',
|
||||
|
||||
mfaTitle: 'Двухфакторная аутентификация',
|
||||
mfaSteps: 'Сохраните секрет SHA256 и подтвердите код подтверждения',
|
||||
mfaEnter: 'Введите Ваш верификационный код',
|
||||
mfaError: 'ошибка проверочного кода',
|
||||
mfaDisabled: 'проверка временно отключена',
|
||||
mfaConfirm: 'Подтвердить',
|
||||
};
|
||||
|
@ -3,6 +3,9 @@ import { setAccountSearchable } from 'api/setAccountSearchable';
|
||||
import { setAccountSeal } from 'api/setAccountSeal';
|
||||
import { getAccountStatus } from 'api/getAccountStatus';
|
||||
import { setAccountLogin } from 'api/setAccountLogin';
|
||||
import { addAccountMFA } from 'api/addAccountMFA';
|
||||
import { setAccountMFA } from 'api/setAccountMFA';
|
||||
import { removeAccountMFA } from 'api/removeAccountMFA';
|
||||
import { StoreContext } from './StoreContext';
|
||||
|
||||
export function useAccountContext() {
|
||||
@ -72,6 +75,16 @@ export function useAccountContext() {
|
||||
setSearchable: async (flag) => {
|
||||
await setAccountSearchable(access.current, flag);
|
||||
},
|
||||
enableMFA: async () => {
|
||||
const secret = await addAccountMFA(access.current);
|
||||
return secret;
|
||||
},
|
||||
disableMFA: async () => {
|
||||
await removeAccountMFA(access.current);
|
||||
},
|
||||
confirmMFA: async (code) => {
|
||||
await setAccountMFA(access.current, code);
|
||||
},
|
||||
setSeal: async (seal, sealKey) => {
|
||||
await setAccountSeal(access.current, seal);
|
||||
await storeContext.actions.setValue("sealKey", sealKey);
|
||||
|
@ -77,8 +77,8 @@ export function useAppContext(websocket) {
|
||||
access: async (token) => {
|
||||
await appAccess(token)
|
||||
},
|
||||
login: async (username, password) => {
|
||||
await appLogin(username, password)
|
||||
login: async (username, password, code) => {
|
||||
await appLogin(username, password, code)
|
||||
},
|
||||
create: async (username, password, token) => {
|
||||
await appCreate(username, password, token)
|
||||
@ -96,7 +96,7 @@ export function useAppContext(websocket) {
|
||||
throw new Error('invalid session state');
|
||||
}
|
||||
await addAccount(username, password, token);
|
||||
const access = await setLogin(username, password, appName, appVersion, userAgent);
|
||||
const access = await setLogin(username, password, null, appName, appVersion, userAgent);
|
||||
storeContext.actions.setValue('login:timestamp', access.created);
|
||||
setSession(access.appToken);
|
||||
appToken.current = access.appToken;
|
||||
@ -108,11 +108,11 @@ export function useAppContext(websocket) {
|
||||
return access.created;
|
||||
}
|
||||
|
||||
const appLogin = async (username, password) => {
|
||||
const appLogin = async (username, password, code) => {
|
||||
if (appToken.current || !checked.current) {
|
||||
throw new Error('invalid session state');
|
||||
}
|
||||
const access = await setLogin(username, password, appName, appVersion, userAgent);
|
||||
const access = await setLogin(username, password, code, appName, appVersion, userAgent);
|
||||
storeContext.actions.setValue('login:timestamp', access.created);
|
||||
setSession(access.appToken);
|
||||
appToken.current = access.appToken;
|
||||
|
@ -4,7 +4,7 @@ import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutl
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { useDashboard } from './useDashboard.hook';
|
||||
import { AccountItem } from './accountItem/AccountItem';
|
||||
import { CopyButton } from './copyButton/CopyButton';
|
||||
import { CopyButton } from '../copyButton/CopyButton';
|
||||
|
||||
export function Dashboard() {
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { AccountItemWrapper, AccessLayout, DeleteButton, EnableButton, DisableBu
|
||||
import { useAccountItem } from './useAccountItem.hook';
|
||||
import { ExclamationCircleOutlined, UserDeleteOutlined, UnlockOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { Modal, Tooltip, Button } from 'antd';
|
||||
import { CopyButton } from '../copyButton/CopyButton';
|
||||
import { CopyButton } from '../../copyButton/CopyButton';
|
||||
|
||||
export function AccountItem({ item, remove }) {
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { AccountAccessWrapper, LoginModal, SealModal, LogoutContent } from './AccountAccess.styled';
|
||||
import { AccountAccessWrapper, LoginModal, MFAModal, SealModal, LogoutContent } from './AccountAccess.styled';
|
||||
import { useAccountAccess } from './useAccountAccess.hook';
|
||||
import { Button, Modal, Switch, Input, Radio, Select } from 'antd';
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined, LockOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { CopyButton } from '../../../../copyButton/CopyButton';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function AccountAccess() {
|
||||
@ -39,6 +40,25 @@ export function AccountAccess() {
|
||||
}
|
||||
};
|
||||
|
||||
const enableMFA = async (enable) => {
|
||||
try {
|
||||
if (enable) {
|
||||
await actions.enableMFA();
|
||||
}
|
||||
else {
|
||||
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 saveLogin = async () => {
|
||||
try {
|
||||
await actions.setLogin();
|
||||
@ -84,6 +104,12 @@ export function AccountAccess() {
|
||||
</div>
|
||||
<div className="switchLabel">{state.strings.registry}</div>
|
||||
</div>
|
||||
<div className="switch">
|
||||
<div className="control">
|
||||
<Switch size="small" checked={state.mfaEnabled} onChange={enable => enableMFA(enable)} />
|
||||
</div>
|
||||
<div className="switchLabel">{state.strings.mfaTitle}</div>
|
||||
</div>
|
||||
<div className="link" onClick={actions.setEditSeal}>
|
||||
<div className="control">
|
||||
<SettingOutlined />
|
||||
@ -238,6 +264,31 @@ export function AccountAccess() {
|
||||
</div>
|
||||
</LoginModal>
|
||||
</Modal>
|
||||
<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.mfaSteps}</div>
|
||||
<img src={state.mfaImage} alt="QRCode" />
|
||||
<div className="secret">
|
||||
<div className="label">{ state.mfaSecret }</div>
|
||||
<CopyButton onCopy={async () => await navigator.clipboard.writeText(state.mfaSecret)} />
|
||||
</div>
|
||||
<Input.OTP onChange={actions.setCode} />
|
||||
<div className="alert">
|
||||
{ state.mfaError && state.mfaErrorCode === '401' && (
|
||||
<span>{state.strings.mfaError}</span>
|
||||
)}
|
||||
{ state.mfaError && state.mfaErrorCode === '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={actions.confirmMFA}
|
||||
disabled={!state.mfaCode} loading={state.busy}>{state.strings.mfaConfirm}</Button>
|
||||
</div>
|
||||
</MFAModal>
|
||||
</Modal>
|
||||
</AccountAccessWrapper>
|
||||
);
|
||||
}
|
||||
|
@ -182,6 +182,75 @@ export const SealModal = 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const LoginModal = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -40,6 +40,14 @@ export function useAccountAccess() {
|
||||
videoId: null,
|
||||
videoInputs: [],
|
||||
|
||||
mfaModal: false,
|
||||
mfaEnabled: null,
|
||||
mfaSecret: null,
|
||||
mfaImage: null,
|
||||
mfaCode: null,
|
||||
mfaError: false,
|
||||
mfaErrorCode: null,
|
||||
|
||||
seal: null,
|
||||
sealKey: null,
|
||||
});
|
||||
@ -60,7 +68,7 @@ export function useAccountAccess() {
|
||||
|
||||
useEffect(() => {
|
||||
const { seal, sealKey, status } = account.state;
|
||||
updateState({ searchable: status?.searchable, seal, sealKey });
|
||||
updateState({ searchable: status?.searchable, mfaEnabled: status?.mfaEnabled, seal, sealKey });
|
||||
}, [account.state]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -307,6 +315,54 @@ export function useAccountAccess() {
|
||||
}
|
||||
}
|
||||
},
|
||||
setCode: async (code) => {
|
||||
updateState({ mfaCode: code });
|
||||
},
|
||||
enableMFA: async () => {
|
||||
if (!state.busy) {
|
||||
try {
|
||||
updateState({ busy: true, mfaSecret: null, mfaImage: null, mfaCode: '' });
|
||||
const mfa = await account.actions.enableMFA();
|
||||
updateState({ busy: false, mfaModal: true, mfaError: false, mfaSecret: mfa.secretText, mfaImage: mfa.secretImage, mfaCode: '' });
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
updateState({ busy: false });
|
||||
throw new Error('faild to enable mfa');
|
||||
}
|
||||
}
|
||||
},
|
||||
disableMFA: async () => {
|
||||
if (!state.busy) {
|
||||
try {
|
||||
updateState({ busy: true });
|
||||
await account.actions.disableMFA();
|
||||
updateState({ busy: false });
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
updateState({ busy: false });
|
||||
throw new Error('failed to disable mfa');
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmMFA: async () => {
|
||||
if (!state.busy) {
|
||||
try {
|
||||
updateState({ busy: true });
|
||||
await account.actions.confirmMFA(state.mfaCode);
|
||||
updateState({ busy: false, mfaModal: false });
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err?.message;
|
||||
updateState({ busy: false, mfaError: true, mfaErrorCode: msg });
|
||||
throw new Error('failed to confirm mfa');
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissMFA: async () => {
|
||||
updateState({ mfaModal: false });
|
||||
},
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user