| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * WCPOS REST API Class, ie: /wcpos/v1/ endpoints. | |
| 3 | + * WCPOS REST API class. | |
| 4 | 4 | * |
| 5 | 5 | * @author Paul Kilmurray <paul@kilbot.com> |
| 6 | 6 | * |
| 7 | 7 | * @see http://wcpos.com |
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | namespace WCPOS\WooCommercePOS; |
| 12 | 12 | |
| 13 | -use Ramsey\Uuid\Uuid; | |
| 14 | 13 | use WCPOS\WooCommercePOS\Services\Auth; |
| 14 | +use WCPOS\WooCommercePOS\Services\Client_Signal; | |
| 15 | +use WCPOS\WooCommercePOS\Services\Settings as SettingsService; | |
| 15 | 16 | use WP_HTTP_Response; |
| 16 | 17 | use WP_REST_Request; |
| 17 | 18 | use WP_REST_Response; |
| 18 | 19 | use WP_REST_Server; |
| @@ -21,8 +22,13 @@ | ||
| 21 | 22 | * API class. |
| 22 | 23 | */ |
| 23 | 24 | class API { |
| 24 | 25 | /** |
| 26 | + * WCPOS REST API namespaces. | |
| 27 | + */ | |
| 28 | + public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' ); | |
| 29 | + | |
| 30 | + /** | |
| 25 | 31 | * WCPOS REST API namespaces and endpoints. |
| 26 | 32 | * |
| 27 | 33 | * @var array |
| 28 | 34 | */ |
| @@ -36,8 +42,15 @@ | ||
| 36 | 42 | */ |
| 37 | 43 | protected $route_map = array(); |
| 38 | 44 | |
| 39 | 45 | /** |
| 46 | + * Route permission-gate classifier. | |
| 47 | + * | |
| 48 | + * @var API\Route_Classifier | |
| 49 | + */ | |
| 50 | + protected $route_classifier; | |
| 51 | + | |
| 52 | + /** | |
| 40 | 53 | * Flag to check if authentication has been checked. |
| 41 | 54 | * |
| 42 | 55 | * @var bool |
| 43 | 56 | */ |
| @@ -58,12 +71,8 @@ | ||
| 58 | 71 | */ |
| 59 | 72 | public function __construct() { |
| 60 | 73 | $this->register_routes(); |
| 61 | 74 | |
| 62 | - // Allows requests from WCPOS Desktop and Mobile Apps. | |
| 63 | - add_filter( 'rest_allowed_cors_headers', array( $this, 'rest_allowed_cors_headers' ), 10, 1 ); | |
| 64 | - add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 10, 4 ); | |
| 65 | - | |
| 66 | 75 | /* |
| 67 | 76 | * Adds authentication to for JWT bearer tokens |
| 68 | 77 | * - We run determine_current_user at 20 to allow other plugins to run first |
| 69 | 78 | */ |
| @@ -75,14 +84,43 @@ | ||
| 75 | 84 | |
| 76 | 85 | // These filters allow changes to the WC REST API response. |
| 77 | 86 | add_filter( 'rest_dispatch_request', array( $this, 'rest_dispatch_request' ), 10, 4 ); |
| 78 | 87 | add_filter( 'rest_pre_dispatch', array( $this, 'rest_pre_dispatch' ), 10, 3 ); |
| 88 | + add_filter( 'rest_pre_dispatch', array( $this, 'clear_third_party_jwt_error' ), 50, 3 ); | |
| 89 | + add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 ); | |
| 79 | 90 | } |
| 80 | 91 | |
| 81 | 92 | /** |
| 93 | + * Get the WCPOS REST API namespaces. | |
| 94 | + * | |
| 95 | + * @return string[] REST API namespaces. | |
| 96 | + */ | |
| 97 | + public function get_route_namespaces(): array { | |
| 98 | + /** | |
| 99 | + * Filter the list of namespaces used in the WCPOS REST API. | |
| 100 | + * | |
| 101 | + * This filter is strictly additive: plugins can register additional WCPOS REST | |
| 102 | + * API namespaces, but the core namespaces cannot be removed — the central | |
| 103 | + * permission gate must keep covering every registered core route. Controllers | |
| 104 | + * remain responsible for declaring any special route classifications within | |
| 105 | + * added namespaces. | |
| 106 | + * | |
| 107 | + * @since 1.10.0 | |
| 108 | + * | |
| 109 | + * @param string[] $namespaces REST API namespaces. | |
| 110 | + */ | |
| 111 | + $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', self::ROUTE_NAMESPACES ); | |
| 112 | + | |
| 113 | + return array_values( array_unique( array_merge( self::ROUTE_NAMESPACES, (array) $namespaces ) ) ); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 82 | 117 | * Register routes for all controllers. |
| 83 | 118 | */ |
| 84 | 119 | public function register_routes(): void { |
| 120 | + $route_namespaces = $this->get_route_namespaces(); | |
| 121 | + $this->route_classifier = new API\Route_Classifier( $route_namespaces ); | |
| 122 | + | |
| 85 | 123 | /** |
| 86 | 124 | * Filter the list of controller classes used in the WCPOS REST API. |
| 87 | 125 | * |
| 88 | 126 | * This filter allows customizing or extending the set of controller classes that handle |
| @@ -87,8 +125,9 @@ | ||
| 87 | 125 | * |
| 88 | 126 | * This filter allows customizing or extending the set of controller classes that handle |
| 89 | 127 | * REST API routes for the WCPOS. By filtering these controllers, plugins can |
| 90 | 128 | * modify existing endpoints or add new controllers for additional functionality. |
| 129 | + * Core legacy controllers use their versioned WCPOS\WooCommercePOS\API\V1 FQCNs. | |
| 91 | 130 | * |
| 92 | 131 | * @since 1.5.0 |
| 93 | 132 | * |
| 94 | 133 | * @param array $controllers Associative array of controller identifiers to their corresponding class names. |
| @@ -109,75 +148,135 @@ | ||
| 109 | 148 | $classes = apply_filters( |
| 110 | 149 | 'woocommerce_pos_rest_api_controllers', |
| 111 | 150 | array( |
| 112 | 151 | // WCPOS rest api controllers. |
| 113 | - 'auth' => API\Auth::class, | |
| 114 | - 'settings' => API\Settings::class, | |
| 115 | - 'cashier' => API\Cashier::class, | |
| 116 | - 'templates' => API\Templates_Controller::class, | |
| 117 | - 'receipts' => API\Receipts_Controller::class, | |
| 118 | - 'print_jobs' => API\Print_Jobs_Controller::class, | |
| 152 | + 'auth' => API\V1\Auth::class, | |
| 153 | + 'settings' => API\V1\Settings::class, | |
| 154 | + 'cashier' => API\V1\Cashier::class, | |
| 155 | + 'templates' => API\V1\Templates_Controller::class, | |
| 156 | + 'receipts' => API\V1\Receipts_Controller::class, | |
| 157 | + 'print_jobs' => API\V1\Print_Jobs_Controller::class, | |
| 119 | 158 | |
| 120 | 159 | // TODO: remove this? |
| 121 | - 'stores' => API\Stores::class, | |
| 122 | - 'extensions' => API\Extensions::class, | |
| 123 | - 'logs' => API\Logs::class, | |
| 124 | - 'payment_gateways' => API\Payment_Gateways::class, | |
| 125 | - 'gateway_bootstrap' => API\Gateway_Bootstrap_Controller::class, | |
| 126 | - 'checkout' => API\Checkout_Controller::class, | |
| 160 | + 'stores' => API\V1\Stores::class, | |
| 161 | + 'extensions' => API\V1\Extensions::class, | |
| 162 | + 'logs' => API\V1\Logs::class, | |
| 163 | + 'payment_gateways' => API\V1\Payment_Gateways::class, | |
| 164 | + 'gateway_bootstrap' => API\V1\Gateway_Bootstrap_Controller::class, | |
| 165 | + 'checkout' => API\V1\Checkout_Controller::class, | |
| 127 | 166 | |
| 128 | 167 | // extend WC REST API controllers. |
| 129 | - 'products' => API\Products_Controller::class, | |
| 130 | - 'product_variations' => API\Product_Variations_Controller::class, | |
| 131 | - 'orders' => API\Orders_Controller::class, | |
| 132 | - 'customers' => API\Customers_Controller::class, | |
| 133 | - 'product_tags' => API\Product_Tags_Controller::class, | |
| 134 | - 'product_categories' => API\Product_Categories_Controller::class, | |
| 135 | - 'product_brands' => API\Product_Brands_Controller::class, | |
| 136 | - 'coupons' => API\Coupons_Controller::class, | |
| 137 | - 'taxes' => API\Taxes_Controller::class, | |
| 138 | - 'shipping_methods' => API\Shipping_Methods_Controller::class, | |
| 139 | - 'tax_classes' => API\Tax_Classes_Controller::class, | |
| 140 | - 'order_statuses' => API\Data_Order_Statuses_Controller::class, | |
| 168 | + 'products' => API\V1\Products_Controller::class, | |
| 169 | + 'product_variations' => API\V1\Product_Variations_Controller::class, | |
| 170 | + 'orders' => API\V1\Orders_Controller::class, | |
| 171 | + 'customers' => API\V1\Customers_Controller::class, | |
| 172 | + 'product_tags' => API\V1\Product_Tags_Controller::class, | |
| 173 | + 'product_categories' => API\V1\Product_Categories_Controller::class, | |
| 174 | + 'product_brands' => API\V1\Product_Brands_Controller::class, | |
| 175 | + 'coupons' => API\V1\Coupons_Controller::class, | |
| 176 | + 'taxes' => API\V1\Taxes_Controller::class, | |
| 177 | + 'shipping_methods' => API\V1\Shipping_Methods_Controller::class, | |
| 178 | + 'tax_classes' => API\V1\Tax_Classes_Controller::class, | |
| 179 | + 'order_statuses' => API\V1\Data_Order_Statuses_Controller::class, | |
| 141 | 180 | ) |
| 142 | 181 | ); |
| 143 | 182 | |
| 183 | + /** | |
| 184 | + * Filter the wcpos/v2 service pass-through controllers (additive to the | |
| 185 | + * frozen v1 surface — the legacy data controllers stay v1-only). | |
| 186 | + * | |
| 187 | + * Extensions that replace a v1 service through | |
| 188 | + * `woocommerce_pos_rest_api_controllers` must carry their service onto | |
| 189 | + * the v2 surface here with their own pass-through subclass (override | |
| 190 | + * `$namespace = 'wcpos/v2'`), exactly as core does — the v2 map is not | |
| 191 | + * derived from the v1 map, so a v1 replacement alone leaves the v2 | |
| 192 | + * twin serving core behavior. | |
| 193 | + * | |
| 194 | + * @since 1.10.0 | |
| 195 | + * | |
| 196 | + * @param array $controllers Associative array of v2 service controller class names. | |
| 197 | + */ | |
| 198 | + $v2_classes = apply_filters( | |
| 199 | + 'woocommerce_pos_rest_api_v2_controllers', | |
| 200 | + array( | |
| 201 | + 'ping' => API\V2\Ping::class, | |
| 202 | + 'echo_probe' => API\V2\Echo_Probe::class, | |
| 203 | + 'site' => API\V2\Site::class, | |
| 204 | + 'auth' => API\V2\Auth::class, | |
| 205 | + 'settings' => API\V2\Settings::class, | |
| 206 | + 'cashier' => API\V2\Cashier::class, | |
| 207 | + 'templates' => API\V2\Templates_Controller::class, | |
| 208 | + 'receipts' => API\V2\Receipts_Controller::class, | |
| 209 | + 'print_jobs' => API\V2\Print_Jobs_Controller::class, | |
| 210 | + 'stores' => API\V2\Stores::class, | |
| 211 | + 'extensions' => API\V2\Extensions::class, | |
| 212 | + 'logs' => API\V2\Logs::class, | |
| 213 | + 'payment_gateways' => API\V2\Payment_Gateways::class, | |
| 214 | + 'gateway_bootstrap' => API\V2\Gateway_Bootstrap_Controller::class, | |
| 215 | + 'checkout' => API\V2\Checkout_Controller::class, | |
| 216 | + 'order_email' => API\V2\Order_Email_Controller::class, | |
| 217 | + 'shipping_methods' => API\V2\Shipping_Methods_Controller::class, | |
| 218 | + 'tax_classes' => API\V2\Tax_Classes_Controller::class, | |
| 219 | + 'order_statuses' => API\V2\Data_Order_Statuses_Controller::class, | |
| 220 | + ) | |
| 221 | + ); | |
| 222 | + foreach ( $v2_classes as $key => $class ) { | |
| 223 | + $classes[ 'v2-' . $key ] = $class; | |
| 224 | + } | |
| 225 | + $legacy_classifications = array( | |
| 226 | + 'auth' => array( 'public' => array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ) ), | |
| 227 | + 'print_jobs' => array( 'printer_token' => array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ) ), | |
| 228 | + 'receipts' => array( 'permission_error_passthrough' => array( '/wcpos/v1/receipts/' ) ), | |
| 229 | + ); | |
| 230 | + | |
| 144 | 231 | foreach ( $classes as $key => $class ) { |
| 145 | 232 | if ( class_exists( $class ) ) { |
| 146 | 233 | $this->controllers[ $key ] = new $class(); |
| 147 | 234 | $this->controllers[ $key ]->register_routes(); |
| 235 | + | |
| 236 | + if ( method_exists( $this->controllers[ $key ], 'wcpos_route_classifications' ) ) { | |
| 237 | + $this->route_classifier->merge( $this->controllers[ $key ]->wcpos_route_classifications() ); | |
| 238 | + } elseif ( isset( $legacy_classifications[ $key ] ) ) { | |
| 239 | + $this->route_classifier->merge( $legacy_classifications[ $key ] ); | |
| 240 | + } | |
| 148 | 241 | } |
| 149 | 242 | } |
| 150 | 243 | |
| 244 | + // Sync classifications are independent of feature-gated route registration. | |
| 245 | + $this->route_classifier->merge( Sync\Api::route_classifications() ); | |
| 246 | + | |
| 151 | 247 | // Build route map for use in rest_dispatch_request(). |
| 152 | 248 | $rest_server = rest_get_server(); |
| 153 | - $all_routes = $rest_server->get_routes( 'wcpos/v1' ); | |
| 154 | 249 | |
| 155 | - foreach ( $all_routes as $route_pattern => $route_handlers ) { | |
| 156 | - foreach ( $route_handlers as $route_handler ) { | |
| 157 | - $callback = $route_handler['callback'] ?? null; | |
| 250 | + foreach ( $route_namespaces as $route_namespace ) { | |
| 251 | + $all_routes = $rest_server->get_routes( $route_namespace ); | |
| 158 | 252 | |
| 159 | - // Extract the controller object from the callback. | |
| 160 | - $controller_obj = null; | |
| 161 | - if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) { | |
| 162 | - $controller_obj = $callback[0]; | |
| 163 | - } elseif ( $callback instanceof \Closure ) { | |
| 164 | - // WC 10.5+ RestApiCache wraps callbacks in closures. | |
| 165 | - // Use reflection to extract the bound $this. | |
| 166 | - $ref = new \ReflectionFunction( $callback ); | |
| 167 | - $controller_obj = $ref->getClosureThis(); | |
| 168 | - } | |
| 253 | + foreach ( $all_routes as $route_pattern => $route_handlers ) { | |
| 254 | + foreach ( $route_handlers as $route_handler ) { | |
| 255 | + $callback = $route_handler['callback'] ?? null; | |
| 169 | 256 | |
| 170 | - if ( ! $controller_obj ) { | |
| 171 | - continue; | |
| 172 | - } | |
| 257 | + // Extract the controller object from the callback. | |
| 258 | + $controller_obj = null; | |
| 259 | + if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) { | |
| 260 | + $controller_obj = $callback[0]; | |
| 261 | + } elseif ( $callback instanceof \Closure ) { | |
| 262 | + // WC 10.5+ RestApiCache wraps callbacks in closures. | |
| 263 | + // Use reflection to extract the bound $this. | |
| 264 | + $ref = new \ReflectionFunction( $callback ); | |
| 265 | + $controller_obj = $ref->getClosureThis(); | |
| 266 | + } | |
| 173 | 267 | |
| 174 | - // Find which controller key this object belongs to. | |
| 175 | - foreach ( $this->controllers as $key => $registered_controller ) { | |
| 176 | - if ( $controller_obj === $registered_controller ) { | |
| 177 | - $this->route_map[ $route_pattern ] = $key; | |
| 178 | - break; | |
| 268 | + if ( ! $controller_obj ) { | |
| 269 | + continue; | |
| 179 | 270 | } |
| 271 | + | |
| 272 | + // Find which controller key this object belongs to. | |
| 273 | + foreach ( $this->controllers as $key => $registered_controller ) { | |
| 274 | + if ( $controller_obj === $registered_controller ) { | |
| 275 | + $this->route_map[ $route_pattern ] = $key; | |
| 276 | + break; | |
| 277 | + } | |
| 278 | + } | |
| 180 | 279 | } |
| 181 | 280 | } |
| 182 | 281 | } |
| 183 | 282 | } |
| @@ -182,42 +281,8 @@ | ||
| 182 | 281 | } |
| 183 | 282 | } |
| 184 | 283 | |
| 185 | 284 | /** |
| 186 | - * Add CORS headers to the REST API response. | |
| 187 | - * | |
| 188 | - * @param string[] $allow_headers The list of request headers to allow. | |
| 189 | - * | |
| 190 | - * @return string[] $allow_headers | |
| 191 | - */ | |
| 192 | - public function rest_allowed_cors_headers( array $allow_headers ): array { | |
| 193 | - $allow_headers[] = 'X-WCPOS'; | |
| 194 | - $allow_headers[] = 'X-HTTP-Method-Override'; | |
| 195 | - $allow_headers[] = 'X-WCPOS-Idempotency-Key'; | |
| 196 | - | |
| 197 | - return $allow_headers; | |
| 198 | - } | |
| 199 | - | |
| 200 | - /** | |
| 201 | - * Add Access Control Allow Headers for POS app. | |
| 202 | - * | |
| 203 | - * NOTE: I have seen this filter called with NULL for $served, it should be a boolean. | |
| 204 | - * | |
| 205 | - * @param mixed $served Whether the request has already been served. | |
| 206 | - * Default false. | |
| 207 | - * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`. | |
| 208 | - * @param WP_REST_Request $request Request used to generate the response. | |
| 209 | - * @param WP_REST_Server $server Server instance. | |
| 210 | - * | |
| 211 | - * @return bool $served | |
| 212 | - */ | |
| 213 | - public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) { | |
| 214 | - $server->send_header( 'Access-Control-Allow-Origin', '*' ); | |
| 215 | - | |
| 216 | - return $served; | |
| 217 | - } | |
| 218 | - | |
| 219 | - /** | |
| 220 | 285 | * Check request for any login tokens. |
| 221 | 286 | * |
| 222 | 287 | * Runs at priority 20, after other plugins (e.g. third-party JWT plugins at |
| 223 | 288 | * priority 10) have had a chance to authenticate the user. If another plugin |
| @@ -265,13 +330,14 @@ | ||
| 265 | 330 | * 1. WooCommerce issue #26847: determine_current_user may not be called when |
| 266 | 331 | * WordPress has already cached the current user. We attempt auth here as a |
| 267 | 332 | * fallback. |
| 268 | 333 | * |
| 269 | - * 2. JWT plugin conflict: a third-party JWT plugin (e.g. jwt-authentication-for-wp-rest-api) | |
| 270 | - * sees our Bearer token, fails to validate it with its own secret, and returns | |
| 271 | - * a WP_Error via rest_authentication_errors at priority 10. We run at priority 50 | |
| 272 | - * and attempt our own Bearer-token validation. If it succeeds, we clear the | |
| 273 | - * stale error — our authentication wins. | |
| 334 | + * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails | |
| 335 | + * to validate it with its own secret, and returns a WP_Error via | |
| 336 | + * rest_authentication_errors at priority 10. We run at priority 50 and attempt | |
| 337 | + * our own Bearer-token validation. If it succeeds, we clear the stale error — | |
| 338 | + * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its | |
| 339 | + * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().) | |
| 274 | 340 | * |
| 275 | 341 | * @param mixed $errors Authentication errors. |
| 276 | 342 | * |
| 277 | 343 | * @return mixed |
| @@ -284,19 +350,9 @@ | ||
| 284 | 350 | if ( ! empty( $errors ) ) { |
| 285 | 351 | // Only clear errors that originate from JWT authentication plugins. Errors |
| 286 | 352 | // from other mechanisms (maintenance locks, IP restrictions, etc.) should |
| 287 | 353 | // be passed through even when the WCPOS Bearer token is valid. |
| 288 | - $is_jwt_plugin_error = is_wp_error( $errors ) && 0 === strpos( $errors->get_error_code(), 'jwt_auth_' ); | |
| 289 | - | |
| 290 | - if ( $is_jwt_plugin_error && ! $this->authenticated_via_wcpos ) { | |
| 291 | - $user_id = $this->authenticate( false ); | |
| 292 | - if ( $user_id && ! is_wp_error( $user_id ) ) { | |
| 293 | - wp_set_current_user( $user_id ); | |
| 294 | - $this->authenticated_via_wcpos = true; | |
| 295 | - } | |
| 296 | - } | |
| 297 | - | |
| 298 | - if ( $this->authenticated_via_wcpos && $is_jwt_plugin_error ) { | |
| 354 | + if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) { | |
| 299 | 355 | return null; |
| 300 | 356 | } |
| 301 | 357 | |
| 302 | 358 | return $errors; |
| @@ -302,20 +358,80 @@ | ||
| 302 | 358 | return $errors; |
| 303 | 359 | } |
| 304 | 360 | |
| 305 | 361 | // check if determine_current_user has been called. |
| 306 | - if ( ! $this->is_auth_checked ) { | |
| 307 | - // Authentication hasn't occurred during `determine_current_user`, so check auth. | |
| 362 | + if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) { | |
| 363 | + // Authentication hadn't occurred during `determine_current_user`, but our token is valid. | |
| 364 | + return true; | |
| 365 | + } | |
| 366 | + | |
| 367 | + return $errors; | |
| 368 | + } | |
| 369 | + | |
| 370 | + /** | |
| 371 | + * Clear a third-party JWT plugin's stale error from the dispatch result. | |
| 372 | + * | |
| 373 | + * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every | |
| 374 | + * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails, | |
| 375 | + * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched; | |
| 376 | + * our priority-20 filter then authenticates the request. The plugin later returns | |
| 377 | + * that stored error from rest_pre_dispatch (priority 10, registered at | |
| 378 | + * plugins_loaded), which replaces the dispatch result with a 403. | |
| 379 | + * | |
| 380 | + * Priority 50: after the plugin's callback, and after our own priority-10 | |
| 381 | + * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched. | |
| 382 | + * | |
| 383 | + * Unlike rest_authentication_errors(), this never switches the current user: the | |
| 384 | + * priority-10 gate and the core-order audit guard have already judged the user in | |
| 385 | + * scope, so the error is cleared only when our token resolves to that same user. | |
| 386 | + * | |
| 387 | + * @param mixed $result Dispatch result, or null to not hijack the request. | |
| 388 | + * @param WP_REST_Server $server Server instance. | |
| 389 | + * @param WP_REST_Request $request Request used to generate the response. | |
| 390 | + * | |
| 391 | + * @return mixed | |
| 392 | + */ | |
| 393 | + public function clear_third_party_jwt_error( $result, $server, $request ) { | |
| 394 | + if ( ! $this->is_third_party_jwt_error( $result ) ) { | |
| 395 | + return $result; | |
| 396 | + } | |
| 397 | + | |
| 398 | + if ( ! $this->authenticated_via_wcpos ) { | |
| 308 | 399 | $user_id = $this->authenticate( false ); |
| 400 | + if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) { | |
| 401 | + $this->authenticated_via_wcpos = true; | |
| 402 | + } | |
| 403 | + } | |
| 404 | + | |
| 405 | + return $this->authenticated_via_wcpos ? null : $result; | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** | |
| 409 | + * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`). | |
| 410 | + * | |
| 411 | + * @param mixed $maybe_error Value to inspect. | |
| 412 | + * | |
| 413 | + * @return bool | |
| 414 | + */ | |
| 415 | + private function is_third_party_jwt_error( $maybe_error ): bool { | |
| 416 | + return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' ); | |
| 417 | + } | |
| 418 | + | |
| 419 | + /** | |
| 420 | + * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet. | |
| 421 | + * | |
| 422 | + * @return bool True when the request is authenticated via a WCPOS-issued token. | |
| 423 | + */ | |
| 424 | + private function ensure_authenticated_via_wcpos(): bool { | |
| 425 | + if ( ! $this->authenticated_via_wcpos ) { | |
| 426 | + $user_id = $this->authenticate( false ); | |
| 309 | 427 | if ( $user_id && ! is_wp_error( $user_id ) ) { |
| 310 | 428 | wp_set_current_user( $user_id ); |
| 311 | 429 | $this->authenticated_via_wcpos = true; |
| 312 | - | |
| 313 | - return true; | |
| 314 | 430 | } |
| 315 | 431 | } |
| 316 | 432 | |
| 317 | - return $errors; | |
| 433 | + return $this->authenticated_via_wcpos; | |
| 318 | 434 | } |
| 319 | 435 | |
| 320 | 436 | /** |
| 321 | 437 | * Extract the Authorization Bearer token from the request. |
| @@ -322,26 +438,9 @@ | ||
| 322 | 438 | * |
| 323 | 439 | * @return false|string |
| 324 | 440 | */ |
| 325 | 441 | public function get_auth_header() { |
| 326 | - // Check if HTTP_AUTHORIZATION is set and not empty | |
| 327 | - // (htaccess SetEnvIf can set an empty value when no header is present). | |
| 328 | - if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { | |
| 329 | - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); | |
| 330 | - } | |
| 331 | - | |
| 332 | - // Check for alternative header in $_SERVER. | |
| 333 | - if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { | |
| 334 | - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); | |
| 335 | - } | |
| 336 | - | |
| 337 | - // Check for authorization param in URL ($_GET). | |
| 338 | - if ( ! empty( $_GET['authorization'] ) ) { | |
| 339 | - return sanitize_text_field( wp_unslash( $_GET['authorization'] ) ); | |
| 340 | - } | |
| 341 | - | |
| 342 | - // Return false if none of the variables are set. | |
| 343 | - return false; | |
| 442 | + return Auth::instance()->get_auth_header(); | |
| 344 | 443 | } |
| 345 | 444 | |
| 346 | 445 | /** |
| 347 | 446 | * Adds info to the WP REST API index response. |
| @@ -352,18 +451,14 @@ | ||
| 352 | 451 | * |
| 353 | 452 | * @return WP_REST_Response |
| 354 | 453 | */ |
| 355 | 454 | public function rest_index( WP_REST_Response $response ): WP_REST_Response { |
| 356 | - $uuid = get_option( 'woocommerce_pos_uuid' ); | |
| 357 | - if ( ! $uuid ) { | |
| 358 | - $uuid = Uuid::uuid4()->toString(); | |
| 359 | - update_option( 'woocommerce_pos_uuid', $uuid ); | |
| 360 | - } | |
| 455 | + $uuid = wcpos_get_site_uuid(); | |
| 361 | 456 | $response->data['uuid'] = $uuid; |
| 362 | 457 | $response->data['wp_version'] = get_bloginfo( 'version' ); |
| 363 | 458 | $response->data['wc_version'] = WC()->version; |
| 364 | 459 | $response->data['wcpos_version'] = VERSION; |
| 365 | - $response->data['use_jwt_as_param'] = woocommerce_pos_get_settings( 'tools', 'use_jwt_as_param' ); | |
| 460 | + $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled(); | |
| 366 | 461 | |
| 367 | 462 | // Add WCPOS authentication endpoint to the response. |
| 368 | 463 | $response->data['authentication']['wcpos'] = array( |
| 369 | 464 | 'endpoints' => array( |
| @@ -398,24 +493,58 @@ | ||
| 398 | 493 | * |
| 399 | 494 | * @return mixed |
| 400 | 495 | */ |
| 401 | 496 | public function rest_pre_dispatch( $result, $server, $request ) { |
| 402 | - if ( strpos( $request->get_route(), '/wcpos/v1/' ) !== 0 ) { | |
| 497 | + if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) { | |
| 403 | 498 | return $result; |
| 404 | 499 | } |
| 405 | 500 | |
| 406 | - // Baseline permission gate: all POS endpoints require access_woocommerce_pos. | |
| 501 | + // Marker-gated on purpose (query var or header, NOT the rest_route arm, | |
| 502 | + // which matches this namespace by construction): every real POS client, | |
| 503 | + // old or new, carries the marker, while unmarked scanner traffic would | |
| 504 | + // otherwise inflate the `channel: none` tail this telemetry exists to | |
| 505 | + // measure (free#1752). The echo and auth lanes are excluded for the same | |
| 506 | + // reason: they are the gate's carve-outs, and a protocol-2 client's | |
| 507 | + // connect-time probes deliberately carry no signal — counting them would | |
| 508 | + // stamp every modern client with a daily false `none` row. | |
| 509 | + if ( 0 === stripos( $request->get_route(), '/wcpos/v2/' ) | |
| 510 | + && 1 !== preg_match( '#^/wcpos/v2/(?:echo$|auth(?:/|$))#i', $request->get_route() ) | |
| 511 | + && ( wcpos_request( 'query_var' ) || wcpos_request( 'header' ) ) ) { | |
| 512 | + try { | |
| 513 | + Client_Signal::record( $request ); | |
| 514 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Telemetry failures are deliberately ignored. | |
| 515 | + // Telemetry must never interrupt a POS request. | |
| 516 | + } | |
| 517 | + } | |
| 518 | + | |
| 519 | + // Latch the till's store scope for the whole request (pro#425). Set | |
| 520 | + // unconditionally — including to null — so a scope never leaks from one | |
| 521 | + // dispatch into the next. Inner `wc/v3` forwards do not reach this line | |
| 522 | + // (they are outside the WCPOS namespace), which is exactly right: the | |
| 523 | + // OUTER request owns the scope and stamps it onto the inner ones. | |
| 524 | + \WCPOS\WooCommercePOS\Sync\Store_Scope::set_current( | |
| 525 | + \WCPOS\WooCommercePOS\Sync\Store_Scope::resolve( $request ) | |
| 526 | + ); | |
| 527 | + | |
| 528 | + // CORS preflights carry no credentials (browsers strip Authorization from OPTIONS), | |
| 529 | + // so the permission gate must never answer them with 401 — a non-2xx preflight blocks | |
| 530 | + // every cross-origin standalone client from the entire namespace. WP core serves | |
| 531 | + // OPTIONS with route metadata and Rest_Cors::rest_pre_serve_request adds the CORS headers. | |
| 532 | + if ( 'OPTIONS' === $request->get_method() ) { | |
| 533 | + return $result; | |
| 534 | + } | |
| 535 | + | |
| 536 | + // Baseline permission gate: POS endpoints require access_woocommerce_pos; the three | |
| 537 | + // sync admin operations instead use their route-level manage_woocommerce check. | |
| 407 | 538 | // Exempt public auth, printer-token polling, and authenticated receipt denials that need |
| 408 | 539 | // the receipt-specific error code. |
| 409 | 540 | $route = $request->get_route(); |
| 410 | - $has_route_specific_permission_error = is_user_logged_in() && 0 === strpos( $route, '/wcpos/v1/receipts/' ); | |
| 411 | - $is_public_auth_route = \in_array( $route, array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ), true ); | |
| 412 | - $is_printer_token_route = \in_array( $route, array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ), true ) | |
| 413 | - || 0 === strpos( $route, '/wcpos/v1/print-jobs/cloudprnt/' ) | |
| 414 | - || 0 === strpos( $route, '/wcpos/v1/print-jobs/epson-sdp/' ); | |
| 415 | - $is_relay_verification_route = '/wcpos/v1/print-jobs/relay-verification' === $route; | |
| 541 | + $has_route_specific_permission_error = is_user_logged_in() && $this->route_classifier->is_permission_error_passthrough( $route ); | |
| 542 | + $is_public_auth_route = $this->route_classifier->is_public( $route ); | |
| 543 | + $is_printer_token_route = $this->route_classifier->is_printer_token( $route ); | |
| 544 | + $is_sync_admin_route = is_user_logged_in() && current_user_can( 'manage_woocommerce' ) && $this->route_classifier->is_admin_op( $route ); | |
| 416 | 545 | |
| 417 | - if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_relay_verification_route ) { | |
| 546 | + if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) { | |
| 418 | 547 | if ( ! current_user_can( 'access_woocommerce_pos' ) ) { |
| 419 | 548 | if ( ! is_user_logged_in() ) { |
| 420 | 549 | return new \WP_Error( |
| 421 | 550 | 'woocommerce_pos_rest_unauthorized', |
| @@ -433,8 +562,16 @@ | ||
| 433 | 562 | } |
| 434 | 563 | |
| 435 | 564 | $max_length = 10000; |
| 436 | 565 | |
| 566 | + // The sync sub-surface speaks its own wire contract (include = raw id | |
| 567 | + // list validated by its controllers); the wcpos_include/exclude rewrite | |
| 568 | + // below is a legacy extended-WC-controller workaround and must not | |
| 569 | + // mangle sync routes. | |
| 570 | + if ( $this->route_classifier->is_rewrite_exempt( $route ) ) { | |
| 571 | + return $result; | |
| 572 | + } | |
| 573 | + | |
| 437 | 574 | // Process 'include' parameter. |
| 438 | 575 | $include = $request->get_param( 'include' ); |
| 439 | 576 | if ( $include ) { |
| 440 | 577 | $processed_include = $this->shorten_param_array( $include, $max_length ); |
| @@ -453,8 +590,37 @@ | ||
| 453 | 590 | return $result; |
| 454 | 591 | } |
| 455 | 592 | |
| 456 | 593 | /** |
| 594 | + * Add the server pressure bucket to WCPOS REST responses. | |
| 595 | + * | |
| 596 | + * @param mixed $response REST response. | |
| 597 | + * @param WP_REST_Server $server REST server. | |
| 598 | + * @param WP_REST_Request $request REST request. | |
| 599 | + * | |
| 600 | + * @return mixed | |
| 601 | + */ | |
| 602 | + public function rest_post_dispatch( $response, $server, $request ) { | |
| 603 | + if ( is_wp_error( $response ) ) { | |
| 604 | + return $response; | |
| 605 | + } | |
| 606 | + | |
| 607 | + try { | |
| 608 | + if ( ! $response instanceof WP_HTTP_Response || ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) { | |
| 609 | + return $response; | |
| 610 | + } | |
| 611 | + $pressure_bucket = API\V2\Ping::pressure_bucket(); | |
| 612 | + if ( null !== $pressure_bucket ) { | |
| 613 | + $response->header( 'X-WCPOS-Pressure', $pressure_bucket ); | |
| 614 | + } | |
| 615 | + } catch ( \Throwable $e ) { | |
| 616 | + return $response; | |
| 617 | + } | |
| 618 | + | |
| 619 | + return $response; | |
| 620 | + } | |
| 621 | + | |
| 622 | + /** | |
| 457 | 623 | * Filters the REST API dispatch request result. |
| 458 | 624 | * |
| 459 | 625 | * @param mixed $dispatch_result Dispatch result, will be used if not empty. |
| 460 | 626 | * @param WP_REST_Request $request Request used to generate the response. |
| @@ -463,9 +629,9 @@ | ||
| 463 | 629 | * |
| 464 | 630 | * @return mixed |
| 465 | 631 | */ |
| 466 | 632 | public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) { |
| 467 | - // Only process wcpos/v1 routes. | |
| 633 | + // Only process mapped WCPOS routes. | |
| 468 | 634 | if ( ! isset( $this->route_map[ $route ] ) ) { |
| 469 | 635 | return $dispatch_result; |
| 470 | 636 | } |
| 471 | 637 | |
| @@ -542,33 +708,13 @@ | ||
| 542 | 708 | * |
| 543 | 709 | * @return false|int|\WP_Error |
| 544 | 710 | */ |
| 545 | 711 | private function authenticate( $user_id ) { |
| 546 | - // check if there is an auth header. | |
| 547 | - $auth_header = $this->get_auth_header(); | |
| 548 | - if ( ! \is_string( $auth_header ) ) { | |
| 549 | - return $user_id; | |
| 550 | - } | |
| 712 | + $authenticated_user_id = Auth::instance()->authenticate_request(); | |
| 551 | 713 | |
| 552 | - // Extract Bearer token from Authorization Header. | |
| 553 | - list($token) = sscanf( $auth_header, 'Bearer %s' ); | |
| 554 | - | |
| 555 | - if ( $token ) { | |
| 556 | - $auth_service = Auth::instance(); | |
| 557 | - $decoded_token = $auth_service->validate_token( $token ); | |
| 558 | - | |
| 559 | - // Check if validate_token returned WP_Error and user_id is null. | |
| 560 | - if ( is_wp_error( $decoded_token ) && false === $user_id ) { | |
| 561 | - return $decoded_token; | |
| 562 | - } | |
| 563 | - | |
| 564 | - // If the token is valid, set the user_id. | |
| 565 | - if ( ! is_wp_error( $decoded_token ) ) { | |
| 566 | - $user_id = $decoded_token->data->user->id; | |
| 567 | - | |
| 568 | - return absint( $user_id ); | |
| 569 | - } | |
| 714 | + if ( is_wp_error( $authenticated_user_id ) ) { | |
| 715 | + return false === $user_id ? $authenticated_user_id : $user_id; | |
| 570 | 716 | } |
| 571 | 717 | |
| 572 | - return $user_id; | |
| 718 | + return false === $authenticated_user_id ? $user_id : $authenticated_user_id; | |
| 573 | 719 | } |
| 574 | 720 | } |