Completed
Push — master ( 9ba779...ede95d )
by Julián
01:59
created

SessionWare::configureSessionSavePath()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 0
loc 18
rs 8.8571
cc 5
eloc 8
nc 3
nop 0
1
<?php
2
/**
3
 * SessionWare (https://github.com/juliangut/sessionware)
4
 * PSR7 session manager middleware
5
 *
6
 * @license BSD-3-Clause
7
 * @author Julián Gutiérrez <[email protected]>
8
 */
9
10
namespace Jgut\Middleware;
11
12
use Psr\Http\Message\ResponseInterface;
13
use Psr\Http\Message\ServerRequestInterface;
14
15
/**
16
 * PHP session handler middleware.
17
 */
18
class SessionWare
19
{
20
    const SESSION_LIFETIME_FLASH    = 300; // 5 minutes
21
    const SESSION_LIFETIME_SHORT    = 600; // 10 minutes
22
    const SESSION_LIFETIME_NORMAL   = 900; // 15 minutes
23
    const SESSION_LIFETIME_DEFAULT  = 1440; // 24 minutes
24
    const SESSION_LIFETIME_EXTENDED = 3600; // 1 hour
25
    const SESSION_LIFETIME_INFINITE = PHP_INT_MAX; // Around 1145 years (x86_64)
26
27
    const TIMEOUT_CONTROL_KEY = '__SESSIONWARE_TIMEOUT_TIMESTAMP__';
28
29
    /**
30
     * Default session settings.
31
     *
32
     * @var array
33
     */
34
    protected static $defaultSettings = [
35
        'name' => null,
36
        'savePath' => null,
37
        'lifetime' => self::SESSION_LIFETIME_DEFAULT,
38
        'timeoutKey' => self::TIMEOUT_CONTROL_KEY,
39
    ];
40
41
    /**
42
     * @var array
43
     */
44
    protected $settings;
45
46
    /**
47
     * @var array
48
     */
49
    protected $initialSessionParams;
50
51
    /**
52
     * @var string
53
     */
54
    protected $sessionName;
55
56
    /**
57
     * @var int
58
     */
59
    protected $sessionLifetime;
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 = array_merge(
70
            self::$defaultSettings,
71
            $this->getSessionParams(),
72
            $settings
73
        );
74
75
        $this->initialSessionParams = $initialSessionParams;
76
    }
77
78
    /**
79
     * Retrieve default session parameters.
80
     *
81
     * @return array
82
     */
83
    protected function getSessionParams()
84
    {
85
        $lifeTime = (int) ini_get('session.cookie_lifetime') === 0
86
            ? (int) ini_get('session.gc_maxlifetime')
87
            : min(ini_get('session.cookie_lifetime'), ini_get('session.gc_maxlifetime'));
88
89
        return [
90
            'lifetime' => $lifeTime > 0 ? $lifeTime : self::$defaultSettings['lifetime'],
91
            'domain' => ini_get('session.cookie_domain'),
92
            'path' => ini_get('session.cookie_path'),
93
            'secure' => ini_get('session.cookie_secure'),
94
            'httponly' => ini_get('session.cookie_httponly'),
95
        ];
96
    }
97
98
    /**
99
     * Execute the middleware.
100
     *
101
     * @param ServerRequestInterface $request
102
     * @param ResponseInterface      $response
103
     * @param callable               $next
104
     *
105
     * @throws \InvalidArgumentException
106
     * @throws \RuntimeException
107
     *
108
     * @return ResponseInterface
109
     */
110
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
111
    {
112
        $this->startSession($request->getCookieParams());
113
114
        $response = $next($request, $response);
115
116
        return $this->respondWithSessionCookie($response);
117
    }
118
119
    /**
120
     * Configure session settings.
121
     *
122
     * @param array $requestCookies
123
     *
124
     * @throws \InvalidArgumentException
125
     * @throws \RuntimeException
126
     */
127
    protected function startSession(array $requestCookies = [])
