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

daycry / auth / 22527357078

28 Feb 2026 07:22PM UTC coverage: 63.267% (+0.7%) from 62.568%
22527357078

push

github

daycry
Remove PHP 8.1 from PHPUnit CI matrix

Update .github/workflows/phpunit.yml to drop PHP 8.1 from the test matrix. CI will now run PHPUnit only on PHP 8.2 and 8.3, reducing the matrix to current supported versions.

3064 of 4843 relevant lines covered (63.27%)

41.52 hits per line

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

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

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
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 Daycry\Auth\Models;
15

16
use CodeIgniter\Database\Exceptions\DataException;
17
use CodeIgniter\Database\RawSql;
18
use CodeIgniter\I18n\Time;
19
use Daycry\Auth\Authentication\Authenticators\Session;
20
use Daycry\Auth\Entities\User;
21
use Daycry\Auth\Entities\UserIdentity;
22
use Daycry\Auth\Exceptions\InvalidArgumentException;
23
use Daycry\Auth\Exceptions\ValidationException;
24
use Daycry\Auth\Interfaces\UserProviderInterface;
25
use Faker\Generator;
26
use Symfony\Component\Uid\Uuid;
27

28
/**
29
 * @phpstan-consistent-constructor
30
 */
31
class UserModel extends BaseModel implements UserProviderInterface
32
{
33
    protected $table;
34
    protected $primaryKey     = 'id';
35
    protected $returnType     = User::class;
36
    protected $useSoftDeletes = false;
37
    protected $allowedFields  = [
38
        'uuid',
39
        'username',
40
        'status',
41
        'status_message',
42
        'active',
43
        'last_active',
44
        'failed_login_count',
45
        'locked_until',
46
    ];
47
    protected $useTimestamps = true;
48
    protected $createdField  = 'created_at';
49
    protected $updatedField  = 'updated_at';
50
    protected $deletedField  = 'deleted_at';
51
    protected $beforeInsert  = ['generateUuid'];
52
    protected $afterFind     = ['fetchIdentities'];
53
    protected $afterInsert   = ['saveEmailIdentity'];
54
    protected $afterUpdate   = ['saveEmailIdentity'];
55

56
    /**
57
     * Whether identity records should be included
58
     * when user records are fetched from the database.
59
     */
60
    protected bool $fetchIdentities = false;
61

62
    /**
63
     * Save the User for afterInsert and afterUpdate
64
     */
65
    protected ?User $tempUser = null;
66

67
    protected function initialize(): void
287✔
68
    {
69
        parent::initialize();
287✔
70

71
        $this->table = $this->tables['users'];
287✔
72
    }
73

74
    /**
75
     * Generates a UUID v7 for new user records.
76
     *
77
     * Model event callback called by `beforeInsert`.
78
     *
79
     * @param array<string, mixed> $data
80
     *
81
     * @return array<string, mixed>
82
     */
83
    protected function generateUuid(array $data): array
232✔
84
    {
85
        if (empty($data['data']['uuid'])) {
232✔
86
            $data['data']['uuid'] = Uuid::v7()->toRfc4122();
232✔
87
        }
88

89
        return $data;
232✔
90
    }
91

92
    /**
93
     * Mark the next find* query to include identities
94
     *
95
     * @return $this
96
     */
97
    public function withIdentities(): self
4✔
98
    {
99
        $this->fetchIdentities = true;
4✔
100

101
        return $this;
4✔
102
    }
103

104
    /**
105
     * Populates identities for all records
106
     * returned from a find* method. Called
107
     * automatically when $this->fetchIdentities == true
108
     *
109
     * Model event callback called by `afterFind`.
110
     */
111
    protected function fetchIdentities(array $data): array
230✔
112
    {
113
        if (! $this->fetchIdentities || empty($data['data'])) {
230✔
114
            return $data;
230✔
115
        }
116

117
        $userIds = $data['singleton']
2✔
118
            ? [$data['data']->id]
1✔
119
            : array_column($data['data'], 'id');
1✔
120

121
        if ($userIds === []) {
2✔
122
            return $data;
×
123
        }
124

125
        /** @var UserIdentityModel $identityModel */
126
        $identityModel = model(UserIdentityModel::class);
2✔
127

128
        // Get our identities for all users
129
        $identities = $identityModel->getIdentitiesByUserIds($userIds);
2✔
130

131
        if (empty($identities)) {
2✔
132
            return $data;
×
133
        }
134

135
        $mappedUsers = $this->assignIdentities($data, $identities);
2✔
136

137
        $data['data'] = $data['singleton'] ? $mappedUsers[array_column($data, 'id')[0]] : $mappedUsers;
2✔
138

139
        return $data;
2✔
140
    }
141

142
    /**
143
     * Map our users by ID to make assigning simpler
144
     *
145
     * @param array              $data       Event $data
146
     * @param list<UserIdentity> $identities
147
     *
148
     * @return         list<User>              UserId => User object
149
     * @phpstan-return array<int|string, User> UserId => User object
150
     */
151
    private function assignIdentities(array $data, array $identities): array
2✔
152
    {
153
        $mappedUsers    = [];
2✔
154
        $userIdentities = [];
2✔
155

156
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
2✔
157

158
        foreach ($users as $user) {
2✔
159
            $mappedUsers[$user->id] = $user;
2✔
160
        }
161
        unset($users);
2✔
162

163
        // Now group the identities by user
164
        foreach ($identities as $identity) {
2✔
165
            $userIdentities[$identity->user_id][] = $identity;
2✔
166
        }
167
        unset($identities);
2✔
168

169
        // Now assign the identities to the user
170
        foreach ($userIdentities as $userId => $identityArray) {
2✔
171
            $mappedUsers[$userId]->identities = $identityArray;
2✔
172
        }
173
        unset($userIdentities);
2✔
174

175
        return $mappedUsers;
2✔
176
    }
177

178
    /**
179
     * Activate a User.
180
     */
181
    public function activate(User $user): void
×
182
    {
183
        $user->active = true;
×
184

185
        $this->save($user);
×
186
    }
187

188
    /**
189
     * Override the BaseModel's `insert()` method.
190
     * If you pass User object, also inserts Email Identity.
191
     *
192
     * @param array|User $data
193
     *
194
     * @return int|string|true Insert ID if $returnID is true
195
     *
196
     * @throws ValidationException
197
     */
198
    public function insert($data = null, bool $returnID = true)
232✔
199
    {
200
        // Clone User object for not changing the passed object.
201
        $this->tempUser = $data instanceof User ? clone $data : null;
232✔
202

203
        $result = parent::insert($data, $returnID);
232✔
204

205
        $this->checkQueryReturn($result);
232✔
206

207
        return $returnID ? $this->insertID : $result;
232✔
208
    }
209

210
    /**
211
     * Override the BaseModel's `update()` method.
212
     * If you pass User object, also updates Email Identity.
213
     *
214
     * @param array|int|list<int|string>|RawSql|string|null $id
215
     * @param array|User                                    $data
216
     *
217
     * @return true if the update is successful
218
     *
219
     * @throws ValidationException
220
     */
221
    public function update($id = null, $data = null): bool
54✔
222
    {
223
        // Clone User object for not changing the passed object.
224
        $this->tempUser = $data instanceof User ? clone $data : null;
54✔
225

226
        try {
227
            /** @throws DataException */
228
            $result = parent::update($id, $data);
54✔
229
        } catch (DataException $e) {
34✔
230
            // When $data is an array.
231
            if ($this->tempUser === null) {
34✔
232
                throw $e;
1✔
233
            }
234

235
            $messages = [
33✔
236
                lang('Database.emptyDataset', ['update']),
33✔
237
            ];
33✔
238

239
            if (in_array($e->getMessage(), $messages, true)) {
33✔
240
                $this->saveEmailIdentity([]);
33✔
241

242
                return true;
33✔
243
            }
244

245
            throw $e;
×
246
        }
247

248
        $this->checkQueryReturn($result);
23✔
249

250
        return true;
23✔
251
    }
252

253
    /**
254
     * Override the BaseModel's `save()` method.
255
     * If you pass User object, also updates Email Identity.
256
     *
257
     * @param array|User $data
258
     *
259
     * @return true if the save is successful
260
     *
261
     * @throws ValidationException
262
     */
263
    public function save($data): bool
57✔
264
    {
265
        $result = parent::save($data);
57✔
266

267
        $this->checkQueryReturn($result);
56✔
268

269
        return true;
56✔
270
    }
271

272
    /**
273
     * Save Email Identity
274
     *
275
     * Model event callback called by `afterInsert` and `afterUpdate`.
276
     */
277
    protected function saveEmailIdentity(array $data): array
232✔
278
    {
279
        // If insert()/update() gets an array data, do nothing.
280
        if ($this->tempUser === null) {
232✔
281
            return $data;
8✔
282
        }
283

284
        /** @var User $user */
285
        $user = $this->tempUser;
231✔
286

287
        if ($user->id === null) {
231✔
288
            $user->id = $data['id'] ?? $this->db->insertID();
224✔
289
        }
290

291
        $email        = $user->getEmail();
231✔
292
        $password     = $user->getPassword();
231✔
293
        $passwordHash = $user->getPasswordHash();
231✔
294

295
        if (($email === null || $email === '' || $email === '0') && ($password === null || $password === '' || $password === '0') && ($passwordHash === null || $passwordHash === '' || $passwordHash === '0')) {
231✔
296
            $this->tempUser = null;
220✔
297

298
            return $data;
220✔
299
        }
300

301
        /** @var UserIdentityModel $identityModel */
302
        $identityModel = model(UserIdentityModel::class);
45✔
303
        $identity      = $identityModel->getIdentityByType($user, Session::ID_TYPE_EMAIL_PASSWORD);
45✔
304

305
        if ($identity === null) {
45✔
306
            $identityModel->createEmailIdentity($user, [
39✔
307
                'email'    => $email,
39✔
308
                'password' => '',
39✔
309
            ]);
39✔
310

311
            $identity = $identityModel->getIdentityByType($user, Session::ID_TYPE_EMAIL_PASSWORD);
39✔
312
        }
313

314
        if ($email !== null && $email !== '' && $email !== '0') {
45✔
315
            $identity->secret = $email;
45✔
316
        }
317

318
        if ($password !== null && $password !== '' && $password !== '0') {
45✔
319
            $identity->secret2 = service('passwords')->hash($password);
14✔
320
        }
321

322
        if ($passwordHash !== null && $passwordHash !== '' && $passwordHash !== '0' && ($password === null || $password === '' || $password === '0')) {
45✔
323
            $identity->secret2 = $passwordHash;
10✔
324
        }
325

326
        try {
327
            /** @throws DataException */
328
            $identityModel->save($identity);
45✔
329
        } catch (DataException $e) {
28✔
330
            // There may be no data to update.
331
            $messages = [
28✔
332
                lang('Database.emptyDataset', ['insert']),
28✔
333
                lang('Database.emptyDataset', ['update']),
28✔
334
            ];
28✔
335
            if (! in_array($e->getMessage(), $messages, true)) {
28✔
336
                throw $e;
×
337
            }
338
        }
339

340
        $this->tempUser = null;
45✔
341

342
        return $data;
45✔
343
    }
344

345
    /**
346
     * Adds a user to the default group.
347
     * Used during registration.
348
     */
349
    public function addToDefaultGroup(User $user): void
1✔
350
    {
351
        $defaultGroup = setting('Auth.defaultGroup');
1✔
352

353
        $rows = model(GroupModel::class)->findAll();
1✔
354

355
        $allowedGroups = array_column($rows, 'name');
1✔
356

357
        if (empty($defaultGroup) || ! in_array($defaultGroup, $allowedGroups, true)) {
1✔
358
            throw new InvalidArgumentException(lang('Auth.unknownGroup', [$defaultGroup ?? '--not found--']));
×
359
        }
360

361
        $user->addGroup($defaultGroup);
1✔
362
    }
363

364
    public function fake(Generator &$faker): User
219✔
365
    {
366
        return new User([
219✔
367
            'username' => $faker->unique()->userName(),
219✔
368
            'active'   => true,
219✔
369
        ]);
219✔
370
    }
371

372
    /**
373
     * Locates a User object by ID.
374
     *
375
     * @param int|string $id
376
     */
377
    public function findById($id): ?User
62✔
378
    {
379
        return $this->find($id);
62✔
380
    }
381

382
    /**
383
     * Locate a User object by the given credentials.
384
     *
385
     * @param array<string, string> $credentials
386
     */
387
    public function findByCredentials(array $credentials): ?User
40✔
388
    {
389
        // Email is stored in an identity so remove that here
390
        $email = $credentials['email'] ?? null;
40✔
391
        unset($credentials['email']);
40✔
392

393
        if ($email === null && $credentials === []) {
40✔
394
            return null;
1✔
395
        }
396

397
        // any of the credentials used should be case-insensitive
398
        foreach ($credentials as $key => $value) {
40✔
399
            $this->where(
2✔
400
                'LOWER(' . $this->db->protectIdentifiers($this->table . ".{$key}") . ')',
2✔
401
                strtolower($value),
2✔
402
            );
2✔
403
        }
404

405
        if ($email !== null) {
40✔
406
            /** @var array<string, int|string|null>|null $data */
407
            $data = $this->select(
38✔
408
                sprintf('%1$s.*, %2$s.secret as email, %2$s.secret2 as password_hash', $this->table, $this->tables['identities']),
38✔
409
            )
38✔
410
                ->join($this->tables['identities'], sprintf('%1$s.user_id = %2$s.id', $this->tables['identities'], $this->table))
38✔
411
                ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD)
38✔
412
                ->where(
38✔
413
                    'LOWER(' . $this->db->protectIdentifiers($this->tables['identities'] . '.secret') . ')',
38✔
414
                    strtolower($email),
38✔
415
                )
38✔
416
                ->asArray()
38✔
417
                ->first();
38✔
418

419
            if ($data === null) {
38✔
420
                return null;
9✔
421
            }
422

423
            $email = $data['email'];
29✔
424
            unset($data['email']);
29✔
425
            $password_hash = $data['password_hash'];
29✔
426
            unset($data['password_hash']);
29✔
427

428
            $user                = new User($data);
29✔
429
            $user->email         = $email;
29✔
430
            $user->password_hash = $password_hash;
29✔
431
            $user->syncOriginal();
29✔
432

433
            return $user;
29✔
434
        }
435

436
        return $this->first();
2✔
437
    }
438

439
    /**
440
     * Updates the user's last active date.
441
     */
442
    public function updateActiveDate(User $user): void
5✔
443
    {
444
        assert($user->last_active instanceof Time);
445

446
        // Safe date string for database
447
        $last_active = $user->last_active->format('Y-m-d H:i:s');
5✔
448

449
        $this->builder()
5✔
450
            ->set('last_active', $last_active)
5✔
451
            ->where('id', $user->id)
5✔
452
            ->update();
5✔
453
    }
454
}
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