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

codeigniter4 / shield / 13372766050

17 Feb 2025 02:40PM UTC coverage: 92.717% (-0.05%) from 92.769%
13372766050

Pull #1219

github

web-flow
Merge 333811eb3 into e782eb076
Pull Request #1219: feat: Add expiration date to access token & hmac keys

48 of 53 new or added lines in 5 files covered. (90.57%)

1 existing line in 1 file now uncovered.

2826 of 3048 relevant lines covered (92.72%)

148.79 hits per line

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

99.51
/src/Models/UserIdentityModel.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter Shield.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
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 CodeIgniter\Shield\Models;
15

16
use CodeIgniter\I18n\Time;
17
use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens;
18
use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256;
19
use CodeIgniter\Shield\Authentication\Authenticators\Session;
20
use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter;
21
use CodeIgniter\Shield\Authentication\Passwords;
22
use CodeIgniter\Shield\Entities\AccessToken;
23
use CodeIgniter\Shield\Entities\User;
24
use CodeIgniter\Shield\Entities\UserIdentity;
25
use CodeIgniter\Shield\Exceptions\LogicException;
26
use CodeIgniter\Shield\Exceptions\ValidationException;
27
use Exception;
28
use Faker\Generator;
29
use InvalidArgumentException;
30
use ReflectionException;
31

32
class UserIdentityModel extends BaseModel
33
{
34
    protected $primaryKey     = 'id';
35
    protected $returnType     = UserIdentity::class;
36
    protected $useSoftDeletes = false;
37
    protected $allowedFields  = [
38
        'user_id',
39
        'type',
40
        'name',
41
        'secret',
42
        'secret2',
43
        'expires',
44
        'extra',
45
        'force_reset',
46
        'last_used_at',
47
    ];
48
    protected $useTimestamps = true;
49

50
    protected function initialize(): void
51
    {
52
        parent::initialize();
1,518✔
53

54
        $this->table = $this->tables['identities'];
1,518✔
55
    }
56

57
    /**
58
     * Inserts a record
59
     *
60
     * @param array|object $data
61
     *
62
     * @throws DatabaseException
63
     */
64
    public function create($data): void
65
    {
66
        $this->disableDBDebug();
66✔
67

68
        $return = $this->insert($data);
66✔
69

70
        $this->checkQueryReturn($return);
66✔
71
    }
72

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

83
        /** @var Passwords $passwords */
84
        $passwords = service('passwords');
522✔
85

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

93
        $this->checkQueryReturn($return);
522✔
94
    }
95

96
    private function checkUserId(User $user): void
97
    {
98
        if ($user->id === null) {
1,398✔
99
            throw new LogicException(
6✔
100
                '"$user->id" is null. You should not use the incomplete User object.',
6✔
101
            );
6✔
102
        }
103
    }
104

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

120
        helper('text');
60✔
121

122
        // Create an identity for the action
123
        $maxTry          = 5;
60✔
124
        $data['user_id'] = $user->id;
60✔
125

126
        while (true) {
60✔
127
            $data['secret'] = $codeGenerator();
60✔
128

129
            try {
130
                $this->create($data);
60✔
131

132
                break;
54✔
133
            } catch (DatabaseException $e) {
6✔
134
                $maxTry--;
6✔
135

136
                if ($maxTry === 0) {
6✔
137
                    throw $e;
6✔
138
                }
139
            }
140
        }
141

142
        return $data['secret'];
54✔
143
    }
144

145
    /**
146
     * Generates a new personal access token for the user.
147
     *
148
     * @param string       $name      Token name
149
     * @param list<string> $scopes    Permissions the token grants
150
     * @param Time         $expiresAt Expiration date
151
     *
152
     * @throws InvalidArgumentException
153
     */
154
    public function generateAccessToken(User $user, string $name, array $scopes = ['*'], ?Time $expiresAt = null): AccessToken
155
    {
156
        $this->checkUserId($user);
138✔
157

158
        helper('text');
138✔
159

160
        $return = $this->insert([
138✔
161
            'type'    => AccessTokens::ID_TYPE_ACCESS_TOKEN,
138✔
162
            'user_id' => $user->id,
138✔
163
            'name'    => $name,
138✔
164
            'secret'  => hash('sha256', $rawToken = random_string('crypto', 64)),
138✔
165
            'expires' => $expiresAt,
138✔
166
            'extra'   => serialize($scopes),
138✔
167
        ]);
138✔
168

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

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

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

178
        return $token;
138✔
179
    }
