merging mfa support from main

This commit is contained in:
Roland Osborne 2024-05-19 10:22:21 -07:00
commit 0cbb8c623a
49 changed files with 1696 additions and 347 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

@ -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: 'Подтвердить',
}
];

View File

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

View File

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

View File

@ -249,7 +249,7 @@ export const styles = StyleSheet.create({
},
visibleLabel: {
fontSize: 16,
color: Colors.text,
color: Colors.linkText,
fontFamily: 'roboto',
paddingRight: 8,
},

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

@ -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: 'Подтвердить',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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