* * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later * * @package MokoSuiteClient * @subpackage API */ namespace Moko\Component\MokoSuiteClient\Api\Controller; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Plugin\PluginHelper; use Joomla\CMS\User\UserHelper; use Joomla\Registry\Registry; /** * Remote user management API controller. * * All endpoints require the MokoSuiteClient health API token via * Authorization: Bearer header. Actions are performed on behalf * of MokoSuiteClientHQ for security incident response and maintenance. * * @since 02.35.00 */ class UsersController extends BaseController { /** * Route to the appropriate action based on task. */ public function execute($task = 'export'): void { $app = Factory::getApplication(); $method = $app->input->getMethod(); // Authenticate via health API token if (!$this->authenticateToken()) { $this->sendJson(401, ['error' => 'Invalid or missing token']); return; } // Get the master usernames to protect them $this->masterUsernames = $this->getMasterUsernames(); match ($task) { 'resetPasswords' => $this->resetPasswords(), 'reset2fa' => $this->reset2fa(), 'disableAll' => $this->disableAll(), 'enableAll' => $this->enableAll(), 'forceLogout' => $this->forceLogout(), 'export' => $this->exportUsers(), default => $this->sendJson(400, ['error' => 'Unknown action: ' . $task]), }; } /** @var array Master usernames that should be protected from mass actions */ private array $masterUsernames = []; /** * Reset all user passwords and force change on next login. * Excludes master user accounts. * * POST /api/index.php/v1/mokosuiteclient/users/reset-passwords */ private function resetPasswords(): void { $db = Factory::getDbo(); $now = Factory::getDate()->toSql(); $count = 0; $users = $this->getNonMasterUsers(); foreach ($users as $user) { // Generate a random password $newPassword = UserHelper::genRandomPassword(16); $hashed = UserHelper::hashPassword($newPassword); $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__users')) ->set($db->quoteName('password') . ' = ' . $db->quote($hashed)) ->set($db->quoteName('requireReset') . ' = 1') ->set($db->quoteName('lastResetTime') . ' = ' . $db->quote($now)) ->where($db->quoteName('id') . ' = ' . (int) $user->id) )->execute(); $count++; } $this->sendJson(200, [ 'status' => 'ok', 'action' => 'reset_passwords', 'count' => $count, 'message' => sprintf('%d user password(s) reset. Users must set a new password on next login.', $count), ]); } /** * Reset/disable 2FA (OTP) for all non-master users. * * POST /api/index.php/v1/mokosuiteclient/users/reset-2fa */ private function reset2fa(): void { $db = Factory::getDbo(); $count = 0; $users = $this->getNonMasterUsers(); $userIds = array_map(fn($u) => (int) $u->id, $users); if (!empty($userIds)) { // Remove OTP config from user profiles $db->setQuery( $db->getQuery(true) ->delete($db->quoteName('#__user_profiles')) ->where($db->quoteName('user_id') . ' IN (' . implode(',', $userIds) . ')') ->where($db->quoteName('profile_key') . ' LIKE ' . $db->quote('joomlatoken.%')) )->execute(); $count += $db->getAffectedRows(); // Also clear any MFA/2FA records try { $db->setQuery( $db->getQuery(true) ->delete($db->quoteName('#__user_mfa')) ->where($db->quoteName('user_id') . ' IN (' . implode(',', $userIds) . ')') )->execute(); $count += $db->getAffectedRows(); } catch (\Throwable $e) { // Table may not exist on older Joomla versions } } $this->sendJson(200, [ 'status' => 'ok', 'action' => 'reset_2fa', 'count' => $count, 'message' => sprintf('2FA/MFA disabled for %d user(s).', \count($userIds)), ]); } /** * Disable (block) all user accounts except master users. * * POST /api/index.php/v1/mokosuiteclient/users/disable-all */ private function disableAll(): void { $db = Factory::getDbo(); $masterIds = $this->getMasterUserIds(); $where = !empty($masterIds) ? $db->quoteName('id') . ' NOT IN (' . implode(',', $masterIds) . ')' : '1=1'; $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__users')) ->set($db->quoteName('block') . ' = 1') ->where($where) ->where($db->quoteName('block') . ' = 0') )->execute(); $count = $db->getAffectedRows(); $this->sendJson(200, [ 'status' => 'ok', 'action' => 'disable_all', 'count' => $count, 'message' => sprintf('%d user account(s) disabled. Master users preserved.', $count), ]); } /** * Re-enable (unblock) all user accounts. * * POST /api/index.php/v1/mokosuiteclient/users/enable-all */ private function enableAll(): void { $db = Factory::getDbo(); $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__users')) ->set($db->quoteName('block') . ' = 0') ->where($db->quoteName('block') . ' = 1') )->execute(); $count = $db->getAffectedRows(); $this->sendJson(200, [ 'status' => 'ok', 'action' => 'enable_all', 'count' => $count, 'message' => sprintf('%d user account(s) re-enabled.', $count), ]); } /** * Force logout all active sessions. * * POST /api/index.php/v1/mokosuiteclient/users/force-logout */ private function forceLogout(): void { $db = Factory::getDbo(); // Clear all sessions except the current API session $db->setQuery( $db->getQuery(true) ->delete($db->quoteName('#__session')) ->where($db->quoteName('client_id') . ' IN (0, 1)') )->execute(); $count = $db->getAffectedRows(); $this->sendJson(200, [ 'status' => 'ok', 'action' => 'force_logout', 'count' => $count, 'message' => sprintf('%d active session(s) terminated.', $count), ]); } /** * Export user list with basic info. * * GET /api/index.php/v1/mokosuiteclient/users/export */ private function exportUsers(): void { $db = Factory::getDbo(); $db->setQuery( $db->getQuery(true) ->select([ $db->quoteName('u.id'), $db->quoteName('u.name'), $db->quoteName('u.username'), $db->quoteName('u.email'), $db->quoteName('u.block'), $db->quoteName('u.lastvisitDate'), $db->quoteName('u.registerDate'), ]) ->from($db->quoteName('#__users', 'u')) ->order($db->quoteName('u.name') . ' ASC') ); $users = $db->loadObjectList() ?: []; // Add group names for each user foreach ($users as $user) { $db->setQuery( $db->getQuery(true) ->select($db->quoteName('g.title')) ->from($db->quoteName('#__usergroups', 'g')) ->join('INNER', $db->quoteName('#__user_usergroup_map', 'm') . ' ON m.group_id = g.id') ->where($db->quoteName('m.user_id') . ' = ' . (int) $user->id) ); $user->groups = $db->loadColumn() ?: []; } $this->sendJson(200, [ 'status' => 'ok', 'action' => 'export', 'count' => \count($users), 'users' => $users, ]); } // ── Helpers ────────────────────────────────────────────────────── /** * Authenticate the request using the health API token. */ private function authenticateToken(): bool { $app = Factory::getApplication(); $auth = $app->input->server->get('HTTP_AUTHORIZATION', '', 'STRING'); $token = ''; if (str_starts_with($auth, 'Bearer ')) { $token = substr($auth, 7); } if (empty($token)) { // Also check JSON body for backwards compatibility $token = $app->input->json->get('token', '', 'RAW'); } if (empty($token)) { return false; } $plugin = PluginHelper::getPlugin('system', 'mokosuiteclient'); if (!$plugin) { return false; } $params = new Registry($plugin->params); $healthToken = $params->get('health_api_token', ''); return !empty($healthToken) && hash_equals($healthToken, $token); } /** * Get master usernames from the MokoSuiteClient plugin config. */ private function getMasterUsernames(): array { $helperFile = JPATH_PLUGINS . '/system/mokosuiteclient/Helper/MokoSuiteClientHelper.php'; if (file_exists($helperFile)) { require_once $helperFile; if (method_exists(\Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper::class, 'getMasterUsernames')) { return \Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper::getMasterUsernames(); } } return []; } /** * Get user IDs of master users. */ private function getMasterUserIds(): array { if (empty($this->masterUsernames)) { return []; } $db = Factory::getDbo(); $quoted = array_map([$db, 'quote'], $this->masterUsernames); $db->setQuery( $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__users')) ->where($db->quoteName('username') . ' IN (' . implode(',', $quoted) . ')') ); return array_map('intval', $db->loadColumn() ?: []); } /** * Get all non-master user records. */ private function getNonMasterUsers(): array { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select([$db->quoteName('id'), $db->quoteName('username'), $db->quoteName('email')]) ->from($db->quoteName('#__users')); $masterIds = $this->getMasterUserIds(); if (!empty($masterIds)) { $query->where($db->quoteName('id') . ' NOT IN (' . implode(',', $masterIds) . ')'); } return $db->setQuery($query)->loadObjectList() ?: []; } /** * Send JSON response and terminate. */ private function sendJson(int $code, array $data): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_SLASHES); Factory::getApplication()->close(); } }