2FA totp, bugfix

This commit is contained in:
2024-04-07 14:23:59 +02:00
parent e97ac34365
commit 0974ac9260
21 changed files with 262 additions and 106 deletions

View File

@@ -1,7 +1,7 @@
import React, {lazy, Suspense, useCallback, useState} from "react";
import {BrowserRouter, Route, Routes} from "react-router-dom";
import Dialog from "shared/elements/dialog";
import Sidebar from "./elements/sidebar";
import Dialog from "./elements/dialog";
import Footer from "./elements/footer";
import {useContext, useEffect} from "react";
import {LocaleContext} from "shared/locale";

View File

@@ -1,47 +0,0 @@
import React from "react";
import clsx from "clsx";
export default function Dialog(props) {
const show = props.show;
const classes = ["modal", "fade"];
const style = { paddingRight: "12px", display: (show ? "block" : "none") };
const onClose = props.onClose || function() { };
const onOption = props.onOption || function() { };
const options = props.options || ["Close"];
let buttons = [];
for (let name of options) {
let type = "default";
if (name === "Yes") type = "warning";
else if(name === "No") type = "danger";
buttons.push(
<button type="button" key={"button-" + name} className={"btn btn-" + type}
data-dismiss={"modal"} onClick={() => { onClose(); onOption(name); }}>
{name}
</button>
)
}
return (
<div className={clsx(classes, show && "show")} style={style} aria-modal={"true"} onClick={() => onClose()}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-content">
<div className="modal-header">
<h4 className="modal-title">{props.title}</h4>
<button type="button" className="close" data-dismiss="modal" aria-label="Close" onClick={() => onClose()}>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="modal-body">
<p>{props.message}</p>
</div>
<div className="modal-footer">
{ buttons }
</div>
</div>
</div>
</div>
);
}

View File

@@ -169,8 +169,7 @@ export default function AccessControlList(props) {
{ type: "label", value: L("permissions.description") + ":" },
{ type: "text", name: "description", value: permission.description, maxLength: 128 }
],
onOption: (option, inputData) => option === 0 && onUpdatePermission(inputData, permission.groups)
})} >
onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, permission.groups) : true })} >
<Edit />
</IconButton>
<IconButton style={{padding: 0}} size={"small"} color={"secondary"}
@@ -179,7 +178,7 @@ export default function AccessControlList(props) {
open: true,
title: L("permissions.delete_permission_confirm"),
message: L("permissions.method") + ": " + permission.method,
onOption: (option) => option === 0 && onDeletePermission(permission.method)
onOption: (option) => option === 0 ? onDeletePermission(permission.method) : true
})} >
<Delete />
</IconButton>
@@ -253,7 +252,7 @@ export default function AccessControlList(props) {
{ type: "label", value: L("permissions.description") + ":" },
{ type: "text", name: "description", maxLength: 128, placeholder: L("permissions.description") }
],
onOption: (option, inputData) => option === 0 && onUpdatePermission(inputData, [])
onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, []) : true
})} >
{L("general.add")}
</Button>

View File

