SessionWare   C
last analyzed

Complexity

Total Complexity 59

Size/Duplication

Total Lines 463
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 0
Metric Value
wmc 59
lcom 1
cbo 4
dl 0
loc 463
rs 6.1904
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A __invoke() 0 8 1
B startSession() 0 45 6
B verifySessionSettings() 0 22 6
A getSessionSettings() 0 17 3
A configureSessionName() 0 10 2
A configureSessionCookies() 0 7 1
D configureSessionSavePath() 0 28 9
A configureSessionTimeout() 0 22 3
A configureSessionSerializer() 0 9 3
A configureSessionId() 0 8 3
A manageSessionTimeout() 0 12 3
A recreateSession() 0 10 1
A populateSession() 0 8 3
C respondWithSessionCookie() 0 42 7
A generateSessionId() 0 8 1
A getSessionSetting() 0 12 3
A setSessionSetting() 0 4 1
A normalizeSessionSettingName() 0 4 2

How to fix   Complexity   

Complex Class

Complex classes like SessionWare 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 SessionWare, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * SessionWare (https://github.com/juliangut/sessionware)
4
 * PSR7 session management middleware
5
 *
6
 * @license BSD-3-Clause
7
 * @author Julián Gutiérrez <[email protected]>
8
 */
9
10
namespace Jgut\Middleware;
11
12
use League\Event\EmitterAwareInterface;
13
use League\Event\EmitterTrait;
14
use League\Event\Event;
15
use Psr\Http\Message\ResponseInterface;
16
use Psr\Http\Message\ServerRequestInterface;
17
18
/**
19
 * PHP session handler middleware.
20
 */
21
class SessionWare implements EmitterAwareInterface
22
{
23
    use EmitterTrait;
24
25
    const SESSION_LIFETIME_FLASH    = 300; // 5 minutes
26
    const SESSION_LIFETIME_SHORT    = 600; // 10 minutes
27
    const SESSION_LIFETIME_NORMAL   = 900; // 15 minutes
28
    const SESSION_LIFETIME_DEFAULT  = 1440; // 24 minutes
29
    const SESSION_LIFETIME_EXTENDED = 3600; // 1 hour
30
    const SESSION_LIFETIME_INFINITE = PHP_INT_MAX; // Around 1145 years (x86_64)
31
32
    const SESSION_TIMEOUT_KEY_DEFAULT = '__SESSIONWARE_TIMEOUT_TIMESTAMP__';
33
34
    const SESSION_ID_LENGTH = 80;
35
36
    /**
37
     * @var array
38
     */
39
    protected $settings;
40
41
    /**
42
     * @var array
43
     */
44
    protected $initialSessionParams;
45
46
    /**
47
     * @var string
48
     */
49
    protected $sessionName;
50
51
    /**
52
     * @var int
53
     */
54
    protected $sessionLifetime;
55
56
    /**
57
     * @var string
58
     */
59
    protected $sessionTimeoutKey;
60
61
    /**
62
     * Middleware constructor.
63
     *
64
     * @param array $settings
65
     * @param array $initialSessionParams
66
     */
67
    public function __construct(array $settings = [], array $initialSessionParams = [])
68
    {
69
        $this->settings = $settings;
70
71
        $this->initialSessionParams = $initialSessionParams;
72
    }
73
74
    /**
75
     * Execute the middleware.
76
     *
77
     * @param ServerRequestInterface $request
78
     * @param ResponseInterface      $response
79
     * @param callable               $next
80
     *
81
     * @throws \InvalidArgumentException
82
     * @throws \RuntimeException
83
     *
84
     * @return ResponseInterface
85
     */
86
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
87
    {
88
        $this->startSession($request);
89
90
        $response = $next($request, $response);
91
92
        return $this->respondWithSessionCookie($response);
93
    }
94
95
    /**
96
     * Configure session settings.
97
     *
98
     * @param ServerRequestInterface $request
99
     *
100
     * @throws \InvalidArgumentException
101
     * @throws \RuntimeException
102
     */
103
    protected function startSession(ServerRequestInterface $request)
104
    {
105
        if (session_status() === PHP_SESSION_DISABLED) {
106
            // @codeCoverageIgnoreStart
107
            throw new \RuntimeException('PHP sessions are disabled');
108
            // @codeCoverageIgnoreEnd
109
        }
110
111
        if (session_status() === PHP_SESSION_ACTIVE) {
112
            throw new \RuntimeException('Session has already been started, review "session.auto_start" ini setting');
113
        }
114
115
        // @codeCoverageIgnoreStart
116
        if (php_sapi_name() !== 'cli') {
117
            $this->verifySessionSettings();
118
        }
119
        // @codeCoverageIgnoreEnd
120
121
        $sessionSettings = array_merge($this->getSessionSettings(), $this->settings);
122
123
        $this->configureSessionName($sessionSettings);
124
125
        $this->configureSessionCookies($sessionSettings);
126
        $this->configureSessionSavePath($sessionSettings);
127
        $this->configureSessionTimeout($sessionSettings);
128
        $this->configureSessionSerializer();
129
130
        $this->configureSessionId($request);
131
132
        session_start();
133
134
        if (session_status() !== PHP_SESSION_ACTIVE) {
135
            // @codeCoverageIgnoreStart
136
            throw new \RuntimeException('Session could not be started');
137
            // @codeCoverageIgnoreEnd
138
        }
139
140
        if (strlen(session_id()) !== static::SESSION_ID_LENGTH) {
141
            $this->recreateSession();
142
        }
143
144
        $this->manageSessionTimeout();
145
146
        $this->populateSession($this->initialSessionParams);
147
    }
148
149
    /**
150
     * Verify session ini settings.
151
     *
152
     * @throws \RuntimeException
153
     *
154
     * @codeCoverageIgnore
155
     */
156
    final protected function verifySessionSettings()
157
    {
158
        if ((bool) $this->getSessionSetting('use_trans_sid') !== false) {
159
            throw new \RuntimeException('"session.use_trans_sid" ini setting must be set to false');
160
        }
161
162
        if ((bool) $this->getSessionSetting('use_cookies') !== true) {
163
            throw new \RuntimeException('"session.use_cookies" ini setting must be set to false');
164
        }
165
166
        if ((bool) $this->getSessionSetting('use_only_cookies') !== true) {
167
            throw new \RuntimeException('"session.use_only_cookies" ini setting must be set to false');
168
        }
169
170
        if ((bool) $this->getSessionSetting('use_strict_mode') !== false) {
171
            throw new \RuntimeException('"session.use_strict_mode" ini setting must be set to false');
172
        }
173
174
        if ($this->getSessionSetting('cache_limiter') !== null) {
175
            throw new \RuntimeException('"session.cache_limiter" ini setting must be set to false');
176
        }
177
    }
178
179
    /**
180
     * Retrieve default session settings.
181
     *
182
     * @return array
183
     */
184
    protected function getSessionSettings()
185
    {
186
        $lifeTime = (int) $this->getSessionSetting('cookie_lifetime') === 0
187
            ? (int) $this->getSessionSetting('gc_maxlifetime')
188
            : min($this->getSessionSetting('cookie_lifetime'), (int) $this->getSessionSetting('gc_maxlifetime'));
189
190
        return [
191
            'name'             => $this->getSessionSetting('name', 'PHPSESSID'),
192
            'path'             => $this->getSessionSetting('cookie_path'),
193
            'domain'           => $this->getSessionSetting('cookie_domain', '/'),
194
            'secure'           => $this->getSessionSetting('cookie_secure'),
195
            'httponly'         => $this->getSessionSetting('cookie_httponly'),
196
            'savePath'         => $this->getSessionSetting('save_path', sys_get_temp_dir()),
197
            'lifetime'         => $lifeTime > 0 ? $lifeTime : static::SESSION_LIFETIME_DEFAULT,
198
            'timeoutKey'       => static::SESSION_TIMEOUT_KEY_DEFAULT,
199
        ];
200
    }
201
202
    /**
203
     * Configure session name.
204
     *
205
     * @param array $settings
206
     *
207
     * @throws \InvalidArgumentException
208
     */
209
    protected function configureSessionName(array $settings)
210
    {
211
        if (trim($settings['name']) === '') {
212
            throw new \InvalidArgumentException('Session name must be a non empty string');
213
        }
214
215
        $this->sessionName = trim($settings['name']);
216
217
        $this->setSessionSetting('name', $this->sessionName);
218
    }
219
220
    /**
221
     * Configure session cookies parameters.
222
     *
223
     * @param array $settings
224
     */
225
    protected function configureSessionCookies(array $settings)
226
    {
227
        $this->setSessionSetting('cookie_path', $settings['path']);
228
        $this->setSessionSetting('cookie_domain', $settings['domain']);
229
        $this->setSessionSetting('cookie_secure', $settings['secure']);
230
        $this->setSessionSetting('cookie_httponly', $settings['httponly']);
231
    }
232
233
    /**
234
     * Configure session save path if using default PHP session save handler.
235
     *
236
     * @param array $settings
237
     *
238
     * @throws \RuntimeException
239
     */
240
    protected function configureSessionSavePath(array $settings)
241
    {
242
        if ($this->getSessionSetting('save_handler') !== 'files') {
243
            // @codeCoverageIgnoreStart
244
            return;
245
            // @codeCoverageIgnoreEnd
246
        }
247
248
        $savePath = trim($settings['savePath']);
249
        if ($savePath === '') {
250
            $savePath = sys_get_temp_dir();
251
        }
252
253
        $savePathParts = explode(DIRECTORY_SEPARATOR, rtrim($savePath, DIRECTORY_SEPARATOR));
254
        if ($this->sessionName !== 'PHPSESSID' && $this->sessionName !== array_pop($savePathParts)) {
255
            $savePath .= DIRECTORY_SEPARATOR . $this->sessionName;
256
        }
257
258
        if ($savePath !== sys_get_temp_dir()
259
            && !@mkdir($savePath, 0775, true) && (!is_dir($savePath) || !is_writable($savePath))
260
        ) {
261
            throw new \RuntimeException(
262
                sprintf('Failed to create session save path at "%s", directory might not be write enabled', $savePath)
263
            );
264
        }
265
266
        $this->setSessionSetting('save_path', $savePath);
267
    }
268
269
    /**
270
     * Configure session timeout.
271
     *
272
     * @param array $settings
273
     *
274
     * @throws \InvalidArgumentException
275
     */
276
    protected function configureSessionTimeout(array $settings)
277
    {
278
        $lifetime = (int) $settings['lifetime'];
279
280
        if ($lifetime < 1) {
281
            throw new \InvalidArgumentException('Session lifetime must be at least 1');
282
        }
283
284
        $this->sessionLifetime = $lifetime;
285
286
        $timeoutKey = trim($settings['timeoutKey']);
287
        if ($timeoutKey === '') {
288
            throw new \InvalidArgumentException(
289
                sprintf('"%s" is not a valid session timeout control key name', $settings['timeoutKey'])
290
            );
291
        }
292
293
        $this->sessionTimeoutKey = $timeoutKey;
294
295
        // Signal garbage collector with defined timeout
296
        $this->setSessionSetting('gc_maxlifetime', $lifetime);
297
    }
298
299
    /**
300
     * Configure session serialize handler.
301
     */
302
    protected function configureSessionSerializer()
303
    {
304
        // Use better session serializer when available
305
        if ($this->getSessionSetting('serialize_handler') === 'php' && version_compare(PHP_VERSION, '5.5.4', '>=')) {
306
            // @codeCoverageIgnoreStart
307
            $this->setSessionSetting('serialize_handler', 'php_serialize');
308
            // @codeCoverageIgnoreEnd
309
        }
310
    }
311
312
    /**
313
     * Configure session identifier.
314
     *
315
     * @param ServerRequestInterface $request
316
     */
317
    protected function configureSessionId(ServerRequestInterface $request)
318
    {
319
        $requestCookies = $request->getCookieParams();
320
321
        if (array_key_exists($this->sessionName, $requestCookies) && trim($requestCookies[$this->sessionName]) !== '') {
322
            session_id(trim($requestCookies[$this->sessionName]));
323
        }
324
    }
325
326
    /**
327
     * Manage session timeout.
328
     *
329
     * @throws \InvalidArgumentException
330
     */
331
    protected function manageSessionTimeout()
0 ignored issues
show
Coding Style introduced by
manageSessionTimeout uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
332
    {
333
        if (array_key_exists($this->sessionTimeoutKey, $_SESSION) && $_SESSION[$this->sessionTimeoutKey] < time()) {
334
            $this->emit(Event::named('pre.session_timeout'), session_id());
335
336
            $this->recreateSession();
337
338
            $this->emit(Event::named('post.session_timeout'), session_id());
339
        }
340
341
        $_SESSION[$this->sessionTimeoutKey] = time() + $this->sessionLifetime;
342
    }
343
344
    /**
345
     * Close previous session and create a new empty one.
346
     */
347
    protected function recreateSession()
0 ignored issues
show
Coding Style introduced by
recreateSession uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
348
    {
349
        $_SESSION = [];
350
        session_unset();
351
        session_destroy();
352
353
        session_id(SessionWare::generateSessionId());
354
355
        session_start();
356
    }
357
358
    /**
359
     * Populate session with initial parameters if they don't exist.
360
     *
361
     * @param array $initialSessionParams
362
     */
363
    protected function populateSession(array $initialSessionParams)
0 ignored issues
show
Coding Style introduced by
populateSession uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
364
    {
365
        foreach ($initialSessionParams as $parameter => $value) {
366
            if (!array_key_exists($parameter, $_SESSION)) {
367
                $_SESSION[$parameter] = $value;
368
            }
369
        }
370
    }
371
372
    /**
373
     * Add session cookie Set-Cookie header to response.
374
     *
375
     * @param ResponseInterface $response
376
     *
377
     * @throws \InvalidArgumentException
378
     *
379
     * @return ResponseInterface
380
     */
381
    protected function respondWithSessionCookie(ResponseInterface $response)
0 ignored issues
show
Coding Style introduced by
respondWithSessionCookie uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
382
    {
383
        if (session_status() !== PHP_SESSION_ACTIVE || !array_key_exists($this->sessionTimeoutKey, $_SESSION)) {
384
            $expireTime = time() - $this->sessionLifetime;
385
        } else {
386
            $expireTime = $_SESSION[$this->sessionTimeoutKey];
387
        }
388
389
        $cookieParams = [
390
            sprintf(
391
                'expires=%s; max-age=%s',
392
                gmdate('D, d M Y H:i:s T', $expireTime),
393
                $this->sessionLifetime
394
            ),
395
        ];
396
397
        if (trim($this->getSessionSetting('cookie_path')) !== '') {
398
            $cookieParams[] = 'path=' . $this->getSessionSetting('cookie_path');
399
        }
400
401
        if (trim($this->getSessionSetting('cookie_domain')) !== '') {
402
            $cookieParams[] = 'domain=' . $this->getSessionSetting('cookie_domain');
403
        }
404
405
        if ((bool) $this->getSessionSetting('cookie_secure')) {
406
            $cookieParams[] = 'secure';
407
        }
408
409
        if ((bool) $this->getSessionSetting('cookie_httponly')) {
410
            $cookieParams[] = 'httponly';
411
        }
412
413
        return $response->withAddedHeader(
414
            'Set-Cookie',
415
            sprintf(
416
                '%s=%s; %s',
417
                urlencode($this->sessionName),
418
                urlencode(session_id()),
419
                implode('; ', $cookieParams)
420
            )
421
        );
422
    }
423
424
    /**
425
     * Generates cryptographically secure session identifier.
426
     *
427
     * @param int $length
428
     *
429
     * @return string
430
     */
431
    final public static function generateSessionId($length = self::SESSION_ID_LENGTH)
432
    {
433
        return substr(
434
            preg_replace('/[^a-zA-Z0-9-]+/', '', base64_encode(random_bytes((int) $length))),
435
            0,
436
            (int) $length
437
        );
438
    }
439
440
    /**
441
     * Retrieve session ini setting.
442
     *
443
     * @param string     $setting
444
     * @param mixed|null $default
445
     *
446
     * @return mixed
447
     */
448
    private function getSessionSetting($setting, $default = null)
449
    {
450
        $setting = ini_get($this->normalizeSessionSettingName($setting));
451
452
        if (is_numeric($setting)) {
453
            return (int) $setting;
454
        }
455
456
        $setting = trim($setting);
457
458
        return $setting !== '' ? $setting : $default;
459
    }
460
461
    /**
462
     * Set session ini setting.
463
     *
464
     * @param string $setting
465
     * @param mixed  $value
466
     */
467
    private function setSessionSetting($setting, $value)
468
    {
469
        ini_set($this->normalizeSessionSettingName($setting), $value);
470
    }
471
472
    /**
473
     * Normalize session setting name to start with 'session.'.
474
     *
475
     * @param string $setting
476
     *
477
     * @return string
478
     */
479
    private function normalizeSessionSettingName($setting)
480
    {
481
        return strpos($setting, 'session.') !== 0 ? 'session.' . $setting : $setting;
482
    }
483
}
484