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 / jetpack_vendor / automattic / jetpack-comments / src / identity / checkpoint / class-checkpoint.php

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

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