@@ -3,11 +3,12 @@ import {Link, useNavigate, useParams} from "react-router-dom";
import {LocaleContext} from "shared/locale";
import SearchField from "shared/elements/search-field";
import React from "react";
import {ControlsColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import {sprintf} from "sprintf-js";
import {DataTable, ControlsColumn, NumericColumn, StringColumn} from "shared/elements/data-table";
import EditIcon from "@mui/icons-material/Edit";
import usePagination from "shared/hooks/pagination";
import Dialog from "shared/elements/dialog";
import {FormControl, FormGroup, FormLabel, styled, TextField, Button, CircularProgress} from "@mui/material";
import {FormControl, FormGroup, FormLabel, TextField, Button, CircularProgress, Box} from "@mui/material";
import {Add, Delete, KeyboardArrowLeft, Save} from "@mui/icons-material";
import {MuiColorInput} from "mui-color-input";
import ButtonBar from "../../elements/button-bar";
@@ -27,6 +28,7 @@ export default function EditGroupView(props) {
const isNewGroup = groupId === "new";
const pagination = usePagination();
const api = props.api;
const showDialog = props.showDialog;
// data
const [fetchGroup, setFetchGroup] = useState(!isNewGroup);
@@ -41,7 +43,7 @@ export default function EditGroupView(props) {
useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
if (!data.success) {
props.showDialog(data.msg, "Error fetching localization");
showDialog(data.msg, "Error fetching localization");
}
});
}, [currentLocale]);
@@ -51,7 +53,7 @@ export default function EditGroupView(props) {
setFetchGroup(false);
api.getGroup(groupId).then(res => {
if (!res.success) {
props.showDialog(res.msg, "Error fetching group");
showDialog(res.msg, "Error fetching group");
navigate("/admin/groups");
} else {
setGroup(res.group);
@@ -66,11 +68,11 @@ export default function EditGroupView(props) {
setMembers(res.users);
pagination.update(res.pagination);
} else {
props.showDialog(res.msg, L("account.fetch_group_members_error"));
showDialog(res.msg, L("account.fetch_group_members_error"));
return null;
}
});
}, [groupId, api, pagination]);
}, [api, showDialog, pagination, groupId]);
const onRemoveMember = useCallback(userId => {
api.removeGroupMember(groupId, userId).then(data => {
@@ -78,16 +80,16 @@ export default function EditGroupView(props) {
let newMembers = members.filter(u => u.id !== userId);
setMembers(newMembers);
} else {
props.showDialog(data.msg, L("account.remove_group_member_error"));
showDialog(data.msg, L("account.remove_group_member_error"));
}
});
}, [api, groupId, members]);
}, [api, showDialog, groupId, members]);
const onAddMember = useCallback(() => {
if (selectedUser) {
api.addGroupMember(groupId, selectedUser.id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("account.add_group_member_error"));
showDialog(data.msg, L("account.add_group_member_error"));
} else {
let newMembers = [...members];
newMembers.push(selectedUser);
@@ -96,7 +98,7 @@ export default function EditGroupView(props) {
setSelectedUser(null);
});
}
}, [api, groupId, selectedUser])
}, [api, showDialog, groupId, selectedUser, members])
const onSave = useCallback(() => {
setSaving(true);
@@ -104,7 +106,7 @@ export default function EditGroupView(props) {
api.createGroup(group.name, group.color).then(data => {
setSaving(false);
if (!data.success) {
props.showDialog(data.msg, L("account.create_group_error"));
showDialog(data.msg, L("account.create_group_error"));
} else {
navigate(`/admin/group/${data.id}`)
}
@@ -113,31 +115,31 @@ export default function EditGroupView(props) {
api.updateGroup(groupId, group.name, group.color).then(data => {
setSaving(false);
if (!data.success) {
props.showDialog(data.msg, L("account.update_group_error"));
showDialog(data.msg, L("account.update_group_error"));
}
});
}
}, [api, groupId, isNewGroup, group]);
}, [api, showDialog, groupId, isNewGroup, group]);
const onSearchUser = useCallback((async (query) => {
let data = await api.searchUser(query);
if (!data.success) {
props.showDialog(data.msg, L("account.search_users_error"));
showDialog(data.msg, L("account.search_users_error"));
return [];
}
return data.users;
}), [api]);
}), [api, showDialog]);
const onDeleteGroup = useCallback(() => {
api.deleteGroup(groupId).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("account.delete_group_error"));
showDialog(data.msg, L("account.delete_group_error"));
} else {
navigate("/admin/groups");
}
});
}, [api, groupId]);
}, [api, showDialog, groupId]);
const onOpenMemberDialog = useCallback(() => {
setDialogData({
@@ -146,30 +148,24 @@ export default function EditGroupView(props) {
message: L("account.add_group_member_text"),
inputs: [
{
type: "custom", name: "search", element: SearchField,
type: "custom", name: "search",
size: "small", key: "search",
element: SearchField,
onSearch: v => onSearchUser(v),
onSelect: u => setSelectedUser(u),
displayText: u => u.fullName || u.name
getOptionLabel: u => u.fullName || u.name
}
],
onOption: (option) => option === 0 ? onAddMember() : setSelectedUser(null)
onOption: (option) => option === 0 ?
onAddMember() :
setSelectedUser(null)
});
}, []);
}, [onAddMember, onSearchUser, setSelectedUser, setDialogData]);
useEffect(() => {
onFetchGroup();
}, []);
const complementaryColor = (color) => {
if (color.startsWith("#")) {
color = color.substring(1);
}
let numericValue = parseInt(color, 16);
return "#" + (0xFFFFFF - numericValue).toString(16);
}
if (group === null) {
return <CircularProgress />
}
@@ -243,7 +239,7 @@ export default function EditGroupView(props) {
open: true,
title: L("account.delete_group_title"),
message: L("account.delete_group_text"),
onOption: option => option === 0 && onDeleteGroup()
onOption: option => option === 0 ? onDeleteGroup() : true
})}>
{L("general.delete")}
</Button>
@@ -252,7 +248,7 @@ export default function EditGroupView(props) {
</div>
</div>
{!isNewGroup && api.hasPermission("groups/getMembers") ?
<div className={"m-3 col-6"}>
<Box m={3} className={"col-6"}>
<h4>{L("account.members")}</h4>
<DataTable
data={members}
@@ -281,7 +277,7 @@ export default function EditGroupView(props) {
open: true,
title: L("account.remove_group_member_title"),
message: sprintf(L("account.remove_group_member_text"), entry.fullName || entry.name),
onOption: (option) => option === 0 && onRemoveMember(entry.id)
onOption: (option) => option === 0 ? onRemoveMember(entry.id) : true
})
}
]),
@@ -295,7 +291,7 @@ export default function EditGroupView(props) {
onClick: onOpenMemberDialog
}]}
/>
</div>
</Box>
: <></>
}
</div>

