Files
MokoSuiteClient/source/packages/com_mokosuiteclient/api/src/Controller/UsersController.php
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

401 lines
9.8 KiB
PHP
Raw Normal View History

<?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();
}
}