Session   C
last analyzed

Complexity

Total Complexity 55

Size/Duplication

Total Lines 484
Duplicated Lines 0 %

Importance

Changes 6
Bugs 0 Features 2
Metric Value
eloc 144
c 6
b 0
f 2
dl 0
loc 484
rs 6
wmc 55

26 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 10 2
A offsetUnset() 0 3 1
A __destruct() 0 4 2
A gc() 0 6 3
A offsetGet() 0 3 1
A regenerateId() 0 11 3
A csrfField() 0 5 1
A getFlash() 0 16 3
A delete() 0 3 1
A validateToken() 0 10 3
A loadFlashData() 0 15 2
A configure() 0 22 2
A clear() 0 3 1
A offsetExists() 0 3 1
A destroy() 0 15 3
A set() 0 3 1
A get() 0 3 1
A validateConfig() 0 6 4
A closeWrite() 0 7 2
A checkRegenerateId() 0 7 2
A offsetSet() 0 3 1
A setFlash() 0 6 1
A hasFlash() 0 21 6
A start() 0 28 4
A has() 0 3 1
A token() 0 12 3

How to fix   Complexity   

Complex Class

Complex classes like Session often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Session, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Fastpress\Security;
6
7
use RuntimeException;
8
9
/**
10
 * A secure session management class with enhanced features.
11
 *
12
 * This class provides a secure way to manage PHP sessions, with features
13
 * such as CSRF protection, flash messages, and session regeneration.
14
 *
15
 * @implements \ArrayAccess<string, mixed>
16
 */
