PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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 / jetpack_vendor / automattic / jetpack-comments / src / identity / checkpoint / class-checkpoint.php

class-checkpoint.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at jetpack_vendor/automattic/jetpack-comments/src/identity/checkpoint/class-checkpoint.php

417 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The checkpoint: where a commenter signed in through WordPress.com is admitted.
4 *
5 * @package automattic/jetpack-comments
6 */
7
8 namespace Automattic\Jetpack\Comments;
9
10 use Automattic\Jetpack\Connection\Client;
11 use Automattic\Jetpack\Connection\Manager;
12 use Automattic\Jetpack\Connection\Tokens;
13 use WP_Error;
14
15 /**
16 * Signs the popup URL, redeems the code it hands back, and attributes the comment.
17 *
18 * The WordPress.com half is the Consulate in wpcom's lib/comment-identity.
19 */
20 class Checkpoint {
21
22 /**
23 * The popup, which mints a one-time code.
24 */
25 const CONNECT_URL = 'https://public-api.wordpress.com/connect/';
26
27 /**
28 * The only origin a result is accepted from.
29 */
30 const MESSAGE_ORIGIN = 'https://public-api.wordpress.com';
31
32 /**
33 * Providers the popup can sign in with, in display order.
34 */
35 const PROVIDERS = array( 'wordpress', 'google', 'facebook' );
36
37 /**
38 * POST field carrying the code the popup handed back.
39 */
40 const CODE_FIELD = 'jetpack_comment_identity_code';
41
42 /**
43 * POST field the form sends when it rendered as signed in on the passport.
44 * Without it the passport is left alone, so a reader whose log-out never
45 * reached the server still posts as the guest the form showed them as.
46 */
47 const PASSPORT_FIELD = 'jetpack_comment_identity_passport';
48
49 /**
50 * How long a signed popup URL stays good. WordPress.com rejects an expiry
51 * past ten minutes out, so a minute is left for clock skew.
52 */
53 const SIGNATURE_TTL = 9 * MINUTE_IN_SECONDS;
54
55 /**
56 * Comment meta: the opaque per-site id WordPress.com derives for the commenter.
57 */
58 const META_ID = 'jetpack_comment_identity_id';
59
60 /**
61 * Comment meta: which provider they signed in with.
62 */
63 const META_PROVIDER = 'jetpack_comment_identity_provider';
64
65 /**
66 * Comment meta: the avatar the provider gave.
67 */
68 const META_AVATAR = 'jetpack_comment_identity_avatar';
69
70 /**
71 * Singleton instance.
72 *
73 * @var Checkpoint|null
74 */
75 private static $instance = null;
76
77 /**
78 * Whether a signing key exists, memoized per request.
79 *
80 * @var bool|null
81 */
82 private static $available = null;
83
84 /**
85 * The identity admitted for the comment being posted now.
86 *
87 * @var array|null
88 */
89 private $identity = null;
90
91 /**
92 * Register the hooks. Safe to call more than once.
93 *
94 * @return Checkpoint
95 */
96 public static function init() {
97 if ( null === self::$instance ) {
98 self::$instance = new self();
99 }
100
101 return self::$instance;
102 }
103
104 /**
105 * Hook in around core's comment handling.
106 */
107 private function __construct() {
108 // After Comment_Form::verify_nonce() at 10, so an unsigned post never reaches the exchange.
109 add_action( 'pre_comment_on_post', array( $this, 'admit' ), 20 );
110 add_filter( 'preprocess_comment', array( $this, 'attribute' ), 0 );
111 add_action( 'comment_post', array( $this, 'record' ) );
112
113 Checkpoint_Endpoint::init();
114 }
115
116 /**
117 * Whether this site can sign a popup URL.
118 *
119 * @return bool
120 */
121 public static function is_available() {
122 if ( null !== self::$available ) {
123 return self::$available;
124 }
125
126 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
127 self::$available = file_exists( WP_CONTENT_DIR . '/lib/comment-identity/class-consulate.php' );
128 } else {
129 $token = ( new Tokens() )->get_access_token();
130 self::$available = $token && ! is_wp_error( $token ) && ! empty( $token->secret );
131 }
132
133 return self::$available;
134 }
135
136 /**
137 * Whether a challenge has the shape the popup echoes back.
138 *
139 * @param mixed $challenge The value to check.
140 * @return bool
141 */
142 public static function is_challenge( $challenge ) {
143 return is_string( $challenge ) && 1 === preg_match( '/^[A-Za-z0-9_-]{32,512}\z/', $challenge );
144 }
145
146 /**
147 * A signed popup URL for one provider.
148 *
149 * A Jetpack or Atomic site proves itself with its blog token. On Simple the
150 * code is already running inside WordPress.com, so the Consulate signs with
151 * the key it will verify against.
152 *
153 * @param string $provider One of PROVIDERS.
154 * @param string $challenge The challenge to sign.
155 * @return array|WP_Error url, expires, challenge.
156 */
157 public static function connect_url( $provider, $challenge ) {
158 if ( ! in_array( $provider, self::PROVIDERS, true ) || ! self::is_challenge( $challenge ) ) {
159 return new WP_Error( 'invalid_request', __( 'Invalid request.', 'jetpack-comments' ), array( 'status' => 400 ) );
160 }
161
162 if ( ! self::is_available() ) {
163 return new WP_Error( 'unavailable', __( 'Sign-in is not available on this site.', 'jetpack-comments' ), array( 'status' => 503 ) );
164 }
165
166 // Scheme, host and port of the page the popup posts back to.
167 $home = wp_parse_url( home_url() );
168 $origin = ( $home['scheme'] ?? 'https' ) . '://' . ( $home['host'] ?? '' ) . ( empty( $home['port'] ) ? '' : ':' . $home['port'] );
169
170 $params = array(
171 'blog_id' => self::blog_id(),
172 'provider' => $provider,
173 'challenge' => $challenge,
174 'origin' => $origin,
175 'expires' => time() + self::SIGNATURE_TTL,
176 );
177
178 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
179 require_once WP_CONTENT_DIR . '/lib/comment-identity/class-consulate.php';
180
181 // @phan-suppress-next-line PhanUndeclaredClassMethod -- wpcom-only; add to stub-defs.php when the wpcom half lands.
182 $signature = \Automattic\Comment_Identity\Consulate::sign( $params );
183 } else {
184 $signature = hash_hmac( 'sha256', self::signing_payload( $params ), ( new Tokens() )->get_access_token()->secret );
185 }
186
187 $query = array_merge( array( 'comment_identity' => 1 ), $params, array( 'signature' => $signature ) );
188
189 return array(
190 'url' => self::CONNECT_URL . '?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ),
191 'expires' => $params['expires'],
192 'challenge' => $challenge,
193 );
194 }
195
196 /**
197 * The string a signature is taken over: key=value lines, sorted by key.
198 *
199 * Must match Consulate::signing_payload() on WordPress.com.
200 *
201 * @param array $params blog_id, challenge, expires, origin, provider.
202 * @return string
203 */
204 public static function signing_payload( array $params ) {
205 $signed = array(
206 'blog_id' => (string) (int) ( $params['blog_id'] ?? 0 ),
207 'challenge' => (string) ( $params['challenge'] ?? '' ),
208 'expires' => (string) (int) ( $params['expires'] ?? 0 ),
209 'origin' => (string) ( $params['origin'] ?? '' ),
210 'provider' => (string) ( $params['provider'] ?? '' ),
211 );
212
213 ksort( $signed );
214
215 $lines = array();
216 foreach ( $signed as $key => $value ) {
217 $lines[] = $key . '=' . $value;
218 }
219
220 return implode( "\n", $lines );
221 }
222
223 /**
224 * The site's id on WordPress.com.
225 *
226 * @return int
227 */
228 public static function blog_id() {
229 return (int) Manager::get_site_id( true );
230 }
231
232 /**
233 * Redeem a code with WordPress.com.
234 *
235 * @param string $code The code the popup handed back.
236 * @return array|WP_Error site_commenter_id, provider, name, email, avatar, expires_at.
237 */
238 public static function exchange( $code ) {
239 $response = Client::wpcom_json_api_request_as_blog(
240 sprintf( '/sites/%d/comments/identity/exchange', self::blog_id() ),
241 '2',
242 array(
243 'method' => 'POST',
244 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
245 'timeout' => 10,
246 ),
247 (string) wp_json_encode( array( 'code' => (string) $code ), JSON_UNESCAPED_SLASHES ),
248 'wpcom'
249 );
250
251 $known = array( 'invalid_code', 'blog_mismatch', 'code_used', 'code_expired', 'rate_limited', 'server_error' );
252
253 if ( is_wp_error( $response ) ) {
254 $data = (array) $response->get_error_data();
255 $code = in_array( $response->get_error_code(), $known, true ) ? $response->get_error_code() : 'server_error';
256
257 return new WP_Error( $code, $response->get_error_message(), array( 'status' => (int) ( $data['status'] ?? 500 ) ) );
258 }
259
260 $status = (int) wp_remote_retrieve_response_code( $response );
261 $body = json_decode( wp_remote_retrieve_body( $response ), true );
262
263 if ( 200 === $status && is_array( $body ) && ! empty( $body['site_commenter_id'] ) && ! empty( $body['provider'] ) ) {
264 return array(
265 'site_commenter_id' => sanitize_text_field( (string) $body['site_commenter_id'] ),
266 'provider' => sanitize_key( (string) $body['provider'] ),
267 'name' => sanitize_text_field( (string) ( $body['name'] ?? '' ) ),
268 'email' => sanitize_email( (string) ( $body['email'] ?? '' ) ),
269 'avatar' => esc_url_raw( (string) ( $body['avatar'] ?? '' ) ),
270 'expires_at' => (int) ( $body['expires_at'] ?? 0 ),
271 );
272 }
273
274 $error = is_array( $body ) && isset( $body['code'] ) && in_array( $body['code'], $known, true ) ? $body['code'] : 'server_error';
275
276 return new WP_Error(
277 $error,
278 is_array( $body ) && ! empty( $body['message'] ) ? (string) $body['message'] : '',
279 array(
280 'status' => $status ? $status : 500,
281 'retry_after' => (int) wp_remote_retrieve_header( $response, 'retry-after' ),
282 )
283 );
284 }
285
286 /**
287 * Admit the commenter, from the code they posted or the passport they carry.
288 *
289 * @param int $comment_post_id The post being commented on.
290 * @return void
291 */
292 public function admit( $comment_post_id = 0 ) {
293 if ( ! Comment_Form::enabled_for_post_type( $comment_post_id ) || is_user_logged_in() ) {
294 return;
295 }
296
297 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Comment_Form::verify_nonce() ran at priority 10.
298 $code = isset( $_POST[ self::CODE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::CODE_FIELD ] ) ) : '';
299 $on_passport = ! empty( $_POST[ self::PASSPORT_FIELD ] );
300 // phpcs:enable WordPress.Security.NonceVerification.Missing
301
302 if ( '' !== $code ) {
303 $identity = self::exchange( $code );
304
305 if ( is_wp_error( $identity ) ) {
306 self::refuse( $identity );
307 }
308
309 Passport::issue( $identity );
310 } elseif ( $on_passport ) {
311 $identity = Passport::read();
312
313 // The form showed a name the passport no longer backs, say after a
314 // log-out in another tab. Refusing beats publishing as a guest.
315 if ( null === $identity ) {
316 self::refuse( new WP_Error( 'code_expired', '', array( 'status' => 403 ) ) );
317 }
318 } else {
319 return;
320 }
321
322 $this->identity = $identity;
323
324 // A signed-in commenter counts as registered, and has given a name and email.
325 add_filter( 'pre_option_comment_registration', '__return_zero' );
326 add_filter( 'pre_option_require_name_email', '__return_zero' );
327 }
328
329 /**
330 * Turn the comment away. Does not return.
331 *
332 * @param WP_Error $error From exchange().
333 * @return void
334 */
335 private static function refuse( WP_Error $error ) {
336 $data = (array) $error->get_error_data();
337 $status = (int) ( $data['status'] ?? 500 );
338
339 switch ( $error->get_error_code() ) {
340 case 'code_expired':
341 case 'code_used':
342 // The sign-in is spent; nothing here can be reused.
343 Passport::revoke();
344 $message = __( 'Your sign-in has expired. Go back and sign in again to leave your comment.', 'jetpack-comments' );
345 $status = 403;
346 break;
347
348 case 'rate_limited':
349 $message = __( 'Too many sign-in attempts right now. Go back and try again in a moment.', 'jetpack-comments' );
350 if ( ! empty( $data['retry_after'] ) ) {
351 header( 'Retry-After: ' . (int) $data['retry_after'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- an integer header value.
352 }
353 break;
354
355 case 'invalid_code':
356 case 'blog_mismatch':
357 $message = __( 'Your sign-in could not be verified. Go back and sign in again to leave your comment.', 'jetpack-comments' );
358 break;
359
360 default:
361 $message = __( 'Sign-in is unavailable right now. Go back and try again in a moment.', 'jetpack-comments' );
362 break;
363 }
364
365 wp_die(
366 esc_html( $message ),
367 esc_html__( 'Comment Submission Failure', 'jetpack-comments' ),
368 array(
369 'response' => absint( $status ),
370 'back_link' => true,
371 )
372 );
373 }
374
375 /**
376 * Attribute the comment to the admitted identity.
377 *
378 * @param array $commentdata Comment data.
379 * @return array
380 */
381 public function attribute( $commentdata ) {
382 if ( null === $this->identity ) {
383 return $commentdata;
384 }
385
386 $commentdata['comment_author'] = $this->identity['name'];
387 $commentdata['comment_author_email'] = $this->identity['email'];
388 $commentdata['comment_author_url'] = '';
389 $commentdata['user_id'] = 0;
390 $commentdata['user_ID'] = 0;
391
392 return $commentdata;
393 }
394
395 /**
396 * Record who left the comment, for the avatar and for moderation.
397 *
398 * Reads the identity admitted on this request rather than $_POST, so any
399 * other producer reaching comment_post writes nothing here.
400 *
401 * @param int $comment_id The comment ID.
402 * @return void
403 */
404 public function record( $comment_id ) {
405 if ( null === $this->identity ) {
406 return;
407 }
408
409 add_comment_meta( $comment_id, self::META_ID, $this->identity['site_commenter_id'], true );
410 add_comment_meta( $comment_id, self::META_PROVIDER, $this->identity['provider'], true );
411
412 if ( '' !== $this->identity['avatar'] ) {
413 add_comment_meta( $comment_id, self::META_AVATAR, $this->identity['avatar'], true );
414 }
415 }
416 }
417