Completed
Push — master ( 5ca900...5fe513 )
by Julián
08:26
created

SessionWare::configureSessionCookies()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 7
rs 9.4285
cc 1
eloc 5
nc 1
nop 1
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_KEY = 'session';
33
    const TIMEOUT_CONTROL_KEY = '__SESSIONWARE_TIMEOUT_TIMESTAMP__';
34
35
    /**
36
     * @var array
37
     */
38
    protected $settings;
39
40
    /**
41
     * @var array
42
     */
43
    protected $initialSessionParams;
44
45
    /**
46
     * @var string
47
     */
48
    protected $sessionName;
49
50
    /**
51
     * @var int
52
     */
53
    protected $sessionLifetime;
54
55
    /**
56
     * @var string
57
     */
58
    protected $sessionTimeoutKey;
59
60
    /**
61
     * Middleware constructor.
62
     *
63
     * @param array $settings
64
     * @param array $initialSessionParams
65
     */
66
    public function __construct(array $settings = [], array $initialSessionParams = [])
67
    {
68
        $this->settings = $settings;
69
70
        $this->initialSessionParams = $initialSessionParams;
71
    }
72
73
    /**
74
     * Execute the middleware.
75
     *
76
     * @param ServerRequestInterface $request
77
     * @param ResponseInterface      $response
78
     * @param callable               $next
79
     *
80
     * @throws \InvalidArgumentException
81
     * @throws \RuntimeException
82
     *
83
     * @return ResponseInterface
84
     */
85
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
86
    {
87
        $this->startSession($request);
88
89
        $response = $next($request, $response);
90
91
        return $this->respondWithSessionCookie($response);
92
    }
93
94
    /**
95
     * Configure session settings.
96
     *
97
     * @param ServerRequestInterface $request
98
     *
99
     * @throws \InvalidArgumentException
100
     * @throws \RuntimeException
101
     */
102
    protected function startSession(ServerRequestInterface $request)
103
    {
104
        if (session_status() === PHP_SESSION_DISABLED) {
105
            // @codeCoverageIgnoreStart
106
            throw new \RuntimeException('PHP sessions are disabled');
107
            // @codeCoverageIgnoreEnd
108
        }
109
110
        if (session_status() === PHP_SESSION_ACTIVE) {
111
            throw new \RuntimeException('Session has already been started, review "session.auto_start" ini setting');
112
        }
113
114
        $sessionSettings = $sessionSettings = $this->getSessionSettings($this->settings);
115
116
        // First configure session name
117
        $this->configureSessionName($sessionSettings);
118
119
        $this->configureSessionCookies($sessionSettings);
120
        $this->configureSessionSavePath($sessionSettings);
121
        $this->configureSessionTimeout($sessionSettings);
122
        $this->configureSessionSerializer();
123
        $this->configureSessionHeaders();
124
125
        $this->configureSessionId($request);
126
127
        session_start();
128
129
        if (session_status() !== PHP_SESSION_ACTIVE) {
130
            // @codeCoverageIgnoreStart
131
            throw new \RuntimeException('Session could not be started');
132
            // @codeCoverageIgnoreEnd
133
        }
134
135
        $this->manageSessionTimeout();
136
137
        $this->populateSession($this->initialSessionParams);
138
    }
139
140
    /**
141
     * Retrieve default session parameters.
142
     *
143
     * @param array $customSettings
144
     *
145
     * @return array
146
     */
147
    protected function getSessionSettings(array $customSettings)
148
    {
149
        $lifeTime = (int) $this->getSessionSetting('cookie_lifetime') === 0
150
            ? (int) $this->getSessionSetting('gc_maxlifetime')
151
            : min($this->getSessionSetting('cookie_lifetime'), (int) $this->getSessionSetting('gc_maxlifetime'));
152
153
        $defaultSettings = [
154
            'name'             => $this->getSessionSetting('name', 'PHPSESSID'),
155
            'path'             => $this->getSessionSetting('cookie_path'),
156
            'domain'           => $this->getSessionSetting('cookie_domain', '/'),
157
            'secure'           => $this->getSessionSetting('cookie_secure'),
158
            'httponly'         => $this->getSessionSetting('cookie_httponly'),
159
            'savePath'         => $this->getSessionSetting('save_path', sys_get_temp_dir()),
160
            'lifetime'         => $lifeTime > 0 ? $lifeTime : static::SESSION_LIFETIME_DEFAULT,
161
            'sessionKey'       => static::SESSION_KEY,
162
            'timeoutKey'       => static::TIMEOUT_CONTROL_KEY,
163
        ];
164
165
        return array_merge($defaultSettings, $customSettings);
166
    }