128
    {
129
        if (session_status() === PHP_SESSION_DISABLED) {
130
            // @codeCoverageIgnoreStart
131
            throw new \RuntimeException('PHP sessions are disabled');
132
            // @codeCoverageIgnoreEnd
133
        }
134
135
        if (session_status() === PHP_SESSION_ACTIVE) {
136
            throw new \RuntimeException('Session has already been started, review "session.auto_start" ini set');
137
        }
138
139
        $this->configureSessionName();
140
        $this->configureSessionId($requestCookies);
141
        $this->configureSessionSavePath();
142
        $this->configureSessionTimeout();
143
144
        // Use better session serializer when available
145
        if (ini_get('session.serialize_handler') === 'php' && version_compare(PHP_VERSION, '5.5.4', '>=')) {
146
            // @codeCoverageIgnoreStart
147
            ini_set('session.serialize_handler', 'php_serialize');
148
            // @codeCoverageIgnoreEnd
149
        }
150
151
        // Prevent headers from being automatically sent to client
152
        ini_set('session.use_trans_sid', false);
153
        ini_set('session.use_cookies', false);
154
        ini_set('session.use_only_cookies', true);
155
        ini_set('session.use_strict_mode', false);
156
        ini_set('session.cache_limiter', '');
157
158
        session_start();
159
160
        $this->populateSession();
161
        $this->manageSessionTimeout();
162
    }
163
164
    /**
165
     * Configure session name.
166
     */
167
    protected function configureSessionName()
168
    {
169
        $this->sessionName = trim($this->settings['name']) !== '' ? trim($this->settings['name']) : session_name();
170
171
        session_name($this->sessionName);
172
    }
173
174
    /**
175
     * Configure session identifier.
176
     *
177
     * @param array $requestCookies
178
     */
179
    protected function configureSessionId(array $requestCookies = [])
180
    {
181
        $sessionId = !empty($requestCookies[$this->sessionName])
182
            ? $requestCookies[$this->sessionName]
183
            : session_id();
184
185
        if (trim($sessionId) === '') {
186
            $sessionId = self::generateSessionId();
187
        }
188
189
        session_id($sessionId);
190
    }
191
192
    /**
193
     * Configure session save path.
194
     *
195
     * @throws \RuntimeException
196
     */
197
    protected function configureSessionSavePath()
198
    {
199
        if (ini_get('session.save_handler') !== 'files') {
200
            // @codeCoverageIgnoreStart
201
            return;
202
            // @codeCoverageIgnoreEnd
203
        }
204
205
        $savePath = $this->getSessionSavePath();
206
207
        if (!@mkdir($savePath, 0775, true) && (!is_dir($savePath) || !is_writable($savePath))) {
208
            throw new \RuntimeException(
209
                sprintf('Failed to create session save path "%s", or directory is not writable', $savePath)
210
            );
211
        }
212
213
        session_save_path($savePath);
214
    }
215
216
    /**
217
     * Return session save path to be used.
218
     *
219
     * @return string
220
     */
221
    protected function getSessionSavePath()
222
    {
223
        $savePath = trim($this->settings['savePath']);
224
225
        if ($savePath === '') {
226
            $savePath = sys_get_temp_dir();
227
            if (session_save_path() !== '') {
228
                $savePath = rtrim(session_save_path(), DIRECTORY_SEPARATOR);
229
            }
230
231
            $savePathParts = explode(DIRECTORY_SEPARATOR, $savePath);
232
            if ($this->sessionName !== 'PHPSESSID' && $this->sessionName !== array_pop($savePathParts)) {
233
                $savePath .= DIRECTORY_SEPARATOR . $this->sessionName;
234
            }
235
        }
236
237
        return $savePath;
238
    }
239
240
    /**
241
     * Configure session timeout.
242
     *
243
     * @throws \InvalidArgumentException
244
     */
245
    protected function configureSessionTimeout()
