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