View File

@@ -0,0 +1,26 @@
import {Box, Paper} from "@mui/material";
import {LocaleContext} from "shared/locale";
import {useCallback, useContext} from "react";
export default function MfaFido(props) {
const {api, showDialog, setDialogData, ...other} = props;
const {translate: L} = useContext(LocaleContext);
const openDialog = useCallback(() => {
if (api.hasPermission("tfa/registerKey")) {
}
}, [api, showDialog]);
const disabledStyle = {
background: "gray",
cursor: "not-allowed"
}
return <Box component={Paper} onClick={openDialog}
style={!api.hasPermission("tfa/registerKey") ? disabledStyle : {}}>
<div><img src={"/img/icons/nitrokey.png"} alt={"[Nitro Key]"} /></div>
<div>{L("account.2fa_type_fido")}</div>
</Box>;
}

View File

@@ -0,0 +1,55 @@
import {Box, Paper} from "@mui/material";
import {useCallback, useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function MfaTotp(props) {
const {setDialogData, api, showDialog, ...other} = props;
const {translate: L} = useContext(LocaleContext);
const onConfirmTOTP = useCallback((code) => {
api.confirmTOTP(code).then(data => {
if (!data.success) {
showDialog(data.msg, L("account.confirm_totp_error"));
} else {
setDialogData({show: false});
showDialog(L("account.confirm_totp_success"), L("general.success"));
}
});
return false;
}, [api, showDialog]);
const openDialog = useCallback(() => {
if (api.hasPermission("tfa/generateQR")) {
setDialogData({
show: true,
title: L("Register a 2FA-Device"),
message: L("Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " +
"On Android, you can use the Google Authenticator."),
inputs: [
{
type: "custom", element: Box, textAlign: "center", children:
<img src={"/api/tfa/generateQR?nocache=" + Math.random()} alt={"[QR-Code]"}/>
},
{
type: "number", placeholder: L("account.6_digit_code"),
inputProps: { maxLength: 6 }, name: "code",
sx: { "& input": { textAlign: "center", fontFamily: "monospace" } },
}
],
onOption: (option, data) => option === 0 ? onConfirmTOTP(data.code) : true
})
}
}, [api, onConfirmTOTP]);
const disabledStyle = {
background: "gray",
cursor: "not-allowed"
}
return <Box component={Paper} onClick={openDialog}
style={!api.hasPermission("tfa/generateQR") ? disabledStyle : {}}>
<div><img src={"/img/icons/google_authenticator.svg"} alt={"[Google Authenticator]"} /></div>
<div>{L("account.2fa_type_totp")}</div>
</Box>
}

View File

@@ -7,7 +7,7 @@ import {
CircularProgress,
FormControl,
FormGroup,
FormLabel, styled,
FormLabel, Paper, styled,
TextField
} from "@mui/material";
import {
@@ -23,6 +23,9 @@ import {
} from "@mui/icons-material";
import CollapseBox from "./collapse-box";
import ButtonBar from "../../elements/button-bar";
import MfaTotp from "./mfa-totp";
import MfaFido from "./mfa-fido";
import Dialog from "shared/elements/dialog";
const GpgKeyField = styled(TextField)((props) => ({
"& > div": {
@@ -46,6 +49,29 @@ const ProfileFormGroup = styled(FormGroup)((props) => ({
marginBottom: props.theme.spacing(2)
}));
const MFAOptions = styled(Box)((props) => ({
"& > div": {
borderColor: props.theme.palette.divider,
borderStyle: "solid",
borderWidth: 1,
borderRadius: 5,
maxWidth: 150,
cursor: "pointer",
textAlign: "center",
display: "inline-grid",
gridTemplateRows: "130px 50px",
alignItems: "center",
padding: props.theme.spacing(1),
marginRight: props.theme.spacing(1),
"&:hover": {
backgroundColor: "lightgray",
},
"& img": {
width: "100%",
},
}
}));
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
@@ -78,12 +104,15 @@ export default function ProfileView(props) {
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
const [gpgKey, setGpgKey] = useState("");
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
const [mfaPassword, set2FAPassword] = useState("");
// ui
const [openedTab, setOpenedTab] = useState(null);
const [isSaving, setSaving] = useState(false);
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
const [is2FARemoving, set2FARemoving] = useState(false);
const [dialogData, setDialogData] = useState({show: false});
const onUpdateProfile = useCallback(() => {
@@ -147,7 +176,22 @@ export default function ProfileView(props) {
}
});
}
}, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword]);
}, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword, profile]);
const onRemove2FA = useCallback(() => {
if (!is2FARemoving) {
set2FARemoving(true);
api.remove2FA(mfaPassword).then(data => {
set2FARemoving(false);
set2FAPassword("");
if (!data.success) {
showDialog(data.msg, L("account.remove_2fa_error"));
} else {
setProfile({...profile, twoFactorToken: null});
}
});
}
}, [api, showDialog, is2FARemoving, mfaPassword, profile]);
const getFileContents = useCallback((file, callback) => {
let reader = new FileReader();
@@ -167,6 +211,8 @@ export default function ProfileView(props) {
reader.readAsText(file);
}, [showDialog]);
console.log("SELECTED USER:", profile.twoFactorToken);
return <>
<div className={"content-header"}>
<div className={"container-fluid"}>
@@ -315,7 +361,37 @@ export default function ProfileView(props) {
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
icon={<Fingerprint />}>
<b>test</b>
{profile.twoFactorToken && profile.twoFactorToken.confirmed ?
<Box>
<GpgFingerprintBox mb={2}>
{ profile.twoFactorToken.confirmed ?
<CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
<ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")} />
}
{L("account.2fa_type_" + profile.twoFactorToken.type)}
</GpgFingerprintBox>
<ProfileFormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"} size={"small"}
value={mfaPassword} type={"password"}
onChange={e => set2FAPassword(e.target.value)}
placeholder={L("account.password")}
/>
</FormControl>
</ProfileFormGroup>
<Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
color={"secondary"} onClick={onRemove2FA}
variant={"outlined"} size={"small"}
disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
{is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
</Button>
</Box> :
<MFAOptions>
<MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}/>
<MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}/>
</MFAOptions>
}
</CollapseBox>
<Box mt={2}>
@@ -327,5 +403,13 @@ export default function ProfileView(props) {
</Button>
</Box>
</div>
<Dialog show={dialogData.show}
title={dialogData.title}
message={dialogData.message}
inputs={dialogData.inputs}
onClose={() => setDialogData({show: false})}
options={[L("general.ok"), L("general.cancel")]}
onOption={dialogData.onOption} />
</>
}

View File

@@ -201,7 +201,7 @@ export default function RouteListView(props) {
{ type: "text", name: "pattern", value: route.pattern, disabled: true}
],
options: [L("general.ok"), L("general.cancel")],
onOption: btn => btn === 0 && onDeleteRoute(route.id)
onOption: btn => btn === 0 ? onDeleteRoute(route.id) : true
})}>
<Delete />
</IconButton>