diff --git a/Core/API/PermissionAPI.class.php b/Core/API/PermissionAPI.class.php
index 995312b..04856a3 100644
--- a/Core/API/PermissionAPI.class.php
+++ b/Core/API/PermissionAPI.class.php
@@ -76,13 +76,9 @@ namespace Core\API\Permission {
}
// user would have required groups, check for 2fa-state
- if ($currentUser) {
- $tfaToken = $currentUser->getTwoFactorToken();
- if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
- $this->lastError = '2FA-Authorization is required';
- http_response_code(401);
- return false;
- }
+ if ($currentUser && !$this->check2FA()) {
+ http_response_code(401);
+ return false;
}
}
diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php
index 894e1d3..ceafd2a 100644
--- a/Core/API/Request.class.php
+++ b/Core/API/Request.class.php
@@ -5,6 +5,8 @@ namespace Core\API;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context;
+use Core\Objects\DatabaseEntity\TwoFactorToken;
+use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use PhpMqtt\Client\MqttClient;
abstract class Request {
@@ -126,6 +128,33 @@ abstract class Request {
protected abstract function _execute(): bool;
public static function getDefaultACL(Insert $insert): void { }
+ protected function check2FA(?TwoFactorToken $tfaToken = null): bool {
+
+ // do not require 2FA for verifying endpoints
+ if ($this instanceof \Core\API\Tfa\VerifyTotp || $this instanceof \Core\API\Tfa\VerifyKey) {
+ return true;
+ }
+
+ if ($tfaToken === null) {
+ $tfaToken = $this->context->getUser()?->getTwoFactorToken();
+ }
+
+ if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
+
+ if ($tfaToken instanceof KeyBasedTwoFactorToken && !$tfaToken->hasChallenge()) {
+ $tfaToken->generateChallenge();
+ }
+
+ $this->lastError = '2FA-Authorization is required';
+ $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
+ "type", "challenge", "authenticated", "confirmed", "credentialID"
+ ]);
+ return false;
+ }
+
+ return true;
+ }
+
public final function execute($values = array()): bool {
$this->params = array_merge([], $this->defaultParams);
@@ -196,15 +225,9 @@ abstract class Request {
$this->lastError = 'You are not logged in.';
http_response_code(401);
return false;
- } else if ($session) {
- $tfaToken = $session->getUser()->getTwoFactorToken();
- if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
- if (!($this instanceof \Core\API\Tfa\VerifyTotp) && !($this instanceof \Core\API\Tfa\VerifyKey)) {
- $this->lastError = '2FA-Authorization is required';
- http_response_code(401);
- return false;
- }
- }
+ } else if ($session && !$this->check2FA()) {
+ http_response_code(401);
+ return false;
}
}
diff --git a/Core/API/Stats.class.php b/Core/API/Stats.class.php
index c4993df..ade02dd 100644
--- a/Core/API/Stats.class.php
+++ b/Core/API/Stats.class.php
@@ -6,6 +6,8 @@ use Core\Driver\SQL\Expression\Count;
use Core\Driver\SQL\Expression\Distinct;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\DatabaseEntity\Group;
+use Core\Objects\DatabaseEntity\Route;
+use Core\Objects\DatabaseEntity\User;
use DateTime;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondBool;
@@ -20,26 +22,6 @@ class Stats extends Request {
parent::__construct($context, $externalCall, array());
}
- private function getUserCount(): int {
- $sql = $this->context->getSQL();
- $res = $sql->select(new Count())->from("User")->execute();
- $this->success = $this->success && ($res !== FALSE);
- $this->lastError = $sql->getLastError();
-
- return ($this->success ? intval($res[0]["count"]) : 0);
- }
-
- private function getPageCount(): int {
- $sql = $this->context->getSQL();
- $res = $sql->select(new Count())->from("Route")
- ->where(new CondBool("active"))
- ->execute();
- $this->success = $this->success && ($res !== FALSE);
- $this->lastError = $sql->getLastError();
-
- return ($this->success ? intval($res[0]["count"]) : 0);
- }
-
private function checkSettings(): bool {
$req = new \Core\API\Settings\Get($this->context);
$this->success = $req->execute(array("key" => "^(mail_enabled|recaptcha_enabled)$"));
@@ -72,8 +54,9 @@ class Stats extends Request {
}
public function _execute(): bool {
- $userCount = $this->getUserCount();
- $pageCount = $this->getPageCount();
+ $sql = $this->context->getSQL();
+ $userCount = User::count($sql);
+ $pageCount = Route::count($sql, new CondBool("active"));
$req = new \Core\API\Visitors\Stats($this->context);
$this->success = $req->execute(array("type"=>"monthly"));
$this->lastError = $req->getLastError();
diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php
index c297284..76d83b6 100644
--- a/Core/API/TfaAPI.class.php
+++ b/Core/API/TfaAPI.class.php
@@ -189,6 +189,11 @@ namespace Core\API\TFA {
$sql = $this->context->getSQL();
$this->success = $twoFactorToken->confirm($sql) !== false;
$this->lastError = $sql->getLastError();
+
+ if ($this->success) {
+ $this->context->invalidateSessions(true);
+ }
+
return $this->success;
}
}
@@ -315,6 +320,7 @@ namespace Core\API\TFA {
if ($this->success) {
$this->result["twoFactorToken"] = $twoFactorToken->jsonSerialize();
+ $this->context->invalidateSessions();
}
}
diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php
index c2db253..aa95faf 100644
--- a/Core/API/UserAPI.class.php
+++ b/Core/API/UserAPI.class.php
@@ -222,7 +222,8 @@ namespace Core\API\User {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall,
- self::getPaginationParameters(['id', 'name', 'email', 'groups', 'registeredAt'])
+ self::getPaginationParameters(['id', 'name', 'email', 'groups', 'registeredAt'],
+ 'id', 'asc')
);
}
@@ -253,8 +254,8 @@ namespace Core\API\User {
$groupNames = new Alias(
$sql->select(new JsonArrayAgg("name"))->from("Group")
- ->leftJoin("NM_Group_User", "NM_Group_User.group_id", "Group.id")
- ->whereEq("NM_Group_User.user_id", new Column("User.id")),
+ ->leftJoin("NM_User_groups", "NM_User_groups.group_id", "Group.id")
+ ->whereEq("NM_User_groups.user_id", new Column("User.id")),
"groups"
);
@@ -588,15 +589,7 @@ namespace Core\API\User {
$this->result["user"] = $user->jsonSerialize();
$this->result["session"] = $session->jsonSerialize();
$this->result["logoutIn"] = $session->getExpiresSeconds();
- if ($tfaToken && $tfaToken->isConfirmed()) {
- if ($tfaToken instanceof KeyBasedTwoFactorToken) {
- $tfaToken->generateChallenge();
- }
-
- $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
- "type", "challenge", "authenticated", "confirmed", "credentialID"
- ]);
- }
+ $this->check2FA($tfaToken);
$this->success = true;
}
} else {
@@ -1116,6 +1109,7 @@ namespace Core\API\User {
if ($user->save($sql)) {
$this->logger->info("Issued password reset for user id=" . $user->getId());
$userToken->invalidate($sql);
+ $this->context->invalidateSessions(false);
return true;
} else {
return $this->createError("Error updating user details: " . $sql->getLastError());
@@ -1152,6 +1146,7 @@ namespace Core\API\User {
}
$sql = $this->context->getSQL();
+ $updateFields = [];
$currentUser = $this->context->getUser();
if ($newUsername !== null) {
@@ -1159,11 +1154,13 @@ namespace Core\API\User {
return false;
} else {
$currentUser->name = $newUsername;
+ $updateFields[] = "name";
}
}
if ($newFullName !== null) {
$currentUser->fullName = $newFullName;
+ $updateFields[] = "fullName";
}
if ($newPassword !== null || $newPasswordConfirm !== null) {
@@ -1175,11 +1172,17 @@ namespace Core\API\User {
}
$currentUser->password = $this->hashPassword($newPassword);
+ $updateFields[] = "password";
}
}
- $this->success = $currentUser->save($sql) !== false;
- $this->lastError = $sql->getLastError();
+ if (!empty($updateFields)) {
+ $this->success = $currentUser->save($sql, $updateFields) !== false;
+ $this->lastError = $sql->getLastError();
+ if ($this->success && in_array("password", $updateFields)) {
+ $this->context->invalidateSessions(true);
+ }
+ }
return $this->success;
}
}
diff --git a/Core/Configuration/CreateDatabase.class.php b/Core/Configuration/CreateDatabase.class.php
index 63c6203..766a027 100644
--- a/Core/Configuration/CreateDatabase.class.php
+++ b/Core/Configuration/CreateDatabase.class.php
@@ -128,7 +128,6 @@ class CreateDatabase extends DatabaseScript {
$method = $reflectionClass->getName() . "::getDefaultACL";
$method($query);
}
-
if ($query->hasRows()) {
$queries[] = $query;
}
diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php
index 89d4dab..6b15685 100644
--- a/Core/Configuration/Settings.class.php
+++ b/Core/Configuration/Settings.class.php
@@ -107,7 +107,10 @@ class Settings {
public static function loadDefaults(): Settings {
$hostname = $_SERVER["SERVER_NAME"] ?? null;
if (empty($hostname)) {
- $hostname = "localhost";
+ $hostname = $_SERVER["HTTP_HOST"];
+ if (empty($hostname)) {
+ $hostname = "localhost";
+ }
}
$protocol = getProtocol();
diff --git a/Core/Driver/SQL/Expression/JsonObjectAgg.class.php b/Core/Driver/SQL/Expression/JsonObjectAgg.class.php
new file mode 100644
index 0000000..de6afd3
--- /dev/null
+++ b/Core/Driver/SQL/Expression/JsonObjectAgg.class.php
@@ -0,0 +1,34 @@
+key = $key;
+ $this->value = $value;
+ }
+
+ public function getExpression(SQL $sql, array &$params): string {
+ $value = is_string($this->value) ? new Column($this->value) : $this->value;
+ $value = $sql->addValue($value, $params);
+ $key = is_string($this->key) ? new Column($this->key) : $this->key;
+ $key = $sql->addValue($key, $params);
+ if ($sql instanceof MySQL) {
+ return "JSON_OBJECTAGG($key, $value)";
+ } else if ($sql instanceof PostgreSQL) {
+ return "JSON_OBJECT_AGG($value)";
+ } else {
+ throw new Exception("JsonObjectAgg not implemented for driver type: " . get_class($sql));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php
index cf159d9..5348c4e 100644
--- a/Core/Objects/Context.class.php
+++ b/Core/Objects/Context.class.php
@@ -211,4 +211,16 @@ class Context {
public function getLanguage(): Language {
return $this->language;
}
+
+ public function invalidateSessions(bool $keepCurrent = true): bool {
+ $query = $this->sql->update("Session")
+ ->set("active", false)
+ ->whereEq("user_id", $this->user->getId());
+
+ if (!$keepCurrent && $this->session !== null) {
+ $query->whereNeq("id", $this->session->getId());
+ }
+
+ return $query->execute();
+ }
}
\ No newline at end of file
diff --git a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
index b7a4b70..a2f7390 100644
--- a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
+++ b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
@@ -36,6 +36,10 @@ class KeyBasedTwoFactorToken extends TwoFactorToken {
return $token;
}
+ public function hasChallenge(): bool {
+ return isset($this->challenge);
+ }
+
public function getChallenge(): string {
return $this->challenge;
}
diff --git a/react/admin-panel/src/views/user-list.js b/react/admin-panel/src/views/user-list.js
index e1a2beb..49ce52a 100644
--- a/react/admin-panel/src/views/user-list.js
+++ b/react/admin-panel/src/views/user-list.js
@@ -1,17 +1,22 @@
import {Link, Navigate, useNavigate} from "react-router-dom";
-import {useCallback, useContext, useEffect} from "react";
+import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {DataColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
import {Button, IconButton} from "@material-ui/core";
import EditIcon from '@mui/icons-material/Edit';
import {Chip} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
+import usePagination from "shared/hooks/pagination";
export default function UserListView(props) {
+ const api = props.api;
+ const showDialog = props.showDialog;
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
const navigate = useNavigate();
+ const pagination = usePagination();
+ const [users, setUsers] = useState([]);
useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
@@ -21,15 +26,17 @@ export default function UserListView(props) {
});
}, [currentLocale]);
- const onFetchUsers = useCallback(async (page, count, orderBy, sortOrder) => {
- let res = await props.api.fetchUsers(page, count, orderBy, sortOrder);
- if (res.success) {
- return Promise.resolve([res.users, res.pagination]);
- } else {
- props.showDialog(res.msg, "Error fetching users");
- return null;
- }
- }, []);
+ const onFetchUsers = useCallback((page, count, orderBy, sortOrder) => {
+ api.fetchUsers(page, count, orderBy, sortOrder).then((res) => {
+ if (res.success) {
+ setUsers(res.users);
+ pagination.update(res.pagination);
+ } else {
+ showDialog(res.msg, "Error fetching users");
+ return null;
+ }
+ });
+ }, [api, showDialog]);
const groupColumn = (() => {
let column = new DataColumn(L("account.groups"), "groups");
@@ -80,9 +87,13 @@ export default function UserListView(props) {
{L("general.create_new")}
-
+
diff --git a/react/shared/api.js b/react/shared/api.js
index 7f00c71..c95ffb5 100644
--- a/react/shared/api.js
+++ b/react/shared/api.js
@@ -49,6 +49,7 @@ export default class API {
let res = await response.json();
if (!res.success && res.msg === "You are not logged in.") {
this.loggedIn = false;
+ this.user = null;
}
return res;
diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js
index 5c51e40..8c96cca 100644
--- a/react/shared/elements/data-table.js
+++ b/react/shared/elements/data-table.js
@@ -5,7 +5,7 @@ import React, {useCallback, useContext, useEffect, useState} from "react";
import "./data-table.css";
import {LocaleContext} from "../locale";
import clsx from "clsx";
-import {Box, IconButton, TextField} from "@mui/material";
+import {Box, IconButton, Select, TextField} from "@mui/material";
import {formatDate, formatDateTime} from "../util";
import CachedIcon from "@material-ui/icons/Cached";
@@ -195,7 +195,7 @@ export class NumericColumn extends DataColumn {
}
renderData(L, entry, index) {
- let number = super.renderData(L, entry).toString();
+ let number = super.renderData(L, entry, index).toString();
if (this.decimalDigits !== null) {
number = number.toFixed(this.decimalDigits);
@@ -223,8 +223,8 @@ export class DateTimeColumn extends DataColumn {
}
renderData(L, entry, index) {
- let date = super.renderData(L, entry);
- return formatDateTime(L, date, this.precise);
+ let date = super.renderData(L, entry, index);
+ return date ? formatDateTime(L, date, this.precise) : "";
}
}
@@ -234,8 +234,8 @@ export class DateColumn extends DataColumn {
}
renderData(L, entry, index) {
- let date = super.renderData(L, entry);
- return formatDate(L, date);
+ let date = super.renderData(L, entry, index);
+ return date ? formatDate(L, date) : "";
}
}
@@ -245,7 +245,7 @@ export class BoolColumn extends DataColumn {
}
renderData(L, entry, index) {
- let data = super.renderData(L, entry);
+ let data = super.renderData(L, entry, index);
return L(data ? "general.yes" : "general.no");
}
}
@@ -260,9 +260,17 @@ export class InputColumn extends DataColumn {
renderData(L, entry, index) {
let value = super.renderData(L, entry, index);
+ let inputProps = typeof this.props === 'function' ? this.props(entry, index) : this.props;
if (this.type === 'text') {
- return this.onChange(entry, index, e.target.value)} />
+ } else if (this.type === "select") {
+ let options = Object.entries(this.params.options || {}).map(([value, label]) =>
+ );
+ return
}
return <>[Invalid type: {this.type}]>
@@ -292,15 +300,19 @@ export class ControlsColumn extends DataColumn {
let props = {
...buttonProps,
key: "button-" + index,
- onClick: (e) => { e.stopPropagation(); button.onClick(entry, index); },
}
+ // TODO: icon button!
if (button.hasOwnProperty("disabled")) {
props.disabled = typeof button.disabled === 'function'
? button.disabled(entry, index)
: button.disabled;
}
+ if (!props.disabled) {
+ props.onClick = (e) => { e.stopPropagation(); button.onClick(entry, index); }
+ }
+
if ((!button.hasOwnProperty("hidden")) ||
(typeof button.hidden === 'function' && !button.hidden(entry, index)) ||
(!button.hidden)) {
diff --git a/react/shared/elements/dialog.jsx b/react/shared/elements/dialog.jsx
index d20db35..c2783a8 100644
--- a/react/shared/elements/dialog.jsx
+++ b/react/shared/elements/dialog.jsx
@@ -7,7 +7,7 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
- Input, List, ListItem, TextField
+ Input, List, ListItem, Select, TextField
} from "@mui/material";
export default function Dialog(props) {
diff --git a/react/shared/hooks/async-search.js b/react/shared/hooks/async-search.js
index 4eb4d3d..b1584a9 100644
--- a/react/shared/hooks/async-search.js
+++ b/react/shared/hooks/async-search.js
@@ -7,7 +7,6 @@ export default function useAsyncSearch(callback, minLength = 1) {
const [results, setResults] = useState(null);
useEffect(() => {
- console.log("searchString:", searchString);
if (minLength > 0 && (!searchString || searchString.length < minLength)) {
setResults([]);
return;