• 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

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
     */
UNCOV
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
     */
UNCOV
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
     */
UNCOV
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
     */
UNCOV
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
     */
UNCOV
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
     */
UNCOV
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
     */
NEW
373
    public function revokeIdentityById(int $id): void
×
374
    {
NEW
375
        $this->where('id', $id)
×
NEW
376
            ->set('revoked_at', Time::now()->format('Y-m-d H:i:s'))
×
NEW
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
     */
NEW
389
    public function createJwtRefreshToken(int $userId, string $rawToken, string $expiresAt): void
×
390
    {
NEW
391
        $this->insert([
×
NEW
392
            'user_id' => $userId,
×
NEW
393
            'type'    => IdentityType::JWT_REFRESH->value,
×
NEW
394
            'secret'  => hash('sha256', $rawToken),
×
NEW
395
            'expires' => $expiresAt,
×
NEW
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
     */
NEW
405
    public function getJwtRefreshToken(int $userId, string $rawToken): ?UserIdentity
×
406
    {
NEW
407
        return $this->where('user_id', $userId)
×
NEW
408
            ->where('type', IdentityType::JWT_REFRESH->value)
×
NEW
409
            ->where('secret', hash('sha256', $rawToken))
×
NEW
410
            ->where('revoked_at', null)
×
NEW
411
            ->where('expires >', Time::now()->format('Y-m-d H:i:s'))
×
NEW
412
            ->first();
×
413
    }
414

415
    /**
416
     * Force password reset for multiple users.
417
     *
418
     * @param list<int>|list<string> $userIds
419
     */
UNCOV
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