180

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

190
    public function getAccessToken(User $user, string $rawToken): ?AccessToken
191
    {
192
        $this->checkUserId($user);
60✔
193

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

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

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

217
    /**
218
     * @return list<AccessToken>
219
     */
220
    public function getAllAccessTokens(User $user): array
221
    {
222
        $this->checkUserId($user);
30✔
223

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

232
    /**
233
     * Updates or sets expiration date of users' AccessToken or HMAC Token by ID. Returns updated row.
234
     *
235
     * @param Time  $expiresAt Expiration date
236
     * @param mixed $id
237
     *
238
     * @return bool Returns true if expiration date was set or updated.
239
     */
240
    public function setIdentityExpirationById($id, User $user, ?Time $expiresAt = null, ?string $type_token = null): bool
241
    {
242
        $this->checkUserId($user);
12✔
243

244
        if ($expiresAt !== null) {
12✔
245
            return $this->where('user_id', $user->id)
12✔
246
                ->where('type', $type_token)
12✔
247
                ->set(['expires' => $expiresAt])
12✔
248
                ->update($id);
12✔
249
        }
250

NEW
UNCOV
251
        return false;
×
252
    }
253

254
    // HMAC
255
    /**
256
     * Find and Retrieve the HMAC AccessToken based on Token alone
257
     *
258
     * @return ?AccessToken Full HMAC Access Token object
259
     */
260
    public function getHmacTokenByKey(string $key): ?AccessToken
261
    {
262
        return $this
72✔
263
            ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
72✔
264
            ->where('secret', $key)
72✔
265
            ->asObject(AccessToken::class)
72✔
266
            ->first();
72✔
267
    }
268

269
    /**
270
     * Generates a new personal access token for the user.
271
     *
272
     * @param string       $name      Token name
273
     * @param list<string> $scopes    Permissions the token grants
274
     * @param Time         $expiresAt Expiration date
275
     *
276
     * @throws Exception
277
     * @throws InvalidArgumentException
278
     * @throws ReflectionException
279
     */
280
    public function generateHmacToken(User $user, string $name, array $scopes = ['*'], ?Time $expiresAt = null): AccessToken
281
    {
282
        $this->checkUserId($user);
168✔
283

284
        $encrypter    = new HmacEncrypter();
168✔
285
        $rawSecretKey = $encrypter->generateSecretKey();
168✔
286
        $secretKey    = $encrypter->encrypt($rawSecretKey);
168✔
287

288
        $return = $this->insert([
168✔
289
            'type'    => HmacSha256::ID_TYPE_HMAC_TOKEN,
168✔
290
            'user_id' => $user->id,
168✔
291
            'name'    => $name,
168✔
292
            'secret'  => bin2hex(random_bytes(16)), // Key
168✔
293
            'secret2' => $secretKey,
168✔
294
            'expires' => $expiresAt,
168✔
295
            'extra'   => serialize($scopes),
168✔
296
        ]);
168✔
297

298
        $this->checkQueryReturn($return);
168✔
299

300
        /** @var AccessToken $token */
301
        $token = $this
168✔
302
            ->asObject(AccessToken::class)
168✔
303
            ->find($this->getInsertID());
168✔
304

305
        $token->rawSecretKey = $rawSecretKey;
168✔
306

307
        return $token;
168✔
308
    }
309

310
    /**
311
     * Retrieve Token object for selected HMAC Token.
312
     * Note: These tokens are not hashed as they are considered shared secrets.
313
     *
314
     * @param User   $user User Object
315
     * @param string $key  HMAC Key String
316
     *
317
     * @return ?AccessToken Full HMAC Access Token
318
     */
319
    public function getHmacToken(User $user, string $key): ?AccessToken
320
    {
321
        $this->checkUserId($user);
54✔
322

323
        return $this->where('user_id', $user->id)
54✔
324
            ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
54✔
325
            ->where('secret', $key)
54✔
326
            ->asObject(AccessToken::class)
54✔
327
            ->first();
54✔
328
    }
329

330
    /**
331
     * Given the ID, returns the given access token.
332
     *
333
     * @param int|string $id
334
     * @param User       $user User Object
335
     *
336
     * @return ?AccessToken Full HMAC Access Token
337
     */
338
    public function getHmacTokenById($id, User $user): ?AccessToken
339
    {
340
        $this->checkUserId($user);
12✔
341

342
        return $this->where('user_id', $user->id)
12✔
343
            ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
12✔
344
            ->where('id', $id)
12✔
345
            ->asObject(AccessToken::class)
12✔
346
            ->first();
12✔
347
    }
348

349
    /**
350
     * Retrieve all HMAC tokes for users
351
     *
352
     * @param User $user User object
353
     *
354
     * @return list<AccessToken>
355
     */
356
    public function getAllHmacTokens(User $user): array
357
    {
358
        $this->checkUserId($user);
18✔
359

360
        return $this
18✔
361
            ->where('user_id', $user->id)
18✔
362
            ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
18✔
363
            ->orderBy($this->primaryKey)
18✔
364
            ->asObject(AccessToken::class)
18✔
365
            ->findAll();
18✔
366
    }
367

368
    /**
369
     * Delete any HMAC tokens for the given key.
370
     *
371
     * @param User   $user User object
372
     * @param string $key  HMAC Key
373
     */
374
    public function revokeHmacToken(User $user, string $key): void
375
    {
376
        $this->checkUserId($user);
6✔
377

378
        $return = $this->where('user_id', $user->id)
6✔
379
            ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
6✔
380
            ->where('secret', $key)
6✔
381
            ->delete();
6✔
382

383
        $this->checkQueryReturn($return);
6✔
384
    }
385

386
    /**
387
     * Revokes all access tokens for this user.
388
     */
389
    public function revokeAllHmacTokens(User $user): void
390
    {
391
        $this->checkUserId($user);
6✔
392

393
        $return = $this->where('user_id', $user->id)
6✔
394
            ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN)
6✔
395
            ->delete();
6✔
396

397
        $this->checkQueryReturn($return);
6✔
398
    }
399

400
    /**
401
     * Used by 'magic-link'.
402
     */
403
    public function getIdentityBySecret(string $type, ?string $secret): ?UserIdentity
404
    {
405
        if ($secret === null) {
24✔
406
            return null;
6✔
407
        }
408

409
        return $this->where('type', $type)
18✔
410
            ->where('secret', $secret)
18✔
411
            ->first();
18✔
412
    }
413

414
    /**
415
     * Returns all identities.
416
     *
417
     * @return list<UserIdentity>
418
     */
419
    public function getIdentities(User $user): array
420
    {
421
        $this->checkUserId($user);
1,398✔
422

423
        return $this->where('user_id', $user->id)->orderBy($this->primaryKey)->findAll();
1,398✔
424
    }
425

426
    /**
427
     * @param list<int>|list<string> $userIds
428
     *
429
     * @return list<UserIdentity>
430
     */
431
    public function getIdentitiesByUserIds(array $userIds): array
432
    {
433
        return $this->whereIn('user_id', $userIds)->orderBy($this->primaryKey)->findAll();
18✔
434
    }
435

436
    /**
437
     * Returns the first identity of the type.
438
     */
439
    public function getIdentityByType(User $user, string $type): ?UserIdentity
440
    {
441
        $this->checkUserId($user);
102✔
442

443
        return $this->where('user_id', $user->id)
102✔
444
            ->where('type', $type)
102✔
445
            ->orderBy($this->primaryKey)
102✔
446
            ->first();
102✔
447
    }
448

449
    /**
450
     * Returns all identities for the specific types.
451
     *
452
     * @param list<string> $types
453
     *
454
     * @return list<UserIdentity>
455
     */
456
    public function getIdentitiesByTypes(User $user, array $types): array
457
    {
458
        $this->checkUserId($user);
102✔
459

460
        if ($types === []) {
102✔
461
            return [];
96✔
462
        }
463

464
        return $this->where('user_id', $user->id)
12✔
465
            ->whereIn('type', $types)
12✔
466
            ->orderBy($this->primaryKey)
12✔
467
            ->findAll();
12✔
468
    }
469

470
    /**
471
     * Update the last used at date for an identity record.
472
     */
473
    public function touchIdentity(UserIdentity $identity): void
474
    {
475
        $identity->last_used_at = Time::now();
66✔
476

477
        $return = $this->save($identity);
66✔
478

479
        $this->checkQueryReturn($return);
66✔
480
    }
481

482
    public function deleteIdentitiesByType(User $user, string $type): void
483
    {
484
        $this->checkUserId($user);
72✔
485

486
        $return = $this->where('user_id', $user->id)
72✔
487
            ->where('type', $type)
72✔
488
            ->delete();
72✔
489

490
        $this->checkQueryReturn($return);
72✔
491
    }
492

493
    /**
494
     * Delete any access tokens for the given raw token.
495
     */
496
    public function revokeAccessToken(User $user, string $rawToken): void
497
    {
498
        $this->checkUserId($user);
6✔
499

500
        $return = $this->where('user_id', $user->id)
6✔
501
            ->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
6✔
502
            ->where('secret', hash('sha256', $rawToken))
6✔
503
            ->delete();
6✔
504

505
        $this->checkQueryReturn($return);
6✔
506
    }
507

508
    /**
509
     * Delete any access tokens for the given secret token.
510
     */
511
    public function revokeAccessTokenBySecret(User $user, string $secretToken): void
512
    {
513
        $this->checkUserId($user);
6✔
514

515
        $return = $this->where('user_id', $user->id)
6✔
516
            ->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
6✔
517
            ->where('secret', $secretToken)
6✔
518
            ->delete();
6✔
519

520
        $this->checkQueryReturn($return);
6✔
521
    }
522

523
    /**
524
     * Revokes all access tokens for this user.
525
     */
526
    public function revokeAllAccessTokens(User $user): void
527
    {
528
        $this->checkUserId($user);
6✔
529

530
        $return = $this->where('user_id', $user->id)
6✔
531
            ->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN)
6✔
532
            ->delete();
6✔
533

534
        $this->checkQueryReturn($return);
6✔
535
    }
