Cache on User Context

Some applications differentiate the content between types of users. For instance, on one and the same URL a guest sees a ‘Log in’ message; an editor sees an ‘Edit’ button and the administrator a link to the admin backend.

The FOSHttpCache library includes a solution to cache responses per user context (whether the user is authenticated, groups the user is in, or other information), rather than individually.

If every user has their own hash, you probably don’t want to cache at all. Or if you found out its worth it, vary on the credentials and don’t use the context hash mechanism.

Caution

Whenever you share caches, make sure to not output any individual content like the user name. If you have individual parts of a page, you can load those parts over AJAX requests or look into ESI. Both approaches integrate with the concepts presented in this chapter.

Overview

Caching on user context works as follows:

  1. A client requests /foo.php (the original request).
  2. The caching proxy receives the request. It sends a request (the hash request) with a special accept header (application/vnd.fos.user-context-hash) to a specific URL, e.g., /_fos_user_context_hash.
  3. The application receives the hash request. The application knows the client’s user context (roles, permissions, etc.) and generates a hash based on that information. The application then returns a response containing that hash in a custom header (X-User-Context-Hash) and with Content-Type application/vnd.fos.user-context-hash.
  4. The caching proxy receives the hash response, copies the hash header to the client’s original request for /foo.php and restarts that request.
  5. If the response to this request should differ per user context, the application specifies so by setting a Vary: X-User-Context-Hash header. The appropriate user role dependent representation of /foo.php will then be returned to the client.

Proxy Client Configuration

Currently, user context caching is only supported by Varnish and by the Symfony HttpCache. See the Varnish Configuration or Symfony HttpCache Configuration.

User Context Hash from Your Application

It is your application’s responsibility to determine the hash for a user. Only your application can know what is relevant for the hash. You can use the path or the accept header to detect that a hash was requested.

Warning

Treat the hash lookup path like the login path so that anonymous users also can get a hash. That means that your cache can access the hash lookup even with no user provided credential and that the hash lookup never redirects to a login page.

Calculating the User Context Hash

The user context hash calculation (step 3 above) is managed by the HashGenerator. Because the calculation itself will be different per application, you need to implement at least one ContextProvider and register that with the HashGenerator:

use FOS\HttpCache\UserContext\HashGenerator;

$hashGenerator = new HashGenerator(array(
    new IsAuthenticatedProvider(),
    new RoleProvider(),
));

Once all providers are registered, call generateHash() to get the hash for the current user context.

Context Providers

Each provider is passed the UserContext and updates that with parameters which influence the varied response.

A provider that looks at whether the user is authenticated could look like this:

use FOS\HttpCache\UserContext\ContextProviderInterface;
use FOS\HttpCache\UserContext\UserContext;

class IsAuthenticatedProvider implements ContextProviderInterface
{
    protected $userService;

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

    public function updateUserContext(UserContext $userContext)
    {
        $userContext->addParameter('authenticated', $this->userService->isAuthenticated());
    }
}

Returning the User Context Hash

It is up to you to return the user context hash in response to the hash request (/_fos_user_context_hash in step 3 above):

// <web-root>/_fos_user_context_hash/index.php

$hash = $hashGenerator->generateHash();

if ('application/vnd.fos.user-context-hash' == strtolower($_SERVER['HTTP_ACCEPT'])) {
    header(sprintf('X-User-Context-Hash: %s', $hash));
    header('Content-Type: application/vnd.fos.user-context-hash');
    exit;
}

// 406 Not acceptable in case of an incorrect accept header
header('HTTP/1.1 406');

If you use Symfony, the FOSHttpCacheBundle will set the correct response headers for you.

Caching the Hash Response

To optimize user context hashing performance, you should cache the hash response. By varying on the Cookie and Authorization header, the application will return the correct hash for each user. This way, subsequent hash requests (step 3 above) will be served from cache instead of requiring a roundtrip to the application.

// The application listens for hash request (by checking the accept header)
// and creates an X-User-Context-Hash based on parameters in the request.
// In this case it's based on Cookie.
if ('application/vnd.fos.user-context-hash' == strtolower($_SERVER['HTTP_ACCEPT'])) {
    header(sprintf('X-User-Context-Hash: %s', $_COOKIE[0]));
    header('Content-Type: application/vnd.fos.user-context-hash');
    header('Cache-Control: max-age=3600');
    header('Vary: cookie, authorization');

    exit;
}

Here we say that the hash is valid for one hour. Keep in mind, however, that you need to invalidate the hash response when the parameters that determine the context change for a user, for instance, when the user logs in or out, or is granted extra permissions by an administrator.

Note

If you base the user hash on the Cookie header, you should clean up that header to make the hash request properly cacheable.

The Original Request

After following the steps above, the following code renders a homepage differently depending on whether the user is logged in or not, using the credentials of the particular user:

// /index.php file
header('Cache-Control: max-age=3600');
header('Vary: X-User-Context-Hash');

$authenticationService = new AuthenticationService();

if ($authenticationService->isAuthenticated()) {
    echo "You are authenticated";
} else {
    echo "You are anonymous";
}

Alternative for Paywalls: Authorization Request

If you can’t efficiently determine a general user hash for the whole application (e.g. you have a paywall where individual users are limited to individual content), you can follow a slightly different approach:

  • Instead of doing a hash lookup request to a specific authentication URL, you keep the request URL unchanged, but send a HEAD request with a specific Accept header.
  • In your application, you intercept such requests after the access decision has taken place but before expensive operations like loading the actual data have taken place and return early with a 200 or 403 status.
  • If the status was 200, you restart the request in Varnish, and cache the response even though a Cookie or Authorization header is present, so that further requests on the same URL by other authorized users can be served from cache. On status 403 you return an error page or redirect to the URL where the content can be bought.