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