1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* XOOPS Kernel Class - Enhanced Member Handler |
4
|
|
|
* |
5
|
|
|
* You may not change or alter any portion of this comment or credits |
6
|
|
|
* of supporting developers from this source code or any supporting source code |
7
|
|
|
* which is considered copyrighted (c) material of the original comment or credit authors. |
8
|
|
|
* This program is distributed in the hope that it will be useful, |
9
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
10
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
11
|
|
|
* |
12
|
|
|
* @copyright (c) 2000-2025 XOOPS Project (https://xoops.org) |
13
|
|
|
* @license GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html) |
14
|
|
|
* @package kernel |
15
|
|
|
* @since 2.0.0 |
16
|
|
|
* @author Kazumi Ono (AKA onokazu) http://www.myweb.ne.jp/, http://jp.xoops.org/ |
17
|
|
|
* @version 2.0.0 - Enhanced with security hardening and PHP 7.4-8.4 compatibility |
18
|
|
|
*/ |
19
|
|
|
|
20
|
|
|
if (!defined('XOOPS_ROOT_PATH')) { |
21
|
|
|
throw new RuntimeException('Restricted access'); |
22
|
|
|
} |
23
|
|
|
|
24
|
|
|
require_once __DIR__ . '/user.php'; |
25
|
|
|
require_once __DIR__ . '/group.php'; |
26
|
|
|
|
27
|
|
|
/** |
28
|
|
|
* XOOPS member handler class. |
29
|
|
|
* This class provides simple interface (a facade class) for handling groups/users/ |
30
|
|
|
* membership data with enhanced security and performance optimizations. |
31
|
|
|
* |
32
|
|
|
* @package kernel |
33
|
|
|
*/ |
34
|
|
|
class XoopsMemberHandler |
35
|
|
|
{ |
36
|
|
|
// Security constants |
37
|
|
|
private const BIDI_CONTROL_REGEX = '/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u'; |
38
|
|
|
private const SENSITIVE_PARAMS = [ |
39
|
|
|
'token', 'access_token', 'id_token', 'password', 'pass', 'pwd', |
40
|
|
|
'secret', 'key', 'api_key', 'apikey', 'auth', 'authorization', |
41
|
|
|
'session', 'sid', 'code', 'csrf', 'nonce' |
42
|
|
|
]; |
43
|
|
|
private const MAX_BATCH_SIZE = 1000; |
44
|
|
|
private const LOG_CONTEXT_MAX_LENGTH = 256; |
45
|
|
|
|
46
|
|
|
/** |
47
|
|
|
* @var XoopsGroupHandler Reference to group handler(DAO) class |
48
|
|
|
*/ |
49
|
|
|
protected $groupHandler; |
50
|
|
|
|
51
|
|
|
/** |
52
|
|
|
* @var XoopsUserHandler Reference to user handler(DAO) class |
53
|
|
|
*/ |
54
|
|
|
protected $userHandler; |
55
|
|
|
|
56
|
|
|
/** |
57
|
|
|
* @var XoopsMembershipHandler Reference to membership handler(DAO) class |
58
|
|
|
*/ |
59
|
|
|
protected $membershipHandler; |
60
|
|
|
|
61
|
|
|
/** |
62
|
|
|
* @var array<int,XoopsUser> Temporary user objects cache |
63
|
|
|
*/ |
64
|
|
|
protected $membersWorkingList = []; |
65
|
|
|
|
66
|
|
|
/** |
67
|
|
|
* Constructor |
68
|
|
|
* @param XoopsDatabase $db Database connection object |
69
|
|
|
*/ |
70
|
|
|
public function __construct(XoopsDatabase $db) |
71
|
|
|
{ |
72
|
|
|
$this->groupHandler = new XoopsGroupHandler($db); |
73
|
|
|
$this->userHandler = new XoopsUserHandler($db); |
74
|
|
|
$this->membershipHandler = new XoopsMembershipHandler($db); |
75
|
|
|
} |
76
|
|
|
|
77
|
|
|
/** |
78
|
|
|
* Create a new group |
79
|
|
|
* @return XoopsGroup Reference to the new group |
80
|
|
|
*/ |
81
|
|
|
public function &createGroup() |
82
|
|
|
{ |
83
|
|
|
$inst = $this->groupHandler->create(); |
84
|
|
|
return $inst; |
85
|
|
|
} |
86
|
|
|
|
87
|
|
|
/** |
88
|
|
|
* Create a new user |
89
|
|
|
* @return XoopsUser Reference to the new user |
90
|
|
|
*/ |
91
|
|
|
public function createUser() |
92
|
|
|
{ |
93
|
|
|
$inst = $this->userHandler->create(); |
94
|
|
|
return $inst; |
95
|
|
|
} |
96
|
|
|
|
97
|
|
|
/** |
98
|
|
|
* Retrieve a group |
99
|
|
|
* @param int $id ID for the group |
100
|
|
|
* @return XoopsGroup|false XoopsGroup reference to the group or false |
101
|
|
|
*/ |
102
|
|
|
public function getGroup($id) |
103
|
|
|
{ |
104
|
|
|
return $this->groupHandler->get($id); |
105
|
|
|
} |
106
|
|
|
|
107
|
|
|
/** |
108
|
|
|
* Retrieve a user (with caching) |
109
|
|
|
* @param int $id ID for the user |
110
|
|
|
* @return XoopsUser|false Reference to the user or false |
111
|
|
|
*/ |
112
|
|
|
public function getUser($id) |
113
|
|
|
{ |
114
|
|
|
$id = (int)$id; |
115
|
|
|
if (!isset($this->membersWorkingList[$id])) { |
116
|
|
|
$this->membersWorkingList[$id] = $this->userHandler->get($id); |
117
|
|
|
} |
118
|
|
|
return $this->membersWorkingList[$id]; |
119
|
|
|
} |
120
|
|
|
|
121
|
|
|
/** |
122
|
|
|
* Delete a group |
123
|
|
|
* @param XoopsGroup $group Reference to the group to delete |
124
|
|
|
* @return bool TRUE on success, FALSE on failure |
125
|
|
|
*/ |
126
|
|
|
public function deleteGroup(XoopsGroup $group) |
127
|
|
|
{ |
128
|
|
|
$criteria = $this->createSafeInCriteria('groupid', $group->getVar('groupid')); |
129
|
|
|
$s1 = $this->membershipHandler->deleteAll($criteria); |
130
|
|
|
$s2 = $this->groupHandler->delete($group); |
131
|
|
|
return ($s1 && $s2); |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
/** |
135
|
|
|
* Delete a user |
136
|
|
|
* @param XoopsUser $user Reference to the user to delete |
137
|
|
|
* @return bool TRUE on success, FALSE on failure |
138
|
|
|
*/ |
139
|
|
|
public function deleteUser(XoopsUser $user) |
140
|
|
|
{ |
141
|
|
|
$criteria = $this->createSafeInCriteria('uid', $user->getVar('uid')); |
142
|
|
|
$s1 = $this->membershipHandler->deleteAll($criteria); |
143
|
|
|
$s2 = $this->userHandler->delete($user); |
144
|
|
|
return ($s1 && $s2); |
145
|
|
|
} |
146
|
|
|
|
147
|
|
|
/** |
148
|
|
|
* Insert a group into the database |
149
|
|
|
* @param XoopsGroup $group Reference to the group to insert |
150
|
|
|
* @return bool TRUE on success, FALSE on failure |
151
|
|
|
*/ |
152
|
|
|
public function insertGroup(XoopsGroup $group) |
153
|
|
|
{ |
154
|
|
|
return $this->groupHandler->insert($group); |
155
|
|
|
} |
156
|
|
|
|
157
|
|
|
/** |
158
|
|
|
* Insert a user into the database |
159
|
|
|
* @param XoopsUser $user Reference to the user to insert |
160
|
|
|
* @param bool $force Force insertion even if user already exists |
161
|
|
|
* @return bool TRUE on success, FALSE on failure |
162
|
|
|
*/ |
163
|
|
|
public function insertUser(XoopsUser $user, $force = false) |
164
|
|
|
{ |
165
|
|
|
return $this->userHandler->insert($user, $force); |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
/** |
169
|
|
|
* Retrieve groups from the database |
170
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} |
171
|
|
|
* @param bool $id_as_key Use the group's ID as key for the array? |
172
|
|
|
* @return array Array of {@link XoopsGroup} objects |
173
|
|
|
*/ |
174
|
|
|
public function getGroups($criteria = null, $id_as_key = false) |
175
|
|
|
{ |
176
|
|
|
return $this->groupHandler->getObjects($criteria, $id_as_key); |
177
|
|
|
} |
178
|
|
|
|
179
|
|
|
/** |
180
|
|
|
* Retrieve users from the database |
181
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} |
182
|
|
|
* @param bool $id_as_key Use the user's ID as key for the array? |
183
|
|
|
* @return array Array of {@link XoopsUser} objects |
184
|
|
|
*/ |
185
|
|
|
public function getUsers($criteria = null, $id_as_key = false) |
186
|
|
|
{ |
187
|
|
|
return $this->userHandler->getObjects($criteria, $id_as_key); |
188
|
|
|
} |
189
|
|
|
|
190
|
|
|
/** |
191
|
|
|
* Get a list of groupnames and their IDs |
192
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} object |
193
|
|
|
* @return array Associative array of group-IDs and names |
194
|
|
|
*/ |
195
|
|
|
public function getGroupList($criteria = null) |
196
|
|
|
{ |
197
|
|
|
$groups = $this->groupHandler->getObjects($criteria, true); |
198
|
|
|
$ret = []; |
199
|
|
|
foreach (array_keys($groups) as $i) { |
200
|
|
|
$ret[$i] = $groups[$i]->getVar('name'); |
201
|
|
|
} |
202
|
|
|
return $ret; |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
/** |
206
|
|
|
* Get a list of usernames and their IDs |
207
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} object |
208
|
|
|
* @return array Associative array of user-IDs and names |
209
|
|
|
*/ |
210
|
|
|
public function getUserList($criteria = null) |
211
|
|
|
{ |
212
|
|
|
$users = $this->userHandler->getObjects($criteria, true); |
213
|
|
|
$ret = []; |
214
|
|
|
foreach (array_keys($users) as $i) { |
215
|
|
|
$ret[$i] = $users[$i]->getVar('uname'); |
216
|
|
|
} |
217
|
|
|
return $ret; |
218
|
|
|
} |
219
|
|
|
|
220
|
|
|
/** |
221
|
|
|
* Add a user to a group |
222
|
|
|
* @param int $group_id ID of the group |
223
|
|
|
* @param int $user_id ID of the user |
224
|
|
|
* @return XoopsMembership|bool XoopsMembership object on success, FALSE on failure |
225
|
|
|
*/ |
226
|
|
|
public function addUserToGroup($group_id, $user_id) |
227
|
|
|
{ |
228
|
|
|
$mship = $this->membershipHandler->create(); |
229
|
|
|
$mship->setVar('groupid', (int)$group_id); |
230
|
|
|
$mship->setVar('uid', (int)$user_id); |
231
|
|
|
$result = $this->membershipHandler->insert($mship); |
232
|
|
|
return $result ? $mship : false; |
233
|
|
|
} |
234
|
|
|
|
235
|
|
|
/** |
236
|
|
|
* Remove a list of users from a group |
237
|
|
|
* @param int $group_id ID of the group |
238
|
|
|
* @param array $user_ids Array of user-IDs |
239
|
|
|
* @return bool TRUE on success, FALSE on failure |
240
|
|
|
*/ |
241
|
|
|
public function removeUsersFromGroup($group_id, $user_ids = []) |
242
|
|
|
{ |
243
|
|
|
$ids = $this->sanitizeIds($user_ids); |
244
|
|
|
if (empty($ids)) { |
245
|
|
|
return true; // No-op success |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
// Handle large batches |
249
|
|
|
if (count($ids) > self::MAX_BATCH_SIZE) { |
250
|
|
|
$batches = array_chunk($ids, self::MAX_BATCH_SIZE); |
251
|
|
|
foreach ($batches as $batch) { |
252
|
|
|
$criteria = new CriteriaCompo(); |
253
|
|
|
$criteria->add(new Criteria('groupid', (int)$group_id)); |
254
|
|
|
$criteria->add(new Criteria('uid', '(' . implode(',', $batch) . ')', 'IN')); |
255
|
|
|
if (!$this->membershipHandler->deleteAll($criteria)) { |
256
|
|
|
return false; |
257
|
|
|
} |
258
|
|
|
} |
259
|
|
|
return true; |
260
|
|
|
} |
261
|
|
|
|
262
|
|
|
$criteria = new CriteriaCompo(); |
263
|
|
|
$criteria->add(new Criteria('groupid', (int)$group_id)); |
264
|
|
|
$criteria->add(new Criteria('uid', '(' . implode(',', $ids) . ')', 'IN')); |
265
|
|
|
return $this->membershipHandler->deleteAll($criteria); |
266
|
|
|
} |
267
|
|
|
|
268
|
|
|
/** |
269
|
|
|
* Get a list of users belonging to a group |
270
|
|
|
* @param int $group_id ID of the group |
271
|
|
|
* @param bool $asobject Return the users as objects? |
272
|
|
|
* @param int $limit Number of users to return |
273
|
|
|
* @param int $start Index of the first user to return |
274
|
|
|
* @return array Array of {@link XoopsUser} objects or user IDs |
275
|
|
|
*/ |
276
|
|
|
public function getUsersByGroup($group_id, $asobject = false, $limit = 0, $start = 0) |
277
|
|
|
{ |
278
|
|
|
$user_ids = $this->membershipHandler->getUsersByGroup($group_id, $limit, $start); |
279
|
|
|
if (!$asobject || empty($user_ids)) { |
280
|
|
|
return $user_ids; |
281
|
|
|
} |
282
|
|
|
|
283
|
|
|
// Batch fetch users for better performance |
284
|
|
|
$criteria = new Criteria('uid', '(' . implode(',', array_map('intval', $user_ids)) . ')', 'IN'); |
285
|
|
|
$users = $this->userHandler->getObjects($criteria, true); |
286
|
|
|
|
287
|
|
|
$ret = []; |
288
|
|
|
foreach ($user_ids as $uid) { |
289
|
|
|
if (isset($users[$uid])) { |
290
|
|
|
$ret[] = $users[$uid]; |
291
|
|
|
} |
292
|
|
|
} |
293
|
|
|
return $ret; |
294
|
|
|
} |
295
|
|
|
|
296
|
|
|
/** |
297
|
|
|
* Get a list of groups that a user is member of |
298
|
|
|
* @param int $user_id ID of the user |
299
|
|
|
* @param bool $asobject Return groups as {@link XoopsGroup} objects or arrays? |
300
|
|
|
* @return array Array of objects or arrays |
301
|
|
|
*/ |
302
|
|
|
public function getGroupsByUser($user_id, $asobject = false) |
303
|
|
|
{ |
304
|
|
|
$group_ids = $this->membershipHandler->getGroupsByUser($user_id); |
305
|
|
|
if (!$asobject || empty($group_ids)) { |
306
|
|
|
return $group_ids; |
307
|
|
|
} |
308
|
|
|
|
309
|
|
|
// Batch fetch groups for better performance |
310
|
|
|
$criteria = new Criteria('groupid', '(' . implode(',', array_map('intval', $group_ids)) . ')', 'IN'); |
311
|
|
|
$groups = $this->groupHandler->getObjects($criteria, true); |
312
|
|
|
|
313
|
|
|
$ret = []; |
314
|
|
|
foreach ($group_ids as $gid) { |
315
|
|
|
if (isset($groups[$gid])) { |
316
|
|
|
$ret[] = $groups[$gid]; |
317
|
|
|
} |
318
|
|
|
} |
319
|
|
|
return $ret; |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
/** |
323
|
|
|
* Log in a user with enhanced security |
324
|
|
|
* @param string $uname Username as entered in the login form |
325
|
|
|
* @param string $pwd Password entered in the login form |
326
|
|
|
* @return XoopsUser|false Logged in XoopsUser, FALSE if failed to log in |
327
|
|
|
*/ |
328
|
|
|
public function loginUser($uname, $pwd) |
329
|
|
|
{ |
330
|
|
|
// Use Criteria for safe querying (no manual escaping of password) |
331
|
|
|
$criteria = new Criteria('uname', (string)$uname); |
332
|
|
|
$criteria->setLimit(2); // Fetch at most 2 to detect duplicates |
333
|
|
|
|
334
|
|
|
$users = $this->userHandler->getObjects($criteria, false); |
335
|
|
|
if (!$users || count($users) != 1) { |
|
|
|
|
336
|
|
|
return false; |
337
|
|
|
} |
338
|
|
|
|
339
|
|
|
/** @var XoopsUser $user */ |
340
|
|
|
$user = $users[0]; |
341
|
|
|
$hash = $user->pass(); |
342
|
|
|
|
343
|
|
|
// Check if password uses modern hashing (PHP 7.4 compatible check) |
344
|
|
|
$isModernHash = (isset($hash[0]) && $hash[0] === '$'); |
345
|
|
|
|
346
|
|
|
if ($isModernHash) { |
347
|
|
|
// Modern password hash |
348
|
|
|
if (!password_verify($pwd, $hash)) { |
349
|
|
|
return false; |
350
|
|
|
} |
351
|
|
|
$rehash = password_needs_rehash($hash, PASSWORD_DEFAULT); |
352
|
|
|
} else { |
353
|
|
|
// Legacy MD5 hash - use timing-safe comparison |
354
|
|
|
$expectedHash = md5($pwd); |
355
|
|
|
if (!$this->hashEquals($expectedHash, $hash)) { |
356
|
|
|
return false; |
357
|
|
|
} |
358
|
|
|
$rehash = true; // Always upgrade from MD5 |
359
|
|
|
} |
360
|
|
|
|
361
|
|
|
// Upgrade password hash if needed |
362
|
|
|
if ($rehash) { |
363
|
|
|
$columnLength = $this->getColumnCharacterLength('users', 'pass'); |
364
|
|
|
if ($columnLength === null || $columnLength < 255) { |
365
|
|
|
$this->logSecurityEvent('Password column too small for modern hashes', [ |
366
|
|
|
'table' => 'users', |
367
|
|
|
'column' => 'pass', |
368
|
|
|
'current_length' => $columnLength |
369
|
|
|
]); |
370
|
|
|
} else { |
371
|
|
|
$newHash = password_hash($pwd, PASSWORD_DEFAULT); |
372
|
|
|
$user->setVar('pass', $newHash); |
373
|
|
|
$this->userHandler->insert($user); |
374
|
|
|
} |
375
|
|
|
} |
376
|
|
|
|
377
|
|
|
return $user; |
378
|
|
|
} |
379
|
|
|
|
380
|
|
|
/** |
381
|
|
|
* Get maximum character length for a table column |
382
|
|
|
* @param string $table Database table name |
383
|
|
|
* @param string $column Table column name |
384
|
|
|
* @return int|null Max length or null on error |
385
|
|
|
*/ |
386
|
|
|
public function getColumnCharacterLength($table, $column) |
387
|
|
|
{ |
388
|
|
|
/** @var XoopsMySQLDatabase $db */ |
389
|
|
|
$db = XoopsDatabaseFactory::getDatabaseConnection(); |
390
|
|
|
|
391
|
|
|
$dbname = constant('XOOPS_DB_NAME'); |
392
|
|
|
$table = $db->prefix($table); |
393
|
|
|
|
394
|
|
|
// Use quoteString if available, otherwise fall back to escape |
395
|
|
|
$quoteFn = method_exists($db, 'quoteString') ? 'quoteString' : 'escape'; |
396
|
|
|
|
397
|
|
|
$sql = sprintf( |
398
|
|
|
'SELECT `CHARACTER_MAXIMUM_LENGTH` FROM `information_schema`.`COLUMNS` |
399
|
|
|
WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s AND `COLUMN_NAME` = %s', |
400
|
|
|
$db->$quoteFn($dbname), |
401
|
|
|
$db->$quoteFn($table), |
402
|
|
|
$db->$quoteFn($column) |
403
|
|
|
); |
404
|
|
|
|
405
|
|
|
/** @var mysqli_result|resource|false $result */ |
406
|
|
|
$result = $db->query($sql); |
407
|
|
|
if ($db->isResultSet($result)) { |
408
|
|
|
$row = $db->fetchRow($result); |
409
|
|
|
if ($row) { |
410
|
|
|
return (int)$row[0]; |
411
|
|
|
} |
412
|
|
|
} |
413
|
|
|
return null; |
414
|
|
|
} |
415
|
|
|
|
416
|
|
|
/** |
417
|
|
|
* Count users matching certain conditions |
418
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} object |
419
|
|
|
* @return int Number of users |
420
|
|
|
*/ |
421
|
|
|
public function getUserCount($criteria = null) |
422
|
|
|
{ |
423
|
|
|
return $this->userHandler->getCount($criteria); |
424
|
|
|
} |
425
|
|
|
|
426
|
|
|
/** |
427
|
|
|
* Count users belonging to a group |
428
|
|
|
* @param int $group_id ID of the group |
429
|
|
|
* @return int Number of users in the group |
430
|
|
|
*/ |
431
|
|
|
public function getUserCountByGroup($group_id) |
432
|
|
|
{ |
433
|
|
|
return $this->membershipHandler->getCount(new Criteria('groupid', (int)$group_id)); |
434
|
|
|
} |
435
|
|
|
|
436
|
|
|
/** |
437
|
|
|
* Update a single field in a user's record |
438
|
|
|
* @param XoopsUser $user Reference to the {@link XoopsUser} object |
439
|
|
|
* @param string $fieldName Name of the field to update |
440
|
|
|
* @param mixed $fieldValue Updated value for the field |
441
|
|
|
* @return bool TRUE if success or unchanged, FALSE on failure |
442
|
|
|
*/ |
443
|
|
|
public function updateUserByField(XoopsUser $user, $fieldName, $fieldValue) |
444
|
|
|
{ |
445
|
|
|
$user->setVar($fieldName, $fieldValue); |
446
|
|
|
return $this->insertUser($user); |
447
|
|
|
} |
448
|
|
|
|
449
|
|
|
/** |
450
|
|
|
* Update a single field for multiple users |
451
|
|
|
* @param string $fieldName Name of the field to update |
452
|
|
|
* @param mixed $fieldValue Updated value for the field |
453
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} object |
454
|
|
|
* @return bool TRUE if success or unchanged, FALSE on failure |
455
|
|
|
*/ |
456
|
|
|
public function updateUsersByField($fieldName, $fieldValue, $criteria = null) |
457
|
|
|
{ |
458
|
|
|
return $this->userHandler->updateAll($fieldName, $fieldValue, $criteria); |
459
|
|
|
} |
460
|
|
|
|
461
|
|
|
/** |
462
|
|
|
* Activate a user |
463
|
|
|
* @param XoopsUser $user Reference to the {@link XoopsUser} object |
464
|
|
|
* @return bool TRUE on success, FALSE on failure |
465
|
|
|
*/ |
466
|
|
|
public function activateUser(XoopsUser $user) |
467
|
|
|
{ |
468
|
|
|
if ($user->getVar('level') != 0) { |
469
|
|
|
return true; |
470
|
|
|
} |
471
|
|
|
$user->setVar('level', 1); |
472
|
|
|
|
473
|
|
|
// Generate more secure activation key |
474
|
|
|
$actkey = $this->generateSecureToken(8); |
475
|
|
|
$user->setVar('actkey', $actkey); |
476
|
|
|
|
477
|
|
|
return $this->userHandler->insert($user, true); |
478
|
|
|
} |
479
|
|
|
|
480
|
|
|
/** |
481
|
|
|
* Get a list of users belonging to certain groups and matching criteria |
482
|
|
|
* @param int|array $groups IDs of groups |
483
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} object |
484
|
|
|
* @param bool $asobject Return the users as objects? |
485
|
|
|
* @param bool $id_as_key Use the UID as key for the array if $asobject is TRUE |
486
|
|
|
* @return array Array of {@link XoopsUser} objects or user IDs |
487
|
|
|
*/ |
488
|
|
|
public function getUsersByGroupLink($groups, $criteria = null, $asobject = false, $id_as_key = false) |
489
|
|
|
{ |
490
|
|
|
$groups = (array)$groups; |
491
|
|
|
$validGroups = $this->sanitizeIds($groups); |
492
|
|
|
|
493
|
|
|
// If groups were specified but none are valid, return empty array |
494
|
|
|
if (!empty($groups) && empty($validGroups)) { |
495
|
|
|
return []; |
496
|
|
|
} |
497
|
|
|
|
498
|
|
|
$isDebug = $this->isDebugAllowed() && !$this->isProductionEnvironment(); |
499
|
|
|
|
500
|
|
|
/** @var XoopsMySQLDatabase $db */ |
501
|
|
|
$db = $this->userHandler->db; |
502
|
|
|
$select = $asobject ? 'u.*' : 'u.uid'; |
503
|
|
|
$sql = "SELECT {$select} FROM " . $db->prefix('users') . ' u'; |
504
|
|
|
$whereParts = []; |
505
|
|
|
|
506
|
|
|
if (!empty($validGroups)) { |
507
|
|
|
$linkTable = $db->prefix('groups_users_link'); |
508
|
|
|
$group_in = '(' . implode(', ', $validGroups) . ')'; |
509
|
|
|
$whereParts[] = "EXISTS (SELECT 1 FROM {$linkTable} m WHERE m.uid = u.uid AND m.groupid IN {$group_in})"; |
510
|
|
|
} |
511
|
|
|
|
512
|
|
|
$limit = 0; |
513
|
|
|
$start = 0; |
514
|
|
|
$orderBy = ''; |
515
|
|
|
|
516
|
|
|
if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) { |
517
|
|
|
$criteriaCompo = new CriteriaCompo(); |
518
|
|
|
$criteriaCompo->add($criteria, 'AND'); |
519
|
|
|
$sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', trim($criteriaCompo->render())); |
520
|
|
|
if ($sqlCriteria !== '') { |
521
|
|
|
$whereParts[] = $sqlCriteria; |
522
|
|
|
} |
523
|
|
|
|
524
|
|
|
$limit = (int)$criteria->getLimit(); |
525
|
|
|
$start = (int)$criteria->getStart(); |
526
|
|
|
|
527
|
|
|
// Apply safe sorting |
528
|
|
|
$sort = trim((string)$criteria->getSort()); |
529
|
|
|
$order = trim((string)$criteria->getOrder()); |
530
|
|
|
if ($sort !== '') { |
531
|
|
|
$allowed = $this->getAllowedSortFields(); |
532
|
|
|
if (isset($allowed[$sort])) { |
533
|
|
|
$orderBy = ' ORDER BY ' . $allowed[$sort]; |
534
|
|
|
$orderBy .= (strtoupper($order) === 'DESC') ? ' DESC' : ' ASC'; |
535
|
|
|
} |
536
|
|
|
} |
537
|
|
|
} |
538
|
|
|
|
539
|
|
|
if (!empty($whereParts)) { |
540
|
|
|
$sql .= ' WHERE ' . implode(' AND ', $whereParts); |
541
|
|
|
} |
542
|
|
|
$sql .= $orderBy; |
543
|
|
|
|
544
|
|
|
$result = $db->query($sql, $limit, $start); |
545
|
|
|
if (!$db->isResultSet($result)) { |
546
|
|
|
$this->logDatabaseError('Query failed in getUsersByGroupLink', $sql, [ |
547
|
|
|
'groups_count' => count($validGroups), |
548
|
|
|
'has_criteria' => isset($criteria) |
549
|
|
|
], $isDebug); |
550
|
|
|
return []; |
551
|
|
|
} |
552
|
|
|
|
553
|
|
|
$ret = []; |
554
|
|
|
/** @var array $myrow */ |
555
|
|
|
while (false !== ($myrow = $db->fetchArray($result))) { |
|
|
|
|
556
|
|
|
if ($asobject) { |
557
|
|
|
$user = new XoopsUser(); |
558
|
|
|
$user->assignVars($myrow); |
559
|
|
|
if ($id_as_key) { |
560
|
|
|
$ret[(int)$myrow['uid']] = $user; |
561
|
|
|
} else { |
562
|
|
|
$ret[] = $user; |
563
|
|
|
} |
564
|
|
|
} else { |
565
|
|
|
$ret[] = (int)$myrow['uid']; |
566
|
|
|
} |
567
|
|
|
} |
568
|
|
|
|
569
|
|
|
return $ret; |
570
|
|
|
} |
571
|
|
|
|
572
|
|
|
/** |
573
|
|
|
* Get count of users belonging to certain groups and matching criteria |
574
|
|
|
* @param array $groups IDs of groups |
575
|
|
|
* @param CriteriaElement|null $criteria {@link CriteriaElement} object |
576
|
|
|
* @return int Count of users |
577
|
|
|
*/ |
578
|
|
|
public function getUserCountByGroupLink(array $groups, $criteria = null) |
579
|
|
|
{ |
580
|
|
|
$validGroups = $this->sanitizeIds($groups); |
581
|
|
|
|
582
|
|
|
// If groups were specified but none are valid, return 0 |
583
|
|
|
if (!empty($groups) && empty($validGroups)) { |
584
|
|
|
return 0; |
585
|
|
|
} |
586
|
|
|
|
587
|
|
|
$isDebug = $this->isDebugAllowed() && !$this->isProductionEnvironment(); |
588
|
|
|
|
589
|
|
|
/** @var XoopsMySQLDatabase $db */ |
590
|
|
|
$db = $this->userHandler->db; |
591
|
|
|
$sql = 'SELECT COUNT(*) FROM ' . $db->prefix('users') . ' u'; |
592
|
|
|
$whereParts = []; |
593
|
|
|
|
594
|
|
|
if (!empty($validGroups)) { |
595
|
|
|
$linkTable = $db->prefix('groups_users_link'); |
596
|
|
|
$group_in = '(' . implode(', ', $validGroups) . ')'; |
597
|
|
|
$whereParts[] = "EXISTS (SELECT 1 FROM {$linkTable} m WHERE m.uid = u.uid AND m.groupid IN {$group_in})"; |
598
|
|
|
} |
599
|
|
|
|
600
|
|
|
if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) { |
601
|
|
|
$criteriaCompo = new CriteriaCompo(); |
602
|
|
|
$criteriaCompo->add($criteria, 'AND'); |
603
|
|
|
$sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', trim($criteriaCompo->render())); |
604
|
|
|
if ($sqlCriteria !== '') { |
605
|
|
|
$whereParts[] = $sqlCriteria; |
606
|
|
|
} |
607
|
|
|
} |
608
|
|
|
|
609
|
|
|
if (!empty($whereParts)) { |
610
|
|
|
$sql .= ' WHERE ' . implode(' AND ', $whereParts); |
611
|
|
|
} |
612
|
|
|
|
613
|
|
|
$result = $db->query($sql); |
614
|
|
|
if (!$db->isResultSet($result)) { |
615
|
|
|
$this->logDatabaseError('Query failed in getUserCountByGroupLink', $sql, [ |
616
|
|
|
'groups_count' => count($validGroups), |
617
|
|
|
'has_criteria' => isset($criteria) |
618
|
|
|
], $isDebug); |
619
|
|
|
return 0; |
620
|
|
|
} |
621
|
|
|
|
622
|
|
|
list($count) = $db->fetchRow($result); |
|
|
|
|
623
|
|
|
return (int)$count; |
624
|
|
|
} |
625
|
|
|
|
626
|
|
|
// ========================================================================= |
627
|
|
|
// Private Helper Methods |
628
|
|
|
// ========================================================================= |
629
|
|
|
|
630
|
|
|
/** |
631
|
|
|
* Create a safe IN criteria for IDs |
632
|
|
|
* @param string $field Field name |
633
|
|
|
* @param mixed $value Single value or array of values |
634
|
|
|
* @return CriteriaElement |
635
|
|
|
*/ |
636
|
|
|
private function createSafeInCriteria($field, $value) |
637
|
|
|
{ |
638
|
|
|
$ids = $this->sanitizeIds((array)$value); |
639
|
|
|
$inClause = !empty($ids) ? '(' . implode(',', $ids) . ')' : '(0)'; |
640
|
|
|
return new Criteria($field, $inClause, 'IN'); |
641
|
|
|
} |
642
|
|
|
|
643
|
|
|
/** |
644
|
|
|
* Sanitize an array of IDs |
645
|
|
|
* @param array $ids Array of potential IDs |
646
|
|
|
* @return array Array of valid integer IDs |
647
|
|
|
*/ |
648
|
|
|
private function sanitizeIds(array $ids) |
649
|
|
|
{ |
650
|
|
|
return array_values(array_filter( |
651
|
|
|
array_map('intval', array_filter($ids, function($v) { |
652
|
|
|
return is_int($v) || (is_string($v) && ctype_digit($v)); |
653
|
|
|
})), |
654
|
|
|
function($v) { return $v > 0; } |
655
|
|
|
)); |
656
|
|
|
} |
657
|
|
|
|
658
|
|
|
/** |
659
|
|
|
* Get allowed sort fields for SQL queries |
660
|
|
|
* @return array Map of allowed field names to SQL columns |
661
|
|
|
*/ |
662
|
|
|
private function getAllowedSortFields() |
663
|
|
|
{ |
664
|
|
|
return [ |
665
|
|
|
// Non-prefixed (backward compatibility) |
666
|
|
|
'uid' => 'u.uid', |
667
|
|
|
'uname' => 'u.uname', |
668
|
|
|
'email' => 'u.email', |
669
|
|
|
'user_regdate' => 'u.user_regdate', |
670
|
|
|
'last_login' => 'u.last_login', |
671
|
|
|
'user_avatar' => 'u.user_avatar', |
672
|
|
|
'name' => 'u.name', |
673
|
|
|
'posts' => 'u.posts', |
674
|
|
|
'level' => 'u.level', |
675
|
|
|
// Prefixed (explicit) |
676
|
|
|
'u.uid' => 'u.uid', |
677
|
|
|
'u.uname' => 'u.uname', |
678
|
|
|
'u.email' => 'u.email', |
679
|
|
|
'u.user_regdate' => 'u.user_regdate', |
680
|
|
|
'u.last_login' => 'u.last_login', |
681
|
|
|
'u.user_avatar' => 'u.user_avatar', |
682
|
|
|
'u.name' => 'u.name', |
683
|
|
|
'u.posts' => 'u.posts', |
684
|
|
|
'u.level' => 'u.level' |
685
|
|
|
]; |
686
|
|
|
} |
687
|
|
|
|
688
|
|
|
/** |
689
|
|
|
* Timing-safe string comparison (PHP 7.4 compatible) |
690
|
|
|
* @param string $expected Expected string |
691
|
|
|
* @param string $actual Actual string |
692
|
|
|
* @return bool TRUE if strings are equal |
693
|
|
|
*/ |
694
|
|
|
private function hashEquals($expected, $actual) |
695
|
|
|
{ |
696
|
|
|
// Use hash_equals if available (PHP 5.6+) |
697
|
|
|
if (function_exists('hash_equals')) { |
698
|
|
|
return hash_equals($expected, $actual); |
699
|
|
|
} |
700
|
|
|
|
701
|
|
|
// Fallback implementation |
702
|
|
|
$expected = (string)$expected; |
703
|
|
|
$actual = (string)$actual; |
704
|
|
|
$expectedLength = strlen($expected); |
705
|
|
|
$actualLength = strlen($actual); |
706
|
|
|
$diff = $expectedLength ^ $actualLength; |
707
|
|
|
|
708
|
|
|
for ($i = 0; $i < $actualLength; $i++) { |
709
|
|
|
$diff |= ord($expected[$i % $expectedLength]) ^ ord($actual[$i]); |
710
|
|
|
} |
711
|
|
|
|
712
|
|
|
return $diff === 0; |
713
|
|
|
} |
714
|
|
|
|
715
|
|
|
/** |
716
|
|
|
* Generate a secure random token |
717
|
|
|
* @param int $length Token length |
718
|
|
|
* @return string Random token |
719
|
|
|
*/ |
720
|
|
|
private function generateSecureToken($length = 16) |
721
|
|
|
{ |
722
|
|
|
if (function_exists('random_bytes')) { |
723
|
|
|
// PHP 7.0+ |
724
|
|
|
return substr(bin2hex(random_bytes($length)), 0, $length); |
725
|
|
|
} elseif (function_exists('openssl_random_pseudo_bytes')) { |
726
|
|
|
// PHP 5.3+ with OpenSSL |
727
|
|
|
return substr(bin2hex(openssl_random_pseudo_bytes($length)), 0, $length); |
728
|
|
|
} else { |
729
|
|
|
// Fallback to less secure method |
730
|
|
|
return substr(md5(uniqid(mt_rand(), true)), 0, $length); |
731
|
|
|
} |
732
|
|
|
} |
733
|
|
|
|
734
|
|
|
/** |
735
|
|
|
* Check if debug output is allowed |
736
|
|
|
* @return bool TRUE if debugging is allowed |
737
|
|
|
*/ |
738
|
|
|
private function isDebugAllowed() |
739
|
|
|
{ |
740
|
|
|
$mode = (int)($GLOBALS['xoopsConfig']['debug_mode'] ?? 0); |
741
|
|
|
if (!in_array($mode, [1, 2], true)) { |
742
|
|
|
return false; |
743
|
|
|
} |
744
|
|
|
|
745
|
|
|
$level = (int)($GLOBALS['xoopsConfig']['debugLevel'] ?? 0); |
746
|
|
|
$user = $GLOBALS['xoopsUser'] ?? null; |
747
|
|
|
$isAdmin = (bool)($GLOBALS['xoopsUserIsAdmin'] ?? false); |
748
|
|
|
|
749
|
|
|
switch ($level) { |
750
|
|
|
case 2: |
751
|
|
|
return $isAdmin; |
752
|
|
|
case 1: |
753
|
|
|
return $user !== null; |
754
|
|
|
default: |
755
|
|
|
return true; |
756
|
|
|
} |
757
|
|
|
} |
758
|
|
|
|
759
|
|
|
/** |
760
|
|
|
* Check if running in production environment |
761
|
|
|
* @return bool TRUE if in production |
762
|
|
|
*/ |
763
|
|
|
private function isProductionEnvironment() |
764
|
|
|
{ |
765
|
|
|
// Check explicit production flag |
766
|
|
|
if (defined('XOOPS_PRODUCTION') && XOOPS_PRODUCTION) { |
|
|
|
|
767
|
|
|
return true; |
768
|
|
|
} |
769
|
|
|
|
770
|
|
|
// Check environment variable |
771
|
|
|
if (getenv('XOOPS_ENV') === 'production') { |
772
|
|
|
return true; |
773
|
|
|
} |
774
|
|
|
|
775
|
|
|
// Check for development indicators |
776
|
|
|
if ((defined('XOOPS_DEBUG') && XOOPS_DEBUG) || |
|
|
|
|
777
|
|
|
(php_sapi_name() === 'cli') || |
778
|
|
|
(isset($_SERVER['SERVER_ADDR']) && in_array($_SERVER['SERVER_ADDR'], ['127.0.0.1', '::1'], true))) { |
779
|
|
|
return false; |
780
|
|
|
} |
781
|
|
|
|
782
|
|
|
// Default to production for safety |
783
|
|
|
return true; |
784
|
|
|
} |
785
|
|
|
|
786
|
|
|
/** |
787
|
|
|
* Redact sensitive information from SQL queries |
788
|
|
|
* @param string $sql SQL query string |
789
|
|
|
* @return string Redacted SQL query |
790
|
|
|
*/ |
791
|
|
|
private function redactSql($sql) |
792
|
|
|
{ |
793
|
|
|
// Redact quoted strings |
794
|
|
|
$sql = preg_replace("/'[^']*'/", "'?'", $sql); |
795
|
|
|
$sql = preg_replace('/"[^"]*"/', '"?"', $sql); |
796
|
|
|
$sql = preg_replace("/x'[0-9A-Fa-f]+'/", "x'?'", $sql); |
797
|
|
|
return $sql; |
798
|
|
|
} |
799
|
|
|
|
800
|
|
|
/** |
801
|
|
|
* Sanitize string for logging |
802
|
|
|
* @param string $str String to sanitize |
803
|
|
|
* @return string Sanitized string |
804
|
|
|
*/ |
805
|
|
|
private function sanitizeForLog($str) |
806
|
|
|
{ |
807
|
|
|
// Remove control characters |
808
|
|
|
$str = preg_replace('/[\x00-\x1F\x7F]/', '', $str); |
809
|
|
|
// Remove BIDI control characters |
810
|
|
|
$str = preg_replace(self::BIDI_CONTROL_REGEX, '', $str); |
811
|
|
|
// Normalize whitespace |
812
|
|
|
$str = preg_replace('/\s+/', ' ', $str); |
813
|
|
|
// Limit length |
814
|
|
|
if (function_exists('mb_substr')) { |
815
|
|
|
return mb_substr($str, 0, self::LOG_CONTEXT_MAX_LENGTH); |
816
|
|
|
} |
817
|
|
|
return substr($str, 0, self::LOG_CONTEXT_MAX_LENGTH); |
818
|
|
|
} |
819
|
|
|
|
820
|
|
|
/** |
821
|
|
|
* Log database errors with context |
822
|
|
|
* @param string $message Error message |
823
|
|
|
* @param string $sql SQL query that failed |
824
|
|
|
* @param array $context Additional context |
825
|
|
|
* @param bool $isDebug Whether debug mode is enabled |
826
|
|
|
*/ |
827
|
|
|
private function logDatabaseError($message, $sql, array $context, $isDebug) |
828
|
|
|
{ |
829
|
|
|
/** @var XoopsMySQLDatabase $db */ |
830
|
|
|
$db = $this->userHandler->db; |
831
|
|
|
|
832
|
|
|
$errorInfo = [ |
833
|
|
|
'message' => $message, |
834
|
|
|
'user_id' => isset($GLOBALS['xoopsUser']) ? (int)$GLOBALS['xoopsUser']->getVar('uid') : 'anonymous', |
835
|
|
|
'timestamp' => date('Y-m-d H:i:s') |
836
|
|
|
]; |
837
|
|
|
|
838
|
|
|
// Add request context |
839
|
|
|
if (isset($_SERVER['REQUEST_METHOD'])) { |
840
|
|
|
$errorInfo['method'] = $this->sanitizeForLog($_SERVER['REQUEST_METHOD']); |
841
|
|
|
$errorInfo['uri'] = $this->sanitizeRequestUri(); |
842
|
|
|
} |
843
|
|
|
|
844
|
|
|
// Add provided context |
845
|
|
|
$errorInfo = array_merge($errorInfo, $context); |
846
|
|
|
|
847
|
|
|
// Add database error if available |
848
|
|
|
if (method_exists($db, 'error')) { |
849
|
|
|
$errorInfo['db_error'] = $db->error(); |
850
|
|
|
} |
851
|
|
|
if (method_exists($db, 'errno')) { |
852
|
|
|
$errorInfo['db_errno'] = $db->errno(); |
853
|
|
|
} |
854
|
|
|
|
855
|
|
|
// Build log message |
856
|
|
|
$logMessage = $message . ' | Context: ' . json_encode($errorInfo, JSON_UNESCAPED_SLASHES); |
857
|
|
|
|
858
|
|
|
// Add SQL in debug mode only |
859
|
|
|
if ($isDebug) { |
860
|
|
|
$logMessage .= ' | SQL: ' . $this->redactSql($sql); |
861
|
|
|
} |
862
|
|
|
|
863
|
|
|
// Log the error |
864
|
|
|
if (class_exists('XoopsLogger')) { |
865
|
|
|
XoopsLogger::getInstance()->handleError(E_USER_WARNING, $logMessage, __FILE__, __LINE__); |
866
|
|
|
} else { |
867
|
|
|
error_log($logMessage); |
868
|
|
|
} |
869
|
|
|
} |
870
|
|
|
|
871
|
|
|
/** |
872
|
|
|
* Sanitize request URI for logging |
873
|
|
|
* @return string Sanitized URI |
874
|
|
|
*/ |
875
|
|
|
private function sanitizeRequestUri() |
876
|
|
|
{ |
877
|
|
|
if (!isset($_SERVER['REQUEST_URI'])) { |
878
|
|
|
return 'cli'; |
879
|
|
|
} |
880
|
|
|
|
881
|
|
|
$parts = parse_url($_SERVER['REQUEST_URI']); |
882
|
|
|
$path = $this->sanitizeForLog($parts['path'] ?? '/'); |
883
|
|
|
|
884
|
|
|
if (isset($parts['query'])) { |
885
|
|
|
parse_str($parts['query'], $queryParams); |
886
|
|
|
foreach ($queryParams as $key => &$value) { |
887
|
|
|
if (in_array(strtolower($key), self::SENSITIVE_PARAMS, true)) { |
888
|
|
|
$value = 'REDACTED'; |
889
|
|
|
} else { |
890
|
|
|
$value = is_array($value) ? '[ARRAY]' : $this->sanitizeForLog((string)$value); |
891
|
|
|
} |
892
|
|
|
} |
893
|
|
|
unset($value); |
894
|
|
|
$queryString = http_build_query($queryParams); |
895
|
|
|
return $queryString ? $path . '?' . $queryString : $path; |
896
|
|
|
} |
897
|
|
|
|
898
|
|
|
return $path; |
899
|
|
|
} |
900
|
|
|
|
901
|
|
|
/** |
902
|
|
|
* Log security-related events |
903
|
|
|
* @param string $event Event description |
904
|
|
|
* @param array $context Additional context |
905
|
|
|
*/ |
906
|
|
|
private function logSecurityEvent($event, array $context = []) |
907
|
|
|
{ |
908
|
|
|
$logData = [ |
909
|
|
|
'event' => $event, |
910
|
|
|
'timestamp' => date('Y-m-d H:i:s'), |
911
|
|
|
'user_id' => isset($GLOBALS['xoopsUser']) ? (int)$GLOBALS['xoopsUser']->getVar('uid') : 'anonymous', |
912
|
|
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' |
913
|
|
|
]; |
914
|
|
|
|
915
|
|
|
$logData = array_merge($logData, $context); |
916
|
|
|
$message = 'Security Event: ' . json_encode($logData, JSON_UNESCAPED_SLASHES); |
917
|
|
|
|
918
|
|
|
if (class_exists('XoopsLogger')) { |
919
|
|
|
XoopsLogger::getInstance()->handleError(E_USER_NOTICE, $message, __FILE__, __LINE__); |
920
|
|
|
} else { |
921
|
|
|
error_log($message); |
922
|
|
|
} |
923
|
|
|
} |
924
|
|
|
} |
925
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.