Passed
Branch feature/member (3c527d)
by Michael
07:47
created

XoopsMemberHandler::generateSecureToken()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 11
rs 10
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $users of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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.

Loading history...
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))) {
0 ignored issues
show
Bug introduced by
It seems like $result can also be of type boolean; however, parameter $result of XoopsMySQLDatabase::fetchArray() does only seem to accept mysqli_result, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

555
        while (false !== ($myrow = $db->fetchArray(/** @scrutinizer ignore-type */ $result))) {
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $result can also be of type boolean; however, parameter $result of XoopsMySQLDatabase::fetchRow() does only seem to accept mysqli_result, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

622
        list($count) = $db->fetchRow(/** @scrutinizer ignore-type */ $result);
Loading history...
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) {
0 ignored issues
show
Bug introduced by
The constant XOOPS_PRODUCTION was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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) ||
0 ignored issues
show
Bug introduced by
The constant XOOPS_DEBUG was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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