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

daycry / auth / 22520725744

27 Feb 2026 09:30PM UTC coverage: 65.761% (-1.1%) from 66.864%
22520725744

push

github

daycry
Add StatelessAuthenticator and refactor token handling

Introduce StatelessAuthenticator as a shared base for JWT/AccessToken and centralize token extraction (getTokenFromRequest). Refactor JWT and AccessToken to extend it and simplify header/query parsing. Add Utils::generateNumericCode and use it in Email2FA/EmailActivator to replace duplicated generators. Centralize model() calls in traits (HasAccessTokens, HasDeviceSessions, HasTotp) via small private getters. Improve filters and error handling: add buildDeniedResponse in AbstractAuthFilter, adjust Group/Permission filters to return ResponseInterface and reuse the builder. Replace static authorization flags with instance properties in AuthenticationException/AuthorizationException and update ExceptionHandler to read them safely. Misc: small controller/type fixes, email helper guard, DeviceSessionModel null handling, active-group/permission query fixes, phpstan baseline updates, and adjust tests to expect 403 for denied JSON responses.

56 of 68 new or added lines in 19 files covered. (82.35%)

115 existing lines in 7 files now uncovered.

2614 of 3975 relevant lines covered (65.76%)

42.9 hits per line

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

94.93
/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

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

51
    /**
52
     * Whether identity records should be included
53
     * when user records are fetched from the database.
54
     */
55
    protected bool $fetchIdentities = false;
56

57
    /**
58
     * Save the User for afterInsert and afterUpdate
59
     */
60
    protected ?User $tempUser = null;
61

62
    protected function initialize(): void
63
    {
64
        parent::initialize();
287✔
65

66
        $this->table = $this->tables['users'];
287✔
67
    }
68

69
    /**
70
     * Mark the next find* query to include identities
71
     *
72
     * @return $this
73
     */
74
    public function withIdentities(): self
75
    {
76
        $this->fetchIdentities = true;
4✔
77

78
        return $this;
4✔
79
    }
80

81
    /**
82
     * Populates identities for all records
83
     * returned from a find* method. Called
84
     * automatically when $this->fetchIdentities == true
85
     *
86
     * Model event callback called by `afterFind`.
87
     */
88
    protected function fetchIdentities(array $data): array
89
    {
90
        if (! $this->fetchIdentities || empty($data['data'])) {
230✔
91
            return $data;
230✔
92
        }
93

94
        $userIds = $data['singleton']
2✔
95
            ? [$data['data']->id]
1✔
96
            : array_column($data['data'], 'id');
1✔
97

98
        if ($userIds === []) {
2✔
UNCOV
99
            return $data;
×
100
        }
101

102
        /** @var UserIdentityModel $identityModel */
103
        $identityModel = model(UserIdentityModel::class);
2✔
104

105
        // Get our identities for all users
106
        $identities = $identityModel->getIdentitiesByUserIds($userIds);
2✔
107

108
        if (empty($identities)) {
2✔
UNCOV
109
            return $data;
×
110
        }
111

112
        $mappedUsers = $this->assignIdentities($data, $identities);
2✔
113

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

116
        return $data;
2✔
117
    }
118

119
    /**
120
     * Map our users by ID to make assigning simpler
121
     *
122
     * @param array              $data       Event $data
123
     * @param list<UserIdentity> $identities
124
     *
125
     * @return         list<User>              UserId => User object
126
     * @phpstan-return array<int|string, User> UserId => User object
127
     */
128
    private function assignIdentities(array $data, array $identities): array
129
    {
130
        $mappedUsers    = [];
2✔
131
        $userIdentities = [];
2✔
132

133
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
2✔
134

135
        foreach ($users as $user) {
2✔
136
            $mappedUsers[$user->id] = $user;
2✔
137
        }
138
        unset($users);
2✔
139

140
        // Now group the identities by user
141
        foreach ($identities as $identity) {
2✔
142
            $userIdentities[$identity->user_id][] = $identity;
2✔
143
        }
144
        unset($identities);
2✔
145

146
        // Now assign the identities to the user
147
        foreach ($userIdentities as $userId => $identityArray) {
2✔
148
            $mappedUsers[$userId]->identities = $identityArray;
2✔
149
        }
150
        unset($userIdentities);
2✔
151

152
        return $mappedUsers;
2✔
153
    }
154

