项目作者: ThijsFeryn

项目描述 :
Develop cacheable websites by leveraging Cache-Control headers, ESI, Vary, content negotiation, conditional requests and JWT authentication
高级语言: PHP
项目地址: git://github.com/ThijsFeryn/cacheable-sites-symfony4.git
创建时间: 2018-03-09T12:04:44Z
项目社区:https://github.com/ThijsFeryn/cacheable-sites-symfony4

开源协议:

下载


Develop cacheable sites by levering HTTP

This piece of example code uses the Symfony 4 framework to illustrate how you can leverage HTTP to develop cacheable sites.

The code uses the following HTTP concepts:

  • The use of Cache-Control headers using directives like Public, Private to decide which HTTP responses are cacheable and which are not
  • The use of Cache-Control headers using directives like Max-Age and S-Maxage to determine how long HTTP responses can be cached
  • Cache variations based on the Vary header
  • Conditional requests based on the Etag header
  • Returning an HTTP 304 status code when content was successfully revalidated
  • Content negotiation and language selection based on the Accept-Language header
  • Block caching using Edge Side Includes
  • Client-side session storage based on JSON Web Tokens

Cacheable

The output that this example code generates is highly cacheable. The proper Cache-Control headers are used to store the output in an HTTP cache.

If a reverse caching proxy (like Varnish) is installed in front of this application, it will respect the time-to-live that was set by the application.

Reverse caching proxies will also create cache variations by respecting the Vary header. A separate version of the response is stored in cache per language.

Non-cacheable content blocks will not cause a full miss on the page. These content blocks are loaded separately using ESI.

ESI tags are rendered by the reverse proxy. If the code notices that there’s no reverse caching proxy in front of the application, it will render the output inline, without ESI.

Conditional requests

This example code uses conditional requests that only loads the full page when the content has modified.

It uses the ETag response header to expose the fingerprint of a page. And validates if the If-None-Match request header matches that fingerprint. If so, the execution of the code is stopped and an HTTP/304 Not Modified response is returned without any payload.

The fact that a HTTP/304 Not Modified response returns no payload, is an optimization in terms of bandwidth. But stopping the execution of the code, also reduces the load on the server.

The example code supports conditional requests via an the CondtionalRequestListener.

Etags are stored in Redis before the output is returned, which happens in the onKernelResponse method. This means you need a Redis dependency. I’m using the Symfony Redis bundle for that.

Etags are validated from Redis in the onKernelRequest method. If the Etag matches, the HTTP response is immediately returned, and the rest of the application bypassed.

  1. <?php
  2. namespace App\EventListener;
  3. use Symfony\Component\HttpFoundation\Response;
  4. use Symfony\Component\HttpFoundation\Request;
  5. use Symfony\Component\HttpKernel\Event\GetResponseEvent;
  6. use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
  7. use SymfonyBundles\RedisBundle\Redis\Client as RedisClient;
  8. class ConditionalRequestListener
  9. {
  10. protected $redis;
  11. public function __construct(RedisClient $redis)
  12. {
  13. $this->redis = $redis;
  14. }
  15. protected function isModified(Request $request, $etag)
  16. {
  17. if ($etags = $request->getETags()) {
  18. return in_array($etag, $etags) || in_array('*', $etags);
  19. }
  20. return true;
  21. }
  22. public function onKernelRequest(GetResponseEvent $event)
  23. {
  24. $request = $event->getRequest();
  25. $etag = $this->redis->get('etag:'.md5($request->getUri()));
  26. if(!$this->isModified($request,$etag)) {
  27. $event->setResponse(Response::create('Not Modified',Response::HTTP_NOT_MODIFIED));
  28. }
  29. }
  30. public function onKernelResponse(FilterResponseEvent $event)
  31. {
  32. $response = $event->getResponse();
  33. $request = $event->getRequest();
  34. $etag = md5($response->getContent());
  35. $response->setEtag($etag);
  36. if($this->isModified($request,$etag)) {
  37. $this->redis->set('etag:'.md5($request->getUri()),$etag);
  38. }
  39. }
  40. }

Authentication

The /private page is protected by a layer of authentication. The Symfony frameworks provides built-in authentication support base on the security bundle.

Symfony security bundle

This bundle provides a security configuration file: config/packages/security.yml. Using simple configuration, as illustrated in the example below, you can define users, roles, and routes that require authentication.

  1. security:
  2. access_denied_url: /login
  3. encoders:
  4. Symfony\Component\Security\Core\User\User:
  5. algorithm: bcrypt
  6. cost: 12
  7. providers:
  8. in_memory:
  9. memory:
  10. users:
  11. admin:
  12. password: $2y$12$R.XN53saKaGFZ5Zqqpv5h.9NzwP0RH4VlEGmRryW1G3cM3ov1yq32
  13. roles: 'ROLE_ADMIN'
  14. firewalls:
  15. dev:
  16. pattern: ^/(_(profiler|wdt)|css|images|js)/
  17. security: false
  18. main:
  19. anonymous: true
  20. form_login:
  21. check_path: /login
  22. access_control:
  23. - { path: ^/private, roles: ROLE_ADMIN }

