• 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

69.01
/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\Exceptions\DatabaseException;
25
use Faker\Generator;
26

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

52
    protected function initialize(): void
53
    {
54
        parent::initialize();
286✔
55

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

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

70
        $return = $this->insert($data);
14✔
71

72
        $this->checkQueryReturn($return);
14✔
73
    }
74

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

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

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

95
        $this->checkQueryReturn($return);
88✔
96
    }
97

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

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

122
        helper('text');
14✔
123

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

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

131
            try {
132
                $this->create($data);
14✔
133

134
                break;
14✔
UNCOV
135
            } catch (DatabaseException $e) {
×
136
                $maxTry--;
×
137

UNCOV
138
                if ($maxTry === 0) {
×
UNCOV
139
                    throw $e;
×
140
                }
141
            }
142
        }
143

144
        return $data['secret'];
14✔
145
    }
146

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

157
        helper('text');
10✔
158

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

167
        $this->checkQueryReturn($return);
10✔
168

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

174
        $token->raw_token = $rawToken;
10✔
175

176
        return $token;
10✔
177
    }
178

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

188
    public function getAccessToken(User $user, string $rawToken): ?AccessTokenIdentity
189
    {
190
        $this->checkUserId($user);
2✔
191

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

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

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

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

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

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

239
        return $this->where('type', $type)
×
UNCOV
240
            ->where('secret', $secret)
×
UNCOV
241
            ->first();
×
242
    }
243

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

253
        return $this->where('user_id', $user->id)->orderBy($this->primaryKey)->findAll();
231✔
254
    }
255

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

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

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

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

290
        if ($types === []) {
4✔
291
            return [];
3✔
292
        }
293

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

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

307
        $return = $this->save($identity);
16✔
308

309
        $this->checkQueryReturn($return);
16✔
310
    }
311

312
    public function deleteIdentitiesByType(User $user, string $type): void
313
    {
314
        $this->checkUserId($user);
20✔
315

316
        $return = $this->where('user_id', $user->id)
20✔
317
            ->where('type', $type)
20✔
318
            ->delete();
20✔
319

320
        $this->checkQueryReturn($return);
20✔
321
    }
322

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

330
        $return = $this->where('user_id', $user->id)
×
331
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
UNCOV
332
            ->where('secret', hash('sha256', $rawToken))
×
333
            ->delete();
×
334

UNCOV
335
        $this->checkQueryReturn($return);
×
336
    }
337

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

345
        $return = $this->where('user_id', $user->id)
×
346
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
UNCOV
347
            ->where('secret', $secretToken)
×
348
            ->delete();
×
349

UNCOV
350
        $this->checkQueryReturn($return);
×
351
    }
352

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

360
        $return = $this->where('user_id', $user->id)
×
UNCOV
361
            ->where('type', AccessToken::ID_TYPE_ACCESS_TOKEN)
×
362
            ->delete();
×
363

UNCOV
364
        $this->checkQueryReturn($return);
×
365
    }
366

367
    /**
368
     * Force password reset for multiple users.
369
     *
370
     * @param list<int>|list<string> $userIds
371
     */
372
    public function forceMultiplePasswordReset(array $userIds): void
373
    {
374
        $this->where(['type' => Session::ID_TYPE_EMAIL_PASSWORD, 'force_reset' => 0]);
×
375
        $this->whereIn('user_id', $userIds);
×
UNCOV
376
        $this->set('force_reset', 1);
×
377
        $return = $this->update();
×
378

UNCOV
379
        $this->checkQueryReturn($return);
×
380
    }
381

382
    /**
383
     * Force global password reset.
384
     * This is useful for enforcing a password reset
385
     * for ALL users in case of a security breach.
386
     */
387
    public function forceGlobalPasswordReset(): void
388
    {
389
        $whereFilter = [
1✔
390
            'type'        => Session::ID_TYPE_EMAIL_PASSWORD,
1✔
391
            'force_reset' => 0,
1✔
392
        ];
1✔
393
        $this->where($whereFilter);
1✔
394
        $this->set('force_reset', 1);
1✔
395
        $return = $this->update();
1✔
396

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

400
    /**
401
     * Override the Model's `update()` method.
402
     * Throws an Exception when it fails.
403
     *
404
     * @param array|int|list<int|string>|RawSql|string|null $id
405
     * @param array|object|null                             $data
406
     *
407
     * @return true if the update is successful
408
     *
409
     * @throws ValidationException
410
     */
411
    public function update($id = null, $data = null): bool
412
    {
413
        $result = parent::update($id, $data);
69✔
414

415
        $this->checkQueryReturn($result);
43✔
416

417
        return true;
43✔
418
    }
419

420
    public function fake(Generator &$faker): UserIdentity
421
    {
422
        return new UserIdentity([
6✔
423
            'user_id'      => fake(UserModel::class)->id,
6✔
424
            'type'         => Session::ID_TYPE_EMAIL_PASSWORD,
6✔
425
            'name'         => null,
6✔
426
            'secret'       => $faker->unique()->email(),
6✔
427
            'secret2'      => password_hash('secret', PASSWORD_DEFAULT),
6✔
428
            'expires'      => null,
6✔
429
            'extra'        => null,
6✔
430
            'force_reset'  => false,
6✔
431
            'last_used_at' => null,
6✔
432
        ]);
6✔
433
    }
434
}
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