155
    /**
156
     * Activate a User.
157
     */
158
    public function activate(User $user): void
159
    {
UNCOV
160
        $user->active = true;
×
161

UNCOV
162
        $this->save($user);
×
163
    }
164

165
    /**
166
     * Override the BaseModel's `insert()` method.
167
     * If you pass User object, also inserts Email Identity.
168
     *
169
     * @param array|User $data
170
     *
171
     * @return int|string|true Insert ID if $returnID is true
172
     *
173
     * @throws ValidationException
174
     */
175
    public function insert($data = null, bool $returnID = true)
176
    {
177
        // Clone User object for not changing the passed object.
178
        $this->tempUser = $data instanceof User ? clone $data : null;
232✔
179

180
        $result = parent::insert($data, $returnID);
232✔
181

182
        $this->checkQueryReturn($result);
232✔
183

184
        return $returnID ? $this->insertID : $result;
232✔
185
    }
186

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

203
        try {
204
            /** @throws DataException */
205
            $result = parent::update($id, $data);
53✔
206
        } catch (DataException $e) {
34✔
207
            // When $data is an array.
208
            if ($this->tempUser === null) {
34✔
209
                throw $e;
1✔
210
            }
211

212
            $messages = [
33✔
213
                lang('Database.emptyDataset', ['update']),
33✔
214
            ];
33✔
215

216
            if (in_array($e->getMessage(), $messages, true)) {
33✔
217
                $this->saveEmailIdentity([]);
33✔
218

219
                return true;
33✔
220
            }
221

UNCOV
222
            throw $e;
×
223
        }
224

225
        $this->checkQueryReturn($result);
22✔
226

227
        return true;
22✔
228
    }
229

230
    /**
231
     * Override the BaseModel's `save()` method.
232
     * If you pass User object, also updates Email Identity.
233
     *
234
     * @param array|User $data
235
     *
236
     * @return true if the save is successful
237
     *
238
     * @throws ValidationException
239
     */
240
    public function save($data): bool
241
    {
242
        $result = parent::save($data);
57✔
243

244
        $this->checkQueryReturn($result);
56✔
245

246
        return true;
56✔
247
    }
248

249
    /**
250
     * Save Email Identity
251
     *
252
     * Model event callback called by `afterInsert` and `afterUpdate`.
253
     */
254
    protected function saveEmailIdentity(array $data): array
255
    {
256
        // If insert()/update() gets an array data, do nothing.
257
        if ($this->tempUser === null) {
232✔
258
            return $data;
7✔
259
        }
260

261
        /** @var User $user */
262
        $user = $this->tempUser;
231✔
263

264
        if ($user->id === null) {
231✔
265
            $user->id = $data['id'] ?? $this->db->insertID();
224✔
266
        }
267

268
        $email        = $user->getEmail();
231✔
269
        $password     = $user->getPassword();
231✔
270
        $passwordHash = $user->getPasswordHash();
231✔
271

272
        if (($email === null || $email === '' || $email === '0') && ($password === null || $password === '' || $password === '0') && ($passwordHash === null || $passwordHash === '' || $passwordHash === '0')) {
231✔
273
            $this->tempUser = null;
220✔
274

275
            return $data;
220✔
276
        }
277

278
        /** @var UserIdentityModel $identityModel */
279
        $identityModel = model(UserIdentityModel::class);
45✔
280
        $identity      = $identityModel->getIdentityByType($user, Session::ID_TYPE_EMAIL_PASSWORD);
45✔
281

282
        if ($identity === null) {
45✔
283
            $identityModel->createEmailIdentity($user, [
39✔
284
                'email'    => $email,
39✔
285
                'password' => '',
39✔
286
            ]);
39✔
287

288
            $identity = $identityModel->getIdentityByType($user, Session::ID_TYPE_EMAIL_PASSWORD);
39✔
289
        }
290

291
        if ($email !== null && $email !== '' && $email !== '0') {
45✔
292
            $identity->secret = $email;
45✔
293
        }
294

295
        if ($password !== null && $password !== '' && $password !== '0') {
45✔
296
            $identity->secret2 = service('passwords')->hash($password);
14✔
297
        }
298

299
        if ($passwordHash !== null && $passwordHash !== '' && $passwordHash !== '0' && ($password === null || $password === '' || $password === '0')) {
45✔
300
            $identity->secret2 = $passwordHash;
10✔
301
        }
302

303
        try {
304
            /** @throws DataException */
305
            $identityModel->save($identity);
45✔
306
        } catch (DataException $e) {
28✔
307
            // There may be no data to update.
308
            $messages = [
28✔
309
                lang('Database.emptyDataset', ['insert']),
28✔
310
                lang('Database.emptyDataset', ['update']),
28✔
311
            ];
28✔
312
            if (! in_array($e->getMessage(), $messages, true)) {
28✔
UNCOV
313
                throw $e;
×
314
            }
315
        }
316

317
        $this->tempUser = null;
45✔
318

319
        return $data;
45✔
320
    }
321

322
    /**
323
     * Adds a user to the default group.
324
     * Used during registration.
325
     */
326
    public function addToDefaultGroup(User $user): void
327
    {
328
        $defaultGroup = setting('Auth.defaultGroup');
1✔
329

330
        $rows = model(GroupModel::class)->findAll();
1✔
331

332
        $allowedGroups = array_column($rows, 'name');
1✔
333

334
        if (empty($defaultGroup) || ! in_array($defaultGroup, $allowedGroups, true)) {
1✔
UNCOV
335
            throw new InvalidArgumentException(lang('Auth.unknownGroup', [$defaultGroup ?? '--not found--']));
×
336
        }
337

338
        $user->addGroup($defaultGroup);
1✔
339
    }
340

341
    public function fake(Generator &$faker): User
342
    {
343
        return new User([
219✔
344
            'username' => $faker->unique()->userName(),
219✔
345
            'active'   => true,
219✔
346
        ]);
219✔
347
    }
348

349
    /**
350
     * Locates a User object by ID.
351
     *
352
     * @param int|string $id
353
     */
354
    public function findById($id): ?User
355
    {
356
        return $this->find($id);
62✔
357
    }
358

359
    /**
360
     * Locate a User object by the given credentials.
361
     *
362
     * @param array<string, string> $credentials
363
     */
364
    public function findByCredentials(array $credentials): ?User
365
    {
366
        // Email is stored in an identity so remove that here
367
        $email = $credentials['email'] ?? null;
40✔
368
        unset($credentials['email']);
40✔
369

370
        if ($email === null && $credentials === []) {
40✔
371
            return null;
1✔
372
        }
373

374
        // any of the credentials used should be case-insensitive
375
        foreach ($credentials as $key => $value) {
40✔
376
            $this->where(
2✔
377
                'LOWER(' . $this->db->protectIdentifiers($this->table . ".{$key}") . ')',
2✔
378
                strtolower($value),
2✔
379
            );
2✔
380
        }
381

382
        if ($email !== null) {
40✔
383
            /** @var array<string, int|string|null>|null $data */
384
            $data = $this->select(
38✔
385
                sprintf('%1$s.*, %2$s.secret as email, %2$s.secret2 as password_hash', $this->table, $this->tables['identities']),
38✔
386
            )
38✔
387
                ->join($this->tables['identities'], sprintf('%1$s.user_id = %2$s.id', $this->tables['identities'], $this->table))
38✔
388
                ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD)
38✔
389
                ->where(
38✔
390
                    'LOWER(' . $this->db->protectIdentifiers($this->tables['identities'] . '.secret') . ')',
38✔
391
                    strtolower($email),
38✔
392
                )
38✔
393
                ->asArray()
38✔
394
                ->first();
38✔
395

396
            if ($data === null) {
38✔
397
                return null;
9✔
398
            }
399

400
            $email = $data['email'];
29✔
401
            unset($data['email']);
29✔
402
            $password_hash = $data['password_hash'];
29✔
403
            unset($data['password_hash']);
29✔
404

405
            $user                = new User($data);
29✔
406
            $user->email         = $email;
29✔
407
            $user->password_hash = $password_hash;
29✔
408
            $user->syncOriginal();
29✔
409

410
            return $user;
29✔
411
        }
412

413
        return $this->first();
2✔
414
    }
415

416
    /**
417
     * Updates the user's last active date.
418
     */
419
    public function updateActiveDate(User $user): void
420
    {
421
        assert($user->last_active instanceof Time);
422

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

426
        $this->builder()
5✔
427
            ->set('last_active', $last_active)
5✔
428
            ->where('id', $user->id)
5✔
429
            ->update();
5✔
430
    }
431
}
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