This example protects the /private route, but unfortunately, this information is stored in PHP session variables, which are stored server side. Accessing this information requires access to the backend and requires a cache bypass.

JSON Web Tokens

Luckily, there is a way to store session state at the client-side, which doesn’t require backend access. We can use JSON Web Tokens to store this information.

The JWT will be stored in the token cookie, which will be managed by the application, but which can also be validated by Varnish.

The LexikJWTAuthenticationBundle can serve as an extension to the standard security bundle and requires just a little bit of extra configuration.

We’ll modify config/packages/security.yml and add custom handlers and a custom authenticator:

  1. security:
  2. access_denied_url: /login
  3. encoders:
  4. Symfony\Component\Security\Core\User\User:
  5. algorithm: bcrypt
  6. cost: 12
  7. providers:
  8. in_memory:
  9. memory:
  10. users:
  11. admin:
  12. password: $2y$12$R.XN53saKaGFZ5Zqqpv5h.9NzwP0RH4VlEGmRryW1G3cM3ov1yq32
  13. roles: 'ROLE_ADMIN'
  14. firewalls:
  15. dev:
  16. pattern: ^/(_(profiler|wdt)|css|images|js)/
  17. security: false
  18. main:
  19. anonymous: true
  20. stateless: true
  21. form_login:
  22. check_path: /login
  23. success_handler: App\Security\JwtAuthenticationSuccessHandler
  24. failure_handler: App\Security\JwtAuthenticationFailureHandler
  25. guard:
  26. authenticators:
  27. - lexik_jwt_authentication.jwt_token_authenticator
  28. access_control:
  29. - { path: ^/private, roles: ROLE_ADMIN }

The JWT bundle also has its own configuration file under config/packages/lxik_jwt_authentication.yml as illustrated below:

  1. lexik_jwt_authentication:
  2. private_key_path: '%kernel.project_dir%/%env(JWT_PRIVATE_KEY_PATH)%'
  3. public_key_path: '%kernel.project_dir%/%env(JWT_PRIVATE_KEY_PATH)%'
  4. token_ttl: 3600
  5. encoder:
  6. signature_algorithm: HS256
  7. service: lexik_jwt_authentication.encoder.lcobucci
  8. token_extractors:
  9. cookie:
  10. enabled: true
  11. name: token

This configuration file defines crypto key locations, the lifetime of the token, the algorithm to use for encryption of the signature and the service to encode and decode the token. You’ll also notice that a token cookie is used to store the token.

The default algorithm is RS256 which uses a private and a public key. This example is based on HS256 which is an HMAC signature that only has a private key. That’s why the private and public key point to the same file.

A website, not an API

JWT is mostly used for API authentication, and the LexikJWTAuthenticationBundle is tailored to the needs of an API. This means that the output is in JSON format. In order to make this HTML-based, I defined a custom event listener and 2 custom handlers.

The App\Security\JwtAuthenticationSuccessHandler will set the token cookie and redirect to the /private page upon successful authentication, instead of displaying the token in JSON format.

The App\Security\JwtAuthenticationFailureHandler will redirect back to the /login when the authentication fails, instead of displaying a JSON error.

The App\EventListener\JwtAuthenticationListener will intercept JSON errors when the token has expired, or is invalid. It will dispatch the /login page when that happens.

Varnish

To see the impact of this code, I would advise you to install Varnish. Varnish will respect the HTTP response headers that were set and will cache the output.

