• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / shield / 22657701952

04 Mar 2026 06:20AM UTC coverage: 92.802% (+0.01%) from 92.788%
22657701952

push

github

web-flow
chore: upgrade `firebase/php-jwt` to v7 (#1316)

* chore: upgrade firebase/php-jwt to v7

* upgrading

2901 of 3126 relevant lines covered (92.8%)

52.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.39
/src/Models/UserModel.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter Shield.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Shield\Models;
15

16
use CodeIgniter\Database\Exceptions\DataException;
17
use CodeIgniter\Database\RawSql;
18
use CodeIgniter\I18n\Time;
19
use CodeIgniter\Shield\Authentication\Authenticators\Session;
20
use CodeIgniter\Shield\Entities\User;
21
use CodeIgniter\Shield\Entities\UserIdentity;
22
use CodeIgniter\Shield\Exceptions\InvalidArgumentException;
23
use CodeIgniter\Shield\Exceptions\LogicException;
24
use CodeIgniter\Shield\Exceptions\ValidationException;
25
use Faker\Generator;
26

27
/**
28
 * @phpstan-consistent-constructor
29
 */
30
class UserModel extends BaseModel
31
{
32
    protected $primaryKey     = 'id';
33
    protected $returnType     = User::class;
34
    protected $useSoftDeletes = true;
35
    protected $allowedFields  = [
36
        'username',
37
        'status',
38
        'status_message',
39
        'active',
40
        'last_active',
41
    ];
42
    protected $useTimestamps = true;
43
    protected $afterFind     = ['fetchIdentities', 'fetchGroups', 'fetchPermissions'];
44
    protected $afterInsert   = ['saveEmailIdentity'];
45
    protected $afterUpdate   = ['saveEmailIdentity'];
46

47
    /**
48
     * Whether identity records should be included
49
     * when user records are fetched from the database.
50
     */
51
    protected bool $fetchIdentities = false;
52

53
    /**
54
     * Whether groups should be included
55
     * when user records are fetched from the database.
56
     */
57
    protected bool $fetchGroups = false;
58

59
    /**
60
     * Whether permissions should be included
61
     * when user records are fetched from the database.
62
     */
63
    protected bool $fetchPermissions = false;
64

65
    /**
66
     * Save the User for afterInsert and afterUpdate
67
     */
68
    protected ?User $tempUser = null;
69

70
    protected function initialize(): void
71
    {
72
        parent::initialize();
604✔
73

74
        $this->table = $this->tables['users'];
604✔
75
    }
76

77
    /**
78
     * Mark the next find* query to include identities
79
     *
80
     * @return $this
81
     */
82
    public function withIdentities(): self
83
    {
84
        $this->fetchIdentities = true;
12✔
85

86
        return $this;
12✔
87
    }
88

89
    /**
90
     * Mark the next find* query to include groups
91
     *
92
     * @return $this
93
     */
94
    public function withGroups(): self
95
    {
96
        $this->fetchGroups = true;
18✔
97

98
        return $this;
18✔
99
    }
100

101
    /**
102
     * Mark the next find* query to include permissions
103
     *
104
     * @return $this
105
     */
106
    public function withPermissions(): self
107
    {
108
        $this->fetchPermissions = true;
16✔
109

110
        return $this;
16✔
111
    }
112

113
    /**
114
     * Populates identities for all records
115
     * returned from a find* method. Called
116
     * automatically when $this->fetchIdentities == true
117
     *
118
     * Model event callback called by `afterFind`.
119
     */
120
    protected function fetchIdentities(array $data): array
121
    {
122
        if (! $this->fetchIdentities) {
524✔
123
            return $data;
524✔
124
        }
125

126
        $userIds = $data['singleton']
12✔
127
            ? array_column($data, 'id')
6✔
128
            : array_column($data['data'], 'id');
6✔
129

130
        if ($userIds === []) {
12✔
131
            return $data;
4✔
132
        }
133

134
        /** @var UserIdentityModel $identityModel */
135
        $identityModel = model(UserIdentityModel::class);
8✔
136

137
        // Get our identities for all users
138
        $identities = $identityModel->getIdentitiesByUserIds($userIds);
8✔
139

140
        if (empty($identities)) {
8✔
141
            return $data;
×
142
        }
143

144
        $mappedUsers = $this->assignIdentities($data, $identities);
8✔
145

146
        if ($data['singleton'] && ! isset($data['id'])) {
8✔
147
            $data['id'] = $data['data']->id;
2✔
148
        }
149

150
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
8✔
151

152
        return $data;
8✔
153
    }
154

155
    /**
156
     * Map our users by ID to make assigning simpler
157
     *
158
     * @param array              $data       Event $data
159
     * @param list<UserIdentity> $identities
160
     *
161
     * @return         list<User>              UserId => User object
162
     * @phpstan-return array<int|string, User> UserId => User object
163
     */
164
    private function assignIdentities(array $data, array $identities): array
165
    {
166
        $mappedUsers    = [];
8✔
167
        $userIdentities = [];
8✔
168

169
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
8✔
170

171
        foreach ($users as $user) {
8✔
172
            $mappedUsers[$user->id] = $user;
8✔
173
        }
174
        unset($users);
8✔
175

176
        // Now group the identities by user
177
        foreach ($identities as $identity) {
8✔
178
            $userIdentities[$identity->user_id][] = $identity;
8✔
179
        }
180
        unset($identities);
8✔
181

182
        // Now assign the identities to the user
183
        foreach ($userIdentities as $userId => $identityArray) {
8✔
184
            $mappedUsers[$userId]->identities = $identityArray;
8✔
185
        }
186
        unset($userIdentities);
8✔
187

188
        return $mappedUsers;
8✔
189
    }
190

191
    /**
192
     * Populates groups for all records
193
     * returned from a find* method. Called
194
     * automatically when $this->fetchGroups == true
195
     *
196
     * Model event callback called by `afterFind`.
197
     */
198
    protected function fetchGroups(array $data): array
199
    {
200
        if (! $this->fetchGroups) {
524✔
201
            return $data;
524✔
202
        }
203

204
        $userIds = $data['singleton']
18✔
205
            ? array_column($data, 'id')
12✔
206
            : array_column($data['data'], 'id');
6✔
207

208
        if ($userIds === []) {
18✔
209
            return $data;
4✔
210
        }
211

212
        /** @var GroupModel $groupModel */
213
        $groupModel = model(GroupModel::class);
14✔
214

215
        // Get our groups for all users
216
        $groups = $groupModel->getGroupsByUserIds($userIds);
14✔
217

218
        $mappedUsers = $this->assignProperties($data, $groups, 'groups');
14✔
219

220
        if ($data['singleton'] && ! isset($data['id'])) {
14✔
221
            $data['id'] = $data['data']->id;
2✔
222
        }
223

224
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
14✔
225

226
        return $data;
14✔
227
    }
228

229
    /**
230
     * Populates permissions for all records
231
     * returned from a find* method. Called
232
     * automatically when $this->fetchPermissions == true
233
     *
234
     * Model event callback called by `afterFind`.
235
     */
236
    protected function fetchPermissions(array $data): array
237
    {
238
        if (! $this->fetchPermissions) {
524✔
239
            return $data;
524✔
240
        }
241

242
        $userIds = $data['singleton']
16✔
243
            ? array_column($data, 'id')
10✔
244
            : array_column($data['data'], 'id');
6✔
245

246
        if ($userIds === []) {
16✔
247
            return $data;
4✔
248
        }
249

250
        /** @var PermissionModel $permissionModel */
251
        $permissionModel = model(PermissionModel::class);
12✔
252

253
        $permissions = $permissionModel->getPermissionsByUserIds($userIds);
12✔
254

255
        $mappedUsers = $this->assignProperties($data, $permissions, 'permissions');
12✔
256

257
        if ($data['singleton'] && ! isset($data['id'])) {
12✔
258
            $data['id'] = $data['data']->id;
2✔
259
        }
260

261
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
12✔
262

263
        return $data;
12✔
264
    }
265

266
    /**
267
     * Map our users by ID to make assigning simpler
268
     *
269
     * @param array       $data       Event $data
270
     * @param list<array> $properties
271
     * @param string      $type       One of: 'groups' or 'permissions'
272
     *
273
     * @return list<User> UserId => User object
274
     */
275
    private function assignProperties(array $data, array $properties, string $type): array
276
    {
277
        $mappedUsers = [];
20✔
278

279
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
20✔
280

281
        foreach ($users as $user) {
20✔
282
            $mappedUsers[$user->id] = $user;
20✔
283
        }
284
        unset($users);
20✔
285

286
        // Build method name
287
        $method = 'set' . ucfirst($type) . 'Cache';
20✔
288

289
        // Assign properties to all users (empty array if no properties found)
290
        foreach ($mappedUsers as $userId => $user) {
20✔
291
            $propertyArray = $properties[$userId] ?? [];
20✔
292
            $user->{$method}($propertyArray);
20✔
293
        }
294
        unset($properties);
20✔
295

296
        return $mappedUsers;
20✔
297
    }
298

299
    /**
300
     * Adds a user to the default group.
301
     * Used during registration.
302
     */
303
    public function addToDefaultGroup(User $user): void
304
    {
305
        $defaultGroup = setting('AuthGroups.defaultGroup');
14✔
306

307
        /** @var GroupModel $groupModel */
308
        $groupModel = model(GroupModel::class);
14✔
309

310
        if (empty($defaultGroup) || ! $groupModel->isValidGroup($defaultGroup)) {
14✔
311
            throw new InvalidArgumentException(lang('Auth.unknownGroup', [$defaultGroup ?? '--not found--']));
×
312
        }
313

314
        $user->addGroup($defaultGroup);
14✔
315
    }
316

317
    public function fake(Generator &$faker): User
318
    {
319
        $this->checkReturnType();
510✔
320

321
        return new $this->returnType([
510✔
322
            'username' => $faker->unique()->userName(),
510✔
323
            'active'   => true,
510✔
324
        ]);
510✔
325
    }
326

327
    /**
328
     * Locates a User object by ID.
329
     *
330
     * @param int|string $id
331
     */
332
    public function findById($id): ?User
333
    {
334
        return $this->find($id);
178✔
335
    }
336

337
    /**
338
     * Locate a User object by the given credentials.
339
     *
340
     * @param array<string, string> $credentials
341
     */
342
    public function findByCredentials(array $credentials): ?User
343
    {
344
        // Email is stored in an identity so remove that here
345
        $email = $credentials['email'] ?? null;
96✔
346
        unset($credentials['email']);
96✔
347

348
        if ($email === null && $credentials === []) {
96✔
349
            return null;
2✔
350
        }
351

352
        // any of the credentials used should be case-insensitive
353
        foreach ($credentials as $key => $value) {
96✔
354
            $this->where(
6✔
355
                'LOWER(' . $this->db->protectIdentifiers($this->table . ".{$key}") . ')',
6✔
356
                strtolower($value),
6✔
357
            );
6✔
358
        }
359

360
        if ($email !== null) {
96✔
361
            /** @var array<string, int|string|null>|null $data */
362
            $data = $this->select(
90✔
363
                sprintf('%1$s.*, %2$s.secret as email, %2$s.secret2 as password_hash', $this->table, $this->tables['identities']),
90✔
364
            )
90✔
365
                ->join($this->tables['identities'], sprintf('%1$s.user_id = %2$s.id', $this->tables['identities'], $this->table))
90✔
366
                ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD)
90✔
367
                ->where(
90✔
368
                    'LOWER(' . $this->db->protectIdentifiers($this->tables['identities'] . '.secret') . ')',
90✔
369
                    strtolower($email),
90✔
370
                )
90✔
371
                ->asArray()
90✔
372
                ->first();
90✔
373

374
            if ($data === null) {
90✔
375
                return null;
22✔
376
            }
377

378
            $email = $data['email'];
68✔
379
            unset($data['email']);
68✔
380
            $password_hash = $data['password_hash'];
68✔
381
            unset($data['password_hash']);
68✔
382

383
            $this->checkReturnType();
68✔
384

385
            $user                = new $this->returnType($data);
68✔
386
            $user->email         = $email;
68✔
387
            $user->password_hash = $password_hash;
68✔
388
            $user->syncOriginal();
68✔
389

390
            return $user;
68✔
391
        }
392

393
        return $this->first();
6✔
394
    }
395

396
    /**
397
     * Activate a User.
398
     */
399
    public function activate(User $user): void
400
    {
401
        $user->active = true;
×
402

403
        $this->save($user);
×
404
    }
405

406
    /**
407
     * Override the BaseModel's `insert()` method.
408
     * If you pass User object, also inserts Email Identity.
409
     *
410
     * @param array|User $row
411
     *
412
     * @return int|string|true Insert ID if $returnID is true
413
     *
414
     * @throws ValidationException
415
     */
416
    public function insert($row = null, bool $returnID = true)
417
    {
418
        // Clone User object for not changing the passed object.
419
        $this->tempUser = $row instanceof User ? clone $row : null;
522✔
420

421
        $result = parent::insert($row, $returnID);
522✔
422

423
        $this->checkQueryReturn($result);
522✔
424

425
        return $returnID ? $this->insertID : $result;
522✔
426
    }
427

428
    /**
429
     * Override the BaseModel's `update()` method.
430
     * If you pass User object, also updates Email Identity.
431
     *
432
     * @param int|list<int|string>|RawSql|string|null $id
433
     * @param array|User                              $row
434
     *
435
     * @return true if the update is successful
436
     *
437
     * @throws ValidationException
438
     */
439
    public function update($id = null, $row = null): bool
440
    {
441
        // Clone User object for not changing the passed object.
442
        $this->tempUser = $row instanceof User ? clone $row : null;
60✔
443

444
        try {
445
            /** @throws DataException */
446
            $result = parent::update($id, $row);
60✔
447
        } catch (DataException $e) {
16✔
448
            // When $data is an array.
449
            if ($this->tempUser === null) {
16✔
450
                throw $e;
2✔
451
            }
452

453
            $messages = [
14✔
454
                lang('Database.emptyDataset', ['update']),
14✔
455
            ];
14✔
456

457
            if (in_array($e->getMessage(), $messages, true)) {
14✔
458
                $this->tempUser->saveEmailIdentity();
14✔
459

460
                return true;
14✔
461
            }
462

463
            throw $e;
×
464
        }
465

466
        $this->checkQueryReturn($result);
48✔
467

468
        return true;
48✔
469
    }
470

471
    /**
472
     * Override the BaseModel's `save()` method.
473
     * If you pass User object, also updates Email Identity.
474
     *
475
     * @param array|User $row
476
     *
477
     * @return true if the save is successful
478
     *
479
     * @throws ValidationException
480
     */
481
    public function save($row): bool
482
    {
483
        $result = parent::save($row);
74✔
484

485
        $this->checkQueryReturn($result);
72✔
486

487
        return true;
72✔
488
    }
489

490
    /**
491
     * Save Email Identity
492
     *
493
     * Model event callback called by `afterInsert` and `afterUpdate`.
494
     */
495
    protected function saveEmailIdentity(array $data): array
496
    {
497
        // If insert()/update() gets an array data, do nothing.
498
        if ($this->tempUser === null) {
522✔
499
            return $data;
20✔
500
        }
501

502
        // Insert
503
        if ($this->tempUser->id === null) {
520✔
504
            /** @var User $user */
505
            $user = $this->find($this->db->insertID());
506✔
506

507
            // If you get identity (email/password), the User object must have the id.
508
            $this->tempUser->id = $user->id;
506✔
509

510
            $user->email         = $this->tempUser->email ?? '';
506✔
511
            $user->password      = $this->tempUser->password ?? '';
506✔
512
            $user->password_hash = $this->tempUser->password_hash ?? '';
506✔
513

514
            $user->saveEmailIdentity();
506✔
515
            $this->tempUser = null;
506✔
516

517
            return $data;
506✔
518
        }
519

520
        // Update
521
        $this->tempUser->saveEmailIdentity();
52✔
522
        $this->tempUser = null;
52✔
523

524
        return $data;
52✔
525
    }
526

527
    /**
528
     * Updates the user's last active date.
529
     */
530
    public function updateActiveDate(User $user): void
531
    {
532
        assert($user->last_active instanceof Time);
533

534
        // Safe date string for database
535
        $last_active = $this->timeToDate($user->last_active);
26✔
536

537
        $this->builder()
26✔
538
            ->set('last_active', $last_active)
26✔
539
            ->where('id', $user->id)
26✔
540
            ->update();
26✔
541
    }
542

543
    private function checkReturnType(): void
544
    {
545
        if (! is_a($this->returnType, User::class, true)) {
528✔
546
            throw new LogicException('Return type must be a subclass of ' . User::class);
×
547
        }
548
    }
549

550
    /**
551
     * Returns a new User Entity.
552
     *
553
     * @param array<string, array<array-key, mixed>|bool|float|int|object|string|null> $data (Optional) user data
554
     */
555
    public function createNewUser(array $data = []): User
556
    {
557
        return new $this->returnType($data);
12✔
558
    }
559
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc