PaivanaController.php (8455B)
1 <?php 2 3 /** 4 * @file 5 * Location: src/Controller/PaivanaController.php 6 * 7 * Confirms a paid Paivana-style purchase, validates the contract 8 * matches a known fulfillment URL and price category, and on success 9 * mints the access cookie and redirects. 10 */ 11 12 namespace Drupal\taler_turnstile\Controller; 13 14 use Drupal\Core\Controller\ControllerBase; 15 use Drupal\Core\Url; 16 use Drupal\node\NodeInterface; 17 use Drupal\path_alias\AliasManagerInterface; 18 use Drupal\taler_turnstile\Entity\TurnstilePriceCategory; 19 use Drupal\taler_turnstile\PaivanaCookie; 20 use Drupal\taler_turnstile\TalerMerchantApiService; 21 use Symfony\Component\DependencyInjection\ContainerInterface; 22 use Symfony\Component\HttpFoundation\JsonResponse; 23 use Symfony\Component\HttpFoundation\RedirectResponse; 24 use Symfony\Component\HttpFoundation\Request; 25 26 /** 27 * Controller backing /taler-turnstile/paivana. 28 */ 29 class PaivanaController extends ControllerBase { 30 31 /** 32 * @var \Drupal\taler_turnstile\TalerMerchantApiService 33 */ 34 protected $apiService; 35 36 /** 37 * @var \Drupal\taler_turnstile\PaivanaCookie 38 */ 39 protected $cookies; 40 41 /** 42 * @var \Drupal\path_alias\AliasManagerInterface 43 */ 44 protected $aliasManager; 45 46 public function __construct(TalerMerchantApiService $api_service, 47 PaivanaCookie $cookies, 48 AliasManagerInterface $alias_manager) { 49 $this->apiService = $api_service; 50 $this->cookies = $cookies; 51 $this->aliasManager = $alias_manager; 52 } 53 54 public static function create(ContainerInterface $container) { 55 return new static( 56 $container->get('taler_turnstile.api_service'), 57 $container->get('taler_turnstile.cookie'), 58 $container->get('path_alias.manager') 59 ); 60 } 61 62 63 /** 64 * Reverse $url to the node it canonically points at, so we can 65 * load its price category. Returns NULL if no node could be 66 * resolved. 67 */ 68 protected function nodeFromUrl(Request $request, string $url): ?NodeInterface { 69 $base = $request->getSchemeAndHttpHost(); 70 if (strpos($url, $base) !== 0) { 71 // The fulfillment URL must live on this site, otherwise we 72 // have nothing meaningful to validate against. 73 return NULL; 74 } 75 $path = parse_url($url, PHP_URL_PATH) ?: '/'; 76 // Strip the language prefix (e.g. "/en/node/3" -> "/node/3") so 77 // the alias manager, which is language-agnostic, can resolve it. 78 foreach ($this->languageManager()->getLanguages() as $language) { 79 $prefix = '/' . $language->getId(); 80 if ($path === $prefix || strpos($path, $prefix . '/') === 0) { 81 $path = substr($path, strlen($prefix)); 82 if ($path === '') { 83 $path = '/'; 84 } 85 break; 86 } 87 } 88 // Resolve any path alias (e.g. "/articles/foo") down to "/node/123". 89 $internal = $this->aliasManager->getPathByAlias($path); 90 if (! preg_match('#^/node/(\d+)$#', $internal, $m)) { 91 return NULL; 92 } 93 $node = $this->entityTypeManager()->getStorage('node')->load($m[1]); 94 return $node instanceof NodeInterface ? $node : NULL; 95 } 96 97 98 /** 99 * Decode a Crockford base32-encoded string (GNUnet flavor: 100 * alphabet "0123456789ABCDEFGHJKMNPQRSTVWXYZ") into raw bytes. 101 * Returns FALSE on malformed input. 102 */ 103 protected static function crock32Decode(string $encoded) { 104 static $rmap = NULL; 105 if ($rmap === NULL) { 106 $rmap = []; 107 $table = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; 108 for ($i = 0; $i < 32; $i++) { 109 $rmap[$table[$i]] = $i; 110 } 111 } 112 $encoded = strtoupper($encoded); 113 $bytes = ''; 114 $buf = 0; 115 $bits = 0; 116 $len = strlen($encoded); 117 for ($i = 0; $i < $len; $i++) { 118 $c = $encoded[$i]; 119 if (!isset($rmap[$c])) { 120 return FALSE; 121 } 122 $buf = ($buf << 5) | $rmap[$c]; 123 $bits += 5; 124 if ($bits >= 8) { 125 $bytes .= chr(($buf >> ($bits - 8)) & 0xFF); 126 $bits -= 8; 127 } 128 } 129 return $bytes; 130 } 131 132 /** 133 * POST handler. Body: {order_id, nonce, cur_time, website}. 134 * On success, returns 303 + Set-Cookie + Location: $website. 135 * 136 * Wire format follows the canonical Paivana spec (see 137 * paivana-httpd_pay.c / paivana-httpd_cookie.c): 138 * - nonce: Crockford base32-encoded 16 binary bytes 139 * - cur_time: GNUnet timestamp object {"t_s": <seconds>} 140 * - paivana_id := <seconds> "-" b64url(SHA256( 141 * nonce_bytes || website_bytes || "\0" || us_BE64)) 142 */ 143 public function confirm(Request $request) { 144 $payload = json_decode((string) $request->getContent(), TRUE); 145 if (! is_array($payload)) { 146 return new JsonResponse(['error' => 'malformed_json'], 400); 147 } 148 $order_id = $payload['order_id'] ?? NULL; 149 $nonce = $payload['nonce'] ?? NULL; 150 $cur_time_obj = $payload['cur_time'] ?? NULL; 151 $website = $payload['website'] ?? NULL; 152 if (! is_string($order_id) || ! is_string($nonce) || 153 ! is_array($cur_time_obj) || ! isset($cur_time_obj['t_s']) || 154 ! is_int($cur_time_obj['t_s']) || ! is_string($website)) { 155 return new JsonResponse(['error' => 'missing_or_malformed_fields'], 400); 156 } 157 $cur_time = $cur_time_obj['t_s']; 158 if ($cur_time <= time()) { 159 return new JsonResponse(['error' => 'expired'], 410); 160 } 161 162 // Canonical PAIVANA_Nonce is 16 bytes (4 * uint32_t), encoded 163 // on the wire as Crockford base32 by GNUNET_JSON_spec_fixed_auto. 164 $nonce_bytes = self::crock32Decode($nonce); 165 if ($nonce_bytes === FALSE || strlen($nonce_bytes) < 16) { 166 return new JsonResponse(['error' => 'bad_nonce'], 400); 167 } 168 $nonce_bytes = substr($nonce_bytes, 0, 16); 169 170 // Reconstruct the paivana ID exactly the same way the JS did. 171 // The hash input is: 16-byte binary nonce || website || NUL || 172 // 8-byte big-endian microseconds. 173 $cur_time_be = pack('J', $cur_time * 1000000); 174 $hash = hash('sha256', 175 $nonce_bytes . $website . "\0" . $cur_time_be, 176 TRUE); 177 $paivana_id = $cur_time . '-' . rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); 178 179 // Resolve the website to a Drupal node so we can determine the 180 // price category and thus the set of acceptable amounts. 181 $node = $this->nodeFromUrl($request, $website); 182 if (! $node) { 183 \Drupal::logger('taler_turnstile')->warning('Confirm: cannot resolve fulfillment URL @url to a node', ['@url' => $website]); 184 return new JsonResponse(['error' => 'unknown_fulfillment_url'], 404); 185 } 186 if (! $node->hasField('field_taler_turnstile_prcat') || 187 $node->get('field_taler_turnstile_prcat')->isEmpty()) { 188 return new JsonResponse(['error' => 'no_price_category'], 404); 189 } 190 /** @var TurnstilePriceCategory $price_category */ 191 $price_category = $node->get('field_taler_turnstile_prcat')->entity; 192 if (! $price_category) { 193 return new JsonResponse(['error' => 'no_price_category'], 404); 194 } 195 // getSubscriptions() always returns a result with at least the 196 // "%none%" entry in data, even on backend failure, so the 197 // acceptable-amounts list can still be computed for non-subscription 198 // prices when the backend is briefly unreachable. 199 $subs_result = $this->apiService->getSubscriptions(); 200 $subscriptions = is_array($subs_result->data) ? $subs_result->data : []; 201 $expected_amounts = $price_category->getAcceptableAmounts($subscriptions); 202 if (empty($expected_amounts)) { 203 return new JsonResponse(['error' => 'no_choices_configured'], 500); 204 } 205 206 $verified = $this->apiService->verifyPaidOrder( 207 $order_id, 208 $paivana_id, 209 $website, 210 $expected_amounts 211 ); 212 if (! $verified) { 213 return new JsonResponse(['error' => 'order_not_validated'], 402); 214 } 215 216 // If the contract bought a subscription, persist that in the 217 // session so subsequent visits can short-circuit even when 218 // the per-URL cookie expires. 219 if (! empty($verified['subscription_slug'])) { 220 _taler_turnstile_grant_subscriber_access( 221 $verified['subscription_slug'], 222 (int) $verified['subscription_expiration'] 223 ); 224 } 225 226 $cookie = $this->cookies->mint( 227 $request, 228 $website, 229 TalerMerchantApiService::ORDER_VALIDITY_SECONDS 230 ); 231 $response = new RedirectResponse($website, 303); 232 $response->headers->setCookie($cookie); 233 // The result is per-cookie and short-lived; do not cache. 234 $response->headers->set('Cache-Control', 'no-store'); 235 return $response; 236 } 237 238 }