This is the minimum amount of VCL code you need to make this work:

  1. vcl 4.0;
  2. import digest;
  3. import std;
  4. import cookie;
  5. import var;
  6. backend default {
  7. .host = "localhost";
  8. .port = "8000";
  9. .probe = {
  10. .url = "/";
  11. .interval = 5s;
  12. .timeout = 5s;
  13. .window = 5;
  14. .threshold = 3;
  15. }
  16. }
  17. sub vcl_recv {
  18. var.set("key","SlowWebSitesSuck");
  19. set req.url = std.querysort(req.url);
  20. if(req.http.accept-language ~ "^\s*(nl)") {
  21. set req.http.accept-language = regsub(req.http.accept-language,"^\s*(nl).*$","\1");
  22. } else {
  23. set req.http.accept-language = "en";
  24. }
  25. set req.http.Surrogate-Capability="key=ESI/1.0";
  26. if ((req.method != "GET" && req.method != "HEAD") || req.http.Authorization) {
  27. return (pass);
  28. }
  29. call jwt;
  30. if(req.url == "/private" && req.http.X-Login != "true") {
  31. std.log("Private content, X-Login is not true");
  32. return(synth(302,"/logout"));
  33. }
  34. return(hash);
  35. }
  36. sub vcl_backend_response {
  37. set beresp.http.x-host = bereq.http.host;
  38. set beresp.http.x-url = bereq.url;
  39. if(beresp.http.Surrogate-Control~"ESI/1.0") {
  40. unset beresp.http.Surrogate-Control;
  41. set beresp.do_esi=true;
  42. }
  43. }
  44. sub vcl_deliver {
  45. unset resp.http.x-host;
  46. unset resp.http.x-url;
  47. unset resp.http.vary;
  48. }
  49. sub vcl_synth {
  50. if (resp.status == 301 || resp.status == 302) {
  51. set resp.http.location = resp.reason;
  52. set resp.reason = "Moved";
  53. return (deliver);
  54. }
  55. }
  56. sub jwt {
  57. unset req.http.X-Login;
  58. std.log("Trying to find token cookie");
  59. if(req.http.cookie ~ "^([^;]+;[ ]*)*token=[^\.]+\.[^\.]+\.[^\.]+([ ]*;[^;]+)*$") {
  60. std.log("Token cookie found");
  61. cookie.parse(req.http.cookie);
  62. cookie.filter_except("token");
  63. var.set("token", cookie.get("token"));
  64. var.set("header", regsub(var.get("token"),"([^\.]+)\.[^\.]+\.[^\.]+","\1"));
  65. var.set("type", regsub(digest.base64url_decode(var.get("header")),{"^.*?"typ"\s*:\s*"(\w+)".*?$"},"\1"));
  66. var.set("algorithm", regsub(digest.base64url_decode(var.get("header")),{"^.*?"alg"\s*:\s*"(\w+)".*?$"},"\1"));
  67. if(var.get("type") != "JWT" || var.get("algorithm") != "HS256") {
  68. std.log("Invalid token header");
  69. return(synth(400, "Invalid token header"));
  70. }
  71. var.set("rawPayload",regsub(var.get("token"),"[^\.]+\.([^\.]+)\.[^\.]+$","\1"));
  72. var.set("signature",regsub(var.get("token"),"^[^\.]+\.[^\.]+\.([^\.]+)$","\1"));
  73. var.set("currentSignature",digest.base64url_nopad_hex(digest.hmac_sha256(var.get("key"),var.get("header") + "." + var.get("rawPayload"))));
  74. var.set("payload", digest.base64url_decode(var.get("rawPayload")));
  75. var.set("exp",regsub(var.get("payload"),{"^.*?"exp"\s*:\s*(\w+).*?$"},"\1"));
  76. var.set("username",regsub(var.get("payload"),{"^.*?"username"\s*:\s*"(\w+)".*?$"},"\1"));
  77. if(var.get("signature") != var.get("currentSignature")) {
  78. std.log("Invalid token signature");
  79. return(synth(400, "Invalid token signature"));
  80. }
  81. std.log("Ready to validate username");
  82. if(var.get("username") ~ "^\w+$") {
  83. std.log("Username: " + var.get("username"));
  84. if(std.time(var.get("exp"),now) >= now) {
  85. std.log("JWT not expired");
  86. set req.http.X-Login="true";
  87. } else {
  88. set req.http.X-Login="false";
  89. std.log("JWT expired");
  90. }
  91. }
  92. }
  93. }

You will need to install the libvmod-digest in order to process the JWT.

This piece of VCL code assumes that Varnish is installed on port 80 and your webserver on port 8000 on the same machine.

This vcl file doesn’t just take care of caching, but also validates the JWT for the /private route. The validation happens in the custom sub jwt procedure.

  • It validates the token cookie
  • In case of a mismatch, an 403 error is returned
  • The login state is extracted from the encoded JSON and stored in the custom X-Login request header
  • The PHP code performs cache variations on the X-Login request header to have 2 versions of the pages that depend on the login state

If you’re planning to change the secret key in your .env.dist file, please also change it in your VCL file.

Summary

The application handles nearly all of the caching logic. The only tricky bit is the authentication and the cache variations for the private part of the site.

Luckily, we can validate the JSON Web Tokens in VCL by performing some regex magic and by using some digest functions, provided by vmod_digest.

The backend is only accessed under the following circumstances:

  • The first hit
  • Cache variations
  • The POST call on the login form

All the rest is delivered from cache. This strategy makes the site extremely cacheable.