diff --git a/Core/API/MailAPI.class.php b/Core/API/MailAPI.class.php
index da3a354..3388a77 100644
--- a/Core/API/MailAPI.class.php
+++ b/Core/API/MailAPI.class.php
@@ -55,7 +55,6 @@ namespace Core\API\Mail {
use Core\External\PHPMailer\PHPMailer;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey;
- use PhpParser\Node\Param;
class Test extends MailAPI {
diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php
index 76d83b6..8e8366e 100644
--- a/Core/API/TfaAPI.class.php
+++ b/Core/API/TfaAPI.class.php
@@ -62,6 +62,7 @@ namespace Core\API\TFA {
use Core\API\Parameter\StringType;
use Core\API\TfaAPI;
use Core\Driver\SQL\Condition\Compare;
+ use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context;
use Core\Objects\TwoFactor\AttestationObject;
use Core\Objects\TwoFactor\AuthenticationData;
@@ -131,6 +132,10 @@ namespace Core\API\TFA {
return $this->success;
}
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow(self::getEndpoint(), [], "Allows users to remove their 2FA-Tokens", true);
+ }
}
// TOTP
@@ -167,11 +172,16 @@ namespace Core\API\TFA {
$this->disableCache();
die($twoFactorToken->generateQRCode($this->context));
}
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow(self::getEndpoint(), [], "Allows users generate a QR-code to add a time-based 2FA-Token", true);
+ }
}
class ConfirmTotp extends VerifyTotp {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall);
+ $this->loginRequired = true;
}
public function _execute(): bool {
@@ -196,6 +206,10 @@ namespace Core\API\TFA {
return $this->success;
}
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow(self::getEndpoint(), [], "Allows users to confirm their time-based 2FA-Token", true);
+ }
}
class VerifyTotp extends TfaAPI {
@@ -211,10 +225,6 @@ namespace Core\API\TFA {
public function _execute(): bool {
$currentUser = $this->context->getUser();
- if (!$currentUser) {
- return $this->createError("You are not logged in.");
- }
-
$twoFactorToken = $currentUser->getTwoFactorToken();
if (!$twoFactorToken) {
return $this->createError("You did not add a two factor token yet.");
@@ -230,6 +240,10 @@ namespace Core\API\TFA {
$twoFactorToken->authenticate();
return $this->success;
}
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow(self::getEndpoint(), [], "Allows users to verify time-based 2FA-Tokens", true);
+ }
}
// Key
@@ -326,6 +340,10 @@ namespace Core\API\TFA {
return $this->success;
}
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow(self::getEndpoint(), [], "Allows users to register a 2FA hardware-key", true);
+ }
}
class VerifyKey extends TfaAPI {
@@ -384,5 +402,9 @@ namespace Core\API\TFA {
return $this->success;
}
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow(self::getEndpoint(), [], "Allows users to verify a 2FA hardware-key", true);
+ }
}
}
\ No newline at end of file
diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php
index b377c25..6af0dcd 100644
--- a/Core/Localization/de_DE/account.php
+++ b/Core/Localization/de_DE/account.php
@@ -90,6 +90,8 @@ return [
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
# 2fa
+ "2fa_type_totp" => "Zeitbasiertes 2FA (TOTP)",
+ "2fa_type_fido" => "Schlüsselbasiertes 2FA",
"register_2fa_device" => "Ein 2FA-Gerät registrieren",
"register_2fa_totp_text" => "Scan den QR-Code mit einem Gerät, das du als Zwei-Faktor-Authentifizierung (2FA) benutzen willst. " .
"Unter Android kannst du den Google Authenticator benutzen.",
diff --git a/Core/Localization/de_DE/general.php b/Core/Localization/de_DE/general.php
index 150e549..c7e307a 100644
--- a/Core/Localization/de_DE/general.php
+++ b/Core/Localization/de_DE/general.php
@@ -35,6 +35,7 @@ return [
"no" => "Nein",
"create_new" => "Erstellen",
"unchanged" => "Unverändert",
+ "click_to_copy" => "Klicken zum Kopieren",
# dialog / actions
"action" => "Aktion",
diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php
index cc49424..19a10dd 100644
--- a/Core/Localization/en_US/account.php
+++ b/Core/Localization/en_US/account.php
@@ -90,6 +90,8 @@ return [
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
# 2fa
+ "2fa_type_totp" => "Time-Based 2FA (TOTP)",
+ "2fa_type_fido" => "Key-Based 2FA",
"register_2fa_device" => "Register a 2FA-Device",
"register_2fa_totp_text" => "Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " .
"On Android, you can use the Google Authenticator.",
diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php
index 3559ddb..aaa8366 100644
--- a/Core/Localization/en_US/general.php
+++ b/Core/Localization/en_US/general.php
@@ -17,6 +17,7 @@ return [
"no" => "No",
"create_new" => "Create",
"unchanged" => "Unchanged",
+ "click_to_copy" => "Click to copy",
# dialog / actions
"action" => "Action",
diff --git a/img/icons/nitrokey.png b/img/icons/nitrokey.png
index e463ffd..5c69624 100644
Binary files a/img/icons/nitrokey.png and b/img/icons/nitrokey.png differ
diff --git a/react/admin-panel/src/AdminDashboard.jsx b/react/admin-panel/src/AdminDashboard.jsx
index 49f5533..fb91d08 100644
--- a/react/admin-panel/src/AdminDashboard.jsx
+++ b/react/admin-panel/src/AdminDashboard.jsx
@@ -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";
diff --git a/react/admin-panel/src/elements/dialog.jsx b/react/admin-panel/src/elements/dialog.jsx
deleted file mode 100644
index 0bc909e..0000000
--- a/react/admin-panel/src/elements/dialog.jsx
+++ /dev/null
@@ -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(
-
- )
- }
-
- return (
-
onClose()}>
-
e.stopPropagation()}>
-
-
-
{props.title}
-
-
-
-
- { buttons }
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/react/admin-panel/src/views/access-control-list.js b/react/admin-panel/src/views/access-control-list.js
index 06eef97..9826c1c 100644
--- a/react/admin-panel/src/views/access-control-list.js
+++ b/react/admin-panel/src/views/access-control-list.js
@@ -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 })} >
option === 0 && onDeletePermission(permission.method)
+ onOption: (option) => option === 0 ? onDeletePermission(permission.method) : true
})} >
@@ -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")}
diff --git a/react/admin-panel/src/views/group/group-edit.js b/react/admin-panel/src/views/group/group-edit.js
index 0327a7e..0d685ee 100644
--- a/react/admin-panel/src/views/group/group-edit.js
+++ b/react/admin-panel/src/views/group/group-edit.js
@@ -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
}
@@ -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")}
@@ -252,7 +248,7 @@ export default function EditGroupView(props) {
{!isNewGroup && api.hasPermission("groups/getMembers") ?
-
+
{L("account.members")}
option === 0 && onRemoveMember(entry.id)
+ onOption: (option) => option === 0 ? onRemoveMember(entry.id) : true
})
}
]),
@@ -295,7 +291,7 @@ export default function EditGroupView(props) {
onClick: onOpenMemberDialog
}]}
/>
-
+
: <>>
}
diff --git a/react/admin-panel/src/views/profile/mfa-fido.js b/react/admin-panel/src/views/profile/mfa-fido.js
new file mode 100644
index 0000000..8267dc6
--- /dev/null
+++ b/react/admin-panel/src/views/profile/mfa-fido.js
@@ -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
+
+ {L("account.2fa_type_fido")}
+ ;
+}
\ No newline at end of file
diff --git a/react/admin-panel/src/views/profile/mfa-totp.js b/react/admin-panel/src/views/profile/mfa-totp.js
new file mode 100644
index 0000000..e8f6848
--- /dev/null
+++ b/react/admin-panel/src/views/profile/mfa-totp.js
@@ -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:
+
+ },
+ {
+ 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
+
+ {L("account.2fa_type_totp")}
+
+}
\ No newline at end of file
diff --git a/react/admin-panel/src/views/profile/profile.js b/react/admin-panel/src/views/profile/profile.js
index 1d44daa..15b588d 100644
--- a/react/admin-panel/src/views/profile/profile.js
+++ b/react/admin-panel/src/views/profile/profile.js
@@ -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 <>
@@ -315,7 +361,37 @@ export default function ProfileView(props) {
setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
icon={}>
- test
+ {profile.twoFactorToken && profile.twoFactorToken.confirmed ?
+
+
+ { profile.twoFactorToken.confirmed ?
+ :
+
+ }
+ {L("account.2fa_type_" + profile.twoFactorToken.type)}
+
+
+ {L("account.password")}
+
+ set2FAPassword(e.target.value)}
+ placeholder={L("account.password")}
+ />
+
+
+ : }
+ color={"secondary"} onClick={onRemove2FA}
+ variant={"outlined"} size={"small"}
+ disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
+ {is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
+
+ :
+
+
+
+
+ }
@@ -327,5 +403,13 @@ export default function ProfileView(props) {
+
+