UserContextListener::preHandle()   A
last analyzed

Complexity

Conditions 6
Paths 4

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 8
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 17
ccs 9
cts 9
cp 1
crap 6
rs 9.2222
1
<?php
2
3
/*
4
 * This file is part of the FOSHttpCache package.
5
 *
6
 * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace FOS\HttpCache\SymfonyCache;
13
14
use FOS\HttpCache\UserContext\AnonymousRequestMatcher;
15
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16
use Symfony\Component\HttpFoundation\Request;
17
use Symfony\Component\HttpFoundation\Response;
18
use Symfony\Component\HttpKernel\HttpKernelInterface;
19
use Symfony\Component\OptionsResolver\OptionsResolver;
20
21
/**
22
 * Caching proxy side of the user context handling for the symfony built-in HttpCache.
23
 *
24
 * @author Jérôme Vieilledent <[email protected]> (courtesy of eZ Systems AS)
25
 *
26
 * {@inheritdoc}
27
 */
28
class UserContextListener implements EventSubscriberInterface
29
{
30
    /**
31
     * The options configured in the constructor argument or default values.
32
     *
33
     * @var array
34
     */
35
    private $options;
36
37
    /**
38
     * Generated user hash.
39
     *
40
     * @var string
41
     */
42
    private $userHash;
43
44
    /**
45
     * When creating this listener, you can configure a number of options.
46
     *
47
     * - anonymous_hash:          Hash used for anonymous user. Hash lookup skipped for anonymous if this is set.
48
     * - user_hash_accept_header: Accept header value to be used to request the user hash to the
49
     *                            backend application. Must match the setup of the backend application.
50
     * - user_hash_header:        Name of the header the user context hash will be stored into. Must
51
     *                            match the setup for the Vary header in the backend application.
52
     * - user_hash_uri:           Target URI used in the request for user context hash generation.
53
     * - user_hash_method:        HTTP Method used with the hash lookup request for user context hash generation.
54
     * - user_identifier_headers: List of request headers that authenticate a non-anonymous request.
55
     * - session_name_prefix:     Prefix for session cookies. Must match your PHP session configuration.
56
     *                            To completely ignore the cookies header and consider requests with cookies
57
     *                            anonymous, pass false for this option.
58
     *
59
     * @param array $options Options to overwrite the default options
60
     *
61
     * @throws \InvalidArgumentException if unknown keys are found in $options
62
     */
63 14
    public function __construct(array $options = [])
64
    {
65 14
        $resolver = new OptionsResolver();
66 14
        $resolver->setDefaults([
67 14
            'anonymous_hash' => null,
68
            'user_hash_accept_header' => 'application/vnd.fos.user-context-hash',
69
            'user_hash_header' => 'X-User-Context-Hash',
70
            'user_hash_uri' => '/_fos_user_context_hash',
71
            'user_hash_method' => 'GET',
72
            'user_identifier_headers' => ['Authorization', 'HTTP_AUTHORIZATION', 'PHP_AUTH_USER'],
73
            'session_name_prefix' => 'PHPSESSID',
74
        ]);
75
76 14
        $resolver->setAllowedTypes('anonymous_hash', ['null', 'string']);
77 14
        $resolver->setAllowedTypes('user_hash_accept_header', ['string']);
78 14
        $resolver->setAllowedTypes('user_hash_header', ['string']);
79 14
        $resolver->setAllowedTypes('user_hash_uri', ['string']);
80 14
        $resolver->setAllowedTypes('user_hash_method', ['string']);
81
        // actually string[] but that is not supported by symfony < 3.4
82 14
        $resolver->setAllowedTypes('user_identifier_headers', ['array']);
83 14
        $resolver->setAllowedTypes('session_name_prefix', ['string', 'boolean']);
84
85 14
        $this->options = $resolver->resolve($options);
86 13
    }
87
88
    /**
89
     * {@inheritdoc}
90
     */
91 1
    public static function getSubscribedEvents(): array
92
    {
93
        return [
94 1
            Events::PRE_HANDLE => 'preHandle',
95
        ];
96
    }
97
98
    /**
99
     * Look at the request before it is handled by the kernel.
100
     *
101
     * Adds the user hash header to the request.
102
     *
103
     * Checks if an external request tries tampering with the use context hash mechanism
104
     * to prevent attacks.
105
     */
106 13
    public function preHandle(CacheEvent $event): void
107
    {
108 13
        $request = $event->getRequest();
109 13
        if (!$this->isInternalRequest($request)) {
110
            // Prevent tampering attacks on the hash mechanism
111 13
            if ($request->headers->get('accept') === $this->options['user_hash_accept_header']
112 13
                || null !== $request->headers->get($this->options['user_hash_header'])
0 ignored issues
show
introduced by
The condition null !== $request->heade...ns['user_hash_header']) is always true.
Loading history...
113
            ) {
114 4
                $event->setResponse(new Response('Bad Request', 400));
115
116 4
                return;
117
            }
118
119
            // We must not call $request->isMethodCacheable() here, because this will mess up with later configuration of the `http_method_override` that is set onto the request by the Symfony FrameworkBundle.
120
            // See http://symfony.com/doc/current/reference/configuration/framework.html#configuration-framework-http-method-override
121 9
            if (in_array($request->getRealMethod(), ['GET', 'HEAD']) && $hash = $this->getUserHash($event->getKernel(), $request)) {
122 8
                $request->headers->set($this->options['user_hash_header'], $hash);
123
            }
124
        }
125
126
        // let the kernel handle this request.
127 9
    }
