Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SessionHeadersHandler
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 9
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 __invoke
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 withNewSessionCookie
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 timestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 withCacheLimiter
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 cacheLimiterPublic
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 cacheLimiterPrivateNoExpire
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 cacheLimiterPrivate
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 cacheLimiterNocache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace BO\Slim\Middleware;
4
5use Psr\Http\Message\ResponseInterface as Response;
6use Psr\Http\Message\ServerRequestInterface as Request;
7use Psr\Http\Server\RequestHandlerInterface;
8use RuntimeException;
9use BO\Slim\Factory\ResponseFactory;
10
11/**
12 *
13 * Sends the session headers in the Response, putting them under manual control
14 * rather than relying on PHP to send them itself.
15 *
16 * This works correctly only if you have these settings:
17 *
18 * ```
19 * ini_set('session.use_trans_sid', false);
20 * ini_set('session.use_cookies', false);
21 * ini_set('session.use_only_cookies', true);
22 * ini_set('session.cache_limiter', '');
23 * ```
24 *
25 * Note that the Last-Modified value will not be the last time the session was
26 * saved, but instead the current `time()`.
27 *
28 *
29 */
30class SessionHeadersHandler
31{
32    /**
33     * The timestamp for "already expired."
34     */
35    const EXPIRED = 'Thu, 19 Nov 1981 08:52:00 GMT';
36
37    /**
38     *
39     * The cache limiter type, if any.
40     *
41     * @var string
42     *
43     * @see session_cache_limiter()
44     *
45     */
46    protected $cacheLimiter;
47
48    /**
49     *
50     * The cache expiration time in minutes.
51     *
52     * @var int
53     *
54     * @see session_cache_expire()
55     *
56     */
57    protected $cacheExpire;
58
59    /**
60     *
61     * The current Unix timestamp.
62     *
63     * @var int
64     *
65     */
66    protected $time;
67
68    /**
69     *
70     * Constructor.
71     *
72     * @param string $cacheLimiter The cache limiter type.
73     *
74     * @param string $cacheExpire The cache expiration time in minutes.
75     *
76     * @throws RuntimeException when the ini settings are incorrect.
77     *
78     */
79    public function __construct($cacheLimiter = 'nocache', $cacheExpire = 180)
80    {
81        ini_set('session.use_trans_sid', false);
82        ini_set('session.use_cookies', false);
83        ini_set('session.use_only_cookies', true);
84        ini_set('session.cache_limiter', '');
85
86        if (ini_get('session.use_trans_sid') != false) {
87            $message = "The .ini setting 'session.use_trans_sid' must be false.";
88            throw new RuntimeException($message);
89        }
90
91        if (ini_get('session.use_cookies') != false) {
92            $message = "The .ini setting 'session.use_cookies' must be false.";
93            throw new RuntimeException($message);
94        }
95
96        if (ini_get('session.use_only_cookies') != true) {
97            $message = "The .ini setting 'session.use_only_cookies' must be true.";
98            throw new RuntimeException($message);
99        }
100
101        if (ini_get('session.cache_limiter') !== '') {
102            $message = "The .ini setting 'session.cache_limiter' must be an empty string.";
103            throw new RuntimeException($message);
104        }
105
106        $this->cacheLimiter = $cacheLimiter;
107        $this->cacheExpire = (int) $cacheExpire;
108    }
109
110    /**
111     *
112     * Sends the session headers in the Response.
113     *
114     * @param Request $request The HTTP request.
115     *
116     * @param Response $response The HTTP response.
117     *
118     * @param callable $next The next middleware in the queue.
119     *
120     * @return Response
121     *
122     */
123    public function __invoke(Request $request, ?RequestHandlerInterface $next)
124    {
125        // retain the incoming session id
126        $oldId = '';
127        $oldName = session_name();
128        $cookies = $request->getCookieParams();
129        if (! empty($cookies[$oldName])) {
130            $oldId = $cookies[$oldName];
131            session_id($oldId);
132        }
133
134        // invoke the next middleware
135        if (null !== $next) {
136            $response = $next->handle($request);
137        } else {
138            $response = (new ResponseFactory())->createResponse();
139        }
140
141        // record the current time
142        $this->time = time();
143
144        // is the session id still the same?
145        $newId = session_id();
146        if ($newId !== $oldId) {
147            // one of the middlewares changed it; send the new one.
148            // capture any session name changes as well.
149            $response = $this->withNewSessionCookie($response, $newId);
150        }
151
152        // if there is a session id, also send the cache limiters
153        if ($newId) {
154            $response = $this->withCacheLimiter($response);
155        }
156
157        // done!
158        return $response;
159    }
160
161    /**
162     *
163     * Adds a session cookie header to the Response.
164     *
165     * @param Response $response The HTTP response.
166     *
167     * @param string $sessionId The new session ID.
168     *
169     * @return Response
170     *
171     * @see https://github.com/php/php-src/blob/PHP-5.6.20/ext/session/session.c#L1337-L1408
172     *
173     */
174    protected function withNewSessionCookie(Response $response, $sessionId)
175    {
176        $cookie = urlencode(session_name()) . '=' . urlencode($sessionId);
177
178        $params = session_get_cookie_params();
179
180        if ($params['lifetime']) {
181            $expires = $this->timestamp($params['lifetime']);
182            $cookie .= "; expires={$expires}; max-age={$params['lifetime']}";
183        }
184
185        if ($params['domain']) {
186            $cookie .= "; domain={$params['domain']}";
187        }
188
189        if ($params['path']) {
190            $cookie .= "; path={$params['path']}";
191        }
192
193        if ($params['secure']) {
194            $cookie .= '; secure';
195        }
196
197        if ($params['httponly']) {
198            $cookie .= '; httponly';
199        }
200
201        return $response->withAddedHeader('Set-Cookie', $cookie);
202    }
203
204    /**
205     *
206     * Returns a cookie-formatted timestamp.
207     *
208     * @param int $adj Adjust the time by this many seconds before formatting.
209     *
210     * @return string
211     *
212     */
213    protected function timestamp($adj = 0)
214    {
215        return gmdate('D, d M Y H:i:s T', $this->time + $adj);
216    }
217
218    /**
219     *
220     * Returns a Response with added cache limiter headers.
221     *
222     * @param Response $response The HTTP response.
223     *
224     * @return Response
225     *
226     */
227    protected function withCacheLimiter(Response $response)
228    {
229        switch ($this->cacheLimiter) {
230            case 'public':
231                return $this->cacheLimiterPublic($response);
232            case 'private_no_expire':
233                return $this->cacheLimiterPrivateNoExpire($response);
234            case 'private':
235                return $this->cacheLimiterPrivate($response);
236            case 'nocache':
237                return $this->cacheLimiterNocache($response);
238            default:
239                return $response;
240        }
241    }
242
243    /**
244     *
245     * Returns a Response with 'public' cache limiter headers.
246     *
247     * @param Response $response The HTTP response.
248     *
249     * @return Response
250     *
251     * @see https://github.com/php/php-src/blob/PHP-5.6.20/ext/session/session.c#L1196-L1213
252     *
253     */
254    protected function cacheLimiterPublic(Response $response)
255    {
256        $maxAge = $this->cacheExpire * 60;
257        $expires = $this->timestamp($maxAge);
258        $cacheControl = "public, max-age={$maxAge}";
259        $lastModified = $this->timestamp();
260
261        return $response
262            ->withAddedHeader('Expires', $expires)
263            ->withAddedHeader('Cache-Control', $cacheControl)
264            ->withAddedHeader('Last-Modified', $lastModified);
265    }
266
267    /**
268     *
269     * Returns a Response with 'private_no_expire' cache limiter headers.
270     *
271     * @param Response $response The HTTP response.
272     *
273     * @return Response
274     *
275     * @see https://github.com/php/php-src/blob/PHP-5.6.20/ext/session/session.c#L1215-L1224
276     *
277     */
278    protected function cacheLimiterPrivateNoExpire(Response $response)
279    {
280        $maxAge = $this->cacheExpire * 60;
281        $cacheControl = "private, max-age={$maxAge}, pre-check={$maxAge}";
282        $lastModified = $this->timestamp();
283
284        return $response
285            ->withAddedHeader('Cache-Control', $cacheControl)
286            ->withAddedHeader('Last-Modified', $lastModified);
287    }
288
289    /**
290     *
291     * Returns a Response with 'private' cache limiter headers.
292     *
293     * @param Response $response The HTTP response.
294     *
295     * @return Response
296     *
297     * @see https://github.com/php/php-src/blob/PHP-5.6.20/ext/session/session.c#L1226-L1231
298     *
299     */
300    protected function cacheLimiterPrivate(Response $response)
301    {
302        if (0 == count($response->getHeader('Expires'))) {
303            $response = $response->withAddedHeader('Expires', self::EXPIRED);
304        }
305        return $this->cacheLimiterPrivateNoExpire($response);
306    }
307
308    /**
309     *
310     * Returns a Response with 'nocache' cache limiter headers.
311     *
312     * @param Response $response The HTTP response.
313     *
314     * @return Response
315     *
316     * @see https://github.com/php/php-src/blob/PHP-5.6.20/ext/session/session.c#L1233-L1243
317     *
318     */
319    protected function cacheLimiterNocache(Response $response)
320    {
321        if (0 == count($response->getHeader('Expires'))) {
322            $response = $response->withAddedHeader('Expires', self::EXPIRED);
323        }
324        return $response
325            ->withAddedHeader(
326                'Cache-Control',
327                'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'
328            )
329            ->withAddedHeader('Pragma', 'no-cache');
330    }
331}