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

daycry / auth / 22527658769

28 Feb 2026 07:41PM UTC coverage: 63.267% (-3.6%) from 66.864%
22527658769

push

github

web-flow
Merge pull request #36 from daycry/development

Implement TOTP 2FA, JWT auth, device session tracking, and docs overhaul

465 of 1168 new or added lines in 52 files covered. (39.81%)

129 existing lines in 46 files now uncovered.

3064 of 4843 relevant lines covered (63.27%)

41.53 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
     */
UNCOV
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