1<?php 2 3/** 4 * DokuWiki Plugin authwordpress (Auth Component) 5 * 6 * Provides authentication against a WordPress MySQL database backend 7 * 8 * This program is free software; you can redistribute it and/or modify 9 * it under the terms of the GNU General Public License as published by 10 * the Free Software Foundation; version 2 of the License 11 * 12 * This program is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU General Public License for more details. 16 * 17 * See the COPYING file in your DokuWiki folder for details 18 * 19 * @author Damien Regad <dregad@mantisbt.org> 20 * @copyright 2015 Damien Regad 21 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 22 * @version 1.4.0 23 * @link https://github.com/dregad/dokuwiki-plugin-authwordpress 24 * 25 * @noinspection PhpComposerExtensionStubsInspection 26 * @noinspection PhpUnused 27 * @noinspection PhpMissingReturnTypeInspection 28 */ 29 30// must be run within Dokuwiki 31if (!defined('DOKU_INC')) { 32 die(); 33} 34 35use dokuwiki\Extension\AuthPlugin; 36use dokuwiki\Logger; 37 38/** 39 * WordPress password hashing framework 40 */ 41require_once('class-phpass.php'); 42 43/** 44 * Authentication class 45 */ 46// @codingStandardsIgnoreLine 47class auth_plugin_authwordpress extends AuthPlugin 48{ 49 /** 50 * SQL statement to retrieve User data from WordPress DB 51 * (including group memberships) 52 * '%prefix%' will be replaced by the actual prefix (from plugin config) 53 * @var string $sql_wp_user_data 54 */ 55 protected $sql_wp_user_data = "SELECT 56 id, user_login, user_pass, user_email, display_name, 57 meta_value AS grps 58 FROM %prefix%users u 59 JOIN %prefix%usermeta m ON u.id = m.user_id AND meta_key = '%prefix%capabilities'"; 60 61 /** 62 * Wordpress database connection 63 * @var PDO $db 64 */ 65 protected $db; 66 67 /** 68 * Users cache 69 * @var array $users 70 */ 71 protected $users; 72 73 /** 74 * True if all users have been loaded in the cache 75 * @see $users 76 * @var bool $usersCached 77 */ 78 protected $usersCached = false; 79 80 /** 81 * Filter pattern 82 * @var array $filter 83 */ 84 protected $filter; 85 86 /** 87 * Constructor. 88 */ 89 public function __construct() 90 { 91 parent::__construct(); 92 93 // Plugin capabilities 94 $this->cando['getUsers'] = true; 95 $this->cando['getUserCount'] = true; 96 97 // Try to establish a connection to the WordPress DB 98 // abort in case of failure 99 try { 100 $this->connectWordpressDb(); 101 } catch (PDOException $e) { 102 msg(sprintf($this->getLang('error_connect_failed'), $e->getMessage())); 103 $this->success = false; 104 return; 105 } 106 107 // Initialize SQL query with configured prefix 108 $this->sql_wp_user_data = str_replace( 109 '%prefix%', 110 $this->getConf('prefix'), 111 $this->sql_wp_user_data 112 ); 113 114 $this->success = true; 115 } 116 117 118 /** 119 * Check user+password. 120 * 121 * Starting with WordPress 6.8, passwords are bcrypt-hashed with standard 122 * php functions. 123 * {@see https://make.wordpress.org/core/2025/02/17/wordpress-6-8-will-use-bcrypt-for-password-hashing/} 124 * 125 * Earlier versions of WordPress add slashes to the password before generating the hash 126 * {@see https://developer.wordpress.org/reference/functions/wp_magic_quotes/}, 127 * so we need to do the same otherwise password containing `\`, `'` or `"` will 128 * never match ({@see https://github.com/dregad/dokuwiki-plugin-authwordpress/issues/23)}. 129 * 130 * @param string $user the username 131 * @param string $pass the clear text password 132 * 133 * @return bool 134 * 135 * @uses PasswordHash::CheckPassword WordPress password hasher 136 */ 137 public function checkPass($user, $pass) 138 { 139 $data = $this->getUserData($user); 140 if ($data === false) { 141 return false; 142 } 143 // Check for WordPress 6.8+ type hash 144 if (str_starts_with($data['pass'], '$wp')) { 145 $password_to_verify = base64_encode(hash_hmac('sha384', $pass, 'wp-sha384', true)); 146 $check = password_verify($password_to_verify, substr($data['pass'], 3)); 147 } else { 148 $hasher = new PasswordHash(8, true); 149 // Add slashes to match WordPress behavior 150 $check = $hasher->CheckPassword(addslashes($pass), $data['pass']); 151 } 152 $this->logDebug("Password " . ($check ? 'OK' : 'Invalid')); 153 154 return $check; 155 } 156 157 /** 158 * Bulk retrieval of user data. 159 * 160 * @param int $start index of first user to be returned 161 * @param int $limit max number of users to be returned 162 * @param array $filter array of field/pattern pairs 163 * 164 * @return array userinfo (refer getUserData for internal userinfo details) 165 */ 166 public function retrieveUsers($start = 0, $limit = 0, $filter = array()) 167 { 168 msg($this->getLang('user_list_use_wordpress')); 169 170 $this->cacheAllUsers(); 171 172 // Apply filter and pagination 173 $this->setFilter($filter); 174 $list = array(); 175 $count = $i = 0; 176 foreach ($this->users as $user => $info) { 177 if ($this->applyFilter($user, $info)) { 178 if ($i >= $start) { 179 $list[$user] = $info; 180 $count++; 181 if ($limit > 0 && $count >= $limit) { 182 break; 183 } 184 } 185 $i++; 186 } 187 } 188 189 return $list; 190 } 191 192 /** 193 * Return a count of the number of user which meet $filter criteria. 194 * 195 * @param array $filter 196 * 197 * @return int 198 */ 199 public function getUserCount($filter = array()) 200 { 201 $this->cacheAllUsers(); 202 203 if (empty($filter)) { 204 $count = count($this->users); 205 } else { 206 $this->setFilter($filter); 207 $count = 0; 208 foreach ($this->users as $user => $info) { 209 $count += (int)$this->applyFilter($user, $info); 210 } 211 } 212 return $count; 213 } 214 215 216 /** 217 * Returns info about the given user. 218 * 219 * @param string $user the user name 220 * @param bool $requireGroups defaults to true 221 * 222 * @return array|false containing user data or false in case of error 223 */ 224 public function getUserData($user, $requireGroups = true) 225 { 226 if (isset($this->users[$user])) { 227 return $this->users[$user]; 228 } 229 230 $sql = $this->sql_wp_user_data 231 . 'WHERE user_login = :user'; 232 233 $stmt = $this->db->prepare($sql); 234 $stmt->bindParam(':user', $user); 235 $this->logDebug("Retrieving data for user '$user'\n$sql"); 236 237 if (!$stmt->execute()) { 238 // Query execution failed 239 $err = $stmt->errorInfo(); 240 $this->logDebug("Error $err[1]: $err[2]"); 241 return false; 242 } 243 244 $user = $stmt->fetch(PDO::FETCH_ASSOC); 245 if ($user === false) { 246 // Unknown user 247 $this->logDebug("Unknown user"); 248 return false; 249 } 250 251 return $this->cacheUser($user); 252 } 253 254 255 /** 256 * Connect to Wordpress database. 257 * 258 * Initializes $db property as PDO object. 259 * 260 * @return void 261 * @throws PDOException 262 */ 263 protected function connectWordpressDb(): void 264 { 265 if ($this->db) { 266 // Already connected 267 return; 268 } 269 270 // Build connection string 271 $dsn = array( 272 'host=' . $this->getConf('hostname'), 273 'dbname=' . $this->getConf('database'), 274 'charset=UTF8', 275 ); 276 $port = $this->getConf('port'); 277 if ($port) { 278 $dsn[] = 'port=' . $port; 279 } 280 $dsn = 'mysql:' . implode(';', $dsn); 281 282 $this->db = new PDO($dsn, $this->getConf('username'), $this->getConf('password')); 283 } 284 285 /** 286 * Cache User Data. 287 * 288 * Convert a Wordpress DB User row to DokuWiki user info array 289 * and stores it in the users cache. 290 * 291 * @param array $row Raw Wordpress user table row 292 * 293 * @return array user data 294 */ 295 protected function cacheUser(array $row): array 296 { 297 global $conf; 298 299 $login = $row['user_login']; 300 301 // If the user is already cached, just return it 302 if (isset($this->users[$login])) { 303 return $this->users[$login]; 304 } 305 306 // Group membership - add DokuWiki's default group 307 $groups = array_keys(unserialize($row['grps'])); 308 if ($this->getConf('usedefaultgroup')) { 309 $groups[] = $conf['defaultgroup']; 310 } 311 312 $info = array( 313 'user' => $login, 314 'name' => $row['display_name'], 315 'pass' => $row['user_pass'], 316 'mail' => $row['user_email'], 317 'grps' => $groups, 318 ); 319 320 $this->users[$login] = $info; 321 return $info; 322 } 323 324 /** 325 * Loads all Wordpress users into the cache. 326 * 327 * @return void 328 */ 329 protected function cacheAllUsers() 330 { 331 if ($this->usersCached) { 332 return; 333 } 334 335 $stmt = $this->db->prepare($this->sql_wp_user_data); 336 $stmt->execute(); 337 338 foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $user) { 339 $this->cacheUser($user); 340 } 341 342 $this->usersCached = true; 343 } 344 345 /** 346 * Build filter patterns from given criteria. 347 * 348 * @param array $filter 349 * 350 * @return void 351 */ 352 protected function setFilter(array $filter): void 353 { 354 $this->filter = array(); 355 foreach ($filter as $field => $value) { 356 // Build PCRE pattern, utf8 + case insensitive 357 $this->filter[$field] = '/' . str_replace('/', '\/', $value) . '/ui'; 358 } 359 } 360 361 /** 362 * Return true if given user matches filter pattern, false otherwise. 363 * 364 * @param string $user login 365 * @param array $info User data 366 * 367 * @return bool 368 * @noinspection PhpUnusedParameterInspection 369 */ 370 protected function applyFilter(string $user, array $info): bool 371 { 372 foreach ($this->filter as $elem => $pattern) { 373 if ($elem == 'grps') { 374 if (!preg_grep($pattern, $info['grps'])) { 375 return false; 376 } 377 } else { 378 if (!preg_match($pattern, $info[$elem])) { 379 return false; 380 } 381 } 382 } 383 return true; 384 } 385 386 /** 387 * Add message to debug log. 388 * 389 * @param string $msg 390 * 391 * @return void 392 */ 393 protected function logDebug(string $msg): void 394 { 395 global $updateVersion; 396 if ($updateVersion >= 52) { 397 Logger::debug($msg); 398 } else { 399 /** @noinspection PhpDeprecationInspection */ 400 dbglog($msg); 401 } 402 } 403} 404 405// vim:ts=4:sw=4:noet: 406