PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / extensions / blocks / premium-content / _inc / subscription-service / class-abstract-token-subscription-service.php

class-abstract-token-subscription-service.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at extensions/blocks/premium-content/_inc/subscription-service/class-abstract-token-subscription-service.php

922 lines 30.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * A paywall that exchanges JWT tokens from WordPress.com to allow
4 * a current visitor to view content that has been deemed "Premium content".
5 *
6 * @package Automattic\Jetpack\Extensions\Premium_Content
7 */
8
9 namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
10
11 use Automattic\Jetpack\Extensions\Premium_Content\JWT;
12 use WP_Error;
13 use WP_Post;
14 use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit( 0 );
18 }
19
20 /**
21 * Class Abstract_Token_Subscription_Service
22 *
23 * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
24 */
25 abstract class Abstract_Token_Subscription_Service implements Subscription_Service {
26
27 const JWT_AUTH_TOKEN_COOKIE_NAME = 'wp-jp-premium-content-session'; // wp prefix helps with skipping batcache
28 const DECODE_EXCEPTION_FEATURE = 'memberships';
29 const DECODE_EXCEPTION_MESSAGE = 'Problem decoding provided token';
30 const REST_URL_ORIGIN = 'https://subscribe.wordpress.com/';
31 const BLOG_SUB_ACTIVE = 'active';
32 const BLOG_SUB_PENDING = 'pending';
33 const POST_ACCESS_LEVEL_EVERYBODY = 'everybody';
34 const POST_ACCESS_LEVEL_SUBSCRIBERS = 'subscribers';
35 const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS = 'paid_subscribers';
36 const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS = 'paid_subscribers_all_tiers';
37
38 /**
39 * An optional user_id to query against (omitting this will use either the token or current user id)
40 *
41 * @var int|null
42 */
43 protected $user_id = null;
44
45 /**
46 * Constructor
47 *
48 * @param int|null $user_id An optional user_id to query subscriptions against. Uses token from request/cookie or logged-in user information if omitted.
49 */
50 public function __construct( $user_id = null ) {
51 $this->user_id = $user_id;
52 }
53
54 /**
55 * Initialize the token subscription service.
56 *
57 * @inheritDoc
58 */
59 public function initialize() {
60 $this->get_and_set_token_from_request();
61 }
62
63 /**
64 * Set the token from the Request to the cookie and retrieve the token.
65 *
66 * @return string|null
67 */
68 public function get_and_set_token_from_request() {
69 // URL token always has a precedence, so it can overwrite the cookie when new data available.
70 $token = $this->token_from_request();
71 if ( null !== $token ) {
72 $this->set_token_cookie( $token );
73 $this->maybe_link_wpcom_user_id( $token );
74 return $token;
75 }
76
77 return $this->token_from_cookie();
78 }
79
80 /**
81 * Self-heals the local user's wpcom_user_id meta from a freshly-verified magic-link
82 * token, so future requests can resolve this visitor's subscriptions via the
83 * authoritative filter (see get_subscriptions_for_logged_in_user() in access-check.php)
84 * without needing another magic-link round trip once the token/cookie expires.
85 *
86 * Safe because the token's user_id was just verified by WordPress.com's own session
87 * at subscribe.wordpress.com -- never read from anything stored locally. A local
88 * account's email is not proof of identity by itself (anyone with admin access could
89 * type in any email when creating a local user), so this additionally requires the
90 * local account's own email to match the token's blog_subscriber email before linking,
91 * to avoid mis-linking a local account to whichever WordPress.com session happens to
92 * be active in the browser (e.g. a shared device) when the two aren't otherwise related.
93 *
94 * @param string $token The raw token string from the incoming request.
95 * @return void
96 */
97 private function maybe_link_wpcom_user_id( $token ) {
98 if ( ! is_user_logged_in() ) {
99 return;
100 }
101
102 $payload = $this->decode_token( $token );
103 if ( empty( $payload['user_id'] ) || empty( $payload['blog_subscriber'] ) ) {
104 return;
105 }
106
107 $local_user = wp_get_current_user();
108 if ( strcasecmp( $local_user->user_email, $payload['blog_subscriber'] ) !== 0 ) {
109 return;
110 }
111
112 if ( (int) get_user_meta( $local_user->ID, 'wpcom_user_id', true ) === (int) $payload['user_id'] ) {
113 return;
114 }
115
116 update_user_meta( $local_user->ID, 'wpcom_user_id', (int) $payload['user_id'] );
117 }
118
119 /**
120 * Attempt to refresh the current token against the WordPress.com refresh endpoint
121 * and, on success, persist the fresh token in the cookie and return the decoded
122 * payload.
123 *
124 * This is called when a subscriber has a JWT token whose subscription data is
125 * stale (e.g. the cookie contains an old end_date from before a Stripe renewal).
126 * The refresh endpoint accepts the existing token, re-queries billing, and
127 * returns a fresh token reflecting current subscription state.
128 *
129 * @return array|null Decoded fresh payload on success, null on any failure.
130 */
131 public function refresh_token_payload() {
132 $current_token = $this->get_and_set_token_from_request();
133 if ( empty( $current_token ) ) {
134 return null;
135 }
136
137 $fresh_token = $this->fetch_refreshed_token( $current_token );
138 if ( empty( $fresh_token ) ) {
139 return null;
140 }
141
142 $fresh_payload = $this->decode_token( $fresh_token );
143 if ( empty( $fresh_payload ) ) {
144 return null;
145 }
146
147 $this->set_token_cookie( $fresh_token );
148 return $fresh_payload;
149 }
150
151 /**
152 * POST the current token to the WordPress.com memberships token-refresh endpoint
153 * and return a fresh JWT string, or null on any failure.
154 *
155 * Endpoint contract (POST /sites/<site_id>/memberships/token/refresh):
156 * - 200 + { success: true, jwt_token: "<jwt>" } → fresh token; return it.
157 * - 200 + { success: false, ... } → wpcom refused the refresh
158 * (token no longer eligible, or signature/site/user check failed).
159 * Deterministic; clear the cookie so the visitor is routed through the normal
160 * auth flow on the next page load.
161 * - Anything else (non-200, WP_Error, network timeout, malformed body) → transient;
162 * leave the cookie alone so a temporary outage does not mass-log-out subscribers.
163 *
164 * @param string $current_token The token to present for refresh.
165 * @return string|null Fresh token string, or null on failure.
166 */
167 protected function fetch_refreshed_token( $current_token ) {
168 $site_id = (int) $this->get_site_id();
169 if ( $site_id <= 0 ) {
170 return null;
171 }
172
173 $response = wp_remote_post(
174 sprintf(
175 'https://public-api.wordpress.com/rest/v1.1/sites/%d/memberships/token/refresh',
176 $site_id
177 ),
178 array(
179 'timeout' => 5,
180 'headers' => array( 'Content-Type' => 'application/json' ),
181 'body' => wp_json_encode(
182 array( 'jwt_token' => $current_token ),
183 JSON_UNESCAPED_SLASHES
184 ),
185 )
186 );
187
188 if ( is_wp_error( $response ) ) {
189 return null;
190 }
191
192 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
193 return null;
194 }
195
196 $body = json_decode( wp_remote_retrieve_body( $response ), true );
197 if ( ! is_array( $body ) || ! isset( $body['success'] ) ) {
198 return null;
199 }
200
201 if ( true === $body['success'] ) {
202 if ( ! empty( $body['jwt_token'] ) && is_string( $body['jwt_token'] ) ) {
203 return $body['jwt_token'];
204 }
205 // Malformed 200: success: true but no usable jwt_token. Treat as transient —
206 // leave the cookie alone rather than logging the visitor out over a response
207 // shape problem.
208 return null;
209 }
210
211 // success === false → deterministic auth failure. Clear cookie.
212 self::clear_token_cookie();
213 return null;
214 }
215
216 /**
217 * Whether the token already carries a subscription whose product_id matches one of
218 * the required plans. Used to gate the refresh path: combined with `validate_subscriptions`
219 * having returned false, a match here implies the matching subscription's end_date is
220 * in the past — the only case the refresh endpoint can help with.
221 *
222 * @param int[] $valid_plan_ids Plan IDs required by the post.
223 * @param array $token_subscriptions Subscriptions from the current token (keyed by product_id).
224 * @return bool
225 */
226 public function token_has_matching_product( array $valid_plan_ids, array $token_subscriptions ) {
227 if ( empty( $token_subscriptions ) ) {
228 return false;
229 }
230 foreach ( $valid_plan_ids as $plan_id ) {
231 $product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
232 if ( $product_id > 0 && isset( $token_subscriptions[ $product_id ] ) ) {
233 return true;
234 }
235 }
236 return false;
237 }
238
239 /**
240 * Get the site ID for the current site.
241 *
242 * @return int
243 */
244 abstract public function get_site_id();
245
246 /**
247 * Get the token payload .
248 *
249 * @return array
250 */
251 public function get_token_payload() {
252 $token = $this->get_and_set_token_from_request();
253 if ( empty( $token ) ) {
254 return array();
255 }
256 $token_payload = $this->decode_token( $token );
257 if ( ! is_array( $token_payload ) ) {
258 return array();
259 }
260 return $token_payload;
261 }
262
263 /**
264 * Get a token property, otherwise return false.
265 *
266 * @param string $key the property name.
267 *
268 * @return mixed|false
269 */
270 public function get_token_property( $key ) {
271 $token_payload = $this->get_token_payload();
272 if ( ! isset( $token_payload[ $key ] ) ) {
273 return false;
274 }
275 return $token_payload[ $key ];
276 }
277
278 /**
279 * The user is visiting with a subscriber token cookie.
280 *
281 * This is theoretically where the cookie JWT signature verification
282 * thing will happen.
283 *
284 * How to obtain one of these (or what exactly it is) is
285 * still a WIP (see api/auth branch)
286 *
287 * @inheritDoc
288 *
289 * @param array $valid_plan_ids List of valid plan IDs.
290 * @param array $access_level Access level for content.
291 *
292 * @return bool Whether the user can view the content
293 */
294 public function visitor_can_view_content( $valid_plan_ids, $access_level ) {
295 global $current_user;
296 $old_user = $current_user; // backup the current user so we can set the current user to the token user for paywall purposes
297
298 $payload = $this->get_token_payload();
299 $is_valid_token = ! empty( $payload );
300
301 if ( $is_valid_token && isset( $payload['user_id'] ) ) {
302 // set the current user to the payload's user id
303 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
304 $current_user = get_user_by( 'id', $payload['user_id'] );
305 }
306
307 $is_blog_subscriber = false;
308 $is_paid_subscriber = false;
309 $subscriptions = array();
310
311 if ( $is_valid_token ) {
312 /**
313 * Allow access to the content if:
314 *
315 * Active: user has a valid subscription
316 */
317 $is_blog_subscriber = in_array(
318 $payload['blog_sub'],
319 array(
320 self::BLOG_SUB_ACTIVE,
321 ),
322 true
323 );
324 $subscriptions = (array) $payload['subscriptions'];
325 $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
326
327 // Only attempt a refresh in the specific stale-end_date case: the token already carries a
328 // subscription whose product_id matches one of the required plans, but validation failed
329 // (which, given the match, can only be because end_date is in the past). This excludes
330 // free subscribers, tier mismatches, and cancellations from triggering an HTTP call on
331 // every render.
332 if (
333 ! $is_paid_subscriber
334 && ! empty( $valid_plan_ids )
335 && $this->token_has_matching_product( $valid_plan_ids, $subscriptions )
336 ) {
337 $fresh_payload = $this->refresh_token_payload();
338 if ( ! empty( $fresh_payload ) ) {
339 $payload = $fresh_payload;
340 $is_blog_subscriber = isset( $payload['blog_sub'] ) && self::BLOG_SUB_ACTIVE === $payload['blog_sub'];
341 $subscriptions = isset( $payload['subscriptions'] ) ? (array) $payload['subscriptions'] : array();
342 $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
343 }
344 }
345 }
346
347 $has_access = $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, get_the_ID(), $subscriptions );
348 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
349 $current_user = $old_user;
350 return $has_access;
351 }
352
353 /**
354 * Retrieves the email of the currently authenticated subscriber.
355 *
356 * @return string The email address of the current user.
357 */
358 public function get_subscriber_email() {
359 $email = $this->get_token_property( 'blog_subscriber' );
360 if ( empty( $email ) ) {
361 return '';
362 }
363 return $email;
364 }
365
366 /**
367 * Returns true if the current authenticated user is subscribed to the current site.
368 *
369 * @return boolean
370 */
371 public function is_current_user_subscribed() {
372 return $this->get_token_property( 'blog_sub' ) === 'active';
373 }
374
375 /**
376 * Returns true if the current authenticated user has a pending subscription to the current site.
377 *
378 * @return bool
379 */
380 abstract public function is_current_user_pending_subscriber(): bool;
381
382 /**
383 * Return if the user has access to the content depending on the access level and the user rights
384 *
385 * @param string $access_level Post or blog access level.
386 * @param bool $is_blog_subscriber Is user a subscriber of the blog.
387 * @param bool $is_paid_subscriber Is user a paid subscriber of the blog.
388 * @param int $post_id Post ID.
389 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
390 *
391 * @return bool Whether the user has access to the content.
392 */
393 protected function user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, $post_id, $user_abbreviated_subscriptions ) {
394
395 if ( is_user_logged_in() && current_user_can( 'edit_post', $post_id ) ) {
396 // Admin has access
397 $has_access = true;
398 } else {
399 switch ( $access_level ) {
400 case self::POST_ACCESS_LEVEL_EVERYBODY:
401 default:
402 $has_access = true;
403 break;
404 case self::POST_ACCESS_LEVEL_SUBSCRIBERS:
405 $has_access = $is_blog_subscriber || $is_paid_subscriber;
406 break;
407 case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS:
408 $has_access = $is_paid_subscriber;
409 break;
410 case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS:
411 $has_access = $is_paid_subscriber &&
412 ! $this->maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions );
413 break;
414 }
415 }
416
417 do_action( 'earn_user_has_access', $access_level, $has_access, $is_blog_subscriber, $is_paid_subscriber, $post_id );
418 return $has_access;
419 }
420
421 /**
422 * Check post access for tiers.
423 *
424 * @param int $post_id Current post id.
425 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
426 *
427 * @return bool
428 */
429 private function maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions ) {
430 $tier_id = intval(
431 get_post_meta( $post_id, META_NAME_FOR_POST_TIER_ID_SETTINGS, true )
432 );
433
434 if ( ! $tier_id ) {
435 return false;
436 }
437
438 return $this->maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions );
439 }
440
441 /**
442 * Get all plans id that make access valid for a post with this tier id.
443 *
444 * @param int $tier_id Newsletter tier post ID.
445 *
446 * @return array|WP_Error
447 */
448 public static function get_valid_plan_ids_for_tier( int $tier_id ) {
449 // Valid plans are:
450 // - monthly plan with ID $tier_id
451 // - yearly plan related to this $tier_id (in meta jetpack_memberships_tier)
452 // - monthly tiers with same currency and price same or higher than original tier
453 // - yearly plans that are more expensive than the yearly plan linked to the original tier
454
455 $valid_plan_ids = array();
456
457 $all_plans = \Jetpack_Memberships::get_all_plans();
458
459 // Let's get the current tier
460 $tier = null;
461 foreach ( $all_plans as $post ) {
462 if ( $post->ID === $tier_id ) {
463 $tier = $post;
464 break;
465 }
466 }
467
468 if ( $tier === null ) {
469 // We have an error
470 return new WP_Error( 'related-plan-not-found', 'The plan related to the tier cannot be found' );
471 }
472
473 $tier_price = self::find_metadata( $tier, 'jetpack_memberships_price' );
474 $tier_currency = self::find_metadata( $tier, 'jetpack_memberships_currency' );
475 $tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
476
477 if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
478 // There is an issue with the meta
479 return new WP_Error( 'wrong-data-plan-not-found', 'The plan related to the tier is missing data' );
480 }
481
482 $valid_plan_ids[] = $tier_id;
483
484 $tier_price = floatval( $tier_price );
485
486 // At this point we know the post is
487 $annual_tier = null;
488 foreach ( $all_plans as $plan ) {
489 if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
490 $annual_tier = $plan;
491 break;
492 }
493 }
494
495 $annual_tier_price = null;
496 if ( ! empty( $annual_tier ) ) {
497 $annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
498 $valid_plan_ids[] = $annual_tier->ID;
499 }
500
501 foreach ( $all_plans as $post ) {
502 if ( in_array( $post->ID, $valid_plan_ids, true ) ) {
503 continue;
504 }
505
506 $plan_price = self::find_metadata( $post, 'jetpack_memberships_price' );
507 $plan_currency = self::find_metadata( $post, 'jetpack_memberships_currency' );
508 $plan_interval = self::find_metadata( $post, 'jetpack_memberships_interval' );
509
510 if ( $plan_price === null || $plan_currency === null || $plan_interval === null ) {
511 // There is an issue with the meta
512 continue;
513 }
514
515 $plan_price = floatval( $plan_price );
516
517 if ( $tier_currency !== $plan_currency ) {
518 // For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
519 continue;
520 }
521
522 if ( ( $plan_interval === '1 month' && $plan_price >= $tier_price ) ||
523 ( $annual_tier_price !== null && $plan_interval === '1 year' && $plan_price >= $annual_tier_price )
524 ) {
525 $valid_plan_ids [] = $post->ID;
526 }
527 }
528
529 return $valid_plan_ids;
530 }
531
532 /**
533 * Find metadata in post
534 *
535 * @param WP_Post|object $post Post.
536 * @param string $meta_key Meta to retrieve.
537 *
538 * @return mixed|null
539 */
540 private static function find_metadata( $post, $meta_key ) {
541
542 if ( $post instanceof WP_Post ) {
543 return $post->{$meta_key};
544 }
545
546 foreach ( $post->metadata as $meta ) {
547 if ( $meta->key === $meta_key ) {
548 return $meta->value;
549 }
550 }
551
552 return null;
553 }
554
555 /**
556 * Check access for tier.
557 *
558 * @param int $tier_id Tier id.
559 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
560 *
561 * @return bool
562 */
563 public function maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions ) {
564
565 $plan_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();
566
567 if ( ! in_array( $tier_id, $plan_ids, true ) ) {
568 // If the tier is not in the plans, we bail
569 return false;
570 }
571
572 // We now need the tier price and currency, and the same for the annual price (if available)
573 $all_plans = \Jetpack_Memberships::get_all_plans();
574 $tier = null;
575 foreach ( $all_plans as $post ) {
576 if ( $post->ID === $tier_id ) {
577 $tier = $post;
578 break;
579 }
580 }
581
582 if ( $tier === null ) {
583 return false;
584 }
585
586 $tier_price = self::find_metadata( $tier, 'jetpack_memberships_price' );
587 $tier_currency = self::find_metadata( $tier, 'jetpack_memberships_currency' );
588 $tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
589 $annual_tier_price = $tier_price * 12;
590
591 if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
592 // There is an issue with the meta
593 return false;
594 }
595
596 $tier_price = floatval( $tier_price );
597
598 // At this point we know the post is
599 $annual_tier_id = null;
600 $annual_tier = null;
601 foreach ( $all_plans as $plan ) {
602 if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
603 $annual_tier = $plan;
604 break;
605 }
606 }
607
608 $annual_tier_price = null;
609 if ( ! empty( $annual_tier ) ) {
610 $annual_tier_id = $annual_tier->ID;
611 $annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
612 }
613
614 foreach ( $user_abbreviated_subscriptions as $subscription_plan_id => $details ) {
615 $details = (array) $details;
616
617 if ( ! self::subscription_grants_access( $details ) ) {
618 // Subscription not active anymore (its end_date day has fully passed).
619 continue;
620 }
621
622 $subscription_post = null;
623 foreach ( $all_plans as $plan ) {
624 if ( intval( self::find_metadata( $plan, 'jetpack_memberships_product_id' ) ) === intval( $subscription_plan_id ) ) {
625 $subscription_post = $plan;
626 break;
627 }
628 }
629
630 if ( empty( $subscription_post ) ) {
631 // No post linked to this plan
632 continue;
633 }
634
635 // Comp grants linked to a plan on this site bypass the tier price comparison.
636 if ( ! empty( $details['is_comp'] ) ) {
637 return false;
638 }
639
640 $subscription_post_id = $subscription_post->ID;
641
642 if ( $subscription_post_id === $tier_id || $subscription_post_id === $annual_tier_id ) {
643 // User is subscribed to the right tier
644 return false;
645 }
646
647 $subscription_price = self::find_metadata( $subscription_post, 'jetpack_memberships_price' );
648 $subscription_currency = self::find_metadata( $subscription_post, 'jetpack_memberships_currency' );
649 $subscription_interval = self::find_metadata( $subscription_post, 'jetpack_memberships_interval' );
650
651 if ( $subscription_price === null || $subscription_currency === null || $subscription_interval === null ) {
652 // There is an issue with the meta
653 continue;
654 }
655
656 $subscription_price = floatval( $subscription_price );
657
658 if ( $tier_currency !== $subscription_currency ) {
659 // For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
660 continue;
661 }
662
663 if ( ( $subscription_interval === '1 month' && $subscription_price >= $tier_price ) ||
664 ( $annual_tier_price !== null && $subscription_interval === '1 year' && $subscription_price >= $annual_tier_price )
665 ) {
666 // One subscription is more expensive than the minimum set by the post' selected tier
667 return false;
668 }
669 }
670 return true; // No user subscription is more expensive than the post's tier price...
671 }
672
673 /**
674 * Decode the given token.
675 *
676 * @param string $token Token to decode.
677 *
678 * @return array|false
679 */
680 public function decode_token( $token ) {
681 if ( empty( $token ) ) {
682 return false;
683 }
684
685 try {
686 $key = $this->get_key();
687 return $key ? (array) JWT::decode( $token, $key, array( 'HS256' ) ) : false;
688 } catch ( \Exception $exception ) {
689 return false;
690 }
691 }
692
693 /**
694 * Get the key for decoding the auth token.
695 *
696 * @return string|false
697 */
698 abstract public function get_key();
699
700 // phpcs:disable
701 /**
702 * Get the URL to access the protected content.
703 *
704 * @param string $mode Access mode (either "subscribe" or "login").
705 */
706 public function access_url( $mode = 'subscribe', $permalink = null ) {
707 global $wp;
708 if ( empty( $permalink ) ) {
709 $permalink = get_permalink();
710 if ( empty( $permalink ) ) {
711 $permalink = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
712 }
713 }
714
715 $login_url = $this->get_rest_api_token_url( $this->get_site_id(), $permalink );
716 return $login_url;
717 }
718 // phpcs:enable
719
720 /**
721 * Get the token stored in the auth cookie.
722 *
723 * @return ?string
724 */
725 private function token_from_cookie() {
726 if ( isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) ) {
727 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
728 return $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ];
729 }
730 }
731
732 /**
733 * Check whether the JWT_TOKEN cookie is set
734 *
735 * @return bool
736 */
737 public static function has_token_from_cookie() {
738 return isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) && ! empty( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
739 }
740
741 /**
742 * Store the auth cookie.
743 *
744 * Updates `$_COOKIE` in memory so subsequent code in the same request (e.g. another
745 * Premium Content block on the same post) reads the new value and doesn't re-trigger
746 * a refresh against a now-stale value. The Set-Cookie header is emitted via the
747 * standard `setcookie()` — on Atomic / wpcom the response is output-buffered so this
748 * still works during `the_content`; on stricter self-hosted setups the header may
749 * silently be dropped after output starts, in which case the browser keeps the prior
750 * cookie value and the refresh fires again on the next visit (correct degradation).
751 *
752 * @param string $token Auth token.
753 * @return void
754 */
755 private function set_token_cookie( $token ) {
756 if ( empty( $token ) ) {
757 return;
758 }
759
760 $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] = $token;
761
762 if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
763 return;
764 }
765
766 if ( ! headers_sent() ) {
767 // phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
768 setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, strtotime( '+1 month' ), '/', '', is_ssl(), false );
769 }
770 }
771
772 /**
773 * Clear the auth cookie. Mirrors set_token_cookie(): updates `$_COOKIE` for
774 * in-request consistency, then emits a clearing Set-Cookie header.
775 */
776 public static function clear_token_cookie() {
777 unset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
778
779 if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
780 return;
781 }
782
783 if ( ! headers_sent() ) {
784 // phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
785 setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, '', 1, '/', '', is_ssl(), false );
786 }
787 }
788
789 /**
790 * Get the token if present in the current request.
791 *
792 * @return ?string
793 */
794 private function token_from_request() {
795 $token = null;
796 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
797 if ( isset( $_GET['token'] ) && is_string( $_GET['token'] ) ) {
798 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
799 if ( preg_match( '/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/', $_GET['token'], $matches ) ) {
800 // token matches a valid JWT token pattern.
801 $token = reset( $matches );
802 }
803 }
804 return $token;
805 }
806
807 /**
808 * Return true if an abbreviated subscription currently grants access.
809 *
810 * Access is granted through the end of the subscription's end_date day
811 * (23:59:59 UTC), not the exact end_date timestamp. Memberships store
812 * end_date as the precise purchase timestamp, while billing renews via a
813 * deferred day-0 job that can run several hours after that timestamp. Since
814 * the wider platform already treats end-of-day as the formal expiration
815 * time for subscriptions, expiring at EOD here keeps a same-day auto-renewal
816 * from cutting off access before its renewal completes, and aligns Paid
817 * Content with the rest of WordPress.com / Jetpack.
818 *
819 * Cancelled and otherwise inactive subscriptions never reach this check —
820 * they are dropped by the `'active' === status` filter in
821 * abbreviate_subscriptions() — so this only ever extends an already-active
822 * subscription to the end of its final day.
823 *
824 * @param object|array $subscription Abbreviated subscription (see abbreviate_subscriptions()).
825 *
826 * @return bool
827 */
828 protected static function subscription_grants_access( $subscription ) {
829 $subscription = (array) $subscription;
830 if ( empty( $subscription['end_date'] ) ) {
831 return false;
832 }
833
834 $end = is_int( $subscription['end_date'] ) ? $subscription['end_date'] : strtotime( $subscription['end_date'] );
835 if ( false === $end ) {
836 return false;
837 }
838
839 // Expire at the end of the end_date's day (UTC), matching the platform's
840 // formal expiration convention rather than the exact purchase timestamp.
841 $end_of_day = strtotime( gmdate( 'Y-m-d 23:59:59', $end ) . ' UTC' );
842
843 return $end_of_day >= time();
844 }
845
846 /**
847 * Return true if any ID/date pairs are valid. Otherwise false.
848 *
849 * @param int[] $valid_plan_ids List of valid plan IDs.
850 * @param object[] $token_subscriptions : ID must exist in the provided <code>$valid_subscriptions</code> parameter.
851 * The provided end date needs to fall on or after today,
852 * i.e. access lasts through the end of the end_date day (UTC).
853 *
854 * @return bool
855 */
856 public static function validate_subscriptions( array $valid_plan_ids, array $token_subscriptions ) {
857 // Create a list of product_ids to compare against.
858 $product_ids = array();
859 foreach ( $valid_plan_ids as $plan_id ) {
860 $product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
861 if ( isset( $product_id ) ) {
862 $product_ids[] = $product_id;
863 }
864 }
865
866 foreach ( $token_subscriptions as $product_id => $token_subscription ) {
867 if ( in_array( intval( $product_id ), $product_ids, true ) ) {
868 if ( static::subscription_grants_access( $token_subscription ) ) {
869 return true;
870 }
871 }
872 }
873 return false;
874 }
875
876 /**
877 * Get the URL of the JWT endpoint.
878 *
879 * @param int $site_id Site ID.
880 * @param string $redirect_url URL to redirect after checking the token validity.
881 * @return string URL of the JWT endpoint.
882 */
883 private function get_rest_api_token_url( $site_id, $redirect_url ) {
884 // The redirect url might have a part URL encoded but not the whole URL.
885 $redirect_url = rawurldecode( $redirect_url );
886 return sprintf( '%smemberships/jwt?site_id=%d&redirect_url=%s', self::REST_URL_ORIGIN, $site_id, rawurlencode( $redirect_url ) );
887 }
888
889 /**
890 * Report the subscriptions as an ID => [ 'end_date' => ]. mapping
891 *
892 * @param array $subscriptions_from_bd List of subscriptions from BD.
893 *
894 * @return array<int, array>
895 */
896 public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
897
898 if ( empty( $subscriptions_from_bd ) ) {
899 return array();
900 }
901
902 $subscriptions = array();
903 foreach ( $subscriptions_from_bd as $subscription ) {
904 // We are picking the expiry date that is the most in the future.
905 if (
906 'active' === $subscription['status'] && (
907 ! isset( $subscriptions[ $subscription['product_id'] ] ) ||
908 empty( $subscription['end_date'] ) || // Special condition when subscription has no expiry date - we will default to a year from now for the purposes of the token.
909 strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
910 )
911 ) {
912 $subscriptions[ $subscription['product_id'] ] = new \stdClass();
913 $subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
914 if ( ! empty( $subscription['is_comp'] ) ) {
915 $subscriptions[ $subscription['product_id'] ]->is_comp = true;
916 }
917 }
918 }
919 return $subscriptions;
920 }
921 }
922