turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

PaivanaCookie.php (3578B)


      1 <?php
      2 
      3 /**
      4  * @file
      5  * Helper for the Turnstile Paivana access cookie.
      6  */
      7 
      8 namespace Drupal\taler_turnstile;
      9 
     10 use Drupal\Core\PrivateKey;
     11 use Symfony\Component\HttpFoundation\Cookie;
     12 use Symfony\Component\HttpFoundation\Request;
     13 
     14 /**
     15  * Mints and verifies the cookie that grants access to a previously
     16  * paid-for fulfillment URL. The cookie is bound to the visitor's IP
     17  * address and the fulfillment URL, and carries its own expiration in
     18  * its prefix:
     19  *
     20  *   value := <cur_time>-<base64url(HMAC-SHA256(secret, cur_time||url||ip))>
     21  *
     22  * Mirrors the paivana-httpd implementation but uses HMAC-SHA256 with
     23  * Drupal's private_key as keying material so we do not need a new
     24  * config storage for a server-side secret.
     25  */
     26 class PaivanaCookie {
     27 
     28   /**
     29    * Cookie name; must match the constant exported from the .module file.
     30    */
     31   const COOKIE_NAME = 'taler_turnstile_paivana';
     32 
     33   /**
     34    * @var \Drupal\Core\PrivateKey
     35    */
     36   protected $privateKey;
     37 
     38   public function __construct(PrivateKey $private_key) {
     39     $this->privateKey = $private_key;
     40   }
     41 
     42 
     43   /**
     44    * Compute the keyed hash for ($cur_time, $website, $client_addr).
     45    * Returned as URL-safe base64 without padding.
     46    */
     47   protected function hash(int $cur_time, string $website, string $client_addr): string {
     48     $msg = pack('NN', 0, $cur_time) . $website . "\0" . $client_addr;
     49     $raw = hash_hmac('sha256', $msg, $this->privateKey->get(), TRUE);
     50     return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
     51   }
     52 
     53 
     54   /**
     55    * Build a cookie carrying access for $website, valid until
     56    * now + $lifetime_seconds. Cookie is bound to the request's
     57    * client address.
     58    *
     59    * The cookie's Path attribute is scoped to the website's URL path,
     60    * so it is only sent back when the browser visits exactly that
     61    * resource. This mirrors paivana-httpd's default (per-URL cookie):
     62    * other paywalled pages on the same host get their own cookie and
     63    * cannot piggy-back on this one. As a side effect, it also keeps
     64    * the cookie out of unrelated requests, so the page cache for
     65    * other URLs stays warm.
     66    *
     67    * @return \Symfony\Component\HttpFoundation\Cookie
     68    */
     69   public function mint(Request $request, string $website, int $lifetime_seconds): Cookie {
     70     $expires = time() + $lifetime_seconds;
     71     $client_addr = $request->getClientIp() ?? '';
     72     $value = $expires . '-' . $this->hash($expires, $website, $client_addr);
     73     $path = parse_url($website, PHP_URL_PATH);
     74     if (! is_string($path) || $path === '') {
     75       $path = '/';
     76     }
     77     return Cookie::create(
     78       self::COOKIE_NAME,
     79       $value,
     80       $expires,
     81       $path,
     82       NULL,
     83       $request->isSecure(),
     84       TRUE,
     85       FALSE,
     86       Cookie::SAMESITE_LAX
     87     );
     88   }
     89 
     90 
     91   /**
     92    * Check whether the request carries a valid Turnstile cookie for
     93    * $website. Returns TRUE only if the cookie's MAC matches and its
     94    * timestamp is still in the future.
     95    */
     96   public function verify(Request $request, string $website): bool {
     97     $value = $request->cookies->get(self::COOKIE_NAME);
     98     if (! is_string($value) || $value === '') {
     99       return FALSE;
    100     }
    101     $dash = strpos($value, '-');
    102     if ($dash === FALSE) {
    103       return FALSE;
    104     }
    105     $ts_part = substr($value, 0, $dash);
    106     $mac_part = substr($value, $dash + 1);
    107     if (! ctype_digit($ts_part)) {
    108       return FALSE;
    109     }
    110     $ts = (int) $ts_part;
    111     if ($ts <= time()) {
    112       return FALSE;
    113     }
    114     $client_addr = $request->getClientIp() ?? '';
    115     $expected = $this->hash($ts, $website, $client_addr);
    116     return hash_equals($expected, $mac_part);
    117   }
    118 
    119 }