Completed
Push — master ( a1cb11...9ba779 )
by Julián
02:03
created

SessionWare::regenerateSessionId()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
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 = [])
0 ignored issues
show
Coding Style introduced by
startSession 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...
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
        // Populate session with initial parameters if they don't exist
161
        foreach ($this->initialSessionParams as $parameter => $value) {
162
            if (!array_key_exists($parameter, $_SESSION)) {
163
                $_SESSION[$parameter] = $value;
164
            }
165
        }
166
167
        $this->manageSessionTimeout();
168
    }
169
170
    /**
171
     * Configure session name.
172
     */
173
    protected function configureSessionName()
174
    {
175
        $this->sessionName = trim($this->settings['name']) !== '' ? trim($this->settings['name']) : session_name();
176
177
        session_name($this->sessionName);
178
    }
179
180
    /**
181
     * Configure session identifier.
182
     *
183
     * @param array $requestCookies
184
     */
185
    protected function configureSessionId(array $requestCookies = [])
186
    {
187
        $sessionId = !empty($requestCookies[$this->sessionName])
188
            ? $requestCookies[$this->sessionName]
189
            : session_id();
190
191
        if (trim($sessionId) === '') {
192
            $sessionId = self::generateSessionId();
193
        }
194
195
        session_id($sessionId);
196
    }
197
198
    /**
199
     * Configure session save path.
200
     *
201
     * @throws \RuntimeException
202
     */
203
    protected function configureSessionSavePath()
204
    {
205
        if (ini_get('session.save_handler') !== 'files') {
206
            // @codeCoverageIgnoreStart
207
            return;
208
            // @codeCoverageIgnoreEnd
209
        }
210
211
        $savePath = trim($this->settings['savePath']);
212
        if ($savePath === '') {
213
            $savePath = sys_get_temp_dir();
214
            if (session_save_path() !== '') {
215
                $savePath = rtrim(session_save_path(), DIRECTORY_SEPARATOR);
216
            }
217
218
            $savePathParts = explode(DIRECTORY_SEPARATOR, $savePath);
219
            if ($this->sessionName !== 'PHPSESSID' && $this->sessionName !== array_pop($savePathParts)) {
220
                $savePath .= DIRECTORY_SEPARATOR . $this->sessionName;
221
            }
222
        }
223
224
        if (!@mkdir($savePath, 0775, true) && (!is_dir($savePath) || !is_writable($savePath))) {
225
            throw new \RuntimeException(
226
                sprintf('Failed to create session save path "%s", or directory is not writable', $savePath)
227
            );
228
        }
229
230
        session_save_path($savePath);
231
    }
232
233
    /**
234
     * Configure session timeout.
235
     *
236
     * @throws \InvalidArgumentException
237
     */
238
    protected function configureSessionTimeout()
239
    {
240
        $lifetime = (int) $this->settings['lifetime'];
241
242
        if ($lifetime < 1) {
243
            throw new \InvalidArgumentException(sprintf('"%s" is not a valid session lifetime', $lifetime));
244
        }
245
246
        $this->sessionLifetime = $lifetime;
247
248
        // Signal garbage collector with defined timeout
249
        ini_set('session.gc_maxlifetime', $lifetime);
250
    }
251
252
    /**
253
     * Manage session timeout.
254
     *
255
     * @throws \InvalidArgumentException
256
     */
257
    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...
258
    {
259
        $timeoutKey = $this->getSessionTimeoutControlKey();
260
261
        if (array_key_exists($timeoutKey, $_SESSION) && $_SESSION[$timeoutKey] < time()) {
262
            session_unset();
263
            session_destroy();
264
265
            self::regenerateSessionId();
266
267
            session_start();
268
        }
269
270
        $_SESSION[$timeoutKey] = time() + $this->sessionLifetime;
271
    }
272
273
    /**
274
     * Add session cookie Set-Cookie header to response.
275
     *
276
     * @param ResponseInterface $response
277
     *
278
     * @throws \InvalidArgumentException
279
     *
280
     * @return ResponseInterface
281
     */
282
    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...
283
    {
284
        $cookieParams = [
285
            sprintf(
286
                'expires=%s; max-age=%s',
287
                gmdate('D, d M Y H:i:s T', $_SESSION[$this->getSessionTimeoutControlKey()]),
288
                $this->sessionLifetime
289
            ),
290
        ];
291
292
        if (trim($this->settings['path']) !== '') {
293
            $cookieParams[] = 'path=' . $this->settings['path'];
294
        }
295
296
        if (trim($this->settings['domain']) !== '') {
297
            $cookieParams[] = 'domain=' . $this->settings['domain'];
298
        }
299
300
        if ((bool) $this->settings['secure']) {
301
            $cookieParams[] = 'secure';
302
        }
303
304
        if ((bool) $this->settings['httponly']) {
305
            $cookieParams[] = 'httponly';
306
        }
307
308
        return $response->withAddedHeader(
309
            'Set-Cookie',
310
            sprintf(
311
                '%s=%s; %s',
312
                urlencode($this->sessionName),
313
                urlencode(session_id()),
314
                implode('; ', $cookieParams)
315
            )
316
        );
317
    }
318
319
    /**
320
     * Retrieve session timeout control key.
321
     *
322
     * @throws \InvalidArgumentException
323
     *
324
     * @return string
325
     */
326
    protected function getSessionTimeoutControlKey()
327
    {
328
        $timeoutKey = trim($this->settings['timeoutKey']);
329
        if ($timeoutKey === '') {
330
            throw new \InvalidArgumentException(
331
                sprintf('"%s" is not a valid session timeout control key name', $this->settings['timeoutKey'])
332
            );
333
        }
334
335
        return $timeoutKey;
336
    }
337
338
    /**
339
     * Regenerate session id with cryptographically secure session identifier
340
     */
341
    final public static function regenerateSessionId()
342
    {
343
        session_id(self::generateSessionId());
344
    }
345
346
    /**
347
     * Generates cryptographically secure session identifier.
348
     *
349
     * @param int $length
350
     *
351
     * @return string
352
     */
353
    final protected static function generateSessionId($length = 80)
354
    {
355
        return substr(
356
            preg_replace('/[^a-zA-Z0-9-]+/', '', base64_encode(random_bytes((int) $length))),
357
            0,
358
            (int) $length
359
        );
360
    }
361
}
362