PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/API.php +108 -194 1.10.41.10.19 View file →
@@ -9,8 +9,9 @@
9 9 */
10 10
11 11 namespace WCPOS\WooCommercePOS;
12 12
13 +use WCPOS\WooCommercePOS\API\Controller_Registry;
13 14 use WCPOS\WooCommercePOS\Services\Auth;
14 15 use WCPOS\WooCommercePOS\Services\Client_Signal;
15 16 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
16 17 use WP_HTTP_Response;
@@ -27,23 +28,15 @@
27 28 */
28 29 public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' );
29 30
30 31 /**
31 - * WCPOS REST API namespaces and endpoints.
32 + * Controller instances and route attribution.
32 33 *
33 - * @var array
34 + * @var Controller_Registry
34 35 */
35 - protected $controllers = array();
36 + protected Controller_Registry $registry;
36 37
37 38 /**
38 - * Map of route patterns to controller keys.
39 - * Built during register_routes() for use in rest_dispatch_request().
40 - *
41 - * @var array<string, string>
42 - */
43 - protected $route_map = array();
44 -
45 - /**
46 39 * Route permission-gate classifier.
47 40 *
48 41 * @var API\Route_Classifier
49 42 */
@@ -56,8 +49,15 @@
56 49 */
57 50 protected $is_auth_checked = false;
58 51
59 52 /**
53 + * Validation error for a token presented during this request.
54 + *
55 + * @var \WP_Error|null
56 + */
57 + private $auth_error = null;
58 +
59 + /**
60 60 * Flag to track whether WCPOS successfully authenticated the current request
61 61 * via its own Bearer token. Used to suppress errors from third-party JWT
62 62 * plugins that inspected the same Authorization header but could not validate
63 63 * a WCPOS-issued token with their own secret.
@@ -84,8 +84,9 @@
84 84
85 85 // These filters allow changes to the WC REST API response.
86 86 add_filter( 'rest_dispatch_request', array( $this, 'rest_dispatch_request' ), 10, 4 );
87 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 );
88 89 add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 );
89 90 }
90 91
91 92 /**
@@ -118,167 +119,13 @@
118 119 public function register_routes(): void {
119 120 $route_namespaces = $this->get_route_namespaces();
120 121 $this->route_classifier = new API\Route_Classifier( $route_namespaces );
121 122
122 - /**
123 - * Filter the list of controller classes used in the WCPOS REST API.
124 - *
125 - * This filter allows customizing or extending the set of controller classes that handle
126 - * REST API routes for the WCPOS. By filtering these controllers, plugins can
127 - * modify existing endpoints or add new controllers for additional functionality.
128 - * Core legacy controllers use their versioned WCPOS\WooCommercePOS\API\V1 FQCNs.
129 - *
130 - * @since 1.5.0
131 - *
132 - * @param array $controllers Associative array of controller identifiers to their corresponding class names.
133 - * - 'auth' => Fully qualified name of the class handling authentication.
134 - * - 'settings' => Fully qualified name of the class handling settings.
135 - * - 'cashier' => Fully qualified name of the class handling cashier management.
136 - * - 'products' => Fully qualified name of the class handling products.
137 - * - 'product_variations' => Fully qualified name of the class handling product variations.
138 - * - 'orders' => Fully qualified name of the class handling orders.
139 - * - 'customers' => Fully qualified name of the class handling customers.
140 - * - 'product_tags' => Fully qualified name of the class handling product tags.
141 - * - 'product_categories' => Fully qualified name of the class handling product categories.
142 - * - 'taxes' => Fully qualified name of the class handling taxes.
143 - * - 'shipping_methods' => Fully qualified name of the class handling shipping methods.
144 - * - 'tax_classes' => Fully qualified name of the class handling tax classes.
145 - * - 'order_statuses' => Fully qualified name of the class handling order statuses.
146 - */
147 - $classes = apply_filters(
148 - 'woocommerce_pos_rest_api_controllers',
149 - array(
150 - // WCPOS rest api controllers.
151 - 'auth' => API\V1\Auth::class,
152 - 'settings' => API\V1\Settings::class,
153 - 'cashier' => API\V1\Cashier::class,
154 - 'templates' => API\V1\Templates_Controller::class,
155 - 'receipts' => API\V1\Receipts_Controller::class,
156 - 'print_jobs' => API\V1\Print_Jobs_Controller::class,
123 + $this->registry = new Controller_Registry();
124 + $this->registry->register( $this->route_classifier );
157 125
158 - // TODO: remove this?
159 - 'stores' => API\V1\Stores::class,
160 - 'extensions' => API\V1\Extensions::class,
161 - 'logs' => API\V1\Logs::class,
162 - 'payment_gateways' => API\V1\Payment_Gateways::class,
163 - 'gateway_bootstrap' => API\V1\Gateway_Bootstrap_Controller::class,
164 - 'checkout' => API\V1\Checkout_Controller::class,
165 -
166 - // extend WC REST API controllers.
167 - 'products' => API\V1\Products_Controller::class,
168 - 'product_variations' => API\V1\Product_Variations_Controller::class,
169 - 'orders' => API\V1\Orders_Controller::class,
170 - 'customers' => API\V1\Customers_Controller::class,
171 - 'product_tags' => API\V1\Product_Tags_Controller::class,
172 - 'product_categories' => API\V1\Product_Categories_Controller::class,
173 - 'product_brands' => API\V1\Product_Brands_Controller::class,
174 - 'coupons' => API\V1\Coupons_Controller::class,
175 - 'taxes' => API\V1\Taxes_Controller::class,
176 - 'shipping_methods' => API\V1\Shipping_Methods_Controller::class,
177 - 'tax_classes' => API\V1\Tax_Classes_Controller::class,
178 - 'order_statuses' => API\V1\Data_Order_Statuses_Controller::class,
179 - )
180 - );
181 -
182 - /**
183 - * Filter the wcpos/v2 service pass-through controllers (additive to the
184 - * frozen v1 surface — the legacy data controllers stay v1-only).
185 - *
186 - * Extensions that replace a v1 service through
187 - * `woocommerce_pos_rest_api_controllers` must carry their service onto
188 - * the v2 surface here with their own pass-through subclass (override
189 - * `$namespace = 'wcpos/v2'`), exactly as core does — the v2 map is not
190 - * derived from the v1 map, so a v1 replacement alone leaves the v2
191 - * twin serving core behavior.
192 - *
193 - * @since 1.10.0
194 - *
195 - * @param array $controllers Associative array of v2 service controller class names.
196 - */
197 - $v2_classes = apply_filters(
198 - 'woocommerce_pos_rest_api_v2_controllers',
199 - array(
200 - 'ping' => API\V2\Ping::class,
201 - 'echo_probe' => API\V2\Echo_Probe::class,
202 - 'site' => API\V2\Site::class,
203 - 'auth' => API\V2\Auth::class,
204 - 'settings' => API\V2\Settings::class,
205 - 'cashier' => API\V2\Cashier::class,
206 - 'templates' => API\V2\Templates_Controller::class,
207 - 'receipts' => API\V2\Receipts_Controller::class,
208 - 'print_jobs' => API\V2\Print_Jobs_Controller::class,
209 - 'stores' => API\V2\Stores::class,
210 - 'extensions' => API\V2\Extensions::class,
211 - 'logs' => API\V2\Logs::class,
212 - 'payment_gateways' => API\V2\Payment_Gateways::class,
213 - 'gateway_bootstrap' => API\V2\Gateway_Bootstrap_Controller::class,
214 - 'checkout' => API\V2\Checkout_Controller::class,
215 - 'order_email' => API\V2\Order_Email_Controller::class,
216 - 'shipping_methods' => API\V2\Shipping_Methods_Controller::class,
217 - 'tax_classes' => API\V2\Tax_Classes_Controller::class,
218 - 'order_statuses' => API\V2\Data_Order_Statuses_Controller::class,
219 - )
220 - );
221 - foreach ( $v2_classes as $key => $class ) {
222 - $classes[ 'v2-' . $key ] = $class;
223 - }
224 - $legacy_classifications = array(
225 - 'auth' => array( 'public' => array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ) ),
226 - 'print_jobs' => array( 'printer_token' => array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ) ),
227 - 'receipts' => array( 'permission_error_passthrough' => array( '/wcpos/v1/receipts/' ) ),
228 - );
229 -
230 - foreach ( $classes as $key => $class ) {
231 - if ( class_exists( $class ) ) {
232 - $this->controllers[ $key ] = new $class();
233 - $this->controllers[ $key ]->register_routes();
234 -
235 - if ( method_exists( $this->controllers[ $key ], 'wcpos_route_classifications' ) ) {
236 - $this->route_classifier->merge( $this->controllers[ $key ]->wcpos_route_classifications() );
237 - } elseif ( isset( $legacy_classifications[ $key ] ) ) {
238 - $this->route_classifier->merge( $legacy_classifications[ $key ] );
239 - }
240 - }
241 - }
242 -
243 126 // Sync classifications are independent of feature-gated route registration.
244 127 $this->route_classifier->merge( Sync\Api::route_classifications() );
245 -
246 - // Build route map for use in rest_dispatch_request().
247 - $rest_server = rest_get_server();
248 -
249 - foreach ( $route_namespaces as $route_namespace ) {
250 - $all_routes = $rest_server->get_routes( $route_namespace );
251 -
252 - foreach ( $all_routes as $route_pattern => $route_handlers ) {
253 - foreach ( $route_handlers as $route_handler ) {
254 - $callback = $route_handler['callback'] ?? null;
255 -
256 - // Extract the controller object from the callback.
257 - $controller_obj = null;
258 - if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) {
259 - $controller_obj = $callback[0];
260 - } elseif ( $callback instanceof \Closure ) {
261 - // WC 10.5+ RestApiCache wraps callbacks in closures.
262 - // Use reflection to extract the bound $this.
263 - $ref = new \ReflectionFunction( $callback );
264 - $controller_obj = $ref->getClosureThis();
265 - }
266 -
267 - if ( ! $controller_obj ) {
268 - continue;
269 - }
270 -
271 - // Find which controller key this object belongs to.
272 - foreach ( $this->controllers as $key => $registered_controller ) {
273 - if ( $controller_obj === $registered_controller ) {
274 - $this->route_map[ $route_pattern ] = $key;
275 - break;
276 - }
277 - }
278 - }
279 - }
280 - }
281 128 }
282 129
283 130 /**
284 131 * Check request for any login tokens.
@@ -329,13 +176,14 @@
329 176 * 1. WooCommerce issue #26847: determine_current_user may not be called when
330 177 * WordPress has already cached the current user. We attempt auth here as a
331 178 * fallback.
332 179 *
333 - * 2. JWT plugin conflict: a third-party JWT plugin (e.g. jwt-authentication-for-wp-rest-api)
334 - * sees our Bearer token, fails to validate it with its own secret, and returns
335 - * a WP_Error via rest_authentication_errors at priority 10. We run at priority 50
336 - * and attempt our own Bearer-token validation. If it succeeds, we clear the
337 - * stale error — our authentication wins.
180 + * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails
181 + * to validate it with its own secret, and returns a WP_Error via
182 + * rest_authentication_errors at priority 10. We run at priority 50 and attempt
183 + * our own Bearer-token validation. If it succeeds, we clear the stale error —
184 + * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its
185 + * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().)
338 186 *
339 187 * @param mixed $errors Authentication errors.
340 188 *
341 189 * @return mixed
@@ -348,19 +196,9 @@
348 196 if ( ! empty( $errors ) ) {
349 197 // Only clear errors that originate from JWT authentication plugins. Errors
350 198 // from other mechanisms (maintenance locks, IP restrictions, etc.) should
351 199 // be passed through even when the WCPOS Bearer token is valid.
352 - $is_jwt_plugin_error = is_wp_error( $errors ) && 0 === strpos( $errors->get_error_code(), 'jwt_auth_' );
353 -
354 - if ( $is_jwt_plugin_error && ! $this->authenticated_via_wcpos ) {
355 - $user_id = $this->authenticate( false );
356 - if ( $user_id && ! is_wp_error( $user_id ) ) {
357 - wp_set_current_user( $user_id );
358 - $this->authenticated_via_wcpos = true;
359 - }
360 - }
361 -
362 - if ( $this->authenticated_via_wcpos && $is_jwt_plugin_error ) {
200 + if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) {
363 201 return null;
364 202 }
365 203
366 204 return $errors;
@@ -366,20 +204,80 @@
366 204 return $errors;
367 205 }
368 206
369 207 // check if determine_current_user has been called.
370 - if ( ! $this->is_auth_checked ) {
371 - // Authentication hasn't occurred during `determine_current_user`, so check auth.
208 + if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) {
209 + // Authentication hadn't occurred during `determine_current_user`, but our token is valid.
210 + return true;
211 + }
212 +
213 + return $errors;
214 + }
215 +
216 + /**
217 + * Clear a third-party JWT plugin's stale error from the dispatch result.
218 + *
219 + * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every
220 + * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails,
221 + * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched;
222 + * our priority-20 filter then authenticates the request. The plugin later returns
223 + * that stored error from rest_pre_dispatch (priority 10, registered at
224 + * plugins_loaded), which replaces the dispatch result with a 403.
225 + *
226 + * Priority 50: after the plugin's callback, and after our own priority-10
227 + * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched.
228 + *
229 + * Unlike rest_authentication_errors(), this never switches the current user: the
230 + * priority-10 gate and the core-order audit guard have already judged the user in
231 + * scope, so the error is cleared only when our token resolves to that same user.
232 + *
233 + * @param mixed $result Dispatch result, or null to not hijack the request.
234 + * @param WP_REST_Server $server Server instance.
235 + * @param WP_REST_Request $request Request used to generate the response.
236 + *
237 + * @return mixed
238 + */
239 + public function clear_third_party_jwt_error( $result, $server, $request ) {
240 + if ( ! $this->is_third_party_jwt_error( $result ) ) {
241 + return $result;
242 + }
243 +
244 + if ( ! $this->authenticated_via_wcpos ) {
372 245 $user_id = $this->authenticate( false );
246 + if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) {
247 + $this->authenticated_via_wcpos = true;
248 + }
249 + }
250 +
251 + return $this->authenticated_via_wcpos ? null : $result;
252 + }
253 +
254 + /**
255 + * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`).
256 + *
257 + * @param mixed $maybe_error Value to inspect.
258 + *
259 + * @return bool
260 + */
261 + private function is_third_party_jwt_error( $maybe_error ): bool {
262 + return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' );
263 + }
264 +
265 + /**
266 + * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet.
267 + *
268 + * @return bool True when the request is authenticated via a WCPOS-issued token.
269 + */
270 + private function ensure_authenticated_via_wcpos(): bool {
271 + if ( ! $this->authenticated_via_wcpos ) {
272 + $user_id = $this->authenticate( false );
373 273 if ( $user_id && ! is_wp_error( $user_id ) ) {
374 274 wp_set_current_user( $user_id );
375 275 $this->authenticated_via_wcpos = true;
376 -
377 - return true;
378 276 }
379 277 }
380 278
381 - return $errors;
279 + return $this->authenticated_via_wcpos;
382 280 }
383 281
384 282 /**
385 283 * Extract the Authorization Bearer token from the request.
@@ -493,12 +391,27 @@
493 391
494 392 if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) {
495 393 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
496 394 if ( ! is_user_logged_in() ) {
395 + $data = array( 'status' => 401 );
396 + if ( null !== $this->auth_error ) {
397 + $data['reason'] = $this->auth_error->get_error_code();
398 + if ( 'woocommerce_pos_auth_token_expired' !== $data['reason'] ) {
399 + Logger::warning(
400 + 'POS request refused: ' . $data['reason'] . ' — ' . $this->auth_error->get_error_message(),
401 + array(
402 + 'route' => $route,
403 + 'method' => $request->get_method(),
404 + 'reason' => $data['reason'],
405 + )
406 + );
407 + }
408 + }
409 +
497 410 return new \WP_Error(
498 411 'woocommerce_pos_rest_unauthorized',
499 412 __( 'Authentication required.', 'woocommerce-pos' ),
500 - array( 'status' => 401 )
413 + $data
501 414 );
502 415 }
503 416
504 417 return new \WP_Error(
@@ -578,9 +491,10 @@
578 491 * @return mixed
579 492 */
580 493 public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) {
581 494 // Only process mapped WCPOS routes.
582 - if ( ! isset( $this->route_map[ $route ] ) ) {
495 + $controller = $this->registry->controller_for_route( $route );
496 + if ( null === $controller ) {
583 497 return $dispatch_result;
584 498 }
585 499
586 500 /*
@@ -599,12 +513,9 @@
599 513 @ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- intentionally disabling error display for POS API responses.
600 514 @ini_set( 'precision', '10' );
601 515 @ini_set( 'serialize_precision', '10' );
602 516
603 - $key = $this->route_map[ $route ];
604 - $controller = $this->controllers[ $key ] ?? null;
605 -
606 - if ( $controller && method_exists( $controller, 'wcpos_dispatch_request' ) ) {
517 + if ( method_exists( $controller, 'wcpos_dispatch_request' ) ) {
607 518 return $controller->wcpos_dispatch_request( $dispatch_result, $request, $route, $handler );
608 519 }
609 520
610 521 return $dispatch_result;
@@ -656,11 +567,14 @@
656 567 *
657 568 * @return false|int|\WP_Error
658 569 */
659 570 private function authenticate( $user_id ) {
571 + // Per-request: never let a previous authentication's verdict describe this one.
572 + $this->auth_error = null;
660 573 $authenticated_user_id = Auth::instance()->authenticate_request();
661 574
662 575 if ( is_wp_error( $authenticated_user_id ) ) {
576 + $this->auth_error = $authenticated_user_id;
663 577 return false === $user_id ? $authenticated_user_id : $user_id;
664 578 }
665 579
666 580 return false === $authenticated_user_id ? $user_id : $authenticated_user_id;