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;