PluginProbe
WooCommerce / 11.1.0-beta.1
WooCommerce v11.1.0-beta.1
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 All 648 releases
woocommerce / src / Admin / API / MobileAppQRLogin.php
MobileAppQRLogin.php
2,092 lines 74.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API Mobile App QR Login controller.
4 *
5 * Handles requests to generate and exchange QR login tokens for direct mobile
6 * app authentication via Application Passwords. Token generation is gated on
7 * the `manage_woocommerce` capability (administrators and shop managers by
8 * default).
9 */
10
11 declare( strict_types=1 );
12
13 namespace Automattic\WooCommerce\Admin\API;
14
15 use Automattic\WooCommerce\Admin\API\RateLimits\QRLoginRateLimits;
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Mobile App QR Login controller.
21 *
22 * @internal
23 */
24 class MobileAppQRLogin extends \WC_REST_Data_Controller {
25
26 /**
27 * Endpoint namespace.
28 *
29 * @var string
30 */
31 protected $namespace = 'wc-admin';
32
33 /**
34 * Route base.
35 *
36 * @var string
37 */
38 protected $rest_base = 'mobile-app';
39
40 /**
41 * Token TTL in seconds (5 minutes).
42 */
43 const TOKEN_TTL = 300;
44
45 /**
46 * Transient prefix for QR login tokens.
47 */
48 const TOKEN_TRANSIENT_PREFIX = '_wc_qr_login_token_';
49
50 /**
51 * Max tokens per user per 15-minute window.
52 */
53 const MAX_TOKENS_PER_WINDOW = 5;
54
55 /**
56 * Max exchange attempts per valid token per 15-minute window.
57 */
58 const MAX_EXCHANGE_ATTEMPTS = 10;
59
60 /**
61 * Max invalid-token exchange attempts per IP per 15-minute window.
62 */
63 const MAX_INVALID_EXCHANGE_ATTEMPTS = 100;
64
65 /**
66 * Max invalid-token scan attempts per IP per 15-minute window.
67 */
68 const MAX_INVALID_SCAN_ATTEMPTS = 100;
69
70 /**
71 * Broad anonymous exchange abuse guard per IP per 15-minute window.
72 */
73 const MAX_EXCHANGE_IP_ATTEMPTS = 1000;
74
75 /**
76 * Option prefix for database-backed atomic token claims.
77 */
78 const CLAIM_OPTION_PREFIX = '_wc_qr_login_claim_';
79
80 /**
81 * Scan-claim option prefix. Independent from `CLAIM_OPTION_PREFIX` so the
82 * scan and exchange mutexes can't deadlock each other; they protect
83 * different write windows on the same token record.
84 */
85 const SCAN_CLAIM_OPTION_PREFIX = '_wc_qr_login_scan_claim_';
86
87 /**
88 * Approval-claim option prefix. Prevents concurrent number choices from
89 * racing the one-strike scanned -> approved/rejected transition.
90 */
91 const APPROVE_CLAIM_OPTION_PREFIX = '_wc_qr_login_approve_claim_';
92
93 /**
94 * Stable Application Passwords `app_id` for credentials issued by this
95 * flow. Lets administrators identify QR-issued credentials in the
96 * Application Passwords screen and revoke them in bulk.
97 */
98 const APP_ID = '0b540e2f-86b7-4b8a-8e0c-f61e9bfbde59';
99
100 /**
101 * Transient prefix for the "token consumed" record written after a successful
102 * exchange. The wc-admin UI polls a status endpoint that reads this so it can
103 * transition to a confirmation panel and surface the device that signed in.
104 */
105 const CONSUMED_TRANSIENT_PREFIX = '_wc_qr_login_consumed_';
106
107 /**
108 * Max status checks per user per 15-minute window. The polling client hits
109 * this every ~2.5s while a QR is on screen; 600/15min ≈ 40/min, comfortably
110 * above the polling rate but tight enough to short-circuit a misbehaving
111 * client or a credential-stuffing scan.
112 */
113 const MAX_STATUS_CHECKS_PER_WINDOW = 600;
114
115 /**
116 * Max revoke attempts per user per 15-minute window.
117 */
118 const MAX_REVOKE_ATTEMPTS = 10;
119
120 /**
121 * Whitelisted keys for the `device` payload sent by the mobile app on the
122 * scan call. Anything outside this set is dropped before storage.
123 *
124 * `brand` is Android-only (`Build.BRAND`, e.g. "google", "samsung"); iOS
125 * doesn't have a direct analogue and clients that don't have the field
126 * just leave it absent.
127 *
128 * @var string[]
129 */
130 const DEVICE_PAYLOAD_KEYS = array( 'os', 'os_version', 'model', 'brand', 'app_version' );
131
132 /**
133 * Maximum length (chars) for any individual sanitized device-payload field.
134 * Defends against accidental or hostile bloat ending up in transients and
135 * the Application Password name.
136 */
137 const DEVICE_FIELD_MAX_LENGTH = 64;
138
139 /**
140 * State machine values for the per-token record. Transitions are gated
141 * by an explicit `current_state` check at the top of each handler
142 * (scan/approve/exchange) so the only writers are the handlers themselves.
143 */
144 const STATE_PENDING = 'pending';
145 const STATE_SCANNED = 'scanned';
146 const STATE_APPROVED = 'approved';
147 const STATE_REJECTED = 'rejected';
148 const STATE_EXPIRED = 'expired';
149 const STATE_CONSUMED = 'consumed';
150
151 /**
152 * Pick window after the app scans a QR (seconds). The merchant has this
153 * long to tap the matching number on wc-admin before the session
154 * auto-rejects. Short enough to limit replay; long enough for a confused
155 * user to read the phone, find their browser, and click.
156 */
157 const CHALLENGE_TTL_SECONDS = 90;
158
159 /**
160 * Length (bytes pre-bin2hex) of the exchange-grant nonce minted on
161 * approval. The grant gates the final `/qr-login-exchange` call so an
162 * attacker who learned the token can't race the legit app to exchange
163 * after approval. 32 bytes = 64 hex chars = 256 bits of entropy.
164 */
165 const EXCHANGE_GRANT_BYTES = 32;
166
167 /**
168 * Invalid exchange grants allowed before the token is terminally rejected.
169 */
170 const MAX_INVALID_GRANT_ATTEMPTS = 3;
171
172 /**
173 * Transient prefix mapping `session_id` → `token_hash` so the mobile-side
174 * `/qr-login-session-status` poll can resolve a session id back to the
175 * underlying token record without exposing the original token to the
176 * polling channel.
177 */
178 const SESSION_TRANSIENT_PREFIX = '_wc_qr_login_session_';
179
180 /**
181 * Rate limit for /qr-login-scan (per IP per 15 min).
182 */
183 const MAX_SCAN_PER_WINDOW = 10;
184
185 /**
186 * Rate limit for /qr-login-approve (per user per 15 min).
187 */
188 const MAX_APPROVE_PER_WINDOW = 20;
189
190 /**
191 * Rate limit for /qr-login-session-status (per session id per 15 min).
192 *
193 * Accounts for ~2-s polling over a 90-s challenge window plus headroom.
194 */
195 const MAX_SESSION_STATUS_PER_WINDOW = 60;
196
197 /**
198 * Register routes.
199 *
200 * @return void
201 */
202 public function register_routes() {
203 // Generate a QR login token (requires authentication and `manage_woocommerce` capability).
204 register_rest_route(
205 $this->namespace,
206 '/' . $this->rest_base . '/qr-login-token',
207 array(
208 array(
209 'methods' => \WP_REST_Server::CREATABLE,
210 'callback' => array( $this, 'generate_token' ),
211 'permission_callback' => array( $this, 'get_items_permissions_check' ),
212 ),
213 'schema' => array( $this, 'get_public_item_schema' ),
214 )
215 );
216
217 // Exchange a QR login token for Application Password (no authentication required).
218 // The device payload is captured at /qr-login-scan time and sourced from the
219 // approved record — the exchange call only needs the token + grant nonce.
220 register_rest_route(
221 $this->namespace,
222 '/' . $this->rest_base . '/qr-login-exchange',
223 array(
224 array(
225 'methods' => \WP_REST_Server::CREATABLE,
226 'callback' => array( $this, 'exchange_token' ),
227 'permission_callback' => '__return_true',
228 'args' => array(
229 'token' => array(
230 'required' => true,
231 'type' => 'string',
232 'sanitize_callback' => 'sanitize_text_field',
233 ),
234 // Soft-required: the handler enforces presence + validity
235 // via constant-time comparison and returns a clear
236 // `invalid_exchange_grant` 412 if missing. We don't make
237 // it `required: true` at the schema layer because that
238 // would short-circuit earlier checks (HTTPS, invalid
239 // token, rate limit) with a generic WP validation 400
240 // before our diagnostic responses can fire.
241 'exchange_grant' => array(
242 'type' => 'string',
243 'sanitize_callback' => 'sanitize_text_field',
244 ),
245 ),
246 ),
247 'schema' => array( $this, 'get_public_item_schema' ),
248 )
249 );
250
251 // Poll for token status (consumed yet?). Used by wc-admin to transition
252 // the modal from "QR shown" to "Signed in successfully on {device}".
253 register_rest_route(
254 $this->namespace,
255 '/' . $this->rest_base . '/qr-login-status',
256 array(
257 array(
258 'methods' => \WP_REST_Server::CREATABLE,
259 'callback' => array( $this, 'get_status' ),
260 'permission_callback' => array( $this, 'get_items_permissions_check' ),
261 'args' => array(
262 'token' => array(
263 'required' => true,
264 'type' => 'string',
265 'sanitize_callback' => 'sanitize_text_field',
266 ),
267 ),
268 ),
269 'schema' => array( $this, 'get_public_item_schema' ),
270 )
271 );
272
273 // Revoke (delete) the Application Password issued by an exchange. The
274 // user must own the AP — verified inside the callback via the WP API.
275 register_rest_route(
276 $this->namespace,
277 '/' . $this->rest_base . '/qr-login-revoke',
278 array(
279 array(
280 'methods' => \WP_REST_Server::DELETABLE,
281 'callback' => array( $this, 'revoke_password' ),
282 'permission_callback' => array( $this, 'get_items_permissions_check' ),
283 'args' => array(
284 'uuid' => array(
285 'required' => true,
286 'type' => 'string',
287 'sanitize_callback' => 'sanitize_text_field',
288 ),
289 ),
290 ),
291 'schema' => array( $this, 'get_public_item_schema' ),
292 )
293 );
294
295 // Mobile app reports the QR was scanned. Server generates the
296 // number-match challenge and returns the *real* number to the app
297 // only. Public — token + capability flag are the auth.
298 register_rest_route(
299 $this->namespace,
300 '/' . $this->rest_base . '/qr-login-scan',
301 array(
302 array(
303 'methods' => \WP_REST_Server::CREATABLE,
304 'callback' => array( $this, 'scan_token' ),
305 'permission_callback' => '__return_true',
306 'args' => array(
307 'token' => array(
308 'required' => true,
309 'type' => 'string',
310 'sanitize_callback' => 'sanitize_text_field',
311 ),
312 // The device payload is required: it shows up in the
313 // merchant's "match this number" device card, in the
314 // Application Password name, and in the sign-in
315 // notification email. Mobile clients always have these
316 // fields available from the platform SDK (Build.MODEL,
317 // UIDevice.current.model, etc.), so requiring the
318 // payload at the protocol level keeps every downstream
319 // surface honest.
320 'device' => array(
321 'required' => true,
322 'type' => 'object',
323 'properties' => array(
324 'os' => array( 'type' => 'string' ),
325 'os_version' => array( 'type' => 'string' ),
326 'model' => array( 'type' => 'string' ),
327 'brand' => array( 'type' => 'string' ),
328 'app_version' => array( 'type' => 'string' ),
329 ),
330 ),
331 // Capability flag the mobile app sets to advertise that
332 // it implements the number-matching protocol. Reserved
333 // for future protocol bumps that might gate behavior on
334 // further capability bits.
335 'supports_number_matching' => array(
336 'required' => true,
337 'type' => 'boolean',
338 ),
339 ),
340 ),
341 'schema' => array( $this, 'get_public_item_schema' ),
342 )
343 );
344
345 // Merchant taps a number on wc-admin. Server validates against the
346 // stored real number with hash_equals; correct → approved, wrong →
347 // rejected (terminal, no retry).
348 register_rest_route(
349 $this->namespace,
350 '/' . $this->rest_base . '/qr-login-approve',
351 array(
352 array(
353 'methods' => \WP_REST_Server::CREATABLE,
354 'callback' => array( $this, 'approve_token' ),
355 'permission_callback' => array( $this, 'get_items_permissions_check' ),
356 'args' => array(
357 'token' => array(
358 'required' => true,
359 'type' => 'string',
360 'sanitize_callback' => 'sanitize_text_field',
361 ),
362 'choice' => array(
363 'required' => true,
364 'type' => 'string',
365 'sanitize_callback' => 'sanitize_text_field',
366 ),
367 ),
368 ),
369 'schema' => array( $this, 'get_public_item_schema' ),
370 )
371 );
372
373 // Mobile app polls this with the session id returned from /scan.
374 // While in `scanned` we say so; on `approved` we hand over the
375 // short-lived `exchange_grant` nonce required by the final
376 // /qr-login-exchange call.
377 register_rest_route(
378 $this->namespace,
379 '/' . $this->rest_base . '/qr-login-session-status',
380 array(
381 array(
382 'methods' => \WP_REST_Server::READABLE,
383 'callback' => array( $this, 'get_session_status' ),
384 'permission_callback' => '__return_true',
385 'args' => array(
386 'session_id' => array(
387 'required' => true,
388 'type' => 'string',
389 'sanitize_callback' => 'sanitize_text_field',
390 ),
391 'token_hash' => array(
392 'type' => 'string',
393 'sanitize_callback' => 'sanitize_text_field',
394 ),
395 ),
396 ),
397 'schema' => array( $this, 'get_public_item_schema' ),
398 )
399 );
400
401 // Cheap up-front capability check so wc-admin can render a permanently
402 // disabled QR card (rather than spin up a token request that will fail)
403 // when application passwords are unavailable on this site. Same gate as
404 // the token endpoint so a subscriber cannot probe site configuration.
405 register_rest_route(
406 $this->namespace,
407 '/' . $this->rest_base . '/qr-login-availability',
408 array(
409 array(
410 'methods' => \WP_REST_Server::READABLE,
411 'callback' => array( $this, 'get_availability' ),
412 'permission_callback' => array( $this, 'get_items_permissions_check' ),
413 ),
414 'schema' => array( $this, 'get_public_item_schema' ),
415 )
416 );
417
418 parent::register_routes();
419 }
420
421 /**
422 * Check whether the current user can generate a QR login token.
423 *
424 * Requires the `manage_woocommerce` capability, which covers administrators and
425 * shop managers out of the box. The check is deliberately explicit (not routed
426 * through `wc_rest_check_manager_permissions()`) so it cannot be loosened by the
427 * `woocommerce_rest_check_permissions` filter that other Admin API endpoints share.
428 *
429 * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
430 * @return \WP_Error|bool True if the user has the required capability, WP_Error otherwise.
431 */
432 public function get_items_permissions_check( $request ) {
433 unset( $request );
434 // Parameter required by WP REST contract but unused here.
435
436 if ( ! current_user_can( 'manage_woocommerce' ) ) {
437 return new \WP_Error(
438 'woocommerce_rest_cannot_view',
439 __( 'Sorry, you are not allowed to generate a mobile app QR login token.', 'woocommerce' ),
440 array( 'status' => rest_authorization_required_code() )
441 );
442 }
443
444 return true;
445 }
446
447 /**
448 * Check if Application Passwords are available.
449 *
450 * @return bool
451 */
452 private function are_application_passwords_available() {
453 return function_exists( 'wp_is_application_passwords_available' )
454 && wp_is_application_passwords_available();
455 }
456
457 /**
458 * Return a REST response carrying WordPress' no-cache headers.
459 *
460 * @param array<string, mixed> $data Response payload.
461 * @return \WP_REST_Response
462 */
463 private function rest_ensure_nocache_response( array $data ): \WP_REST_Response {
464 $response = rest_ensure_response( $data );
465
466 foreach ( wp_get_nocache_headers() as $header_name => $header_value ) {
467 if ( false === $header_value ) {
468 continue;
469 }
470
471 $response->header( $header_name, (string) $header_value );
472 }
473
474 return $response;
475 }
476
477 /**
478 * Reason codes returned by `/qr-login-availability` so wc-admin can
479 * tailor the disabled card to the specific cause.
480 */
481 const AVAILABILITY_REASON_HTTPS_REQUIRED = 'https_required';
482 const AVAILABILITY_REASON_AP_UNSUPPORTED = 'application_passwords_unsupported';
483 const AVAILABILITY_REASON_AP_DISABLED_BY_FILTER = 'application_passwords_disabled_by_filter';
484
485 /**
486 * Report whether QR login is currently available on this site.
487 *
488 * Lets wc-admin render a permanently-disabled QR card with the right
489 * explanation up-front, instead of mounting `<QRDirectLoginCode />`,
490 * spinning, calling `/qr-login-token`, and only then showing a generic
491 * error. The reason code is the heuristic best we can do without each
492 * security plugin self-identifying:
493 *
494 * - `https_required` — `is_ssl()` is false or the raw/final `siteurl` is
495 * `http://`. The most common cause is a local dev environment;
496 * production sites without HTTPS can't use QR login at all.
497 * - `application_passwords_unsupported` — WordPress core's own support
498 * gate (`wp_is_application_passwords_supported()`) returns false.
499 * Ships true on every modern WP host that has SSL or is local; false
500 * here typically means the site is non-local + non-SSL.
501 * - `application_passwords_disabled_by_filter` — the WP support gate
502 * passes, but the `wp_is_application_passwords_available` filter
503 * returns false. This is the case where a security plugin (Wordfence,
504 * Solid Security, etc.) or a custom code snippet has explicitly
505 * disabled application passwords. We can't name the exact source from
506 * the filter alone; the docs link in the merchant-facing UI covers it.
507 *
508 * `nocache_headers()` so an upstream cache cannot pin a stale
509 * "unavailable" response for a site that just installed an HTTPS cert.
510 *
511 * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
512 * @return \WP_REST_Response
513 */
514 public function get_availability( $request ): \WP_REST_Response {
515 unset( $request );
516
517 nocache_headers();
518
519 $site_url = $this->get_secure_site_url();
520 $https_ok = ! is_wp_error( $site_url );
521 $ap_supported = function_exists( 'wp_is_application_passwords_supported' )
522 && wp_is_application_passwords_supported();
523 $ap_available = $this->are_application_passwords_available();
524
525 $https_ok = is_ssl() && $https_ok;
526 $available = $https_ok && $ap_available;
527 $reason = null;
528
529 if ( ! $available ) {
530 if ( ! $https_ok ) {
531 $reason = self::AVAILABILITY_REASON_HTTPS_REQUIRED;
532 } elseif ( ! $ap_supported ) {
533 $reason = self::AVAILABILITY_REASON_AP_UNSUPPORTED;
534 } else {
535 $reason = self::AVAILABILITY_REASON_AP_DISABLED_BY_FILTER;
536 }
537 }
538
539 return $this->rest_ensure_nocache_response(
540 array(
541 'available' => $available,
542 'reason' => $reason,
543 )
544 );
545 }
546
547 /**
548 * Check rate limit for token generation.
549 *
550 * @param int $user_id The user ID.
551 * @return bool True if within rate limit.
552 */
553 private function check_generation_rate_limit( $user_id ) {
554 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_GENERATION, (string) $user_id );
555 }
556
557 /**
558 * Broad anonymous abuse guard for token exchange.
559 *
560 * This intentionally has a high ceiling. It is only meant to slow obvious
561 * unauthenticated floods; valid-token and invalid-token traffic use separate
562 * lower buckets so a few random requests from a shared proxy IP cannot block
563 * legitimate QR login exchanges.
564 *
565 * @return bool True if within rate limit.
566 */
567 private function check_exchange_ip_rate_limit() {
568 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_EXCHANGE_IP, $this->get_client_ip() );
569 }
570
571 /**
572 * Check rate limit for random/nonexistent exchange tokens.
573 *
574 * @return bool True if within rate limit.
575 */
576 private function check_invalid_exchange_rate_limit() {
577 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_EXCHANGE, $this->get_client_ip() );
578 }
579
580 /**
581 * Check rate limit for random/nonexistent scan tokens.
582 *
583 * @return bool True if within rate limit.
584 */
585 private function check_invalid_scan_rate_limit() {
586 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_SCAN, $this->get_client_ip() );
587 }
588
589 /**
590 * Check rate limit for exchange attempts against a valid token.
591 *
592 * @param string $token_hash SHA-256 hash of the plaintext token.
593 * @return bool True if within rate limit.
594 */
595 private function check_valid_exchange_rate_limit( $token_hash ) {
596 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_VALID_EXCHANGE, $token_hash );
597 }
598
599 /**
600 * Get the client IP address used as the per-IP rate-limit key.
601 *
602 * Uses `REMOTE_ADDR` exclusively. We intentionally do not honor
603 * `HTTP_X_FORWARDED_FOR` here: the exchange endpoint is unauthenticated, and
604 * without a project-wide trusted-proxy list we cannot tell a legitimate
605 * proxy header from an attacker-supplied one. Trusting the first XFF value
606 * would let any client choose a fresh rate-limit bucket per request and
607 * bypass per-IP caps. On sites behind a CDN/load balancer that all clients
608 * share, REMOTE_ADDR is the proxy IP, so exchange uses broad IP throttling
609 * only as an abuse guard and relies on token-scoped buckets for security.
610 *
611 * @return string
612 */
613 private function get_client_ip() {
614 if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
615 return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
616 }
617 return '';
618 }
619
620 /**
621 * Build the option name used for a token exchange claim.
622 *
623 * @param string $token_hash SHA-256 hash of the plaintext token.
624 * @return string
625 */
626 private function get_token_claim_key( $token_hash ) {
627 return self::CLAIM_OPTION_PREFIX . $token_hash;
628 }
629
630 /**
631 * Atomically claim a token for exchange using the options table.
632 *
633 * `add_option()` is backed by a unique option_name constraint, so it works
634 * across PHP workers even on default installs without a persistent object
635 * cache. Stale claims are cleaned only if their stored value still matches
636 * the value this request observed, avoiding deletion of another worker's
637 * fresh claim.
638 *
639 * @param string $token_hash SHA-256 hash of the plaintext token.
640 * @param int $expires_at Unix timestamp when the token expires.
641 * @return bool True if the claim was acquired.
642 */
643 private function claim_token_for_exchange( $token_hash, $expires_at ) {
644 return $this->claim_token_with_option_key(
645 $this->get_token_claim_key( $token_hash ),
646 $expires_at
647 );
648 }
649
650 /**
651 * Atomically claim a token using an option key.
652 *
653 * @param string $claim_key Option key used as the claim mutex.
654 * @param int $expires_at Unix timestamp when the token expires.
655 * @return bool True if the claim was acquired.
656 */
657 private function claim_token_with_option_key( $claim_key, $expires_at ) {
658 $claim_expires_at = max( time() + 30, (int) $expires_at );
659
660 if ( add_option( $claim_key, (string) $claim_expires_at, '', false ) ) {
661 return true;
662 }
663
664 $existing_expires_at = (int) get_option( $claim_key, 0 );
665 if ( $existing_expires_at > 0 && $existing_expires_at <= time() ) {
666 $this->delete_claim_if_value_matches( $claim_key, (string) $existing_expires_at );
667 return add_option( $claim_key, (string) $claim_expires_at, '', false );
668 }
669
670 return false;
671 }
672
673 /**
674 * Delete a claim option only if it still has the value this request observed.
675 *
676 * @param string $claim_key Option key used as the claim mutex.
677 * @param string $observed_claim_value Claim expiry value previously read from the option.
678 * @return bool True if the observed stale claim was deleted.
679 */
680 private function delete_claim_if_value_matches( $claim_key, $observed_claim_value ) {
681 global $wpdb;
682
683 $result = $wpdb->query(
684 $wpdb->prepare(
685 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
686 $claim_key,
687 $observed_claim_value
688 )
689 );
690
691 if ( false === $result ) {
692 wc_get_logger()->warning(
693 sprintf(
694 'QR login stale-claim cleanup query failed for %s: %s',
695 $claim_key,
696 $wpdb->last_error
697 ),
698 array( 'source' => 'mobile-app-qr-login' )
699 );
700 }
701
702 wp_cache_delete( $claim_key, 'options' );
703
704 return (int) $result > 0;
705 }
706
707 /**
708 * Release a token exchange claim.
709 *
710 * @param string $token_hash SHA-256 hash of the plaintext token.
711 * @return void
712 */
713 private function release_token_exchange_claim( $token_hash ) {
714 delete_option( $this->get_token_claim_key( $token_hash ) );
715 }
716
717 /**
718 * Atomically claim a token for scan. Mirrors `claim_token_for_exchange()`
719 * (same `add_option()` unique-constraint mutex, same staleness recovery)
720 * but uses its own option key so the scan and exchange windows are
721 * independent.
722 *
723 * Without this, two concurrent `/qr-login-scan` requests both pass the
724 * `state === pending` gate, both write a fresh challenge, and the last
725 * writer wins — leaving the loser's session_id silently orphaned and
726 * pointing at the wrong challenge.
727 *
728 * @param string $token_hash SHA-256 hash of the plaintext token.
729 * @param int $expires_at Unix timestamp when the token expires.
730 * @return bool True if the claim was acquired.
731 */
732 private function claim_token_for_scan( $token_hash, $expires_at ) {
733 return $this->claim_token_with_option_key(
734 self::SCAN_CLAIM_OPTION_PREFIX . $token_hash,
735 $expires_at
736 );
737 }
738
739 /**
740 * Release a token scan claim owned by this request.
741 *
742 * @param string $token_hash SHA-256 hash of the plaintext token.
743 * @return void
744 */
745 private function release_token_scan_claim( $token_hash ) {
746 delete_option( self::SCAN_CLAIM_OPTION_PREFIX . $token_hash );
747 }
748
749 /**
750 * Atomically claim a token for approval.
751 *
752 * @param string $token_hash SHA-256 hash of the plaintext token.
753 * @param int $expires_at Unix timestamp when the claim should expire.
754 * @return bool True if the claim was acquired.
755 */
756 private function claim_token_for_approval( $token_hash, $expires_at ) {
757 return $this->claim_token_with_option_key(
758 self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash,
759 $expires_at
760 );
761 }
762
763 /**
764 * Release a token approval claim owned by this request.
765 *
766 * @param string $token_hash SHA-256 hash of the plaintext token.
767 * @return void
768 */
769 private function release_token_approval_claim( $token_hash ) {
770 delete_option( self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash );
771 }
772
773 /**
774 * Get the remaining storage TTL for a token record.
775 *
776 * @param array<string, mixed> $token_data Token record.
777 * @return int Remaining TTL in seconds.
778 */
779 private function get_token_record_ttl( array $token_data ) {
780 return max(
781 1,
782 isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] - time() : self::TOKEN_TTL
783 );
784 }
785
786 /**
787 * Delete the session-id to token-hash mapping for a token record.
788 *
789 * @param array<string, mixed> $token_data Token record.
790 * @return void
791 */
792 private function delete_session_mapping_for_record( array $token_data ) {
793 if ( empty( $token_data['challenge']['session_id'] ) ) {
794 return;
795 }
796
797 delete_transient(
798 self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', (string) $token_data['challenge']['session_id'] )
799 );
800 }
801
802 /**
803 * Validate that the configured site URL is HTTPS and return it.
804 *
805 * `is_ssl()` only tells us the current REQUEST is HTTPS — it says nothing about
806 * the canonical site URL WordPress is configured to advertise. `get_site_url()`
807 * itself is also insufficient because it passes its result through
808 * `set_url_scheme()`, which rewrites the scheme to match `is_ssl()` — so
809 * `get_site_url()` will return `https://…` whenever the request happens to be
810 * HTTPS, masking a stale `http://` `siteurl` option underneath. We therefore
811 * check the RAW stored option, which is what reflects admin configuration
812 * and what shows up in reset-password emails, webhooks, canonical redirects,
813 * etc. If that is `http://`, a misconfigured proxy that terminated TLS before
814 * reaching PHP could still cause this endpoint to hand the mobile app a cleartext
815 * site URL for the token-exchange POST.
816 *
817 * We deliberately reject (rather than silently normalizing to `https://`)
818 * because:
819 * 1. The misconfig usually affects other things (reset-password emails,
820 * webhooks, canonical redirects). Failing loudly surfaces it.
821 * 2. Normalizing assumes the site actually serves HTTPS on the same host,
822 * which we cannot verify from within a single request.
823 * 3. A 500 is strictly safer than a leaky success.
824 *
825 * @return string|\WP_Error The HTTPS site URL, or a WP_Error if it is not HTTPS.
826 */
827 private function get_secure_site_url() {
828 // Raw option: what the admin actually configured, before `set_url_scheme()`
829 // inside `get_site_url()` normalizes it based on the current request's scheme.
830 $raw_site_url = get_option( 'siteurl' );
831 $raw_scheme = is_string( $raw_site_url ) ? wp_parse_url( $raw_site_url, PHP_URL_SCHEME ) : null;
832
833 if ( 'https' !== $raw_scheme ) {
834 return new \WP_Error(
835 'insecure_site_url',
836 __( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
837 array( 'status' => 500 )
838 );
839 }
840
841 // Use get_site_url() for the returned value so any scheme normalization
842 // or filtering that WordPress applies downstream is preserved, then
843 // validate the final value too. A plugin can still filter `site_url`
844 // after the raw option check above; never hand the mobile app an
845 // HTTP exchange target.
846 $site_url = get_site_url();
847 $final_scheme = wp_parse_url( $site_url, PHP_URL_SCHEME );
848
849 if ( 'https' !== $final_scheme ) {
850 return new \WP_Error(
851 'insecure_site_url',
852 __( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
853 array( 'status' => 500 )
854 );
855 }
856
857 return $site_url;
858 }
859
860 /**
861 * Generate a QR login token.
862 *
863 * Creates a short-lived one-time token that can be exchanged for an Application
864 * Password by the mobile app. The caller is assumed to have already passed the
865 * `manage_woocommerce` capability check in `get_items_permissions_check()`.
866 *
867 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
868 * @return \WP_REST_Response|\WP_Error
869 */
870 public function generate_token( $request ) {
871 unset( $request );
872 // Parameter required by WP REST contract but unused here.
873
874 // Check HTTPS.
875 if ( ! is_ssl() ) {
876 return new \WP_Error(
877 'ssl_required',
878 __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
879 array( 'status' => 403 )
880 );
881 }
882
883 // Verify the canonical site URL is HTTPS — is_ssl() alone is not enough
884 // when WordPress is behind a misconfigured proxy.
885 $site_url = $this->get_secure_site_url();
886 if ( is_wp_error( $site_url ) ) {
887 return $site_url;
888 }
889
890 // Check Application Passwords are available.
891 if ( ! $this->are_application_passwords_available() ) {
892 return new \WP_Error(
893 'application_passwords_unavailable',
894 __( 'Application Passwords are not available on this site.', 'woocommerce' ),
895 array( 'status' => 501 )
896 );
897 }
898
899 // Check rate limit.
900 if ( ! $this->check_generation_rate_limit( get_current_user_id() ) ) {
901 return new \WP_Error(
902 'rate_limit_exceeded',
903 __( 'Too many QR login requests. Please try again later.', 'woocommerce' ),
904 array( 'status' => 429 )
905 );
906 }
907
908 // Generate a cryptographically secure token.
909 $token = wp_generate_password( 64, false );
910 $token_hash = hash( 'sha256', $token );
911 $now = time();
912 $expires_at = $now + self::TOKEN_TTL;
913
914 // Structured state-machine record. Subsequent transitions
915 // (scan/approve/exchange) gate themselves on the current state at the
916 // top of each handler.
917 $token_data = array(
918 'state' => self::STATE_PENDING,
919 'created_at' => $now,
920 'state_at' => $now,
921 'user_id' => get_current_user_id(),
922 'site_url' => $site_url,
923 'expires_at' => $expires_at,
924 );
925
926 set_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash, $token_data, self::TOKEN_TTL );
927
928 // Build the QR URL (deep link for the mobile app).
929 $qr_url = sprintf(
930 'woocommerce://qr-login?token=%s&siteUrl=%s',
931 rawurlencode( $token ),
932 rawurlencode( $site_url )
933 );
934
935 return rest_ensure_response(
936 array(
937 'qr_url' => $qr_url,
938 'expires_at' => $expires_at,
939 'ttl' => self::TOKEN_TTL,
940 )
941 );
942 }
943
944 /**
945 * Exchange a QR login token for an Application Password.
946 *
947 * This endpoint does not require authentication — the token serves
948 * as the authentication mechanism.
949 *
950 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
951 * @return \WP_REST_Response|\WP_Error
952 */
953 public function exchange_token( $request ) {
954 // Refuse to return credentials over a non-HTTPS request.
955 if ( ! is_ssl() ) {
956 return new \WP_Error(
957 'ssl_required',
958 __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
959 array( 'status' => 403 )
960 );
961 }
962
963 // Refuse to return credentials bound to a non-HTTPS site URL — see
964 // get_secure_site_url() for rationale. A token that was minted while the
965 // siteurl was still https:// but has since been changed to http:// should
966 // also be refused here.
967 $site_url = $this->get_secure_site_url();
968 if ( is_wp_error( $site_url ) ) {
969 return $site_url;
970 }
971
972 // Defensive sanitize even though the REST `sanitize_callback` already
973 // did so — guards against future refactors that bypass the callback.
974 $token = sanitize_text_field( (string) $request->get_param( 'token' ) );
975 $token_hash = hash( 'sha256', $token );
976 $key = self::TOKEN_TRANSIENT_PREFIX . $token_hash;
977
978 $token_data = get_transient( $key );
979 if ( ! is_array( $token_data ) ) {
980 if ( ! $this->check_invalid_exchange_rate_limit() ) {
981 return new \WP_Error(
982 'rate_limit_exceeded',
983 __( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
984 array( 'status' => 429 )
985 );
986 }
987
988 return new \WP_Error(
989 'invalid_token',
990 __( 'Invalid or expired QR login token.', 'woocommerce' ),
991 array( 'status' => 401 )
992 );
993 }
994
995 // Broad anonymous abuse guard applies only after token lookup. Random
996 // invalid requests use the invalid-token bucket above so they cannot
997 // exhaust this shared-IP guard for later valid exchanges behind the same
998 // proxy/CDN IP.
999 if ( ! $this->check_exchange_ip_rate_limit() ) {
1000 return new \WP_Error(
1001 'rate_limit_exceeded',
1002 __( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
1003 array( 'status' => 429 )
1004 );
1005 }
1006
1007 if ( ! $this->check_valid_exchange_rate_limit( $token_hash ) ) {
1008 return new \WP_Error(
1009 'rate_limit_exceeded',
1010 __( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
1011 array( 'status' => 429 )
1012 );
1013 }
1014
1015 if ( ! $this->claim_token_for_exchange( $token_hash, isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] : time() + self::TOKEN_TTL ) ) {
1016 return new \WP_Error(
1017 'invalid_token',
1018 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1019 array( 'status' => 401 )
1020 );
1021 }
1022
1023 // Re-read after acquiring the database claim in case another process
1024 // consumed or expired the token while this request was waiting.
1025 $token_data = get_transient( $key );
1026 if ( ! is_array( $token_data ) ) {
1027 $this->release_token_exchange_claim( $token_hash );
1028 return new \WP_Error(
1029 'invalid_token',
1030 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1031 array( 'status' => 401 )
1032 );
1033 }
1034
1035 // Validate token hasn't expired (belt and suspenders with transient TTL).
1036 if ( ! empty( $token_data['expires_at'] ) && time() >= (int) $token_data['expires_at'] ) {
1037 delete_transient( $key );
1038 $this->delete_session_mapping_for_record( $token_data );
1039 $this->release_token_exchange_claim( $token_hash );
1040 return new \WP_Error(
1041 'token_expired',
1042 __( 'QR login token has expired.', 'woocommerce' ),
1043 array( 'status' => 401 )
1044 );
1045 }
1046
1047 // Number-matching enforcement: exchange must be preceded by /scan +
1048 // /approve. Anything other than `approved` (including `pending` —
1049 // scan was skipped — and `scanned` — scan completed but merchant
1050 // didn't tap a number yet) is a hard 412.
1051 $current_state = isset( $token_data['state'] ) ? (string) $token_data['state'] : self::STATE_PENDING;
1052
1053 if ( self::STATE_APPROVED !== $current_state ) {
1054 $this->release_token_exchange_claim( $token_hash );
1055 return new \WP_Error(
1056 'qr_login_not_approved',
1057 __( 'This QR login session has not been approved.', 'woocommerce' ),
1058 array( 'status' => 412 )
1059 );
1060 }
1061
1062 // Constant-time grant comparison. The grant is bound to this token
1063 // at /approve time and only handed back to the polling app via
1064 // /session-status, so an attacker who somehow learned the token
1065 // can't race the legit app to exchange after approval.
1066 $submitted_grant = (string) $request->get_param( 'exchange_grant' );
1067 $stored_grant = isset( $token_data['exchange_grant'] ) ? (string) $token_data['exchange_grant'] : '';
1068
1069 if ( '' === $stored_grant || ! hash_equals( $stored_grant, $submitted_grant ) ) {
1070 $invalid_grant_attempts = isset( $token_data['invalid_grant_attempts'] ) ? (int) $token_data['invalid_grant_attempts'] : 0;
1071 ++$invalid_grant_attempts;
1072
1073 $token_data['invalid_grant_attempts'] = $invalid_grant_attempts;
1074 $token_data['invalid_grant_last_attempted_at'] = time();
1075
1076 if ( $invalid_grant_attempts >= self::MAX_INVALID_GRANT_ATTEMPTS ) {
1077 $token_data['state'] = self::STATE_REJECTED;
1078 $token_data['state_at'] = time();
1079
1080 wc_get_logger()->warning(
1081 'QR login rejected after repeated invalid exchange grants',
1082 array(
1083 'source' => 'qr-login-security',
1084 'user_id' => isset( $token_data['user_id'] ) ? (int) $token_data['user_id'] : 0,
1085 'ip' => $this->get_client_ip(),
1086 )
1087 );
1088 }
1089
1090 set_transient( $key, $token_data, $this->get_token_record_ttl( $token_data ) );
1091 $this->release_token_exchange_claim( $token_hash );
1092 return new \WP_Error(
1093 'invalid_exchange_grant',
1094 __( 'Invalid exchange grant for this QR login session.', 'woocommerce' ),
1095 array( 'status' => 412 )
1096 );
1097 }//end if
1098
1099 $user_id = $token_data['user_id'];
1100 $user = get_userdata( $user_id );
1101
1102 if ( ! $user ) {
1103 $this->release_token_exchange_claim( $token_hash );
1104 return new \WP_Error(
1105 'user_not_found',
1106 __( 'User associated with this token no longer exists.', 'woocommerce' ),
1107 array( 'status' => 404 )
1108 );
1109 }
1110
1111 // Application Passwords may have been disabled after the token was minted.
1112 if ( ! $this->are_application_passwords_available() ) {
1113 $this->release_token_exchange_claim( $token_hash );
1114 return new \WP_Error(
1115 'application_passwords_unavailable',
1116 __( 'Application Passwords are not available on this site.', 'woocommerce' ),
1117 array( 'status' => 501 )
1118 );
1119 }
1120
1121 // Mirror the permission check WP core performs in
1122 // WP_REST_Application_Passwords_Controller::create_item_permissions_check().
1123 // Capability or per-user availability filters could have changed in the
1124 // window between token generation and exchange.
1125 if ( ! user_can( $user, 'create_app_password', $user_id ) ) {
1126 $this->release_token_exchange_claim( $token_hash );
1127 return new \WP_Error(
1128 'rest_cannot_create_application_passwords',
1129 __( 'Application passwords are not available for your account. Please contact the site administrator for assistance.', 'woocommerce' ),
1130 array( 'status' => rest_authorization_required_code() )
1131 );
1132 }
1133
1134 // Source the device payload from the scan record. /qr-login-scan
1135 // requires a device object, so by the time we reach `approved` it's
1136 // guaranteed present. Re-sanitize defensively in case the transient
1137 // was tampered with at the storage layer.
1138 $device_source = isset( $token_data['challenge']['device'] ) && is_array( $token_data['challenge']['device'] )
1139 ? $token_data['challenge']['device']
1140 : array();
1141 $device = $this->sanitize_device_payload( $device_source );
1142
1143 // Create an Application Password for the mobile app. The name is
1144 // descriptive (e.g. "Woo Mobile · iPhone 15 · 2026-04-28") so the user
1145 // can identify it later in Users → Profile → Application Passwords.
1146 $app_password_result = \WP_Application_Passwords::create_new_application_password(
1147 $user_id,
1148 array(
1149 'name' => $this->format_application_password_name( $device ),
1150 'app_id' => self::APP_ID,
1151 )
1152 );
1153
1154 if ( is_wp_error( $app_password_result ) ) {
1155 wc_get_logger()->error(
1156 sprintf(
1157 'QR login: failed to create Application Password for user %d: %s',
1158 $user_id,
1159 $app_password_result->get_error_message()
1160 ),
1161 array( 'source' => 'mobile-app-qr-login' )
1162 );
1163 $this->release_token_exchange_claim( $token_hash );
1164 return new \WP_Error(
1165 'application_password_failed',
1166 __( 'Could not create a mobile-app credential. Please try again, or contact your site administrator.', 'woocommerce' ),
1167 array( 'status' => 500 )
1168 );
1169 }
1170
1171 list( $new_password, $item ) = $app_password_result;
1172
1173 // Write a "consumed" record so wc-admin's polling client can transition
1174 // from "QR shown" to "Signed in successfully on {device}" and surface
1175 // a revoke button. Same TTL as the original token transient — there's
1176 // no value in keeping this record longer than the modal that polls it.
1177 $consumed_record = array(
1178 'consumed_at' => time(),
1179 'user_id' => $user_id,
1180 'ap_uuid' => $item['uuid'],
1181 'ap_name' => $item['name'],
1182 'device' => $device,
1183 );
1184 set_transient(
1185 self::CONSUMED_TRANSIENT_PREFIX . $token_hash,
1186 $consumed_record,
1187 self::TOKEN_TTL
1188 );
1189
1190 // One-shot: consume only after the Application Password has been
1191 // successfully created and the consumed record is visible to wc-admin's
1192 // polling client.
1193 delete_transient( $key );
1194 $this->delete_session_mapping_for_record( $token_data );
1195 $this->release_token_exchange_claim( $token_hash );
1196
1197 // Notify the merchant out-of-band so they're aware of a fresh sign-in
1198 // even when they aren't currently looking at wc-admin. Wrapped in a
1199 // try/catch + filter to keep the exchange path uninterrupted if the
1200 // site's mailer is misconfigured.
1201 $this->maybe_send_sign_in_notification_email( $user, $consumed_record );
1202
1203 return rest_ensure_response(
1204 array(
1205 'success' => true,
1206 'user_login' => $user->user_login,
1207 'user_email' => $user->user_email,
1208 'user_id' => $user_id,
1209 'site_url' => $site_url,
1210 'application_password' => $new_password,
1211 'uuid' => $item['uuid'],
1212 )
1213 );
1214 }
1215
1216 /**
1217 * Get the status of a previously generated QR login token.
1218 *
1219 * Used by the wc-admin UI to poll while the QR is on screen. Returns one of:
1220 * - `pending` — token transient exists, has not been exchanged yet.
1221 * - `consumed` — token has been exchanged; payload includes the device that
1222 * signed in and the AP UUID so the UI can render the
1223 * confirmation panel and (optionally) revoke the AP.
1224 * - `expired` — neither transient exists, so the token has expired or
1225 * was never valid for this user.
1226 *
1227 * The user calling this endpoint must be the same user who minted the token.
1228 * That's defense in depth — tokens are 64 random chars and not realistically
1229 * guessable, but cross-user status reads should be impossible regardless.
1230 *
1231 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
1232 * @return \WP_REST_Response|\WP_Error
1233 */
1234 public function get_status( $request ) {
1235 // Defeat any intermediary cache (Cloudflare, NGINX micro-cache, browser, edge proxy)
1236 // that might pin this GET to its first response. Polling endpoints are by definition
1237 // state-bearing — every tick must see the live transient. Returning a stale `scanned`
1238 // response forever is exactly the symptom we'd see if the cache pins the first hit.
1239 nocache_headers();
1240
1241 $user_id = get_current_user_id();
1242
1243 if ( ! $this->check_status_rate_limit( $user_id ) ) {
1244 return new \WP_Error(
1245 'rate_limit_exceeded',
1246 __( 'Too many QR login status checks. Please try again later.', 'woocommerce' ),
1247 array( 'status' => 429 )
1248 );
1249 }
1250
1251 $token = (string) $request->get_param( 'token' );
1252 if ( '' === $token ) {
1253 return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
1254 }
1255
1256 $token_hash = hash( 'sha256', $token );
1257
1258 // Consumed lookup first — once a token has been exchanged the main
1259 // transient is deleted, but we keep a one-way breadcrumb at the
1260 // `_wc_qr_login_consumed_` key so the polling client (which still
1261 // has the plaintext token) can render the success panel.
1262 $consumed = get_transient( self::CONSUMED_TRANSIENT_PREFIX . $token_hash );
1263 if ( is_array( $consumed ) ) {
1264 if ( ! isset( $consumed['user_id'] ) || (int) $consumed['user_id'] !== (int) $user_id ) {
1265 return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
1266 }
1267
1268 return $this->rest_ensure_nocache_response(
1269 array(
1270 'status' => self::STATE_CONSUMED,
1271 'consumed_at' => isset( $consumed['consumed_at'] ) ? (int) $consumed['consumed_at'] : null,
1272 'ap_uuid' => isset( $consumed['ap_uuid'] ) ? (string) $consumed['ap_uuid'] : null,
1273 'ap_name' => isset( $consumed['ap_name'] ) ? (string) $consumed['ap_name'] : null,
1274 'device' => isset( $consumed['device'] ) && is_array( $consumed['device'] ) ? $consumed['device'] : array(),
1275 )
1276 );
1277 }
1278
1279 $record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
1280 if ( ! is_array( $record ) ) {
1281 return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
1282 }
1283
1284 // Cross-user defense in depth — same as before.
1285 if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
1286 return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
1287 }
1288
1289 $state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
1290
1291 // Rejected / expired states are terminal — surface them directly so
1292 // wc-admin can render the "Login denied" terminal screen.
1293 if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
1294 return $this->rest_ensure_nocache_response( array( 'status' => $state ) );
1295 }
1296
1297 if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
1298 return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
1299 }
1300
1301 // While in `scanned`, surface the shuffled candidate triple and the
1302 // device that scanned so wc-admin can render the matching UI. The
1303 // REAL number is never returned via this endpoint — only the
1304 // shuffled triple of (real + 2 distractors) is, so an XSS / hostile
1305 // extension can't read which one is correct from JS state.
1306 if ( self::STATE_SCANNED === $state ) {
1307 $challenge = isset( $record['challenge'] ) && is_array( $record['challenge'] ) ? $record['challenge'] : array();
1308 $numbers = $this->shuffled_candidate_numbers( $challenge );
1309
1310 return $this->rest_ensure_nocache_response(
1311 array(
1312 'status' => self::STATE_SCANNED,
1313 'numbers' => $numbers,
1314 'device' => isset( $challenge['device'] ) && is_array( $challenge['device'] ) ? $challenge['device'] : array(),
1315 'expires_at' => isset( $challenge['expires_at'] ) ? (int) $challenge['expires_at'] : null,
1316 )
1317 );
1318 }
1319
1320 // Approved (post-tap, pre-exchange) — surface so a wc-admin tab that
1321 // reloaded between approve and exchange shows the "Signing in…"
1322 // transitional state rather than going back to the QR.
1323 if ( self::STATE_APPROVED === $state ) {
1324 return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_APPROVED ) );
1325 }
1326
1327 // Pending: same shape as before, plus the new `state` field for
1328 // clients that want to switch on it directly.
1329 return $this->rest_ensure_nocache_response(
1330 array(
1331 'status' => self::STATE_PENDING,
1332 'expires_at' => isset( $record['expires_at'] ) ? (int) $record['expires_at'] : null,
1333 )
1334 );
1335 }
1336
1337 /**
1338 * Revoke (delete) the Application Password issued by a QR login exchange.
1339 *
1340 * The current user must own the AP being revoked — verified via
1341 * `WP_Application_Passwords::get_user_application_password()`. We
1342 * deliberately do NOT use `current_user_can( 'edit_user', $user_id )`
1343 * because that would let a higher-privilege admin revoke another user's AP
1344 * here; the QR flow's revoke surface is for "I just authorized this — undo,"
1345 * not for site-wide AP management (which lives at Users → Profile).
1346 *
1347 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
1348 * @return \WP_REST_Response|\WP_Error
1349 */
1350 public function revoke_password( $request ) {
1351 $user_id = get_current_user_id();
1352
1353 if ( ! $this->check_revoke_rate_limit( $user_id ) ) {
1354 return new \WP_Error(
1355 'rate_limit_exceeded',
1356 __( 'Too many QR login revoke attempts. Please try again later.', 'woocommerce' ),
1357 array( 'status' => 429 )
1358 );
1359 }
1360
1361 if ( ! $this->are_application_passwords_available() ) {
1362 return new \WP_Error(
1363 'application_passwords_unavailable',
1364 __( 'Application Passwords are not available on this site.', 'woocommerce' ),
1365 array( 'status' => 501 )
1366 );
1367 }
1368
1369 $uuid = (string) $request->get_param( 'uuid' );
1370
1371 // Ownership check: the AP must exist AND belong to the current user.
1372 $ap = \WP_Application_Passwords::get_user_application_password( $user_id, $uuid );
1373 if ( ! is_array( $ap ) ) {
1374 return new \WP_Error(
1375 'application_password_not_found',
1376 __( 'No matching Application Password to revoke.', 'woocommerce' ),
1377 array( 'status' => 404 )
1378 );
1379 }
1380
1381 $deleted = \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
1382 if ( true !== $deleted ) {
1383 return new \WP_Error(
1384 'application_password_revoke_failed',
1385 __( 'Could not revoke the Application Password. Please try again.', 'woocommerce' ),
1386 array( 'status' => 500 )
1387 );
1388 }
1389
1390 return rest_ensure_response(
1391 array(
1392 'success' => true,
1393 'uuid' => $uuid,
1394 )
1395 );
1396 }
1397
1398 /**
1399 * Whitelist + sanitize the `device` payload sent by the mobile app.
1400 *
1401 * Returns an array of strings keyed by the whitelisted keys defined in
1402 * `DEVICE_PAYLOAD_KEYS`. Anything outside that whitelist is dropped. Each
1403 * value is run through `sanitize_text_field()` and capped at
1404 * `DEVICE_FIELD_MAX_LENGTH` characters. The function is total — pass `null`
1405 * or anything non-array and you get back `array()`.
1406 *
1407 * @param mixed $device Raw payload from the request.
1408 * @return array<string, string>
1409 */
1410 private function sanitize_device_payload( $device ) {
1411 if ( ! is_array( $device ) ) {
1412 return array();
1413 }
1414
1415 $sanitized = array();
1416 foreach ( self::DEVICE_PAYLOAD_KEYS as $key ) {
1417 if ( ! isset( $device[ $key ] ) || ! is_scalar( $device[ $key ] ) ) {
1418 continue;
1419 }
1420 $value = sanitize_text_field( (string) $device[ $key ] );
1421 if ( '' === $value ) {
1422 continue;
1423 }
1424 if ( strlen( $value ) > self::DEVICE_FIELD_MAX_LENGTH ) {
1425 $value = substr( $value, 0, self::DEVICE_FIELD_MAX_LENGTH );
1426 }
1427 $sanitized[ $key ] = $value;
1428 }
1429
1430 return $sanitized;
1431 }
1432
1433 /**
1434 * Build a descriptive name for the Application Password issued by the QR
1435 * login exchange.
1436 *
1437 * Preferred: `Woo Mobile · iPhone 15 · 2026-04-28` (model + ISO date).
1438 * Falls back to `Woo Mobile · iOS · 2026-04-28` when only the OS is known.
1439 * The scan endpoint requires at least model or OS. The legacy fallback is
1440 * retained as a defensive guard in case stored token data is corrupted.
1441 *
1442 * The name is what the merchant sees in WP admin → Users → Profile →
1443 * Application Passwords, so it should be human-readable, single-line, and
1444 * not contain anything that would only make sense to an engineer.
1445 *
1446 * @param array<string, string> $device Sanitized device payload.
1447 * @return string
1448 */
1449 private function format_application_password_name( array $device ): string {
1450 $model = isset( $device['model'] ) ? trim( $device['model'] ) : '';
1451 $os = isset( $device['os'] ) ? trim( $device['os'] ) : '';
1452
1453 // Prefer model (e.g. "iPhone 15", "Pixel 10"); fall back to the OS
1454 // label if a particular device build returns an empty MODEL string.
1455 // Both fields come from the platform SDK on the mobile side and are
1456 // effectively always populated, but defending against an empty model
1457 // is cheaper than chasing the edge case at runtime.
1458 $descriptor = '' !== $model ? $model : $os;
1459 if ( '' === $descriptor ) {
1460 return __( 'WooCommerce Mobile App (QR Login)', 'woocommerce' );
1461 }
1462
1463 // Use the site's configured timezone so the date the merchant sees in
1464 // the AP list matches what they'd see in the rest of wp-admin.
1465 $date = wp_date( 'Y-m-d' );
1466
1467 /* translators: 1: device descriptor (model or OS, e.g. "iPhone 15"). 2: ISO date the AP was created. */
1468 return sprintf( __( 'Woo Mobile · %1$s · %2$s', 'woocommerce' ), $descriptor, $date );
1469 }
1470
1471 /**
1472 * Per-user rate limit for the polling status endpoint.
1473 *
1474 * @param int $user_id The user ID.
1475 * @return bool True if within rate limit.
1476 */
1477 private function check_status_rate_limit( $user_id ) {
1478 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_STATUS, (string) $user_id );
1479 }
1480
1481 /**
1482 * Per-user rate limit for the revoke endpoint.
1483 *
1484 * @param int $user_id The user ID.
1485 * @return bool True if within rate limit.
1486 */
1487 private function check_revoke_rate_limit( $user_id ) {
1488 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_REVOKE, (string) $user_id );
1489 }
1490
1491 /**
1492 * Mobile app reports the QR was scanned. Generates the number-match
1493 * challenge, marks the state as `scanned`, and returns the *real* number
1494 * + a session id back to the app. Public — the token is the auth.
1495 *
1496 * Hard-break compat: clients that don't send `supports_number_matching`
1497 * get 426 Upgrade Required. The Android Task 7 PR adds the flag; older
1498 * apps in the wild see a clear "update required" error.
1499 *
1500 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
1501 * @return \WP_REST_Response|\WP_Error
1502 */
1503 public function scan_token( $request ) {
1504 if ( ! is_ssl() ) {
1505 return new \WP_Error(
1506 'ssl_required',
1507 __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
1508 array( 'status' => 403 )
1509 );
1510 }
1511
1512 if ( true !== (bool) $request->get_param( 'supports_number_matching' ) ) {
1513 return new \WP_Error(
1514 'mobile_app_update_required',
1515 __( 'This Woo mobile app version is no longer supported for QR sign-in. Please update the app and try again.', 'woocommerce' ),
1516 array( 'status' => 426 )
1517 );
1518 }
1519
1520 $token = (string) $request->get_param( 'token' );
1521 $token_hash = hash( 'sha256', $token );
1522 $key = self::TOKEN_TRANSIENT_PREFIX . $token_hash;
1523
1524 $record = get_transient( $key );
1525 if ( ! is_array( $record ) ) {
1526 if ( ! $this->check_invalid_scan_rate_limit() ) {
1527 return new \WP_Error(
1528 'rate_limit_exceeded',
1529 __( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
1530 array( 'status' => 429 )
1531 );
1532 }
1533
1534 return new \WP_Error(
1535 'invalid_token',
1536 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1537 array( 'status' => 401 )
1538 );
1539 }
1540
1541 if ( ! $this->check_scan_rate_limit() ) {
1542 return new \WP_Error(
1543 'rate_limit_exceeded',
1544 __( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
1545 array( 'status' => 429 )
1546 );
1547 }
1548
1549 $device = $this->sanitize_device_payload( $request->get_param( 'device' ) );
1550 if ( empty( $device['model'] ) && empty( $device['os'] ) ) {
1551 return new \WP_Error(
1552 'invalid_device',
1553 __( 'QR login requires device information from the mobile app.', 'woocommerce' ),
1554 array( 'status' => 400 )
1555 );
1556 }
1557
1558 // Atomic mutex on the read-mutate-write window. Without this, two
1559 // concurrent scans both pass the state==pending check below and both
1560 // write a new challenge — last writer wins, the loser's session_id
1561 // is silently orphaned. The state check is kept as defense-in-depth
1562 // for any path that bypasses the claim (e.g. the staleness branch).
1563 if ( ! $this->claim_token_for_scan(
1564 $token_hash,
1565 isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL
1566 ) ) {
1567 return new \WP_Error(
1568 'qr_login_already_scanned',
1569 __( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
1570 array( 'status' => 409 )
1571 );
1572 }
1573
1574 $current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
1575 if ( self::STATE_PENDING !== $current_state ) {
1576 $this->release_token_scan_claim( $token_hash );
1577 return new \WP_Error(
1578 'qr_login_already_scanned',
1579 __( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
1580 array( 'status' => 409 )
1581 );
1582 }
1583
1584 $challenge_numbers = $this->generate_challenge_numbers();
1585 $session_id = wp_generate_uuid4();
1586 $now = time();
1587 $token_expires_at = isset( $record['expires_at'] ) ? (int) $record['expires_at'] : $now + self::TOKEN_TTL;
1588
1589 if ( $token_expires_at <= $now ) {
1590 $this->release_token_scan_claim( $token_hash );
1591 return new \WP_Error(
1592 'invalid_token',
1593 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1594 array( 'status' => 401 )
1595 );
1596 }
1597
1598 $challenge_expires_at = min( $now + self::CHALLENGE_TTL_SECONDS, $token_expires_at );
1599 $challenge_ttl = max( 1, $challenge_expires_at - $now );
1600
1601 // Shuffle the candidate triple ONCE at scan time and persist the chosen ordering so
1602 // every subsequent /qr-login-status poll returns the same array. Re-shuffling per-poll
1603 // would make the wc-admin tile order flicker every 2.5 s — terrible UX, and makes the
1604 // merchant doubt they're reading the right number.
1605 $candidates = array_merge( array( $challenge_numbers['real'] ), $challenge_numbers['distractors'] );
1606 shuffle( $candidates );
1607
1608 $record['state'] = self::STATE_SCANNED;
1609 $record['state_at'] = $now;
1610 $record['challenge'] = array(
1611 'real' => $challenge_numbers['real'],
1612 'distractors' => $challenge_numbers['distractors'],
1613 'shuffled' => $candidates,
1614 'session_id' => $session_id,
1615 'expires_at' => $challenge_expires_at,
1616 'device' => $device,
1617 );
1618
1619 // Re-use whatever TTL the original transient had left. The challenge
1620 // window itself is capped to the remaining token lifetime, while the
1621 // storage TTL keeps the full challenge visible for normal fresh scans.
1622 $ttl_left = max( 1, $token_expires_at - $now );
1623 $storage_ttl = min( $ttl_left, self::CHALLENGE_TTL_SECONDS + 30 );
1624 set_transient( $key, $record, $storage_ttl );
1625
1626 // Sibling transient that resolves session_id → token_hash for the
1627 // app's session-status poll. Stored hashed so the session id isn't
1628 // directly indexable in wp_options.
1629 set_transient(
1630 self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ),
1631 $token_hash,
1632 $storage_ttl
1633 );
1634
1635 $this->release_token_scan_claim( $token_hash );
1636
1637 return rest_ensure_response(
1638 array(
1639 'session_id' => $session_id,
1640 'real_number' => $challenge_numbers['real'],
1641 'expires_in' => $challenge_ttl,
1642 )
1643 );
1644 }
1645
1646 /**
1647 * Merchant taps a number on wc-admin. Server validates against the
1648 * stored real number with `hash_equals()` (constant-time). Correct →
1649 * `approved` + mints `exchange_grant`. Wrong → `rejected` (terminal,
1650 * security event logged). One-strike: no retry.
1651 *
1652 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
1653 * @return \WP_REST_Response|\WP_Error
1654 */
1655 public function approve_token( $request ) {
1656 $user_id = get_current_user_id();
1657
1658 if ( ! $this->check_approve_rate_limit( $user_id ) ) {
1659 return new \WP_Error(
1660 'rate_limit_exceeded',
1661 __( 'Too many QR login approval attempts. Please try again later.', 'woocommerce' ),
1662 array( 'status' => 429 )
1663 );
1664 }
1665
1666 $token = (string) $request->get_param( 'token' );
1667 $token_hash = hash( 'sha256', $token );
1668 $key = self::TOKEN_TRANSIENT_PREFIX . $token_hash;
1669
1670 $record = get_transient( $key );
1671 if ( ! is_array( $record ) ) {
1672 return new \WP_Error(
1673 'invalid_token',
1674 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1675 array( 'status' => 401 )
1676 );
1677 }
1678
1679 $approval_claim_expires_at = ! empty( $record['challenge']['expires_at'] )
1680 ? (int) $record['challenge']['expires_at']
1681 : ( isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL );
1682 if ( ! $this->claim_token_for_approval( $token_hash, $approval_claim_expires_at ) ) {
1683 return new \WP_Error(
1684 'qr_login_approval_in_progress',
1685 __( 'This QR login session is already being approved.', 'woocommerce' ),
1686 array( 'status' => 409 )
1687 );
1688 }
1689
1690 // Re-read after acquiring the database claim in case another request
1691 // approved, rejected, or expired the challenge while this one was waiting.
1692 $record = get_transient( $key );
1693 if ( ! is_array( $record ) ) {
1694 $this->release_token_approval_claim( $token_hash );
1695 return new \WP_Error(
1696 'invalid_token',
1697 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1698 array( 'status' => 401 )
1699 );
1700 }
1701
1702 // Same cross-user defense as get_status — only the user that minted
1703 // the token can approve it.
1704 if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
1705 $this->release_token_approval_claim( $token_hash );
1706 return new \WP_Error(
1707 'invalid_token',
1708 __( 'Invalid or expired QR login token.', 'woocommerce' ),
1709 array( 'status' => 401 )
1710 );
1711 }
1712
1713 if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
1714 $record['state'] = self::STATE_EXPIRED;
1715 $record['state_at'] = time();
1716 set_transient( $key, $record, 60 );
1717 $this->release_token_approval_claim( $token_hash );
1718 return new \WP_Error(
1719 'qr_login_expired',
1720 __( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
1721 array( 'status' => 410 )
1722 );
1723 }
1724
1725 $current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
1726 if ( self::STATE_SCANNED !== $current_state ) {
1727 $this->release_token_approval_claim( $token_hash );
1728 return new \WP_Error(
1729 'qr_login_not_scanned',
1730 __( 'This QR login session is not waiting for approval.', 'woocommerce' ),
1731 array( 'status' => 409 )
1732 );
1733 }
1734
1735 // Challenge expiry — normally 90 s after scan, capped by token expiry.
1736 if ( ! empty( $record['challenge']['expires_at'] ) && time() > (int) $record['challenge']['expires_at'] ) {
1737 $record['state'] = self::STATE_EXPIRED;
1738 $record['state_at'] = time();
1739 set_transient( $key, $record, 60 );
1740 $this->release_token_approval_claim( $token_hash );
1741 return new \WP_Error(
1742 'qr_login_expired',
1743 __( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
1744 array( 'status' => 410 )
1745 );
1746 }
1747
1748 $choice = (string) $request->get_param( 'choice' );
1749 $real = isset( $record['challenge']['real'] ) ? (string) $record['challenge']['real'] : '';
1750
1751 // Constant-time compare. Defends against PHP string-comparison fast
1752 // paths that can leak prefix-matching info via timing.
1753 if ( '' === $real || ! hash_equals( $real, $choice ) ) {
1754 $record['state'] = self::STATE_REJECTED;
1755 $record['state_at'] = time();
1756 set_transient( $key, $record, 60 );
1757
1758 wc_get_logger()->warning(
1759 'QR login number-match rejected — wrong choice submitted',
1760 array(
1761 'source' => 'qr-login-security',
1762 'user_id' => (int) $user_id,
1763 'ip' => $this->get_client_ip(),
1764 'device' => isset( $record['challenge']['device'] ) ? $record['challenge']['device'] : array(),
1765 )
1766 );
1767
1768 $this->release_token_approval_claim( $token_hash );
1769 return rest_ensure_response( array( 'state' => self::STATE_REJECTED ) );
1770 }
1771
1772 $record['state'] = self::STATE_APPROVED;
1773 $record['state_at'] = time();
1774 $record['exchange_grant'] = bin2hex( random_bytes( self::EXCHANGE_GRANT_BYTES ) );
1775 $ttl = max(
1776 1,
1777 isset( $record['expires_at'] ) ? (int) $record['expires_at'] - time() : self::CHALLENGE_TTL_SECONDS
1778 );
1779
1780 set_transient( $key, $record, $ttl );
1781 $this->release_token_approval_claim( $token_hash );
1782
1783 return rest_ensure_response( array( 'state' => self::STATE_APPROVED ) );
1784 }
1785
1786 /**
1787 * Mobile app polls this with the session id from /scan. Returns the
1788 * current state of the underlying token, plus — when state is
1789 * `approved` — the `exchange_grant` nonce required by /qr-login-exchange.
1790 *
1791 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
1792 * @return \WP_REST_Response|\WP_Error
1793 */
1794 public function get_session_status( $request ) {
1795 // Defeat any intermediary cache (Cloudflare, NGINX micro-cache, OkHttp's shared
1796 // cache, edge proxy) that might pin this GET to its first response. Polling
1797 // endpoints are by definition state-bearing — every tick must see the live
1798 // transient. Returning a stale `scanned` response forever is exactly the
1799 // symptom we see if the cache pins the first hit.
1800 nocache_headers();
1801
1802 if ( ! is_ssl() ) {
1803 return new \WP_Error(
1804 'ssl_required',
1805 __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
1806 array( 'status' => 403 )
1807 );
1808 }
1809
1810 $session_id = (string) $request->get_param( 'session_id' );
1811 $submitted_hash = (string) $request->get_param( 'token_hash' );
1812
1813 $token_hash = get_transient( self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ) );
1814 if ( ! is_string( $token_hash ) || '' === $token_hash ) {
1815 // Either the session never existed or it has expired. Either way,
1816 // surface as expired to the polling app.
1817 return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
1818 }
1819
1820 // Bind grant delivery to proof of token knowledge: an attacker who
1821 // learns the session_id alone (mobile logs, network capture, debug
1822 // output) cannot poll for state transitions and walk away with the
1823 // `exchange_grant` the moment the merchant approves. The mobile app
1824 // already holds the plaintext token from the QR scan — passing
1825 // SHA-256(token) on every poll is essentially free for it.
1826 // `hash_equals` for constant-time comparison; `expired` opacity so
1827 // we never leak whether the session_id is real or not.
1828 if ( ! hash_equals( $token_hash, $submitted_hash ) ) {
1829 return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
1830 }
1831
1832 if ( ! $this->check_session_status_rate_limit( $session_id ) ) {
1833 return new \WP_Error(
1834 'rate_limit_exceeded',
1835 __( 'Too many QR login session-status checks. Please try again later.', 'woocommerce' ),
1836 array( 'status' => 429 )
1837 );
1838 }
1839
1840 $record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
1841 if ( ! is_array( $record ) ) {
1842 return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
1843 }
1844
1845 $state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
1846 $response = array( 'state' => $state );
1847
1848 if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
1849 return $this->rest_ensure_nocache_response( $response );
1850 }
1851
1852 if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
1853 return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
1854 }
1855
1856 if ( self::STATE_APPROVED === $state && ! empty( $record['exchange_grant'] ) ) {
1857 $response['exchange_grant'] = (string) $record['exchange_grant'];
1858 }
1859
1860 return $this->rest_ensure_nocache_response( $response );
1861 }
1862
1863 /**
1864 * Generate a 1-real + 2-distractor number triple for the match
1865 * challenge. Distractors must differ from the real number and from each
1866 * other by ≥ 100 — defends against a partial-read leak fingerprinting
1867 * the real one (no `042` vs `043` near-misses).
1868 *
1869 * Uses `random_int()` (CSPRNG-backed) rather than `wp_rand()`, which can
1870 * fall back to mt_rand() and is predictable.
1871 *
1872 * @return array{real: string, distractors: array<int, string>}
1873 * @throws \RuntimeException If a valid distractor set cannot be generated.
1874 */
1875 private function generate_challenge_numbers(): array {
1876 $real = random_int( 0, 999 );
1877 $valid_candidates = array();
1878
1879 for ( $candidate = 0; $candidate <= 999; $candidate++ ) {
1880 if ( $candidate !== $real && abs( $candidate - $real ) >= 100 ) {
1881 $valid_candidates[] = $candidate;
1882 }
1883 }
1884
1885 if ( empty( $valid_candidates ) ) {
1886 throw new \RuntimeException( 'QR login challenge generator could not find a valid first distractor.' );
1887 }
1888
1889 $first_index = random_int( 0, count( $valid_candidates ) - 1 );
1890 $first = $valid_candidates[ $first_index ];
1891
1892 $second_candidates = array_values(
1893 array_filter(
1894 $valid_candidates,
1895 static function ( $candidate ) use ( $first ) {
1896 return abs( $candidate - $first ) >= 100;
1897 }
1898 )
1899 );
1900
1901 if ( empty( $second_candidates ) ) {
1902 throw new \RuntimeException( 'QR login challenge generator could not find a valid second distractor.' );
1903 }
1904
1905 $second = $second_candidates[ random_int( 0, count( $second_candidates ) - 1 ) ];
1906 $distractors = array( $first, $second );
1907
1908 return array(
1909 'real' => str_pad( (string) $real, 3, '0', STR_PAD_LEFT ),
1910 'distractors' => array_map(
1911 static function ( $n ) {
1912 return str_pad( (string) $n, 3, '0', STR_PAD_LEFT );
1913 },
1914 $distractors
1915 ),
1916 );
1917 }
1918
1919 /**
1920 * Build the shuffled candidate triple returned by `/qr-login-status`
1921 * while in the `scanned` state. The order is fixed at scan time (in
1922 * `scan_token`) and stored in `challenge.shuffled` so every poll returns
1923 * the same array — re-shuffling per-poll caused visible tile flicker on
1924 * wc-admin. Falls back to building + shuffling on the fly for any token
1925 * record that predates the persisted-shuffle change.
1926 *
1927 * @param array<string, mixed> $challenge The challenge payload from the token record.
1928 * @return array<int, string>
1929 */
1930 private function shuffled_candidate_numbers( array $challenge ): array {
1931 if ( isset( $challenge['shuffled'] ) && is_array( $challenge['shuffled'] ) ) {
1932 return array_map( 'strval', $challenge['shuffled'] );
1933 }
1934
1935 $real = isset( $challenge['real'] ) ? (string) $challenge['real'] : '';
1936 $distractors = isset( $challenge['distractors'] ) && is_array( $challenge['distractors'] )
1937 ? array_map( 'strval', $challenge['distractors'] )
1938 : array();
1939
1940 $candidates = array_merge( array( $real ), $distractors );
1941 shuffle( $candidates );
1942 return $candidates;
1943 }
1944
1945 /**
1946 * Per-IP rate limit on /qr-login-scan.
1947 *
1948 * @return bool True if within rate limit.
1949 */
1950 private function check_scan_rate_limit() {
1951 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SCAN, $this->get_client_ip() );
1952 }
1953
1954 /**
1955 * Per-user rate limit on /qr-login-approve.
1956 *
1957 * @param int $user_id The user ID.
1958 * @return bool True if within rate limit.
1959 */
1960 private function check_approve_rate_limit( $user_id ) {
1961 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_APPROVE, (string) $user_id );
1962 }
1963
1964 /**
1965 * Per-session rate limit on /qr-login-session-status.
1966 *
1967 * @param string $session_id The session ID.
1968 * @return bool True if within rate limit.
1969 */
1970 private function check_session_status_rate_limit( $session_id ) {
1971 return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SESSION_STATUS, $session_id );
1972 }
1973
1974 /**
1975 * Send the merchant a transactional email summarizing a successful QR
1976 * sign-in, unless they (or a site owner) opt out via the
1977 * `woocommerce_qr_login_should_send_signin_email` filter.
1978 *
1979 * Wrapped so a misconfigured mailer cannot break the exchange path. Mailer
1980 * false returns and exceptions are logged, but delivery never blocks the API
1981 * response.
1982 *
1983 * @param \WP_User $user The user who minted the token (recipient).
1984 * @param array<string, mixed> $consumed_record The record persisted to the consumed transient (keys: consumed_at, user_id, ap_uuid, ap_name, device).
1985 * @return void
1986 */
1987 private function maybe_send_sign_in_notification_email( \WP_User $user, array $consumed_record ): void {
1988 /**
1989 * Filter whether to send the QR sign-in notification email.
1990 *
1991 * Default: true. Return false to suppress the send for a specific
1992 * user, environment (e.g. staging), or test run.
1993 *
1994 * @since 10.9.0
1995 *
1996 * @param bool $should_send Whether to send the email.
1997 * @param \WP_User $user The user who minted the QR token.
1998 * @param array<string, mixed> $consumed_record The consumed record about to be emailed (keys: consumed_at, user_id, ap_uuid, ap_name, device).
1999 */
2000 $should_send = (bool) apply_filters(
2001 'woocommerce_qr_login_should_send_signin_email',
2002 true,
2003 $user,
2004 $consumed_record
2005 );
2006
2007 if ( ! $should_send ) {
2008 return;
2009 }
2010
2011 try {
2012 if ( ! $this->send_sign_in_notification_email( $user, $consumed_record ) ) {
2013 wc_get_logger()->warning(
2014 sprintf(
2015 'QR sign-in notification email failed for user %d: wp_mail returned false',
2016 $user->ID
2017 ),
2018 array( 'source' => 'mobile-app-qr-login' )
2019 );
2020 }
2021 } catch ( \Throwable $e ) {
2022 // Don't surface mailer failures to the exchange response — the
2023 // merchant already has the API result, and the email is best-effort.
2024 // Log instead so a misconfigured mailer is observable rather than
2025 // invisible. Catch \Throwable so an \Error from the mailer also
2026 // stays out of the exchange path.
2027 wc_get_logger()->warning(
2028 sprintf(
2029 'QR sign-in notification email failed for user %d: %s',
2030 $user->ID,
2031 $e->getMessage()
2032 ),
2033 array( 'source' => 'mobile-app-qr-login' )
2034 );
2035 }
2036 }
2037
2038 /**
2039 * Render and dispatch the sign-in notification email.
2040 *
2041 * Uses `wp_mail()` directly with our own minimal HTML shell rather than
2042 * `WC()->mailer()->wrap_message()` — the WC wrapper auto-prepends a small
2043 * site-name header that duplicates the subject line shown by most clients
2044 * and constrains the body width. Owning the wrapper lets us deliver one
2045 * coherent layout.
2046 *
2047 * @param \WP_User $user Recipient.
2048 * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
2049 * @return bool True if WordPress accepted the message for delivery.
2050 */
2051 private function send_sign_in_notification_email( \WP_User $user, array $consumed_record ): bool {
2052 $site_name = wp_specialchars_decode(
2053 (string) get_bloginfo( 'name' ),
2054 ENT_QUOTES
2055 );
2056
2057 /* translators: %s: site name. */
2058 $subject = sprintf( __( 'A new device signed in to %s', 'woocommerce' ), $site_name );
2059
2060 $body_html = $this->render_sign_in_notification_email_body( $user, $consumed_record, $site_name, $subject );
2061
2062 return wp_mail(
2063 $user->user_email,
2064 $subject,
2065 $body_html,
2066 array( 'Content-Type: text/html; charset=UTF-8' )
2067 );
2068 }
2069
2070 /**
2071 * Render the full HTML email document for the sign-in notification.
2072 *
2073 * @param \WP_User $user Recipient.
2074 * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
2075 * @param string $site_name Decoded site name (passed in to avoid double-decoding).
2076 * @param string $subject Email subject; rendered as the in-body heading.
2077 * @return string Rendered HTML document.
2078 */
2079 private function render_sign_in_notification_email_body( \WP_User $user, array $consumed_record, string $site_name, string $subject ): string {
2080 $device = $consumed_record['device'] ?? array();
2081 $consumed_at = isset( $consumed_record['consumed_at'] ) ? (int) $consumed_record['consumed_at'] : time();
2082 $ap_name = $consumed_record['ap_name'] ?? '';
2083 $applications_url = admin_url( 'profile.php#application-passwords-section' );
2084
2085 ob_start();
2086 include __DIR__ . '/views/mobile-app-qr-login-signin-email.php';
2087 $html = ob_get_clean();
2088
2089 return is_string( $html ) ? $html : '';
2090 }
2091 }
2092