Skip to content

Commit c1a083e

Browse files
committed
Implemented Symfony reverse proxy support for user context hash.
Ref #26 Added tests for Symfony RP
1 parent 851a001 commit c1a083e

File tree

5 files changed

+357
-2
lines changed

5 files changed

+357
-2
lines changed

EventListener/UserContextSubscriber.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ public function onKernelRequest(GetResponseEvent $event)
103103
if ($this->ttl > 0) {
104104
$response->setClientTtl($this->ttl);
105105
$response->setVary($this->userIdentifierHeaders);
106+
$response->setPublic();
106107
} else {
107108
$response->setClientTtl(0);
108109
$response->headers->addCacheControlDirective('no-cache');

HttpCache.php

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the FOSHttpCacheBundle 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\HttpCacheBundle;
13+
14+
use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache as BaseHttpCache;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\HttpKernelInterface;
18+
19+
/**
20+
* Base class for enhanced Symfony reverse proxy.
21+
*
22+
* @author Jérôme Vieilledent <[email protected]>
23+
*
24+
* {@inheritdoc}
25+
*/
26+
abstract class HttpCache extends BaseHttpCache
27+
{
28+
/**
29+
* Hash for anonymous user.
30+
*/
31+
const ANONYMOUS_HASH = '38015b703d82206ebc01d17a39c727e5';
32+
33+
/**
34+
* Accept header value to be used to request the user hash to the backend application.
35+
* It must match the one defined in FOSHttpCacheBundle's configuration.
36+
*/
37+
const USER_HASH_ACCEPT_HEADER = 'application/vnd.fos.user-context-hash';
38+
39+
/**
40+
* Name of the header the user context hash will be stored into.
41+
* It must match the one defined in FOSHttpCacheBundle's configuration.
42+
*/
43+
const USER_HASH_HEADER = 'X-User-Context-Hash';
44+
45+
/**
46+
* URI used with the forwarded request for user context hash generation.
47+
*/
48+
const USER_HASH_URI = '/_fos_user_context_hash';
49+
50+
/**
51+
* HTTP Method used with the forwarded request for user context hash generation.
52+
*/
53+
const USER_HASH_METHOD = 'GET';
54+
55+
/**
56+
* Prefix for session names.
57+
* Must match your session configuration.
58+
*/
59+
const SESSION_NAME_PREFIX = 'PHPSESSID';
60+
61+
/**
62+
* Generated user hash.
63+
*
64+
* @var string
65+
*/
66+
private $userHash;
67+
68+
public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
69+
{
70+
if (!$this->isInternalRequest($request)) {
71+
// Prevent tampering attacks on the hash mechanism
72+
if ($request->headers->get('accept') === static::USER_HASH_ACCEPT_HEADER
73+
|| $request->headers->get(static::USER_HASH_HEADER) !== null) {
74+
return new Response('', 400);
75+
}
76+
77+
if ($request->isMethodSafe()) {
78+
$request->headers->set(static::USER_HASH_HEADER, $this->getUserHash($request));
79+
}
80+
}
81+
82+
return parent::handle($request, $type, $catch);
83+
}
84+
85+
/**
86+
* Checks if passed request object is to be considered internal (e.g. for user hash lookup).
87+
*
88+
* @param Request $request
89+
*
90+
* @return bool
91+
*/
92+
protected function isInternalRequest(Request $request)
93+
{
94+
return $request->attributes->get('internalRequest', false) === true;
95+
}
96+
97+
/**
98+
* Returns the user context hash for $request.
99+
*
100+
* @param Request $request
101+
*
102+
* @return string
103+
*/
104+
protected function getUserHash(Request $request)
105+
{
106+
if (isset($this->userHash)) {
107+
return $this->userHash;
108+
}
109+
110+
if ($this->isAnonymous($request)) {
111+
return $this->userHash = static::ANONYMOUS_HASH;
112+
}
113+
114+
// Forward the request to generate the user hash
115+
$forwardReq = $this->generateForwardRequest($request);
116+
$resp = $this->handle($forwardReq);
117+
// Store the user hash in memory for sub-requests (processed in the same thread).
118+
return $this->userHash = $resp->headers->get(static::USER_HASH_HEADER);
119+
}
120+
121+
/**
122+
* Checks if current request is considered anonymous.
123+
*
124+
* @param Request $request
125+
*
126+
* @return bool
127+
*/
128+
protected function isAnonymous(Request $request)
129+
{
130+
foreach ($request->cookies as $name => $value) {
131+
if ($this->isSessionName($name)) {
132+
return false;
133+
}
134+
}
135+
136+
return true;
137+
}
138+
139+
/**
140+
* Checks if passed string can be considered as a session name, such as would be used in cookies.
141+
*
142+
* @param string $name
143+
*
144+
* @return bool
145+
*/
146+
protected function isSessionName($name)
147+
{
148+
return strpos($name, static::SESSION_NAME_PREFIX) === 0;
149+
}
150+
151+
/**
152+
* Generates the request object that will be forwarded to get the user context hash.
153+
*
154+
* @param Request $request
155+
*
156+
* @return Request
157+
*/
158+
protected function generateForwardRequest(Request $request)
159+
{
160+
$forwardReq = Request::create(static::USER_HASH_URI, static::USER_HASH_METHOD, array(), $request->cookies->all(), array(), $request->server->all());
161+
$forwardReq->attributes->set('internalRequest', true);
162+
$forwardReq->headers->set('Accept', static::USER_HASH_ACCEPT_HEADER);
163+
// Clean cookie header to only get proper sessionIds in it. This is to make the hash request cacheable.
164+
$sessionIds = array();
165+
foreach ($request->cookies as $name => $value) {
166+
if ($this->isSessionName($name)) {
167+
$sessionIds[] = $value;
168+
}
169+
}
170+
$forwardReq->headers->set('Cookie', implode('|', $sessionIds));
171+
172+
return $forwardReq;
173+
}
174+
}

Resources/doc/reference/configuration/user-context.rst

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,44 @@ Configuration
1111
Caching Proxy Configuration
1212
~~~~~~~~~~~~~~~~~~~~~~~~~~~
1313

14-
First you need to set up your caching proxy as explained in the
14+
Varnish
15+
"""""""
16+
17+
Set up Varnish caching proxy as explained in the
1518
:ref:`user context documentation <foshttpcache:user-context>`.
1619

20+
Symfony reverse proxy
21+
"""""""""""""""""""""
22+
23+
If you use Symfony reverse proxy (aka ``HttpCache``), you'll need to make your ``AppCache`` class extend
24+
``\FOS\HttpCacheBundle\HttpCache`` instead of ``\Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache``.
25+
26+
``\FOS\HttpCacheBundle\HttpCache`` defines constants that can be overriden in your ``AppCache`` class:
27+
28+
* ``USER_HASH_ACCEPT_HEADER``: Accept header value to be used to request the user hash to the backend application.
29+
It must match the one defined in FOSHttpCacheBundle's configuration (see below).
30+
31+
**default**: ``application/vnd.fos.user-context-hash``
32+
33+
* ``USER_HASH_HEADER``: Name of the header the user context hash will be stored into.
34+
It must match the one defined in FOSHttpCacheBundle's configuration (see below).
35+
36+
**default**: ``X-User-Context-Hash``
37+
38+
* ``USER_HASH_URI``: URI used with the forwarded request for user context hash generation.
39+
40+
**default**: ``/_fos_user_context_hash``
41+
42+
* ``USER_HASH_METHOD``: HTTP Method used with the forwarded request for user context hash generation.
43+
44+
**default**: ``GET``
45+
46+
* ``SESSION_NAME_PREFIX``: Prefix for session names. Must match your session configuration.
47+
Needed for caching correctly generated user context hash for each user session.
48+
49+
**default**: ``PHPSESSID``
50+
51+
1752
Context Hash Route
1853
~~~~~~~~~~~~~~~~~~
1954

Tests/Unit/EventListener/UserContextSubscriberTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ public function testOnKernelRequestCached()
9494
$this->assertInstanceOf('\Symfony\Component\HttpFoundation\Response', $response);
9595
$this->assertEquals('hash', $response->headers->get('X-Hash'));
9696
$this->assertEquals('X-SessionId', $response->headers->get('Vary'));
97-
$this->assertEquals('max-age=30, private', $response->headers->get('Cache-Control'));
97+
$this->assertEquals('max-age=30, public', $response->headers->get('Cache-Control'));
9898
}
9999

100100
public function testOnKernelRequestNotMatched()

Tests/Unit/HttpCacheTest.php

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the FOSHttpCacheBundle 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\HttpCacheBundle\Tests\Unit;
13+
14+
use FOS\HttpCacheBundle\HttpCache;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\HttpKernelInterface;
18+
19+
class HttpCacheTest extends \PHPUnit_Framework_TestCase
20+
{
21+
/**
22+
* @return \FOS\HttpCacheBundle\HttpCache|\PHPUnit_Framework_MockObject_MockObject
23+
*/
24+
protected function getHttpCachePartialMock(array $mockedMethods = null)
25+
{
26+
$mock = $this->getMockBuilder( '\FOS\HttpCacheBundle\HttpCache' )
27+
->setMethods( $mockedMethods )
28+
->disableOriginalConstructor()
29+
->getMock();
30+
31+
// Force setting options property since we can't use original constructor.
32+
$options = array(
33+
'debug' => false,
34+
'default_ttl' => 0,
35+
'private_headers' => array( 'Authorization', 'Cookie' ),
36+
'allow_reload' => false,
37+
'allow_revalidate' => false,
38+
'stale_while_revalidate' => 2,
39+
'stale_if_error' => 60,
40+
);
41+
42+
$refMock = new \ReflectionObject($mock);
43+
$refHttpCache = $refMock
44+
// \FOS\HttpCacheBundle\HttpCache
45+
->getParentClass()
46+
// \Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache
47+
->getParentClass()
48+
// \Symfony\Component\HttpKernel\HttpCache\HttpCache
49+
->getParentClass();
50+
// Workaround for Symfony 2.3 where $options property is not defined.
51+
if (!$refHttpCache->hasProperty('options')) {
52+
$mock->options = $options;
53+
} else {
54+
$refOptions = $refHttpCache
55+
->getProperty('options');
56+
$refOptions->setAccessible(true);
57+
$refOptions->setValue($mock, $options );
58+
}
59+
60+
return $mock;
61+
}
62+
63+
public function testGenerateUserHashNotAllowed()
64+
{
65+
$request = new Request();
66+
$request->headers->set('accept', HttpCache::USER_HASH_ACCEPT_HEADER);
67+
$httpCache = $this->getHttpCachePartialMock();
68+
$response = $httpCache->handle($request);
69+
$this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response);
70+
$this->assertSame(400, $response->getStatusCode());
71+
}
72+
73+
public function testPassingUserHashNotAllowed()
74+
{
75+
$request = new Request();
76+
$request->headers->set(HttpCache::USER_HASH_HEADER, 'foo');
77+
$httpCache = $this->getHttpCachePartialMock();
78+
$response = $httpCache->handle($request);
79+
$this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response);
80+
$this->assertSame(400, $response->getStatusCode());
81+
}
82+
83+
public function testUserHashAnonymous()
84+
{
85+
$request = new Request();
86+
$catch = true;
87+
88+
$httpCache = $this->getHttpCachePartialMock(array('lookup'));
89+
$response = new Response();
90+
$httpCache
91+
->expects($this->once())
92+
->method('lookup')
93+
->with($request, $catch)
94+
->will($this->returnValue($response));
95+
96+
$this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch));
97+
$this->assertTrue($request->headers->has(HttpCache::USER_HASH_HEADER));
98+
$this->assertSame(HttpCache::ANONYMOUS_HASH, $request->headers->get(HttpCache::USER_HASH_HEADER));
99+
}
100+
101+
public function testUserHashUserWithSession()
102+
{
103+
$catch = true;
104+
$sessionId = 'my_session_id';
105+
$cookies = array(
106+
'PHPSESSID' => $sessionId,
107+
'foo' => 'bar'
108+
);
109+
$cookieString = "PHPSESSID=$sessionId; foo=bar";
110+
$request = Request::create('/foo', 'GET', array(), $cookies, array(), array('Cookie' => $cookieString));
111+
$response = new Response();
112+
113+
$hashRequest = Request::create(HttpCache::USER_HASH_URI, HttpCache::USER_HASH_METHOD, array(), $request->cookies->all(), array(), $request->server->all());
114+
$hashRequest->attributes->set('internalRequest', true);
115+
$hashRequest->headers->set('Accept', HttpCache::USER_HASH_ACCEPT_HEADER);
116+
$hashRequest->headers->set('Cookie', $sessionId);
117+
// Ensure request properties have been filled up.
118+
$hashRequest->getPathInfo();
119+
$hashRequest->getMethod();
120+
121+
$expectedContextHash = 'my_generated_hash';
122+
// Just avoid the response to modify the request object, otherwise it's impossible to test objects equality.
123+
/** @var \Symfony\Component\HttpFoundation\Response|\PHPUnit_Framework_MockObject_MockObject $hashResponse */
124+
$hashResponse = $this->getMockBuilder('\Symfony\Component\HttpFoundation\Response')
125+
->setMethods(array('prepare'))
126+
->getMock();
127+
$hashResponse->headers->set(HttpCache::USER_HASH_HEADER, $expectedContextHash );
128+
129+
$httpCache = $this->getHttpCachePartialMock(array('lookup'));
130+
$httpCache
131+
->expects($this->at(0))
132+
->method('lookup')
133+
->with($hashRequest, $catch)
134+
->will($this->returnValue($hashResponse));
135+
$httpCache
136+
->expects($this->at(1))
137+
->method('lookup')
138+
->with($request)
139+
->will($this->returnValue($response));
140+
141+
$this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch));
142+
$this->assertTrue($request->headers->has(HttpCache::USER_HASH_HEADER));
143+
$this->assertSame($expectedContextHash, $request->headers->get(HttpCache::USER_HASH_HEADER));
144+
}
145+
}

0 commit comments

Comments
 (0)