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

daycry / auth / 27063173516

06 Jun 2026 09:58AM UTC coverage: 76.796% (+0.4%) from 76.441%
27063173516

push

github

web-flow
Merge pull request #61 from daycry/perf/db-queries

perf: optimize database queries (indexes, hot-path query reduction, index-friendly login)

191 of 212 new or added lines in 9 files covered. (90.09%)

5441 of 7085 relevant lines covered (76.8%)

80.09 hits per line

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

98.11
/src/Models/DeviceSessionModel.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\I18n\Time;
17
use Daycry\Auth\Entities\DeviceSession;
18
use Daycry\Auth\Entities\User;
19

20
class DeviceSessionModel extends BaseModel
21
{
22
    protected $primaryKey     = 'id';
23
    protected $returnType     = DeviceSession::class;
24
    protected $useSoftDeletes = false;
25
    protected $allowedFields  = [
26
        'uuid',
27
        'user_id',
28
        'session_id',
29
        'device_name',
30
        'ip_address',
31
        'user_agent',
32
        'last_active',
33
        'logged_out_at',
34
        'trusted_until',
35
    ];
36
    protected $useTimestamps = true;
37
    protected $createdField  = 'created_at';
38
    protected $updatedField  = 'updated_at';
39
    protected $beforeInsert  = ['generateUuid'];
40

41
    protected function initialize(): void
60 ✔
42
    {
43
        parent::initialize();
60 ✔
44

45
        $this->table = $this->tables['device_sessions'];
60 ✔
46
    }
47

48
    /**
49
     * Generates a UUID v7 for new device session records.
50
     *
51
     * Model event callback called by `beforeInsert`.
52
     *
53
     * @param array<string, mixed> $data
54
     *
55
     * @return array<string, mixed>
56
     */
57
    protected function generateUuid(array $data): array
44 ✔
58
    {
59
        if (empty($data['data']['uuid'])) {
44 ✔
60
            $data['data']['uuid'] = service('uuid')->uuid7()->toRfc4122();
44 ✔
61
        }
62

63
        return $data;
44 ✔
64
    }
65

66
    /**
67
     * Returns all active (not logged out) device sessions for the given user.
68
     *
69
     * @return list<DeviceSession>
70
     */
71
    public function getActiveForUser(User $user): array
19 ✔
72
    {
73
        return $this->where('user_id', $user->id)
19 ✔
74
            ->where('logged_out_at')
19 ✔
75
            ->orderBy('last_active', 'DESC')
19 ✔
76
            ->findAll();
19 ✔
77
    }
78

79
    /**
80
     * Returns all device sessions (active and terminated) for the given user.
81
     *
82
     * @return list<DeviceSession>
83
     */
84
    public function getAllForUser(User $user): array
1 ✔
85
    {
86
        return $this->where('user_id', $user->id)
1 ✔
87
            ->orderBy('last_active', 'DESC')
1 ✔
88
            ->findAll();
1 ✔
89
    }
90

91
    /**
92
     * Finds a device session by its PHP session ID.
93
     */
94
    public function findBySessionId(string $sessionId): ?DeviceSession
12 ✔
95
    {
96
        return $this->where('session_id', $sessionId)
12 ✔
97
            ->first();
12 ✔
98
    }
99

100
    /**
101
     * Returns whether the given PHP session id still maps to an ACTIVE device
102
     * session.
103
     *
104
     * Returns false ONLY when a row exists and has been terminated
105
     * (`logged_out_at` set) — i.e. the session was revoked remotely or evicted
106
     * by the concurrent-session limit. Returns true when no row exists, so
107
     * sessions that predate device tracking (or whose recording silently
108
     * failed) are never falsely invalidated.
109
     */
110
    public function isSessionActive(string $sessionId): bool
4 ✔
111
    {
112
        if ($sessionId === '') {
4 ✔
113
            return true;
×
114
        }
115

116
        $session = $this->findBySessionId($sessionId);
4 ✔
117

118
        return $session === null || empty($session->logged_out_at);
4 ✔
119
    }
120

121
    /**
122
     * Creates a new device session record.
123
     */
124
    public function createSession(User $user, string $sessionId, string $ipAddress, ?string $userAgent = null): DeviceSession
44 ✔
125
    {
126
        $deviceName = DeviceSession::parseUserAgent($userAgent ?? '');
44 ✔
127

128
        $data = [
44 ✔
129
            'user_id'     => $user->id,
44 ✔
130
            'session_id'  => $sessionId,
44 ✔
131
            'device_name' => $deviceName,
44 ✔
132
            'ip_address'  => $ipAddress,
44 ✔
133
            'user_agent'  => $userAgent,
44 ✔
134
            'last_active' => Time::now()->format('Y-m-d H:i:s'),
44 ✔
135
        ];
44 ✔
136

137
        $id = $this->insert($data, true);
44 ✔
138

139
        return $this->find($id);
44 ✔
140
    }
141

142
    /**
143
     * Updates the last_active timestamp for the given session.
144
     */
145
    public function touchSession(string $sessionId): void
2 ✔
146
    {
147
        $this->where('session_id', $sessionId)
2 ✔
148
            ->where('logged_out_at')
2 ✔
149
            ->set('last_active', Time::now()->format('Y-m-d H:i:s'))
2 ✔
150
            ->update();
2 ✔
151
    }
152

153
    /**
154
     * Marks the session as terminated by setting logged_out_at.
155
     */
156
    public function terminateSession(string $sessionId): void
11 ✔
157
    {
158
        $this->where('session_id', $sessionId)
11 ✔
159
            ->where('logged_out_at')
11 ✔
160
            ->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
11 ✔
161
            ->update();
11 ✔
162
    }
163

164
    /**
165
     * Terminates the given sessions in a single UPDATE. Only rows still active
166
     * (logged_out_at IS NULL) are affected, matching terminateSession().
167
     *
168
     * @param list<string> $sessionIds
169
     *
170
     * @return int Number of sessions terminated (active rows targeted).
171
     */
172
    public function terminateSessionsByIds(array $sessionIds): int
4 ✔
173
    {
174
        $sessionIds = array_values(array_filter(
4 ✔
175
            $sessionIds,
4 ✔
176
            static fn (string $id): bool => $id !== '',
4 ✔
177
        ));
4 ✔
178

179
        if ($sessionIds === []) {
4 ✔
NEW
180
            return 0;
×
181
        }
182

183
        $this->whereIn('session_id', $sessionIds)
4 ✔
184
            ->where('logged_out_at')
4 ✔
185
            ->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
4 ✔
186
            ->update();
4 ✔
187

188
        return count($sessionIds);
4 ✔
189
    }
190

191
    /**
192
     * Terminates all active sessions for a user, optionally keeping one session alive.
193
     *
194
     * @param string|null $exceptSessionId Session ID to keep active (e.g. current session)
195
     */
196
    public function terminateAllForUser(User $user, ?string $exceptSessionId = null): void
8 ✔
197
    {
198
        $builder = $this->where('user_id', $user->id)
8 ✔
199
            ->where('logged_out_at');
8 ✔
200

201
        if ($exceptSessionId !== null && $exceptSessionId !== '') {
8 ✔
202
            $builder = $builder->where('session_id !=', $exceptSessionId);
3 ✔
203
        }
204

205
        $builder->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
8 ✔
206
            ->update();
8 ✔
207
    }
208

209
    /**
210
     * Removes old terminated sessions older than the given number of days.
211
     */
212
    public function purgeOldSessions(int $days = 30): void
5 ✔
213
    {
214
        $cutoff = Time::now()->subDays($days)->format('Y-m-d H:i:s');
5 ✔
215

216
        $this->where('logged_out_at <', $cutoff)
5 ✔
217
            ->delete();
5 ✔
218
    }
219

220
    /**
221
     * Marks the device session identified by $uuid as trusted for
222
     * $lifetimeSeconds. While `trusted_until` is in the future, 2FA
223
     * challenges can be skipped on this device.
224
     */
225
    public function markTrusted(string $uuid, int $lifetimeSeconds): void
3 ✔
226
    {
227
        if ($uuid === '' || $lifetimeSeconds <= 0) {
3 ✔
228
            return;
1 ✔
229
        }
230

231
        $until = Time::now()->addSeconds($lifetimeSeconds)->format('Y-m-d H:i:s');
2 ✔
232

233
        $this->where('uuid', $uuid)
2 ✔
234
            ->set('trusted_until', $until)
2 ✔
235
            ->update();
2 ✔
236
    }
237

238
    /**
239
     * Returns the device session identified by $uuid when it is currently
240
     * trusted (logged-in, not revoked, and `trusted_until` is in the future).
241
     */
242
    public function findTrustedByUuid(string $uuid): ?DeviceSession
3 ✔
243
    {
244
        if ($uuid === '') {
3 ✔
245
            return null;
1 ✔
246
        }
247

248
        $now = Time::now()->format('Y-m-d H:i:s');
3 ✔
249

250
        $row = $this->where('uuid', $uuid)
3 ✔
251
            ->where('logged_out_at')
3 ✔
252
            ->where('trusted_until >=', $now)
3 ✔
253
            ->first();
3 ✔
254

255
        return $row instanceof DeviceSession ? $row : null;
3 ✔
256
    }
257

258
    /**
259
     * Clears the trust flag from the given device session (e.g. when the
260
     * user explicitly revokes it).
261
     */
262
    public function revokeTrust(string $uuid): void
1 ✔
263
    {
264
        if ($uuid === '') {
1 ✔
265
            return;
1 ✔
266
        }
267

268
        $this->where('uuid', $uuid)
1 ✔
269
            ->set('trusted_until', null)
1 ✔
270
            ->update();
1 ✔
271
    }
272

273
    /**
274
     * Enforces a per-user limit on concurrent active sessions.
275
     *
276
     * If the user already has `>= $limit` active sessions, terminates the
277
     * oldest ones (by `last_active` ascending) until exactly `$limit - 1`
278
     * remain — leaving room for the new session about to be created.
279
     *
280
     * @return int Number of sessions terminated.
281
     */
282
    public function enforceConcurrentSessionLimit(User $user, int $limit): int
4 ✔
283
    {
284
        if ($limit <= 0) {
4 ✔
285
            return 0;
1 ✔
286
        }
287

288
        $active = $this->where('user_id', $user->id)
4 ✔
289
            ->where('logged_out_at')
4 ✔
290
            ->orderBy('last_active', 'ASC')
4 ✔
291
            ->findAll();
4 ✔
292

293
        $excess = count($active) - ($limit - 1);
4 ✔
294

295
        if ($excess <= 0) {
4 ✔
296
            return 0;
1 ✔
297
        }
298

299
        $sessionIds = [];
3 ✔
300

301
        foreach (array_slice($active, 0, $excess) as $session) {
3 ✔
302
            if (! empty($session->session_id)) {
3 ✔
303
                $sessionIds[] = (string) $session->session_id;
3 ✔
304
            }
305
        }
306

307
        return $this->terminateSessionsByIds($sessionIds);
3 ✔
308
    }
309
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc