Completed
Pull Request — master (#414)
by David
20:13 queued 06:57
created

UserContextListener::getUserHash()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 5
cts 5
cp 1
rs 9.2
c 0
b 0
f 0
cc 4
eloc 9
nc 3
nop 2
crap 4
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 13
     * @param array $options Options to overwrite the default options
60
     *
61 13
     * @throws \InvalidArgumentException if unknown keys are found in $options
62 13
     */
63 13
    public function __construct(array $options = [])
64
    {
65
        $resolver = new OptionsResolver();
66
        $resolver->setDefaults([
67
            '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 13
            'user_hash_method' => 'GET',
72 12
            'user_identifier_headers' => ['Authorization', 'HTTP_AUTHORIZATION', 'PHP_AUTH_USER'],
73
            'session_name_prefix' => 'PHPSESSID',
74
        ]);
75
76
        $resolver->setAllowedTypes('anonymous_hash', ['string']);
77 1
        $resolver->setAllowedTypes('user_hash_accept_header', ['string']);
78
        $resolver->setAllowedTypes('user_hash_header', ['string']);
79
        $resolver->setAllowedTypes('user_hash_uri', ['string']);
80 1
        $resolver->setAllowedTypes('user_hash_method', ['string']);
81
        // actually string[] but that is not supported by symfony < 3.4
82
        $resolver->setAllowedTypes('user_identifier_headers', ['array']);
83
        $resolver->setAllowedTypes('session_name_prefix', ['string', 'boolean']);
84
85
        $this->options = $resolver->resolve($options);
86
    }
87
88
    /**
89
     * {@inheritdoc}
90
     */
91
    public static function getSubscribedEvents()
92
    {
93
        return [
94 12
            Events::PRE_HANDLE => 'preHandle',
95
        ];
96 12
    }
97 12
98
    /**
99 12
     * Look at the request before it is handled by the kernel.
100 12
     *
101
     * Adds the user hash header to the request.
102 4
     *
103
     * Checks if an external request tries tampering with the use context hash mechanism
104 4
     * to prevent attacks.
105
     *
106
     * @param CacheEvent $event
107
     */
108
    public function preHandle(CacheEvent $event)
109 8
    {
110 7
        $request = $event->getRequest();
111
        if (!$this->isInternalRequest($request)) {
112
            // Prevent tampering attacks on the hash mechanism
113
            if ($request->headers->get('accept') === $this->options['user_hash_accept_header']
114
                || null !== $request->headers->get($this->options['user_hash_header'])
115 8
            ) {
116
                $event->setResponse(new Response('', 400));
117
118
                return;
119
            }
120
121
            // 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.
122
            // See http://symfony.com/doc/current/reference/configuration/framework.html#configuration-framework-http-method-override
123
            if (in_array($request->getRealMethod(), ['GET', 'HEAD']) && $hash = $this->getUserHash($event->getKernel(), $request)) {
124
                $request->headers->set($this->options['user_hash_header'], $hash);
125
            }
126 5
        }
127
128 5
        // let the kernel handle this request.
129 5
    }
130 4
131 2
    /**
132 4
     * Remove unneeded things from the request for user hash generation.
133
     *
134
     * Cleans cookies header to only keep the session identifier cookie, so the hash lookup request
135
     * can be cached per session.
136 5
     *
137 2
     * @param Request $hashLookupRequest
138
     * @param Request $originalRequest
139 5
     */
140
    protected function cleanupHashLookupRequest(Request $hashLookupRequest, Request $originalRequest)
141
    {
142
        $sessionIds = [];
143
        if (!$this->options['session_name_prefix']) {
144
            $hashLookupRequest->headers->remove('Cookie');
145
146
            return;
147
        }
148 12
149
        foreach ($originalRequest->cookies as $name => $value) {
150 12
            if ($this->isSessionName($name)) {
151
                $sessionIds[$name] = $value;
152
                $hashLookupRequest->cookies->set($name, $value);
153
            }
154
        }
155
156
        if (count($sessionIds) > 0) {
157
            $hashLookupRequest->headers->set('Cookie', http_build_query($sessionIds, '', '; '));
158
        }
159
    }
160
161 7
    /**
162
     * Checks if passed request object is to be considered internal (e.g. for user hash lookup).
163 7
     *
164
     * @param Request $request
165
     *
166
     * @return bool
167 7
     */
168 2
    private function isInternalRequest(Request $request)
169
    {
170
        return true === $request->attributes->get('internalRequest', false);
171
    }
172 5
173 5
    /**
174
     * Returns the user context hash for $request.
175 5
     *
176
     * @param HttpKernelInterface $kernel
177 5
     * @param Request             $request
178
     *
179
     * @return string
180
     */
181
    private function getUserHash(HttpKernelInterface $kernel, Request $request)
182
    {
183
        if (isset($this->userHash)) {
184
            return $this->userHash;
185
        }
186
187 4
        if ($this->options['anonymous_hash'] && $this->isAnonymous($request)) {
188
            return $this->userHash = $this->options['anonymous_hash'];
189
        }
190
191
        // Hash lookup request to let the backend generate the user hash
192 4
        $hashLookupRequest = $this->generateHashLookupRequest($request);
193 4
        $resp = $kernel->handle($hashLookupRequest);
194 4
        // Store the user hash in memory for sub-requests (processed in the same thread).
195
        $this->userHash = $resp->headers->get($this->options['user_hash_header']);
0 ignored issues
show
Documentation Bug introduced by
It seems like $resp->headers->get($thi...ns['user_hash_header']) can also be of type array<integer,string>. However, the property $userHash is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
196 1
197
        return $this->userHash;
198
    }
199 3
200 1
    /**
201 1
     * Checks if current request is considered anonymous.
202
     *
203
     * @param Request $request
204
     *
205 2
     * @return bool
206
     */
207
    private function isAnonymous(Request $request)
208
    {
209
        $anonymousRequestMatcher = new AnonymousRequestMatcher([
210
            'user_identifier_headers' => $this->options['user_identifier_headers'],
211
            'session_name_prefix' => $this->options['session_name_prefix'],
212
        ]);
213
214
        return $anonymousRequestMatcher->matches($request);
215 4
    }
216
217 4
    /**
218
     * Checks if passed string can be considered as a session name, such as would be used in cookies.
219
     *
220
     * @param string $name
221
     *
222
     * @return bool
223
     */
224
    private function isSessionName($name)
225
    {
226
        return 0 === strpos($name, $this->options['session_name_prefix']);
227 5
    }
228
229 5
    /**
230 5
     * Generates the request object that will be forwarded to get the user context hash.
231 5
     *
232 5
     * @param Request $request
233
     *
234 5
     * @return Request the request that will return the user context hash value
235
     */
236
    private function generateHashLookupRequest(Request $request)
237
    {
238
        $hashLookupRequest = Request::create($this->options['user_hash_uri'], $this->options['user_hash_method'], [], [], [], $request->server->all());
239
        $hashLookupRequest->attributes->set('internalRequest', true);
240
        $hashLookupRequest->headers->set('Accept', $this->options['user_hash_accept_header']);
241
        $this->cleanupHashLookupRequest($hashLookupRequest, $request);
242
243
        return $hashLookupRequest;
244
    }
245
}
246