[ Index ] |
PHP Cross Reference of Joomla 4.2.2 documentation |
[Summary view] [Print] [Text view]
1 <?php 2 3 /** 4 * @package Joomla.Plugin 5 * @subpackage Apiauthentication.token 6 * 7 * @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org> 8 * @license GNU General Public License version 2 or later; see LICENSE.txt 9 */ 10 11 namespace Joomla\Plugin\ApiAuthentication\Token\Extension; 12 13 use Joomla\CMS\Authentication\Authentication; 14 use Joomla\CMS\Crypt\Crypt; 15 use Joomla\CMS\Plugin\CMSPlugin; 16 use Joomla\CMS\User\UserFactoryInterface; 17 use Joomla\Component\Plugins\Administrator\Model\PluginModel; 18 use Joomla\Database\DatabaseAwareTrait; 19 use Joomla\Database\ParameterType; 20 use Joomla\Event\DispatcherInterface; 21 use Joomla\Filter\InputFilter; 22 23 // phpcs:disable PSR1.Files.SideEffects 24 \defined('_JEXEC') or die; 25 // phpcs:enable PSR1.Files.SideEffects 26 27 /** 28 * Joomla Token Authentication plugin 29 * 30 * @since 4.0.0 31 */ 32 final class Token extends CMSPlugin 33 { 34 use DatabaseAwareTrait; 35 36 /** 37 * The prefix of the user profile keys, without the dot. 38 * 39 * @var string 40 * @since 4.0.0 41 */ 42 private $profileKeyPrefix = 'joomlatoken'; 43 44 /** 45 * Allowed HMAC algorithms for the token 46 * 47 * @var string[] 48 * @since 4.0.0 49 */ 50 private $allowedAlgos = ['sha256', 'sha512']; 51 52 /** 53 * The user factory 54 * 55 * @var UserFactoryInterface 56 * @since 4.2.0 57 */ 58 private $userFactory; 59 60 /** 61 * The input filter 62 * 63 * @var InputFilter 64 * @since 4.2.0 65 */ 66 private $filter; 67 68 /** 69 * Constructor. 70 * 71 * @param DispatcherInterface $dispatcher The dispatcher 72 * @param array $config An optional associative array of configuration settings 73 * @param UserFactoryInterface $userFactory The user factory 74 * @param InputFilter $filter The input filter 75 * 76 * @since 4.2.0 77 */ 78 public function __construct(DispatcherInterface $dispatcher, array $config, UserFactoryInterface $userFactory, InputFilter $filter) 79 { 80 parent::__construct($dispatcher, $config); 81 82 $this->userFactory = $userFactory; 83 $this->filter = $filter; 84 } 85 86 /** 87 * This method should handle any authentication and report back to the subject 88 * 89 * @param array $credentials Array holding the user credentials 90 * @param array $options Array of extra options 91 * @param object $response Authentication response object 92 * 93 * @return void 94 * 95 * @since 4.0.0 96 */ 97 public function onUserAuthenticate($credentials, $options, &$response): void 98 { 99 // Default response is authentication failure. 100 $response->type = 'Token'; 101 $response->status = Authentication::STATUS_FAILURE; 102 $response->error_message = $this->getApplication()->getLanguage()->_('JGLOBAL_AUTH_FAIL'); 103 104 /** 105 * First look for an HTTP Authorization header with the following format: 106 * Authorization: Bearer <token> 107 * Do keep in mind that Bearer is **case-sensitive**. Whitespace between Bearer and the 108 * token, as well as any whitespace following the token is discarded. 109 */ 110 $authHeader = $this->getApplication()->input->server->get('HTTP_AUTHORIZATION', '', 'string'); 111 $tokenString = ''; 112 113 // Apache specific fixes. See https://github.com/symfony/symfony/issues/19693 114 if ( 115 empty($authHeader) && \PHP_SAPI === 'apache2handler' 116 && function_exists('apache_request_headers') && apache_request_headers() !== false 117 ) { 118 $apacheHeaders = array_change_key_case(apache_request_headers(), CASE_LOWER); 119 120 if (array_key_exists('authorization', $apacheHeaders)) { 121 $authHeader = $this->filter->clean($apacheHeaders['authorization'], 'STRING'); 122 } 123 } 124 125 if (substr($authHeader, 0, 7) == 'Bearer ') { 126 $parts = explode(' ', $authHeader, 2); 127 $tokenString = trim($parts[1]); 128 $tokenString = $this->filter->clean($tokenString, 'BASE64'); 129 } 130 131 if (empty($tokenString)) { 132 $tokenString = $this->getApplication()->input->server->get('HTTP_X_JOOMLA_TOKEN', '', 'string'); 133 } 134 135 // No token: authentication failure 136 if (empty($tokenString)) { 137 return; 138 } 139 140 // The token is a base64 encoded string. Make sure we can decode it. 141 $authString = @base64_decode($tokenString); 142 143 if (empty($authString) || (strpos($authString, ':') === false)) { 144 return; 145 } 146 147 /** 148 * Deconstruct the decoded token string to its three discrete parts: algorithm, user ID and 149 * HMAC of the token string saved in the database. 150 */ 151 $parts = explode(':', $authString, 3); 152 153 if (count($parts) != 3) { 154 return; 155 } 156 157 list($algo, $userId, $tokenHMAC) = $parts; 158 159 /** 160 * Verify the HMAC algorithm requested in the token string is allowed 161 */ 162 $allowedAlgo = in_array($algo, $this->allowedAlgos); 163 164 /** 165 * Make sure the user ID is an integer 166 */ 167 $userId = (int) $userId; 168 169 /** 170 * Calculate the reference token data HMAC 171 */ 172 try { 173 $siteSecret = $this->getApplication()->get('secret'); 174 } catch (\Exception $e) { 175 return; 176 } 177 178 // An empty secret! What kind of monster are you?! 179 if (empty($siteSecret)) { 180 return; 181 } 182 183 $referenceTokenData = $this->getTokenSeedForUser($userId); 184 $referenceTokenData = empty($referenceTokenData) ? '' : $referenceTokenData; 185 $referenceTokenData = base64_decode($referenceTokenData); 186 $referenceHMAC = hash_hmac($algo, $referenceTokenData, $siteSecret); 187 188 // Is the token enabled? 189 $enabled = $this->isTokenEnabledForUser($userId); 190 191 // Do the tokens match? Use a timing safe string comparison to prevent timing attacks. 192 $hashesMatch = Crypt::timingSafeCompare($referenceHMAC, $tokenHMAC); 193 194 // Is the user in the allowed user groups? 195 $inAllowedUserGroups = $this->isInAllowedUserGroup($userId); 196 197 /** 198 * Can we log in? 199 * 200 * DO NOT concatenate in a single line. Due to boolean short-circuit evaluation it might 201 * make timing attacks possible. Using separate lines of code with the previously calculated 202 * boolean value to the right hand side forces PHP to evaluate the conditions in 203 * approximately constant time. 204 */ 205 206 // We need non-empty reference token data (the user must have configured a token) 207 $canLogin = !empty($referenceTokenData); 208 209 // The token must be enabled 210 $canLogin = $enabled && $canLogin; 211 212 // The token hash must be calculated with an allowed algorithm 213 $canLogin = $allowedAlgo && $canLogin; 214 215 // The token HMAC hash coming into the request and our reference must match. 216 $canLogin = $hashesMatch && $canLogin; 217 218 // The user must belong in the allowed user groups 219 $canLogin = $inAllowedUserGroups && $canLogin; 220 221 /** 222 * DO NOT try to be smart and do an early return when either of the individual conditions 223 * are not met. There's a reason we only return after checking all three conditions: it 224 * prevents timing attacks. 225 */ 226 if (!$canLogin) { 227 return; 228 } 229 230 // Get the actual user record 231 $user = $this->userFactory->loadUserById($userId); 232 233 // Disallow login for blocked, inactive or password reset required users 234 if ($user->block || !empty(trim($user->activation)) || $user->requireReset) { 235 $response->status = Authentication::STATUS_DENIED; 236 237 return; 238 } 239 240 // Update the response to indicate successful login 241 $response->status = Authentication::STATUS_SUCCESS; 242 $response->error_message = ''; 243 $response->username = $user->username; 244 $response->email = $user->email; 245 $response->fullname = $user->name; 246 $response->timezone = $user->get('timezone'); 247 $response->language = $user->get('language'); 248 } 249 250 /** 251 * Retrieve the token seed string for the given user ID. 252 * 253 * @param int $userId The numeric user ID to return the token seed string for. 254 * 255 * @return string|null Null if there is no token configured or the user doesn't exist. 256 * @since 4.0.0 257 */ 258 private function getTokenSeedForUser(int $userId): ?string 259 { 260 try { 261 $db = $this->getDatabase(); 262 $query = $db->getQuery(true) 263 ->select($db->quoteName('profile_value')) 264 ->from($db->quoteName('#__user_profiles')) 265 ->where($db->quoteName('profile_key') . ' = :profileKey') 266 ->where($db->quoteName('user_id') . ' = :userId'); 267 268 $profileKey = $this->profileKeyPrefix . '.token'; 269 $query->bind(':profileKey', $profileKey, ParameterType::STRING); 270 $query->bind(':userId', $userId, ParameterType::INTEGER); 271 272 return $db->setQuery($query)->loadResult(); 273 } catch (\Exception $e) { 274 return null; 275 } 276 } 277 278 /** 279 * Is the token enabled for a given user ID? If the user does not exist or has no token it 280 * returns false. 281 * 282 * @param int $userId The User ID to check whether the token is enabled on their account. 283 * 284 * @return boolean 285 * @since 4.0.0 286 */ 287 private function isTokenEnabledForUser(int $userId): bool 288 { 289 try { 290 $db = $this->getDatabase(); 291 $query = $db->getQuery(true) 292 ->select($db->quoteName('profile_value')) 293 ->from($db->quoteName('#__user_profiles')) 294 ->where($db->quoteName('profile_key') . ' = :profileKey') 295 ->where($db->quoteName('user_id') . ' = :userId'); 296 297 $profileKey = $this->profileKeyPrefix . '.enabled'; 298 $query->bind(':profileKey', $profileKey, ParameterType::STRING); 299 $query->bind(':userId', $userId, ParameterType::INTEGER); 300 301 $value = $db->setQuery($query)->loadResult(); 302 303 return $value == 1; 304 } catch (\Exception $e) { 305 return false; 306 } 307 } 308 309 /** 310 * Retrieves a configuration parameter of a different plugin than the current one. 311 * 312 * @param string $folder Plugin folder 313 * @param string $plugin Plugin name 314 * @param string $param Parameter name 315 * @param null $default Default value, in case the parameter is missing 316 * 317 * @return mixed 318 * @since 4.0.0 319 */ 320 private function getPluginParameter(string $folder, string $plugin, string $param, $default = null) 321 { 322 /** @var PluginModel $model */ 323 $model = $this->getApplication()->bootComponent('plugins') 324 ->getMVCFactory()->createModel('Plugin', 'Administrator', ['ignore_request' => true]); 325 326 $pluginObject = $model->getItem(['folder' => $folder, 'element' => $plugin]); 327 328 if (!\is_object($pluginObject) || !$pluginObject->enabled || !\array_key_exists($param, $pluginObject->params)) { 329 return $default; 330 } 331 332 return $pluginObject->params[$param]; 333 } 334 335 /** 336 * Get the configured user groups which are allowed to have access to tokens. 337 * 338 * @return int[] 339 * @since 4.0.0 340 */ 341 private function getAllowedUserGroups(): array 342 { 343 $userGroups = $this->getPluginParameter('user', 'token', 'allowedUserGroups', [8]); 344 345 if (empty($userGroups)) { 346 return []; 347 } 348 349 if (!is_array($userGroups)) { 350 $userGroups = [$userGroups]; 351 } 352 353 return $userGroups; 354 } 355 356 /** 357 * Is the user with the given ID in the allowed User Groups with access to tokens? 358 * 359 * @param int $userId The user ID to check 360 * 361 * @return boolean False when doesn't belong to allowed user groups, user not found, or guest 362 * @since 4.0.0 363 */ 364 private function isInAllowedUserGroup($userId) 365 { 366 $allowedUserGroups = $this->getAllowedUserGroups(); 367 368 $user = $this->userFactory->loadUserById($userId); 369 370 if ($user->id != $userId) { 371 return false; 372 } 373 374 if ($user->guest) { 375 return false; 376 } 377 378 // No specifically allowed user groups: allow ALL user groups. 379 if (empty($allowedUserGroups)) { 380 return true; 381 } 382 383 $groups = $user->getAuthorisedGroups(); 384 $intersection = array_intersect($groups, $allowedUserGroups); 385 386 return !empty($intersection); 387 } 388 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Wed Sep 7 05:41:13 2022 | Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer |