• 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

62.7
/src/Models/UserIdentityModel.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\RawSql;
17
use CodeIgniter\Exceptions\LogicException;
18
use CodeIgniter\I18n\Time;
19
use Daycry\Auth\Authentication\Authenticators\AccessToken;
20
use Daycry\Auth\Authentication\Authenticators\Session;
21
use Daycry\Auth\Entities\AccessToken as AccessTokenIdentity;
22
use Daycry\Auth\Entities\User;
23
use Daycry\Auth\Entities\UserIdentity;
24
use Daycry\Auth\Enums\IdentityType;
25
use Daycry\Auth\Exceptions\DatabaseException;
26
use Faker\Generator;
27

28
class UserIdentityModel extends BaseModel
29
{
30
    protected $table;
31
    protected $primaryKey     = 'id';
32
    protected $returnType     = UserIdentity::class;
33
    protected $useSoftDeletes = false;
34
    protected $allowedFields  = [
35
        'user_id',
36
        'name',
37
        'type',
38
        'secret',
39
        'secret2',
40
        'extra',
41
        'expires',
42
        'force_reset',
43
        'ignore_limits',
44
        'is_private',
45
        'ip_addresses',
46
        'last_used_at',
47
        'revoked_at',
48
    ];
49
    protected $useTimestamps = true;
50
    protected $createdField  = 'created_at';
51
    protected $updatedField  = 'updated_at';
52
    protected $deletedField  = 'deleted_at';
53

54
    protected function initialize(): void
286✔
55
    {
56
        parent::initialize();
286✔
57

58
        $this->table = $this->tables['identities'];
286✔
59
    }
60

61
    /**
62
     * Inserts a record
63
     *
64
     * @param array|object $data
65
     *
66
     * @throws DatabaseException
67
     */
68
    public function create($data): void
14✔
69
    {
70
        $this->disableDBDebug();
14✔
71

72
        $return = $this->insert($data);
14✔
73

74
        $this->checkQueryReturn($return);
14✔
75
    }
76

77
    /**
78
     * Creates a new identity for this user with an email/password
79
     * combination.
80
     *
81
     * @phpstan-param array{email: string, password: string} $credentials
82
     */
83
    public function createEmailIdentity(User $user, array $credentials): void
88✔
84
    {
85
        $this->checkUserId($user);
88✔
86

87
        /** @var Passwords $passwords */
88
        $passwords = service('passwords');
88✔
89

90
        $return = $this->insert([
88✔
91
            'user_id' => $user->id,
88✔
92
            'type'    => Session::ID_TYPE_EMAIL_PASSWORD,
88✔
93
            'secret'  => $credentials['email'],
88✔
94
            'secret2' => $passwords->hash($credentials['password']),
88✔
95
        ]);
88✔
96

97
        $this->checkQueryReturn($return);
88✔
98
    }
99

100
    private function checkUserId(User $user): void
231✔
101
    {
102
        if ($user->id === null) {
231✔
103
            throw new LogicException(
1✔
104
                '"$user->id" is null. You should not use the incomplete User object.',
1✔
105
            );
1✔
106
        }
107
    }
108

109
    /**
110
     * Create an identity with 6 digits code for auth action
111
     *
112
     * @param         callable                                         $codeGenerator generate secret code
113
     * @phpstan-param array{type: string, name: string, extra: string} $data
114
     *
115
     * @return string secret
116
     */
117
    public function createCodeIdentity(
14✔
118
        User $user,
119
        array $data,
120
        callable $codeGenerator,
121
    ): string {
122
        $this->checkUserId($user);
14✔
123

124
        helper('text');
14✔
125

126
        // Create an identity for the action
127
        $maxTry          = 5;
14✔
128
        $data['user_id'] = $user->id;
14✔
129

130
        while (true) {
14✔
131
            $data['secret'] = $codeGenerator();
14✔
132

133
            try {
134
                $this->create($data);
14✔
135

136
                break;
14✔
137
            } catch (DatabaseException $e) {
×
138
                $maxTry--;
×
139

140
                if ($maxTry === 0) {
×
141
                    throw $e;
×
142
                }
143
            }
144
        }
145

146
        return $data['secret'];
14✔
147
    }
148

149
    /**
150
     * Generates a new personal access token for the user.
151
     *
152
     * @param string       $name   Token name
153
     * @param list<string> $scopes Permissions the token grants
154
     */
155
    public function generateAccessToken(User $user, string $name, array $scopes = ['*']): AccessTokenIdentity
10✔
156
    {
157
        $this->checkUserId($user);
10✔
158

159
        helper('text');
10✔
160

161
        $return = $this->insert([
10✔
162
            'type'    => AccessToken::ID_TYPE_ACCESS_TOKEN,
10✔
163
            'user_id' => $user->id,
10✔
164
            'name'    => $name,
10✔
165
            'secret'  => hash('sha256', $rawToken = random_string('crypto', 64)),
10✔
166
            'extra'   => json_encode($scopes, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
10✔
167
        ]);
10✔
168

169
        $this->checkQueryReturn($return);
10✔
170

171
        /** @var AccessTokenIdentity $token */
172
        $token = $this
10✔
173
            ->asObject(AccessTokenIdentity::class)
10✔
174
            ->find($this->getInsertID());
10✔
175

176
        $token->raw_token = $rawToken;
10✔
177

178
        return $token;
10✔
179
    }
180

181
    public function getAccessTokenByRawToken(string $rawToken): ?AccessTokenIdentity
9✔
182
    {
183
        return $this
9✔
184
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
9✔
185
            ->where('secret', hash('sha256', $rawToken))
9✔
186
            ->where('revoked_at', null)
9✔
187
            ->asObject(AccessTokenIdentity::class)
9✔
188
            ->first();
9✔
189
    }
190

191
    public function getAccessToken(User $user, string $rawToken): ?AccessTokenIdentity
2✔
192
    {
193
        $this->checkUserId($user);
2✔
194

195
        return $this->where('user_id', $user->id)
2✔
196
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
2✔
197
            ->where('secret', hash('sha256', $rawToken))
2✔
198
            ->asObject(AccessTokenIdentity::class)
2✔
199
            ->first();
2✔
200
    }
201

202
    /**
203
     * Given the ID, returns the given access token.
204
     *
205
     * @param int|string $id
206
     */
207
    public function getAccessTokenById($id, User $user): ?AccessTokenIdentity
×
208
    {
209
        $this->checkUserId($user);
×
210

211
        return $this->where('user_id', $user->id)
×
212
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
213
            ->where('id', $id)
×
214
            ->asObject(AccessTokenIdentity::class)
×
215
            ->first();
×
216
    }
217

218
    /**
219
     * @return list<AccessTokenIdentity>
220
     */
221
    public function getAllAccessToken(User $user): array
×
222
    {
223
        $this->checkUserId($user);
×
224

225
        return $this
×
226
            ->where('user_id', $user->id)
×
227
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
228
            ->orderBy($this->primaryKey)
×
229
            ->asObject(AccessTokenIdentity::class)
×
230
            ->findAll();
×
231
    }
232

233
    /**
234
     * Used by 'magic-link'.
235
     */
236
    public function getIdentityBySecret(string $type, ?string $secret): ?UserIdentity
×
237
    {
238
        if ($secret === null) {
×
239
            return null;
×
240
        }
241

242
        return $this->where('type', $type)
×
243
            ->where('secret', $secret)
×
244
            ->first();
×
245
    }
246

247
    /**
248
     * Returns all identities.
249
     *
250
     * @return list<UserIdentity>
251
     */
252
    public function getIdentities(User $user): array
231✔
253
    {
254
        $this->checkUserId($user);
231✔
255

256
        return $this->where('user_id', $user->id)->orderBy($this->primaryKey)->findAll();
231✔
257
    }
258

259
    /**
260
     * @param list<int>|list<string> $userIds
261
     *
262
     * @return list<UserIdentity>
263
     */
264
    public function getIdentitiesByUserIds(array $userIds): array
2✔
265
    {
266
        return $this->whereIn('user_id', $userIds)->orderBy($this->primaryKey)->findAll();
2✔
267
    }
268

269
    /**
270
     * Returns the first identity of the type.
271
     */
272
    public function getIdentityByType(User $user, string $type): ?UserIdentity
59✔
273
    {
274
        $this->checkUserId($user);
59✔
275

276
        return $this->where('user_id', $user->id)
59✔
277
            ->where('type', $type)
59✔
278
            ->orderBy($this->primaryKey)
59✔
279
            ->first();
59✔
280
    }
281

282
    /**
283
     * Returns all identities for the specific types.
284
     *
285
     * @param list<string> $types
286
     *
287
     * @return list<UserIdentity>
288
     */
289
    public function getIdentitiesByTypes(User $user, array $types): array
4✔
290
    {
291
        $this->checkUserId($user);
4✔
292

293
        if ($types === []) {
4✔
294
            return [];
3✔
295
        }
296

297
        return $this->where('user_id', $user->id)
1✔
298
            ->whereIn('type', $types)
1✔
299
            ->orderBy($this->primaryKey)
1✔
300
            ->findAll();
1✔
301
    }
302

303
    /**
304
     * Update the last used at date for an identity record.
305
     */
306
    public function touchIdentity(UserIdentity $identity): void
16✔
307
    {
308
        $identity->last_used_at = Time::now()->format('Y-m-d H:i:s');
16✔
309

310
        $return = $this->save($identity);
16✔
311

312
        $this->checkQueryReturn($return);
16✔
313
    }
314

315
    public function deleteIdentitiesByType(User $user, string $type): void
20✔
316
    {
317
        $this->checkUserId($user);
20✔
318

319
        $return = $this->where('user_id', $user->id)
20✔
320
            ->where('type', $type)
20✔
321
            ->delete();
20✔
322

323
        $this->checkQueryReturn($return);
20✔
324
    }
325

326
    /**
327
     * Delete any access tokens for the given raw token.
328
     */
329
    public function revokeAccessToken(User $user, string $rawToken): void
×
330
    {
331
        $this->checkUserId($user);
×
332

333
        $return = $this->where('user_id', $user->id)
×
334
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
335
            ->where('secret', hash('sha256', $rawToken))
×
336
            ->delete();
×
337

338
        $this->checkQueryReturn($return);
×
339
    }
340

341
    /**
342
     * Delete any access tokens for the given secret token.
343
     */
344
    public function revokeAccessTokenBySecret(User $user, string $secretToken): void
×
345
    {
346
        $this->checkUserId($user);
×
347

348
        $return = $this->where('user_id', $user->id)
×
349
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
350
            ->where('secret', $secretToken)
×
351
            ->delete();
×
352

353
        $this->checkQueryReturn($return);
×
354
    }
355

356
    /**
357
     * Revokes all access tokens for this user.
358
     */
359
    public function revokeAllAccessToken(User $user): void
×
360
    {
361
        $this->checkUserId($user);
×
362

363
        $return = $this->where('user_id', $user->id)
×
364
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
365
            ->delete();
×
366

367
        $this->checkQueryReturn($return);
×
368
    }
369

370
    /**
371
     * Soft-revoke an identity by its primary key (sets revoked_at).
372
     */
373
    public function revokeIdentityById(int $id): void
×
374
    {
375
        $this->where('id', $id)
×
376
            ->set('revoked_at', Time::now()->format('Y-m-d H:i:s'))
×
377
            ->update();
×
378
    }
379

380
    /**
381
     * Stores a new JWT refresh token for the given user.
382
     *
383
     * The raw token is hashed (SHA-256) before storage.
384
     *
385
     * @param int    $userId    User primary key
386
     * @param string $rawToken  The raw (unhashed) token to store
387
     * @param string $expiresAt Datetime string 'Y-m-d H:i:s'
388
     */
389
    public function createJwtRefreshToken(int $userId, string $rawToken, string $expiresAt): void
×
390
    {
391
        $this->insert([
×
392
            'user_id' => $userId,
×
393
            'type'    => IdentityType::JWT_REFRESH->value,
×
394
            'secret'  => hash('sha256', $rawToken),
×
395
            'expires' => $expiresAt,
×
396
        ]);
×
397
    }
398

399
    /**
400
     * Finds a valid (non-expired, non-revoked) JWT refresh token.
401
     *
402
     * @param int    $userId   User primary key
403
     * @param string $rawToken The raw (unhashed) token
404
     */
405
    public function getJwtRefreshToken(int $userId, string $rawToken): ?UserIdentity
×
406
    {
407
        return $this->where('user_id', $userId)
×
408
            ->where('type', IdentityType::JWT_REFRESH->value)
×
409
            ->where('secret', hash('sha256', $rawToken))
×
410
            ->where('revoked_at', null)
×
411
            ->where('expires >', Time::now()->format('Y-m-d H:i:s'))
×
412
            ->first();
×
413
    }
414

415
    /**
416
     * Force password reset for multiple users.
417
     *
418
     * @param list<int>|list<string> $userIds
419
     */
420
    public function forceMultiplePasswordReset(array $userIds): void
×
421
    {
422
        $this->where(['type' => Session::ID_TYPE_EMAIL_PASSWORD, 'force_reset' => 0]);
×
423
        $this->whereIn('user_id', $userIds);
×
424
        $this->set('force_reset', 1);
×
425
        $return = $this->update();
×
426

427
        $this->checkQueryReturn($return);
×
428
    }
429

430
    /**
431
     * Force global password reset.
432
     * This is useful for enforcing a password reset
433
     * for ALL users in case of a security breach.
434
     */
435
    public function forceGlobalPasswordReset(): void
1✔
436
    {
437
        $whereFilter = [
1✔
438
            'type'        => Session::ID_TYPE_EMAIL_PASSWORD,
1✔
439
            'force_reset' => 0,
1✔
440
        ];
1✔
441
        $this->where($whereFilter);
1✔
442
        $this->set('force_reset', 1);
1✔
443
        $return = $this->update();
1✔
444

445
        $this->checkQueryReturn($return);
1✔
446
    }
447

448
    /**
449
     * Override the Model's `update()` method.
450
     * Throws an Exception when it fails.
451
     *
452
     * @param array|int|list<int|string>|RawSql|string|null $id
453
     * @param array|object|null                             $data
454
     *
455
     * @return true if the update is successful
456
     *
457
     * @throws ValidationException
458
     */
459
    public function update($id = null, $data = null): bool
69✔
460
    {
461
        $result = parent::update($id, $data);
69✔
462

463
        $this->checkQueryReturn($result);
43✔
464

465
        return true;
43✔
466
    }
467

468
    public function fake(Generator &$faker): UserIdentity
6✔
469
    {
470
        return new UserIdentity([
6✔
471
            'user_id'      => fake(UserModel::class)->id,
6✔
472
            'type'         => Session::ID_TYPE_EMAIL_PASSWORD,
6✔
473
            'name'         => null,
6✔
474
            'secret'       => $faker->unique()->email(),
6✔
475
            'secret2'      => password_hash('secret', PASSWORD_DEFAULT),
6✔
476
            'expires'      => null,
6✔
477
            'extra'        => null,
6✔
478
            'force_reset'  => false,
6✔
479
            'last_used_at' => null,
6✔
480
        ]);
6✔
481
    }
482
}
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