167
168
    /**
169
     * Configure session name.
170
     *
171
     * @throws \InvalidArgumentException
172
     *
173
     * @param array $settings
174
     */
175
    protected function configureSessionName(array $settings)
176
    {
177
        if (trim($settings['name']) === '') {
178
            throw new \InvalidArgumentException('Session name must be a non empty string');
179
        }
180
181
        $this->sessionName = trim($settings['name']);
182
183
        $this->setSessionSetting('name', $this->sessionName);
184
    }
185
186
    /**
187
     * Configure session cookies parameters.
188
     *
189
     * @param array $settings
190
     */
191
    protected function configureSessionCookies(array $settings)
192
    {
193
        $this->setSessionSetting('cookie_path', $settings['path']);
194
        $this->setSessionSetting('cookie_domain', $settings['domain']);
195
        $this->setSessionSetting('cookie_secure', $settings['secure']);
196
        $this->setSessionSetting('cookie_httponly', $settings['httponly']);
197
    }
198
199
    /**
200
     * Configure session save path if using default PHP session save handler.
201
     *
202
     * @param array $settings
203
     *
204
     * @throws \RuntimeException
205
     */
206
    protected function configureSessionSavePath(array $settings)
207
    {
208
        if ($this->getSessionSetting('save_handler') !== 'files') {
209
            // @codeCoverageIgnoreStart
210
            return;
211
            // @codeCoverageIgnoreEnd
212
        }
213
214
        $savePath = trim($settings['savePath']);
215
        if ($savePath === '') {
216
            $savePath = sys_get_temp_dir();
217
        }
218
219
        $savePathParts = explode(DIRECTORY_SEPARATOR, rtrim($savePath, DIRECTORY_SEPARATOR));
220
        if ($this->sessionName !== 'PHPSESSID' && $this->sessionName !== array_pop($savePathParts)) {
221
            $savePath .= DIRECTORY_SEPARATOR . $this->sessionName;
222
        }
223
224
        if ($savePath !== sys_get_temp_dir()
225
            && !@mkdir($savePath, 0775, true) && (!is_dir($savePath) || !is_writable($savePath))
226
        ) {
227
            throw new \RuntimeException(
228
                sprintf('Failed to create session save path "%s", or directory is not writable', $savePath)
229
            );
230
        }
231
232
        $this->setSessionSetting('save_path', $savePath);
233
    }
234
235
    /**
236
     * Configure session timeout.
237
     *
238
     * @param array $settings
239
     *
240
     * @throws \InvalidArgumentException
241
     */
242
    protected function configureSessionTimeout(array $settings)
243
    {
244
        $lifetime = (int) $settings['lifetime'];
245
246
        if ($lifetime < 1) {
247
            throw new \InvalidArgumentException(sprintf('"%s" is not a valid session lifetime', $lifetime));
248
        }
249
250
        $this->sessionLifetime = $lifetime;
251
252
        $timeoutKey = trim($settings['timeoutKey']);
253
        if ($timeoutKey === '') {
254
            throw new \InvalidArgumentException(
255
                sprintf('"%s" is not a valid session timeout control key name', $settings['timeoutKey'])
256
            );
257
        }
258
259
        $this->sessionTimeoutKey = $timeoutKey;
260
261
        // Signal garbage collector with defined timeout
262
        $this->setSessionSetting('gc_maxlifetime', $lifetime);
263
    }
264
265
    /**
266
     * Configure session serialize handler.
267
     */
268
    protected function configureSessionSerializer()
269
    {
270
        // Use better session serializer when available
271
        if ($this->getSessionSetting('serialize_handler') === 'php' && version_compare(PHP_VERSION, '5.5.4', '>=')) {
272
            // @codeCoverageIgnoreStart
273
            $this->setSessionSetting('serialize_handler', 'php_serialize');
274
            // @codeCoverageIgnoreEnd
275
        }
276
    }
277
278
    /**
279
     * Prevent headers from being automatically sent to client on session start.
280
     */
281
    protected function configureSessionHeaders()
282
    {
283
        $this->setSessionSetting('use_trans_sid', false);
284
        $this->setSessionSetting('use_cookies', true);
285
        $this->setSessionSetting('use_only_cookies', true);
286
        $this->setSessionSetting('use_strict_mode', false);
287
        $this->setSessionSetting('cache_limiter', '');
288
    }
289
290
    /**
291
     * Configure session identifier.
292
     *
293
     * @param ServerRequestInterface $request
294
     */
295
    protected function configureSessionId(ServerRequestInterface $request)
296
    {
297
        $requestCookies = $request->getCookieParams();
298
299
        $sessionId = array_key_exists($this->sessionName, $requestCookies)
300
            && trim($requestCookies[$this->sessionName]) !== ''
301
                ? trim($requestCookies[$this->sessionName])
302
                : session_id();
303
304
        if (trim($sessionId) === '') {
305
            $sessionId = static::generateSessionId();
306
        }
307
308
        session_id($sessionId);
309
    }
