turnstile

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

PaivanaCookie.php (3017B)


      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    * @return \Symfony\Component\HttpFoundation\Cookie
     60    */
     61   public function mint(Request $request, string $website, int $lifetime_seconds): Cookie {
     62     $expires = time() + $lifetime_seconds;
     63     $client_addr = $request->getClientIp() ?? '';
     64     $value = $expires . '-' . $this->hash($expires, $website, $client_addr);
     65     return Cookie::create(
     66       self::COOKIE_NAME,
     67       $value,
     68       $expires,
     69       '/',
     70       NULL,
     71       $request->isSecure(),
     72       TRUE,
     73       FALSE,
     74       Cookie::SAMESITE_LAX
     75     );
     76   }
     77 
     78 
     79   /**
     80    * Check whether the request carries a valid Turnstile cookie for
     81    * $website. Returns TRUE only if the cookie's MAC matches and its
     82    * timestamp is still in the future.
     83    */
     84   public function verify(Request $request, string $website): bool {
     85     $value = $request->cookies->get(self::COOKIE_NAME);
     86     if (! is_string($value) || $value === '') {
     87       return FALSE;
     88     }
     89     $dash = strpos($value, '-');
     90     if ($dash === FALSE) {
     91       return FALSE;
     92     }
     93     $ts_part = substr($value, 0, $dash);
     94     $mac_part = substr($value, $dash + 1);
     95     if (! ctype_digit($ts_part)) {
     96       return FALSE;
     97     }
     98     $ts = (int) $ts_part;
     99     if ($ts <= time()) {
    100       return FALSE;
    101     }
    102     $client_addr = $request->getClientIp() ?? '';
    103     $expected = $this->hash($ts, $website, $client_addr);
    104     return hash_equals($expected, $mac_part);
    105   }
    106 
    107 }