27f50468a8
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 14s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 32s
Generic: Project CI / Lint & Validate (pull_request) Successful in 34s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 34s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Merges the full element rename from main into dev, resolving conflicts across 53 files. Migrates new dev-only extensions (com_mokosuite ticketsettings, plg_system_mokosuite_dbip) to MokoSuiteClient naming.
401 lines
9.8 KiB
PHP
401 lines
9.8 KiB
PHP
<?php
|
|
/**
|
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* 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();
|
|
}
|
|
}
|