shell bypass 403
<?php
/**
* @package admintools
* @copyright Copyright (c)2010-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Plugin\System\AdminTools\Feature;
defined('_JEXEC') || die;
use Akeeba\Plugin\System\AdminTools\Feature\Mixin\SuperUsersTrait;
use DateInterval;
use Exception;
use Joomla\CMS\Factory;
use Joomla\CMS\User\User;
/**
* Disable or force a password reset on obsolete administrators (backend users who have not logged into the site for a
* very long time)
*
* WAF configuration parameters:
* disableobsoleteadmins Is this feature enabled? Default: 0.
* disableobsoleteadmins_freq How often to run this feature [minutes]. Default: 60.
* disableobsoleteadmins_groups Which user groups to apply to? Default: empty (all groups)
* disableobsoleteadmins_maxdays Minimum time since last login to trigger this feature [days]. Default: 90
* disableobsoleteadmins_action Action to take (block|reset). Default: reset
* disableobsoleteadmins_protected Protected users
*
* @since 5.3.0
*/
class DisableObsoleteAdmins extends Base
{
use SuperUsersTrait;
/**
* WAF settings key prefix for this feature
*
* @var string
* @since 5.3.0
*/
protected $settingsKey = 'disableobsoleteadmins';
/**
* If the user has not logged in for at least this many days we are going to block / force reset their password.
*
* @var int
* @since 5.3.0
*/
protected $maxDays = 0;
/**
* When saving a user who was previously blocked, undoing the block, I have to update their last visit date to today
* so they don't get auto-blocked again. Joomla! does not let me modify the data before a user is saved to the
* database. What I do instead is intercept the onBeforeSave events, create a list of the users who need to be
* modified and then apply these changes by capturing the onUserAfterSave event for this user. The problem is that
* by doing so I am triggering yet again the onUserBeforeSave which would make me enter an infinite loop. This array
* lets me keep track of the user IDs I am fiddling with so I don't end up in an infinite loop. It's an array
* because due to the way user plugins work I *might* end up in a recursive update situation.
*
* @var array
* @since 5.3.0
*/
protected $toUpdateUsers = [];
/**
* This is part of the solution described in the $toUpdateUsers above. This array keeps track of the User IDs I have
* already started processing onUserAfterSave so I don't process them again.
*
* @var array
* @since 5.3.0
*/
protected $updatedUsers = [];
/**
* Is this feature enabled?
*
* @return bool
*
* @since 5.3.0
*/
public function isEnabled()
{
if ($this->wafParams->getValue($this->settingsKey, 0) != 1)
{
return false;
}
$this->maxDays = $this->wafParams->getValue($this->settingsKey . '_maxdays', 90);
if ($this->maxDays <= 0)
{
return false;
}
return true;
}
/**
* Runs as soon as the application has finished initializing, before it routes to a component. We will run our
* feature at most every disableobsoleteadmins_freq minutes (default: every 60 minutes)
*
* @throws Exception
*
* @since 5.3.0
*/
public function onAfterInitialise(): void
{
$minutes = $this->getRunFrequency();
$lastJob = $this->getTimestamp($this->settingsKey);
$nextJob = $lastJob + $minutes * 60;
$now = clone Factory::getDate();
if ($now->toUnix() >= $nextJob)
{
$this->setTimestamp($this->settingsKey);
$this->disableObsoleteAdmins();
}
}
/**
* Prevent automatic blocking of a backend user manually unblocked by an admin user.
*
* Presumably one of your users got blocked and they asked you to manually reset their password because they can't
* figure out the password reset instructions. If you edit them and remove the forced password reset / user block
* from their user account they will be automatically blocked again by this feature. This happens because their
* last visit date is before the configured max days threshold since they haven't actually logged in yet! We need to
* catch that case and update their last visit day to today to prevent blocking them all over again.
*
* However, Joomla! only allows us to see data onUserBeforeSave, not update them. Therefore I am using the
* onUserBeforeSave event to find out which user accounts are being saved and which need fiddling with per above.
* Then I used onUserAfterSave to update their lastVisitDate.
*
* @param User|array $oldUser The existing user record
* @param bool $isNew Is this a new user?
* @param array $data The data to be saved
*
* @throws Exception When we catch a security exception
*/
public function onUserBeforeSave($oldUser, $isNew, $data): bool
{
// I only care about editing users from the backend
if ($this->app->isClient('site'))
{
return true;
}
// I only care about editing existing users
if ($isNew)
{
return true;
}
// I don't care if you are editing yourself
if ($oldUser['id'] == $this->app->getIdentity()->id)
{
return true;
}
// Do not process the user I am already updating after save.
if (in_array($oldUser['id'], $this->toUpdateUsers))
{
return true;
}
// Do not process the user I have already updated after save.
if (in_array($oldUser['id'], $this->updatedUsers))
{
return true;
}
$action = $this->wafParams->getValue($this->settingsKey . '_action', 'reset') == 'block' ? 'block' : 'reset';
switch ($action)
{
case 'block':
// If the user wasn't blocked I have nothing to do
if ($oldUser['block'] == 0)
{
return true;
}
// If you didn't change the user block status I have nothing to do
if ($data['block'] == 1)
{
return true;
}
break;
case 'reset':
default:
// If the user wasn't required to password reset I have nothing to do
if ($oldUser['requireReset'] == 0)
{
return true;
}
// If you didn't change the user's required password reset status I have nothing to do
if ($data['requireReset'] == 1)
{
return true;
}
break;
}
// You are possibly editing a user I previously disabled automatically. Is this REALLY the case?
if (!empty($oldUser['lastvisitDate']) && ($oldUser['lastvisitDate'] != $this->db->getNullDate()))
{
$now = clone Factory::getDate();
$lastLogin = clone Factory::getDate($oldUser['lastvisitDate']);
$diff = $now->diff($lastLogin, true);
// If the last login was within the allowed number of days you are editing a user I must NOT touch.
if ($diff->days <= $this->maxDays)
{
return true;
}
}
// Mark this user as in need for post-save update
$this->toUpdateUsers[] = $oldUser['id'];
return true;
}
/**
* Part of the automatic update of manually unblocked users, as explained onUserBeforeSave.
*
* @param array $data The user data saved to the database
* @param bool $isNew Was that a new user?
* @param bool $result Did the save succeed?
* @param string $errorMessage The last error message while saving the user.
*
*
* @since 5.3.0
*/
public function onUserAfterSave($data, $isNew, $result, $errorMessage): void
{
// I don't care about new users
if ($isNew)
{
return;
}
// I don't care about failed saves
if (!$result)
{
return;
}
// Get the user ID
$userID = $data['id'];
// Do not process the user I have already updated after save.
if (in_array($userID, $this->updatedUsers))
{
return;
}
// Do not process a user UNLESS I have marked them as in need for an update.
if (!in_array($userID, $this->toUpdateUsers))
{
return;
}
// Mark the user as having their last visit date updated
$this->updatedUsers[] = $userID;
// Update the last visit date to today
$user = self::getUserById($userID);
$user->lastvisitDate = (clone Factory::getDate())->toSql();
$user->save(true);
}
/**
* Find users who belong in the configured backend user groups and who have not logged in for at least the
* configured number of days. Then take the configured action against them (force password reset or block them).
*
* @throws Exception
* @since 5.3.0
*/
protected function disableObsoleteAdmins()
{
// Get applicable user groups
$groups = $this->getBackendUserGroups();
if (empty($groups))
{
return;
}
// Get all applicable users
$users = $this->getUsersByGroups($groups);
// No users? Nothing to do, then.
if (empty($users))
{
return;
}
// Remove "protected" users from this list
$users = array_unique($users);
$users = $this->removeProtectedUsers($users);
// No users left after this operation? Nothing to do, then.
if (empty($users))
{
return;
}
asort($users);
// Get the login date to trigger this feature
$now = clone Factory::getDate();
$interval = new DateInterval(sprintf('P%dD', $this->maxDays));
$then = $now->sub($interval)->toSql();
// Have any of these users not logged in for a while?
try
{
$db = $this->db;
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select([$db->qn('id')])
->from($db->qn('#__users'))
->where($db->qn('id') . ' IN (' . implode(', ', array_map([$db, 'q'], $users)) . ')')
->where($db->qn('block') . ' = ' . $db->q(0))
->where($db->qn('requireReset') . ' = ' . $db->q(0))
->where($db->qn('lastvisitDate') . ' <= ' . $db->q($then));
$actionUsers = $db->setQuery($query)->loadColumn(0);
}
catch (Exception $e)
{
// Database error. Bail out.
}
asort($actionUsers);
// Get the applicable action
$action = $this->wafParams->getValue($this->settingsKey . '_action', 'reset') == 'block' ? 'block' : 'reset';
/**
* Am I trying to block all Super Users AND I am not protecting any Super Users THEN I will not block any Super
* Users at all.
*
* Why not do the same if I am forcing a password reset? Because in this case all Super Users can reset their
* password over email. No harm done. You don't get locked out of your site.
*
* Why do this even when I have protected users? Because the user may have chosen to protect non-Super-Users by
* accident / because they do not understand the consequences. In this case I have to make sure I am not
* blocking any Super Users on their site or they risk getting locked out of it permanently.
*/
if ($action == 'block')
{
$actionUsers = $this->filterActionableUsersToEnsureRemainingSuperUser($actionUsers);
}
// No users to take action against?.
if (empty($actionUsers))
{
return;
}
// Apply the action
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->update($db->qn('#__users'))
->where($db->qn('id') . ' IN (' . implode(', ', array_map([$db, 'q'], $actionUsers)) . ')');
switch ($action)
{
case 'block':
$query->set($db->qn('block') . ' = ' . $db->q(1));
break;
case 'reset':
default:
$query->set($db->qn('requireReset') . ' = ' . $db->q(1));
break;
}
$db->setQuery($query)->execute();
}
/**
* Get the user groups configured by the user, filtered by those which really have backend access. If no groups are
* configured we will use all groups with backend access.
*
* @since 5.3.0
*/
protected function getBackendUserGroups()
{
// Get the configured groups
$groups = $this->wafParams->getValue($this->settingsKey . '_groups', []);
$groups = is_string($groups) ? explode(',', trim($groups)) : $groups;
$groups = array_filter($groups, function ($group) {
return (int) trim($group) != 0;
});
// No groups? Assume we're to look into all Joomla! user groups.
if (empty($groups))
{
$groups = $this->getAllJoomlaUserGroups();
// Filter the configured user groups by those with backend access
$groups = array_filter($groups, [$this, 'isBackendAccessGroup']);
}
return $groups;
}
/**
* Remove the protected users from the given $users list and return the remaining users
*
* @param array $users The users list to filter
*
* @return array The filtered list
*
* @since 5.3.0
*/
protected function removeProtectedUsers(array $users)
{
$protected = $this->getProtectedUsers();
if (empty($protected))
{
return $users;
}
return array_diff($users, $protected);
}
/**
* Filter the list of actionable users in a way that ensures at least one Super User will remain active on the site
*
* @param array $actionableUsers The list of actionable users to filter
*
* @return array The filtered list
*
* @since 5.3.0
*/
protected function filterActionableUsersToEnsureRemainingSuperUser($actionableUsers)
{
$protected = $this->getProtectedUsers();
$superUsers = $this->getSuperUsers();
// If I have any protected Super Users bail out; a Super User is guaranteed to exist on the site.
$protectedSuper = array_intersect($protected, $superUsers);
if (count($protectedSuper))
{
return $actionableUsers;
}
// Remove Super Users from list of blocked users
return array_diff($actionableUsers, $superUsers);
}
/**
* Get the protected users' IDs
*
* @return int[]
*
* @since 5.3.0
*/
protected function getProtectedUsers()
{
$protected = $this->wafParams->getValue($this->settingsKey . '_protected', []);
$protected = is_string($protected) ? array_map('trim', explode(',', trim($protected))) : $protected;
$protected = array_map(
function ($x) {
return is_array($x) ? $x[0] : $x;
}, is_array($protected) ? $protected : []
);
$protected = array_filter(
$protected, function ($userID) {
return (int) trim($userID) != 0;
}
);
return $protected;
}
/**
* Return the frequency [minutes] for running this feature.
*
* @return int
*
* @since 5.3.0
*/
protected function getRunFrequency()
{
$minutes = (int) $this->wafParams->getValue($this->settingsKey . '_freq', 60);
if ($minutes <= 0)
{
$minutes = 60;
}
return $minutes;
}
}