310
311
    /**
312
     * Manage session timeout.
313
     *
314
     * @throws \InvalidArgumentException
315
     */
316
    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...
317
    {
318
        if (array_key_exists($this->sessionTimeoutKey, $_SESSION) && $_SESSION[$this->sessionTimeoutKey] < time()) {
319
            $this->emit(Event::named('pre.session_timeout'), session_id());
320
321
            $_SESSION = [];
322
            session_unset();
323
            session_destroy();
324
325
            session_id(static::generateSessionId());
326
327
            session_start();
328
329
            $this->emit(Event::named('post.session_timeout'), session_id());
330
        }
331
332
        $_SESSION[$this->sessionTimeoutKey] = time() + $this->sessionLifetime;
333
    }
334
335
    /**
336
     * Populate session with initial parameters if they don't exist.
337
     *
338
     * @param array $initialSessionParams
339
     */
340
    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...
341
    {
342
        foreach ($initialSessionParams as $parameter => $value) {
343
            if (!array_key_exists($parameter, $_SESSION)) {
344
                $_SESSION[$parameter] = $value;
345
            }
346
        }
347
    }
348
349
    /**
350
     * Add session cookie Set-Cookie header to response.
351
     *
352
     * @param ResponseInterface $response
353
     *
354
     * @throws \InvalidArgumentException
355
     *
356
     * @return ResponseInterface
357
     */
358
    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...
359
    {
360
        if (session_status() !== PHP_SESSION_ACTIVE || !array_key_exists($this->sessionTimeoutKey, $_SESSION)) {
361
            $expireTime = time() - $this->sessionLifetime;
362
        } else {
363
            $expireTime = $_SESSION[$this->sessionTimeoutKey];
364
        }
365
366
        $cookieParams = [
367
            sprintf(
368
                'expires=%s; max-age=%s',
369
                gmdate('D, d M Y H:i:s T', $expireTime),
370
                $this->sessionLifetime
371
            ),
372
        ];
373
374
        if (trim($this->getSessionSetting('cookie_path')) !== '') {
375
            $cookieParams[] = 'path=' . $this->getSessionSetting('cookie_path');
376
        }
377
378
        if (trim($this->getSessionSetting('cookie_domain')) !== '') {
379
            $cookieParams[] = 'domain=' . $this->getSessionSetting('cookie_domain');
380
        }
381
382
        if ((bool) $this->getSessionSetting('cookie_secure')) {
383
            $cookieParams[] = 'secure';
384
        }
385
386
        if ((bool) $this->getSessionSetting('cookie_httponly')) {
387
            $cookieParams[] = 'httponly';
388
        }
389
390
        return $response->withAddedHeader(
391
            'Set-Cookie',
392
            sprintf(
393
                '%s=%s; %s',
394
                urlencode($this->sessionName),
395
                urlencode(session_id()),
396
                implode('; ', $cookieParams)
397
            )
398
        );
399
    }
400
401
    /**
402
     * Generates cryptographically secure session identifier.
403
     *
404
     * @param int $length
405
     *
406
     * @return string
407
     */
408
    final public static function generateSessionId($length = 80)
409
    {
410
        return substr(
411
            preg_replace('/[^a-zA-Z0-9-]+/', '', base64_encode(random_bytes((int) $length))),
412
            0,
413
            (int) $length
414
        );
415
    }
416
417
    /**
418
     * Retrieve session ini setting.
419
     *
420
     * @param string     $setting
421
     * @param null|mixed $default
422
     *
423
     * @return null|string
424
     */
425
    private function getSessionSetting($setting, $default = null)
426
    {
427
        $param = ini_get($this->normalizeSessionSettingName($setting));
428
429
        if (is_numeric($param)) {
430
            return (int) $param;
431
        }
432
433
        $param = trim($param);
434
435
        return $param !== '' ? $param : $default;
436
    }
437
438
    /**
439
     * Set session ini setting.
440
     *
441
     * @param string $setting
442
     * @param mixed  $value
443
     */
444
    private function setSessionSetting($setting, $value)
445
    {
446
        ini_set($this->normalizeSessionSettingName($setting), $value);
447
    }
448
449
    /**
450
     * Normalize session setting name to start with 'session.'.
451
     *
452
     * @param string $setting
453
     *
454
     * @return string
455
     */
456
    private function normalizeSessionSettingName($setting)
457
    {
458
        return strpos($setting, 'session.') !== 0 ? 'session.' . $setting : $setting;
459
    }
460
}
461