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