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

daycry / auth / 25518434194

07 May 2026 07:49PM UTC coverage: 58.608% (-6.4%) from 64.989%
25518434194

push

github

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

Implement security enhancements and new account features

277 of 1030 new or added lines in 55 files covered. (26.89%)

11 existing lines in 6 files now uncovered.

3544 of 6047 relevant lines covered (58.61%)

47.97 hits per line

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

53.33
/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
use Symfony\Component\Uid\Uuid;
20

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

42
    protected function initialize(): void
16✔
43
    {
44
        parent::initialize();
16✔
45

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

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

64
        return $data;
15✔
65
    }
66

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

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

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

101
    /**
102
     * Creates a new device session record.
103
     */
104
    public function createSession(User $user, string $sessionId, string $ipAddress, ?string $userAgent = null): DeviceSession
15✔
105
    {
106
        $deviceName = DeviceSession::parseUserAgent($userAgent ?? '');
15✔
107

108
        $data = [
15✔
109
            'user_id'     => $user->id,
15✔
110
            'session_id'  => $sessionId,
15✔
111
            'device_name' => $deviceName,
15✔
112
            'ip_address'  => $ipAddress,
15✔
113
            'user_agent'  => $userAgent,
15✔
114
            'last_active' => Time::now()->format('Y-m-d H:i:s'),
15✔
115
        ];
15✔
116

117
        $id = $this->insert($data, true);
15✔
118

119
        return $this->find($id);
15✔
120
    }
121

122
    /**
123
     * Updates the last_active timestamp for the given session.
124
     */
125
    public function touchSession(string $sessionId): void
1✔
126
    {
127
        $this->where('session_id', $sessionId)
1✔
128
            ->where('logged_out_at')
1✔
129
            ->set('last_active', Time::now()->format('Y-m-d H:i:s'))
1✔
130
            ->update();
1✔
131
    }
132

133
    /**
134
     * Marks the session as terminated by setting logged_out_at.
135
     */
136
    public function terminateSession(string $sessionId): void
4✔
137
    {
138
        $this->where('session_id', $sessionId)
4✔
139
            ->where('logged_out_at')
4✔
140
            ->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
4✔
141
            ->update();
4✔
142
    }
143

144
    /**
145
     * Terminates all active sessions for a user, optionally keeping one session alive.
146
     *
147
     * @param string|null $exceptSessionId Session ID to keep active (e.g. current session)
148
     */
149
    public function terminateAllForUser(User $user, ?string $exceptSessionId = null): void
4✔
150
    {
151
        $builder = $this->where('user_id', $user->id)
4✔
152
            ->where('logged_out_at');
4✔
153

154
        if ($exceptSessionId !== null && $exceptSessionId !== '') {
4✔
155
            $builder = $builder->where('session_id !=', $exceptSessionId);
2✔
156
        }
157

158
        $builder->set('logged_out_at', Time::now()->format('Y-m-d H:i:s'))
4✔
159
            ->update();
4✔
160
    }
161

162
    /**
163
     * Removes old terminated sessions older than the given number of days.
164
     */
165
    public function purgeOldSessions(int $days = 30): void
1✔
166
    {
167
        $cutoff = Time::now()->subDays($days)->format('Y-m-d H:i:s');
1✔
168

169
        $this->where('logged_out_at <', $cutoff)
1✔
170
            ->delete();
1✔
171
    }
172

173
    /**
174
     * Marks the device session identified by $uuid as trusted for
175
     * $lifetimeSeconds. While `trusted_until` is in the future, 2FA
176
     * challenges can be skipped on this device.
177
     */
NEW
178
    public function markTrusted(string $uuid, int $lifetimeSeconds): void
×
179
    {
NEW
180
        if ($uuid === '' || $lifetimeSeconds <= 0) {
×
NEW
181
            return;
×
182
        }
183

NEW
184
        $until = Time::now()->addSeconds($lifetimeSeconds)->format('Y-m-d H:i:s');
×
185

NEW
186
        $this->where('uuid', $uuid)
×
NEW
187
            ->set('trusted_until', $until)
×
NEW
188
            ->update();
×
189
    }
190

191
    /**
192
     * Returns the device session identified by $uuid when it is currently
193
     * trusted (logged-in, not revoked, and `trusted_until` is in the future).
194
     */
NEW
195
    public function findTrustedByUuid(string $uuid): ?DeviceSession
×
196
    {
NEW
197
        if ($uuid === '') {
×
NEW
198
            return null;
×
199
        }
200

NEW
201
        $now = Time::now()->format('Y-m-d H:i:s');
×
202

NEW
203
        $row = $this->where('uuid', $uuid)
×
NEW
204
            ->where('logged_out_at')
×
NEW
205
            ->where('trusted_until >=', $now)
×
NEW
206
            ->first();
×
207

NEW
208
        return $row instanceof DeviceSession ? $row : null;
×
209
    }
210

211
    /**
212
     * Clears the trust flag from the given device session (e.g. when the
213
     * user explicitly revokes it).
214
     */
NEW
215
    public function revokeTrust(string $uuid): void
×
216
    {
NEW
217
        if ($uuid === '') {
×
NEW
218
            return;
×
219
        }
220

NEW
221
        $this->where('uuid', $uuid)
×
NEW
222
            ->set('trusted_until', null)
×
NEW
223
            ->update();
×
224
    }
225

226
    /**
227
     * Enforces a per-user limit on concurrent active sessions.
228
     *
229
     * If the user already has `>= $limit` active sessions, terminates the
230
     * oldest ones (by `last_active` ascending) until exactly `$limit - 1`
231
     * remain — leaving room for the new session about to be created.
232
     *
233
     * @return int Number of sessions terminated.
234
     */
NEW
235
    public function enforceConcurrentSessionLimit(User $user, int $limit): int
×
236
    {
NEW
237
        if ($limit <= 0) {
×
NEW
238
            return 0;
×
239
        }
240

NEW
241
        $active = $this->where('user_id', $user->id)
×
NEW
242
            ->where('logged_out_at')
×
NEW
243
            ->orderBy('last_active', 'ASC')
×
NEW
244
            ->findAll();
×
245

NEW
246
        $excess = count($active) - ($limit - 1);
×
247

NEW
248
        if ($excess <= 0) {
×
NEW
249
            return 0;
×
250
        }
251

NEW
252
        $terminated = 0;
×
253

NEW
254
        foreach (array_slice($active, 0, $excess) as $session) {
×
NEW
255
            if (! empty($session->session_id)) {
×
NEW
256
                $this->terminateSession((string) $session->session_id);
×
NEW
257
                $terminated++;
×
258
            }
259
        }
260

NEW
261
        return $terminated;
×
262
    }
263
}
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