128
129
    /**
130
     * Remove unneeded things from the request for user hash generation.
131
     *
132
     * Cleans cookies header to only keep the session identifier cookie, so the hash lookup request
133
     * can be cached per session.
134
     */
135 5
    protected function cleanupHashLookupRequest(Request $hashLookupRequest, Request $originalRequest): void
136
    {
137 5
        $sessionIds = [];
138 5
        if (!$this->options['session_name_prefix']) {
139
            $hashLookupRequest->headers->remove('Cookie');
140
141
            return;
142
        }
143
144 5
        foreach ($originalRequest->cookies as $name => $value) {
145 4
            if ($this->isSessionName($name)) {
146 2
                $sessionIds[$name] = $value;
147 2
                $hashLookupRequest->cookies->set($name, $value);
148
            }
149
        }
150
151 5
        if (count($sessionIds) > 0) {
152 2
            $hashLookupRequest->headers->set('Cookie', http_build_query($sessionIds, '', '; '));
153
        }
154 5
    }
155
156
    /**
157
     * Checks if passed request object is to be considered internal (e.g. for user hash lookup).
158
     */
159 13
    private function isInternalRequest(Request $request): bool
160
    {
161 13
        return true === $request->attributes->get('internalRequest', false);
162
    }
163
164
    /**
165
     * Returns the user context hash for $request.
166
     */
167 8
    private function getUserHash(HttpKernelInterface $kernel, Request $request): ?string
168
    {
169 8
        if (isset($this->userHash)) {
170
            return $this->userHash;
171
        }
172
173 8
        if ($this->options['anonymous_hash'] && $this->isAnonymous($request)) {
174 3
            return $this->userHash = $this->options['anonymous_hash'];
175
        }
176
177
        // Hash lookup request to let the backend generate the user hash
178 5
        $hashLookupRequest = $this->generateHashLookupRequest($request);
179 5
        $resp = $kernel->handle($hashLookupRequest);
180
        // Store the user hash in memory for sub-requests (processed in the same thread).
181 5
        $this->userHash = $resp->headers->get($this->options['user_hash_header']);
182
183 5
        return $this->userHash;
184
    }
185
186
    /**
187
     * Checks if current request is considered anonymous.
188
     */
189 5
    private function isAnonymous(Request $request): bool
190
    {
191 5
        $anonymousRequestMatcher = new AnonymousRequestMatcher([
192 5
            'user_identifier_headers' => $this->options['user_identifier_headers'],
193 5
            'session_name_prefix' => $this->options['session_name_prefix'],
194
        ]);
195
196 5
        return $anonymousRequestMatcher->matches($request);
197
    }
198
199
    /**
200
     * Checks if passed string can be considered as a session name, such as would be used in cookies.
201
     *
202
     * @param string $name
203
     */
204 4
    private function isSessionName($name): bool
205
    {
206 4
        return 0 === strpos($name, $this->options['session_name_prefix']);
207
    }
208
209
    /**
210
     * Generates the request object that will be forwarded to get the user context hash.
211
     *
212
     * @return Request the request that will return the user context hash value
213
     */
214 5
    private function generateHashLookupRequest(Request $request): Request
215
    {
216 5
        $hashLookupRequest = Request::create($this->options['user_hash_uri'], $this->options['user_hash_method'], [], [], [], $request->server->all());
217 5
        $hashLookupRequest->attributes->set('internalRequest', true);
218 5
        $hashLookupRequest->headers->set('Accept', $this->options['user_hash_accept_header']);
219 5
        $this->cleanupHashLookupRequest($hashLookupRequest, $request);
220
221 5
        return $hashLookupRequest;
222
    }
223
}
224