17
class Session implements \ArrayAccess
18
{
19
    /** @var array<string, mixed> The session data. */
20
    private array $session;
21
    /** @var array<string, mixed> Flash message data. */
22
    private array $flashData = [];
23
    /** @var bool Whether the session has been started. */
24
    private bool $isStarted = false;
25
    /** @var int|null The last time the session ID was regenerated. */
26
    private int $lastRegenerationTime;
27
28
    /** @var int Session ID regeneration interval in seconds. */
29
    private const ID_REGENERATION_INTERVAL = 300; // 5 minutes
30
    /** @var int Flash message lifetime in seconds. */
31
    private const FLASH_LIFETIME = 3600; // 1 hour
32
    /** @var int CSRF token lifetime in seconds. */
33
    private const TOKEN_LIFETIME = 1800; // 30 minutes
34
35
    /** @var array<string, mixed> Default session configuration. */
36
    private const DEFAULT_CONFIG = [
37
        'cookie_httponly' => true,
38
        'cookie_samesite' => 'Lax',
39
        'cookie_secure' => true,
40
        'use_only_cookies' => 1,
41
        'use_strict_mode' => 1,
42
        'sid_length' => 48,
43
        'sid_bits_per_character' => 6,
44
        'hash_function' => 'sha256',
45
        'use_trans_sid' => 0,
46
        'gc_maxlifetime' => 7200,
47
        'gc_probability' => 1,
48
        'gc_divisor' => 100,
49
        'cookie_lifetime' => 0,
50
        'cookie_path' => '/',
51
        'cookie_domain' => '',
52
        'cache_limiter' => 'nocache',
53
        'cache_expire' => 180,
54
    ];
55
56
    /**
57
     * Constructor for the Session class.
58
     *
59
     * This constructor initializes the session with the provided configuration.
60
     * If a session has not already been started, it merges the default configuration
61
     * with the provided configuration, configures the session, and starts it.
62
     * 
63
     * @param array $config Optional. An array of configuration settings to override the default settings.
64
     */
65
    public function __construct(array $config = [])
66
    {
67
        // Start session first if needed
68
        if (session_status() === PHP_SESSION_NONE) {
69
            $this->configure(array_merge(self::DEFAULT_CONFIG, $config));
70
            session_start();
71
        }
72
        
73
        $this->session = &$_SESSION;
74
        $this->loadFlashData(); // Add this line to load flash data when session starts
75
    }
76
77
    /**
78
     * Validates the session configuration.
79
     *
80
     * @param array<string, mixed> $config  The session configuration.
81
     *
82
     * @throws RuntimeException If a security critical option is disabled.
83
     */
84
    private function validateConfig(array $config): void
0 ignored issues
show
Unused Code introduced by
The method validateConfig() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
85
    {
86
        $required = ['cookie_secure', 'cookie_httponly', 'use_strict_mode'];
87
        foreach ($required as $key) {
88
            if (isset($config[$key]) && !$config[$key]) {
89
                throw new RuntimeException("Security critical option '$key' cannot be disabled");
90
            }
91
        }
92
    }
93
94
    /**
95
     * Configures the session.
96
     *
97
     * @param array<string, mixed> $config  The session configuration.
98
     */
99
    private function configure(array $config): void
100
    {
101
        // Skip configuration if session is active
102
        if (session_status() === PHP_SESSION_ACTIVE) {
103
            return;
104
        }
105
106
        // Apply session settings
107
        session_set_cookie_params([
108
            'lifetime' => $config['cookie_lifetime'],
109
            'path' => $config['cookie_path'],
110
            'domain' => $config['cookie_domain'],
111
            'secure' => $config['cookie_secure'],
112
            'httponly' => $config['cookie_httponly'],
113
            'samesite' => $config['cookie_samesite']
114
        ]);
115
116
        // Set additional INI settings
117
        ini_set('session.sid_length', (string)$config['sid_length']);
118
        ini_set('session.sid_bits_per_character', (string)$config['sid_bits_per_character']);
119
        ini_set('session.hash_function', $config['hash_function']);
120
        ini_set('session.use_trans_sid', (string)$config['use_trans_sid']);
121
    }
122
123
    /**
124
     * Starts the session.
125
     *
126
     * @return bool True if the session was started, false otherwise.
127
     *
128
     * @throws RuntimeException If headers have already been sent or the session failed to start.
129
     */
130
    public function start(): bool
131
    {
132
        if ($this->isStarted) {
133
            return true;
134
        }
135
136
        if (headers_sent($file, $line)) {
137
            throw new RuntimeException(
138
                sprintf('Headers have already been sent in "%s" at line %d', $file, $line)
139
            );
140
        }
141
142
        // Use options array for atomic session start
143
        $success = session_start([
144
            'use_strict_mode' => 1,
145
            'cookie_httponly' => true,
146
            'cookie_secure' => true
147
        ]);
148
149
        if (!$success) {
150
            throw new RuntimeException('Failed to start session');
151
        }
152
153
        $this->isStarted = true;
154
        $this->checkRegenerateId();
155
        $this->gc();
156
157
        return true;
158
    }
159
160
    /**
161
     * Checks if the session ID needs to be regenerated.
162
     */
163
    private function checkRegenerateId(): void
164
    {
165
        $now = time();
166
        if ($now - ($this->lastRegenerationTime ?? 0) > self::ID_REGENERATION_INTERVAL) {
167
            $this->regenerateId();
168
            $this->lastRegenerationTime = $now;
169
            $this->session['__last_regeneration'] = $now;
170
        }
171
    }
172
173
    /**
174
     * Regenerates the session ID.
175
     *
176
     * @param bool $deleteOldSession Whether to delete the old session.
177
     *
178
     * @return bool True if the session ID was regenerated, false otherwise.
179
     *
180
     * @throws RuntimeException If the session is not started or the ID regeneration failed.
181
     */
182
    public function regenerateId(bool $deleteOldSession = true): bool
183
    {
184
        if (!$this->isStarted) {
185
            throw new RuntimeException('Session not started');
186
        }
187
188
        if (!session_regenerate_id($deleteOldSession)) {
189
            throw new RuntimeException('Failed to regenerate session ID');
190
        }
191
192
        return true;
193
    }
194
195
    /**
196
     * Generates a CSRF token.
197
     *
198
     * @return string The CSRF token.
199
     */
200
    public function token(): string
201
    {
202
        $token = $this->session['_token'] ?? null;
203
        $timestamp = $this->session['_token_timestamp'] ?? 0;
204
205
        if (!$token || (time() - $timestamp) > self::TOKEN_LIFETIME) {
206
            $token = bin2hex(random_bytes(32));
207
            $this->session['_token'] = $token;
208
            $this->session['_token_timestamp'] = time();
209
        }
210
211
        return $token;
212
    }
213
214
    /**
215
     * Validates a CSRF token.
216
     *
217
     * @param string $token The CSRF token to validate.
218
     *
219
     * @return bool True if the token is valid, false otherwise.
220
     */
221
    public function validateToken(string $token): bool
222
    {
223
        $storedToken = $this->session['_token'] ?? null;
224
        $timestamp = $this->session['_token_timestamp'] ?? 0;
225
226
        if (!$storedToken || (time() - $timestamp) > self::TOKEN_LIFETIME) {
227
            return false;
228
        }
229
230
        return hash_equals($storedToken, $token);
231
    }
232
233
    /**
234
     * Sets a flash message.
235
     *
236
     * @param string $key The key of the flash message.
237
     * @param mixed $value The value of the flash message.
238
     * @param string $type The type of the flash message (e.g., 'info', 'error').
239
     */
240
    public function setFlash(string $key, mixed $value, string $type = 'info'): void
241
    {
242
        $this->session['__flash'][$key] = [
243
            'value' => $value,
244
            'type' => $type,
245
            'timestamp' => time()
246
        ];
247
    }
248
    
249
250
    /**
251
     * Gets a flash message.
252
     *
253
     * @param string $key The key of the flash message.
254
     * @param mixed $default The default value to return if the message does not exist.
255
     *
256
     * @return mixed The value of the flash message or the default value.
257
     */
258
    public function getFlash(string $key, mixed $default = null): mixed
259
    {
260
        if (isset($this->session['__flash'][$key])) {
261
            $flash = $this->session['__flash'][$key];
262
    
263
            // Check if flash data has expired
264
            if (time() - $flash['timestamp'] > self::FLASH_LIFETIME) {
265
                unset($this->session['__flash'][$key]);
266
                return $default;
267
            }
268
    
269
            // Mark for removal after being retrieved
270
            unset($this->session['__flash'][$key]);
271
            return $flash['value'];
272
        }
273
        return $default;
274
    }
275
276
277
    /**
278
     * Generates an HTML input field containing the CSRF token.
279
     *
280
     * @return string The HTML input field with the CSRF token.
281
     */
282
    public function csrfField(): string
283
    {
284
        return sprintf(
285
            '<input type="hidden" name="_csrf" value="%s">',
286
            htmlspecialchars($this->token(), ENT_QUOTES, 'UTF-8')
287
        );
288
    }
289
290
    /**
291
     * Checks if a flash message exists and hasn't expired.
292
     *
293
     * @param string $key The key of the flash message.
294
     * @param string|null $type Optional type to check for specific flash message type.
295
     *
296
     * @return bool True if the flash message exists and is valid, false otherwise.
297
     */
298
    public function hasFlash(string $key, ?string $type = null): bool
299
    {
300
        // Check if flash data exists
301
        if (!isset($this->session['__flash'][$key])) {
302
            return false;
303
        }
304
    
305
        $flash = $this->session['__flash'][$key];
306
    
307
        // Check if flash has expired
308
        if (time() - $flash['timestamp'] > self::FLASH_LIFETIME) {
309
            unset($this->session['__flash'][$key]);
310
            return false;
311
        }
312
    
313
        // If type is specified, check if it matches
314
        if ($type !== null && (!isset($flash['type']) || $flash['type'] !== $type)) {
315
            return false;
316
        }
317
    
318
        return true;
319
    }
320
321
    /**
322
     * Loads flash message data from the session.
323
     */
324
    private function loadFlashData(): void
325
    {
326
        // Move new flash data to current
327
        if (isset($this->session['__flash_new'])) {
328
            $this->session['__flash'] = $this->session['__flash_new'];
329
            unset($this->session['__flash_new']);
330
        }
331
332
        // Clean expired flash data
333
        $this->flashData = array_filter(
334
            $this->session['__flash'] ?? [],
335
            fn($flash) => (time() - $flash['timestamp']) <= self::FLASH_LIFETIME
336
        );
337
338
        $this->session['__flash'] = $this->flashData;
339
    }
340
341
    /**
342
     * Closes the session for writing.
343
     *
344
     * @return bool True if the session was closed for writing, false otherwise.
345
     */
346
    public function closeWrite(): bool
347
    {
348
        if ($this->isStarted) {
349
            $this->isStarted = !session_write_close();
350
            return !$this->isStarted;
351
        }
352
        return true;
353
    }
354
355
    /**
356
     * Performs garbage collection on the session.
357
     *
358
     * @param bool $force Whether to force garbage collection.
359
     *
360
     * @return bool True if garbage collection was successful, false otherwise.
361
     */
362
    public function gc(bool $force = false): bool
363
    {
364
        if ($force || (mt_rand(1, 100) <= self::DEFAULT_CONFIG['gc_probability'])) {
365
            return session_gc();
0 ignored issues
show
Bug Best Practice introduced by
The expression return session_gc() returns the type integer which is incompatible with the type-hinted return boolean.
Loading history...
366
        }
367
        return true;
368
    }
369
370
    /**
371
     * Destroys the session.
372
     *
373
     * @throws RuntimeException If the session failed to be destroyed.
374
     */
375
    public function destroy(): void
376
    {
377
        if ($this->isStarted) {
378
            $this->clear();
379
            if (!session_destroy()) {
380
                throw new RuntimeException('Failed to destroy session');
381
            }
382
            $this->isStarted = false;
383
        }
384
385
        $params = session_get_cookie_params();
386
        setcookie(
387
            session_name(),
388
            '',
389
            array_merge($params, ['expires' => time() - 42000])
390
        );
391
    }
392
393
    /**
394
     * Sets a session variable.
395
     *
396
     * @param string $key The key of the variable.
397
     * @param mixed $value The value of the variable.
398
     */
399
    public function set(string $key, mixed $value): void
400
    {
401
        $this->session[$key] = $value;
402
    }
403
404
    /**
405
     * Gets a session variable.
406
     *
407
     * @param string $key The key of the variable.
408
     * @param mixed $default The default value to return if the variable does not exist.
409
     *
410
     * @return mixed The value of the variable or the default value.
411
     */
412
    public function get(string $key, mixed $default = null): mixed
413
    {
414
        return $this->session[$key] ?? $default;
415
    }
416
417
    /**
418
     * Checks if a session variable exists.
419
     *
420
     * @param string $key The key of the variable.
421
     *
422
     * @return bool True if the variable exists, false otherwise.
423
     */
424
    public function has(string $key): bool
425
    {
426
        return isset($this->session[$key]);
427
    }
428
429
    /**
430
     * Deletes a session variable.
431
     *
432
     * @param string $key The key of the variable.
433
     */
434
    public function delete(string $key): void
435
    {
436
        unset($this->session[$key]);
437
    }
438
439
    /**
440
     * Clears all session variables.
441
     */
442
    public function clear(): void
443
    {
444
        $this->session = [];
445
    }
446
447
    /**
448
     * Checks if an offset exists.
449
     *
450
     * @param string $offset The offset to check.
451
     *
452
     * @return bool True if the offset exists, false otherwise.
453
     */
454
    public function offsetExists($offset): bool
455
    {
456
        return $this->has($offset);
457
    }
458
459
    /**
460
     * Gets the value at an offset.
461
     *
462
     * @param string $offset The offset to get the value from.
463
     *
464
     * @return mixed The value at the offset.
465
     */
466
    public function offsetGet($offset): mixed
467
    {
468
        return $this->get($offset);
469
    }
470
471
    /**
472
     * Sets the value at an offset.
473
     *
474
     * @param string $offset The offset to set the value at.
475
     * @param mixed $value The value to set.
476
     */
477
    public function offsetSet($offset, $value): void
478
    {
479
        $this->set($offset, $value);
480
    }
481
482
    /**
483
     * Unsets the value at an offset.
484
     *
485
     * @param string $offset The offset to unset.
486
     */
487
    public function offsetUnset($offset): void
488
    {
489
        $this->delete($offset);
490
    }
491
492
    /**
493
     * Destructor.
494
     *
495
     * Closes the session for writing when the object is destroyed.
496
     */
497
    public function __destruct()
498
    {
499
        if ($this->isStarted) {
500
            $this->closeWrite();
501
        }
502
    }
503
}