246
    {
247
        $lifetime = (int) $this->settings['lifetime'];
248
249
        if ($lifetime < 1) {
250
            throw new \InvalidArgumentException(sprintf('"%s" is not a valid session lifetime', $lifetime));
251
        }
252
253
        $this->sessionLifetime = $lifetime;
254
255
        // Signal garbage collector with defined timeout
256
        ini_set('session.gc_maxlifetime', $lifetime);
257
    }
258
259
    /**
260
     * Populate session with initial parameters if they don't exist.
261
     */
262
    protected function populateSession()
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...
263
    {
264
        foreach ($this->initialSessionParams as $parameter => $value) {
265
            if (!array_key_exists($parameter, $_SESSION)) {
266
                $_SESSION[$parameter] = $value;
267
            }
268
        }
269
    }
270
271
    /**
272
     * Manage session timeout.
273
     *
274
     * @throws \InvalidArgumentException
275
     */
276
    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...
277
    {
278
        $timeoutKey = $this->getSessionTimeoutControlKey();
279
280
        if (array_key_exists($timeoutKey, $_SESSION) && $_SESSION[$timeoutKey] < time()) {
281
            session_unset();
282
            session_destroy();
283
284
            self::regenerateSessionId();
285
286
            session_start();
287
        }
288
289
        $_SESSION[$timeoutKey] = time() + $this->sessionLifetime;
290
    }
291
292
    /**
293
     * Add session cookie Set-Cookie header to response.
294
     *
295
     * @param ResponseInterface $response
296
     *
297
     * @throws \InvalidArgumentException
298
     *
299
     * @return ResponseInterface
300
     */
301
    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...
302
    {
303
        $cookieParams = [
304
            sprintf(
305
                'expires=%s; max-age=%s',
306
                gmdate('D, d M Y H:i:s T', $_SESSION[$this->getSessionTimeoutControlKey()]),
307
                $this->sessionLifetime
308
            ),
309
        ];
310
311
        if (trim($this->settings['path']) !== '') {
312
            $cookieParams[] = 'path=' . $this->settings['path'];
313
        }
314
315
        if (trim($this->settings['domain']) !== '') {
316
            $cookieParams[] = 'domain=' . $this->settings['domain'];
317
        }
318
319
        if ((bool) $this->settings['secure']) {
320
            $cookieParams[] = 'secure';
321
        }
322
323
        if ((bool) $this->settings['httponly']) {
324
            $cookieParams[] = 'httponly';
325
        }
326
327
        return $response->withAddedHeader(
328
            'Set-Cookie',
329
            sprintf(
330
                '%s=%s; %s',
331
                urlencode($this->sessionName),
332
                urlencode(session_id()),
333
                implode('; ', $cookieParams)
334
            )
335
        );
336
    }
337
338
    /**
339
     * Retrieve session timeout control key.
340
     *
341
     * @throws \InvalidArgumentException
342
     *
343
     * @return string
344
     */
345
    protected function getSessionTimeoutControlKey()
346
    {
347
        $timeoutKey = trim($this->settings['timeoutKey']);
348
        if ($timeoutKey === '') {
349
            throw new \InvalidArgumentException(
350
                sprintf('"%s" is not a valid session timeout control key name', $this->settings['timeoutKey'])
351
            );
352
        }
353
354
        return $timeoutKey;
355
    }
356
357
    /**
358
     * Regenerate session id with cryptographically secure session identifier
359
     */
360
    final public static function regenerateSessionId()
361
    {
362
        session_id(self::generateSessionId());
363
    }
364
365
    /**
366
     * Generates cryptographically secure session identifier.
367
     *
368
     * @param int $length
369
     *
370
     * @return string
371
     */
372
    final protected static function generateSessionId($length = 80)
373
    {
374
        return substr(
375
            preg_replace('/[^a-zA-Z0-9-]+/', '', base64_encode(random_bytes((int) $length))),
376
            0,
377
            (int) $length
378
        );
379
    }
380
}
381