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

daycry / auth / 26937880755

04 Jun 2026 07:38AM UTC coverage: 75.983% (+4.4%) from 71.569%
26937880755

push

github

web-flow
Merge pull request #56 from daycry/development

feat

613 of 719 new or added lines in 42 files covered. (85.26%)

3 existing lines in 3 files now uncovered.

5179 of 6816 relevant lines covered (75.98%)

69.66 hits per line

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

98.95
/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
55✔
42
    {
43
        parent::initialize();
55✔
44

45
        $this->table = $this->tables['device_sessions'];
55✔
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
39✔
58
    {
59
        if (empty($data['data']['uuid'])) {
39✔
60
            $data['data']['uuid'] = service('uuid')->uuid7()->toRfc4122();
39✔
61
        }
62

63
        return $data;
39✔
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
17✔
72
    {
73
        return $this->where('user_id', $user->id)
17✔
74
            ->where('logged_out_at')
17✔
75
            ->orderBy('last_active', 'DESC')
17✔
76
            ->findAll();
17✔
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
11✔
95
    {
96
        return $this->where('session_id', $sessionId)
11✔
97
            ->first();
11✔
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✔
NEW
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
39✔
125
    {
126
        $deviceName = DeviceSession::parseUserAgent($userAgent ?? '');
39✔
127

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

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

139
        return $this->find($id);
39✔
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
10✔
157
    {
158
        $this->where('session_id', $sessionId)
10✔
159
            ->where('logged_out_at')
10✔
160
            ->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
10✔
161
            ->update();
10✔
162
    }
163

164
    /**
165
     * Terminates all active sessions for a user, optionally keeping one session alive.
166
     *
167
     * @param string|null $exceptSessionId Session ID to keep active (e.g. current session)
168
     */
169
    public function terminateAllForUser(User $user, ?string $exceptSessionId = null): void
8✔
170
    {
171
        $builder = $this->where('user_id', $user->id)
8✔
172
            ->where('logged_out_at');
8✔
173

174
        if ($exceptSessionId !== null && $exceptSessionId !== '') {
8✔
175
            $builder = $builder->where('session_id !=', $exceptSessionId);
3✔
176
        }
177

178
        $builder->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
8✔
179
            ->update();
8✔
180
    }
181

182
    /**
183
     * Removes old terminated sessions older than the given number of days.
184
     */
185
    public function purgeOldSessions(int $days = 30): void
4✔
186
    {
187
        $cutoff = Time::now()->subDays($days)->format('Y-m-d H:i:s');
4✔
188

189
        $this->where('logged_out_at <', $cutoff)
4✔
190
            ->delete();
4✔
191
    }
192

193
    /**
194
     * Marks the device session identified by $uuid as trusted for
195
     * $lifetimeSeconds. While `trusted_until` is in the future, 2FA
196
     * challenges can be skipped on this device.
197
     */
198
    public function markTrusted(string $uuid, int $lifetimeSeconds): void
3✔
199
    {
200
        if ($uuid === '' || $lifetimeSeconds <= 0) {
3✔
201
            return;
1✔
202
        }
203

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

206
        $this->where('uuid', $uuid)
2✔
207
            ->set('trusted_until', $until)
2✔
208
            ->update();
2✔
209
    }
210

211
    /**
212
     * Returns the device session identified by $uuid when it is currently
213
     * trusted (logged-in, not revoked, and `trusted_until` is in the future).
214
     */
215
    public function findTrustedByUuid(string $uuid): ?DeviceSession
3✔
216
    {
217
        if ($uuid === '') {
3✔
218
            return null;
1✔
219
        }
220

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

223
        $row = $this->where('uuid', $uuid)
3✔
224
            ->where('logged_out_at')
3✔
225
            ->where('trusted_until >=', $now)
3✔
226
            ->first();
3✔
227

228
        return $row instanceof DeviceSession ? $row : null;
3✔
229
    }
230

231
    /**
232
     * Clears the trust flag from the given device session (e.g. when the
233
     * user explicitly revokes it).
234
     */
235
    public function revokeTrust(string $uuid): void
1✔
236
    {
237
        if ($uuid === '') {
1✔
238
            return;
1✔
239
        }
240

241
        $this->where('uuid', $uuid)
1✔
242
            ->set('trusted_until', null)
1✔
243
            ->update();
1✔
244
    }
245

246
    /**
247
     * Enforces a per-user limit on concurrent active sessions.
248
     *
249
     * If the user already has `>= $limit` active sessions, terminates the
250
     * oldest ones (by `last_active` ascending) until exactly `$limit - 1`
251
     * remain — leaving room for the new session about to be created.
252
     *
253
     * @return int Number of sessions terminated.
254
     */
255
    public function enforceConcurrentSessionLimit(User $user, int $limit): int
2✔
256
    {
257
        if ($limit <= 0) {
2✔
258
            return 0;
1✔
259
        }
260

261
        $active = $this->where('user_id', $user->id)
2✔
262
            ->where('logged_out_at')
2✔
263
            ->orderBy('last_active', 'ASC')
2✔
264
            ->findAll();
2✔
265

266
        $excess = count($active) - ($limit - 1);
2✔
267

268
        if ($excess <= 0) {
2✔
269
            return 0;
1✔
270
        }
271

272
        $terminated = 0;
1✔
273

274
        foreach (array_slice($active, 0, $excess) as $session) {
1✔
275
            if (! empty($session->session_id)) {
1✔
276
                $this->terminateSession((string) $session->session_id);
1✔
277
                $terminated++;
1✔
278
            }
279
        }
280

281
        return $terminated;
1✔
282
    }
283
}
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