536

537
    /**
538
     * Force password reset for multiple users.
539
     *
540
     * @param list<int>|list<string> $userIds
541
     */
542
    public function forceMultiplePasswordReset(array $userIds): void
543
    {
544
        $this->where(['type' => Session::ID_TYPE_EMAIL_PASSWORD, 'force_reset' => 0]);
6✔
545
        $this->whereIn('user_id', $userIds);
6✔
546
        $this->set('force_reset', 1);
6✔
547
        $return = $this->update();
6✔
548

549
        $this->checkQueryReturn($return);
6✔
550
    }
551

552
    /**
553
     * Force global password reset.
554
     * This is useful for enforcing a password reset
555
     * for ALL users in case of a security breach.
556
     */
557
    public function forceGlobalPasswordReset(): void
558
    {
559
        $whereFilter = [
6✔
560
            'type'        => Session::ID_TYPE_EMAIL_PASSWORD,
6✔
561
            'force_reset' => 0,
6✔
562
        ];
6✔
563
        $this->where($whereFilter);
6✔
564
        $this->set('force_reset', 1);
6✔
565
        $return = $this->update();
6✔
566

567
        $this->checkQueryReturn($return);
6✔
568
    }
569

570
    /**
571
     * Override the Model's `update()` method.
572
     * Throws an Exception when it fails.
573
     *
574
     * @param array|int|string|null $id
575
     * @param array|object|null     $row
576
     *
577
     * @return true if the update is successful
578
     *
579
     * @throws ValidationException
580
     */
581
    public function update($id = null, $row = null): bool
582
    {
583
        $result = parent::update($id, $row);
378✔
584

585
        $this->checkQueryReturn($result);
372✔
586

587
        return true;
372✔
588
    }
589

590
    public function fake(Generator &$faker): UserIdentity
591
    {
592
        return new UserIdentity([
66✔
593
            'user_id'      => fake(UserModel::class)->id,
66✔
594
            'type'         => Session::ID_TYPE_EMAIL_PASSWORD,
66✔
595
            'name'         => null,
66✔
596
            'secret'       => $faker->unique()->email(),
66✔
597
            'secret2'      => password_hash('secret', PASSWORD_DEFAULT),
66✔
598
            'expires'      => null,
66✔
599
            'extra'        => null,
66✔
600
            'force_reset'  => false,
66✔
601
            'last_used_at' => null,
66✔
602
        ]);
66✔
603
    }
604
}
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

© 2025 Coveralls, Inc