PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.9
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.9
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
woocommerce-pos / includes / Templates / Auth.php

Auth.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.9, at includes/Templates/Auth.php

590 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Auth template
4 * NOTE: This is for authentication via JWT, used by mobile/desktop apps.
5 *
6 * Security measures:
7 * - Direct credential validation (bypasses wp_authenticate to avoid 2FA/captcha plugins)
8 * - Rate limiting by IP address
9 * - Account lockout after failed attempts
10 * - Honeypot field for bot detection
11 * - Auth session expiration
12 * - State parameter validation
13 * - Redirect URI scheme validation
14 *
15 * @package WCPOS\WooCommercePOS
16 */
17
18 namespace WCPOS\WooCommercePOS\Templates;
19
20 use WCPOS\WooCommercePOS\Logger;
21 use WCPOS\WooCommercePOS\Services\Auth as AuthService;
22 use WP_Error;
23 use WP_User;
24
25 /**
26 * Auth template.
27 */
28 class Auth {
29 /**
30 * Rate limit: max attempts per IP per time window.
31 */
32 private const MAX_ATTEMPTS_PER_IP = 10;
33
34 /**
35 * Rate limit: time window in seconds (15 minutes).
36 */
37 private const RATE_LIMIT_WINDOW = 900;
38
39 /**
40 * Account lockout: max failed attempts per username.
41 */
42 private const MAX_FAILED_ATTEMPTS = 5;
43
44 /**
45 * Account lockout: duration in seconds (15 minutes).
46 */
47 private const LOCKOUT_DURATION = 900;
48
49 /**
50 * Auth session expiration in seconds (10 minutes).
51 */
52 private const AUTH_SESSION_EXPIRY = 600;
53
54 /**
55 * Allowed redirect URI schemes.
56 *
57 * The native app registers one URL scheme per build profile so that a
58 * device with two variants installed (e.g. the store build and the dev
59 * client) returns the login to the app that started it: `wcpos` for the
60 * store build, `wcpos-dev` for the development client, `wcpos-adhoc` for
61 * ad-hoc test builds (monorepo `apps/main/app.config.ts`). `exp` is the
62 * Expo Go client. Matching is an exact `<scheme>://` prefix, so listing
63 * `wcpos-dev` does not admit `wcpos-devious`.
64 *
65 * @var array
66 */
67 private const ALLOWED_SCHEMES = array( 'wcpos', 'wcpos-dev', 'wcpos-adhoc', 'exp', 'https', 'http' );
68
69 /**
70 * The redirect URI.
71 *
72 * @var string
73 */
74 private $redirect_uri;
75
76 /**
77 * The state parameter.
78 *
79 * @var string
80 */
81 private $state;
82
83 /**
84 * Error message.
85 *
86 * @var string
87 */
88 private $error;
89
90 /**
91 * Auth session token (for expiring auth URLs).
92 *
93 * @var string
94 */
95 private $auth_session;
96
97 /**
98 * Constructor.
99 */
100 public function __construct() {
101 // Hide the admin bar for a clean login UI.
102 add_filter( 'show_admin_bar', '__return_false' );
103
104 // Initialize properties.
105 $this->redirect_uri = $this->validate_redirect_uri( isset( $_REQUEST['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['redirect_uri'] ) ) : '' );
106 $this->state = sanitize_text_field( wp_unslash( $_REQUEST['state'] ?? '' ) );
107 $this->auth_session = sanitize_text_field( wp_unslash( $_REQUEST['auth_session'] ?? '' ) );
108 $this->error = '';
109
110 // Validate required parameters.
111 if ( empty( $this->redirect_uri ) ) {
112 $this->error = __( 'Missing or invalid redirect_uri parameter.', 'woocommerce-pos' );
113
114 return;
115 }
116
117 if ( empty( $this->state ) ) {
118 $this->error = /* translators: Short WCPOS UI label; keep concise. */ __( 'Missing state parameter.', 'woocommerce-pos' );
119
120 return;
121 }
122
123 // Create or validate auth session (for expiring auth URLs).
124 if ( ! $this->validate_or_create_auth_session() ) {
125 return;
126 }
127
128 // Check IP rate limit before processing.
129 if ( $this->is_ip_rate_limited() ) {
130 $this->error = __( 'Too many requests. Please try again later.', 'woocommerce-pos' );
131 $this->log_auth_attempt( '', 'rate_limited' );
132
133 return;
134 }
135
136 // Handle form submission.
137 $this->handle_form_submission();
138 }
139
140 /**
141 * Get the redirect URI.
142 *
143 * @return string
144 */
145 public function get_redirect_uri(): string {
146 return $this->redirect_uri;
147 }
148
149 /**
150 * Get the state parameter.
151 *
152 * @return string
153 */
154 public function get_state(): string {
155 return $this->state;
156 }
157
158 /**
159 * Get the error message.
160 *
161 * @return string
162 */
163 public function get_error(): string {
164 return $this->error;
165 }
166
167 /**
168 * Get the auth session token.
169 *
170 * @return string
171 */
172 public function get_auth_session(): string {
173 return $this->auth_session;
174 }
175
176 /**
177 * Render the auth template.
178 *
179 * @return void
180 */
181 public function get_template(): void {
182 // NOTE: We intentionally do NOT call do_action('login_init') here.
183 // This auth form bypasses WordPress's standard login flow to avoid
184 // interference from security plugins (2FA, captcha, etc.).
185
186 /*
187 * Fires before the WCPOS auth template is rendered.
188 *
189 * @since 1.0.0
190 *
191 * @hook woocommerce_pos_auth_template_redirect
192 */
193 do_action( 'woocommerce_pos_auth_template_redirect' );
194
195 // Make this instance available to the template.
196 global $wcpos_auth_instance; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
197 $wcpos_auth_instance = $this; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
198
199 include woocommerce_pos_locate_template( 'auth.php' );
200 exit;
201 }
202
203 /**
204 * Validate and sanitize redirect URI.
205 *
206 * @param string $uri The URI to validate.
207 *
208 * @return string Empty string if invalid.
209 */
210 private function validate_redirect_uri( string $uri ): string {
211 if ( empty( $uri ) ) {
212 return '';
213 }
214
215 // Remove control characters.
216 $uri = preg_replace( '/[\x00-\x1f\x7f]/', '', $uri );
217
218 // Check if URI starts with an allowed scheme.
219 foreach ( self::ALLOWED_SCHEMES as $scheme ) {
220 if ( 0 === stripos( $uri, $scheme . '://' ) ) {
221 // For http/https, use esc_url for full validation.
222 if ( 'http' === $scheme || 'https' === $scheme ) {
223 return esc_url( $uri, array( 'http', 'https' ) );
224 }
225
226 // For custom schemes (wcpos://, exp://), just return it
227 // These are app deep links, not web URLs.
228 return $uri;
229 }
230 }
231
232 return '';
233 }
234
235 /**
236 * Validate or create auth session to prevent expired/reused auth URLs.
237 *
238 * @return bool
239 */
240 private function validate_or_create_auth_session(): bool {
241 $session_key = 'wcpos_auth_session_' . md5( $this->state . $this->redirect_uri );
242
243 if ( empty( $this->auth_session ) ) {
244 // First visit - create session.
245 $this->auth_session = wp_generate_password( 32, false );
246 set_transient( $session_key, $this->auth_session, self::AUTH_SESSION_EXPIRY );
247
248 return true;
249 }
250
251 // Validate existing session.
252 $stored_session = get_transient( $session_key );
253
254 if ( ! $stored_session ) {
255 $this->error = __( 'Auth session expired. Please try logging in again from the app.', 'woocommerce-pos' );
256
257 return false;
258 }
259
260 if ( ! hash_equals( $stored_session, $this->auth_session ) ) {
261 $this->error = __( 'Invalid auth session. Please try logging in again from the app.', 'woocommerce-pos' );
262
263 return false;
264 }
265
266 return true;
267 }
268
269 /**
270 * Handle form submission.
271 *
272 * @return void
273 */
274 private function handle_form_submission(): void {
275 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
276 return;
277 }
278
279 // Verify nonce for security.
280 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'wcpos_auth' ) ) {
281 $this->error = __( 'Security check failed. Please try again.', 'woocommerce-pos' );
282
283 return;
284 }
285
286 // Check honeypot field (should be empty).
287 if ( ! empty( $_POST['wcpos_website'] ?? '' ) ) {
288 // Bot detected - silently fail with generic error.
289 $this->log_auth_attempt( '', 'honeypot_triggered' );
290 sleep( 2 ); // Slow down bots.
291 $this->error = /* translators: Short WCPOS UI label; keep concise. */ __( 'Authentication failed.', 'woocommerce-pos' );
292
293 return;
294 }
295
296 $username = sanitize_user( wp_unslash( $_POST['wcpos-log'] ?? '' ) );
297 $password = isset( $_POST['wcpos-pwd'] ) ? wp_unslash( $_POST['wcpos-pwd'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Password must not be sanitized before authentication.
298
299 // Check if username is locked out.
300 if ( $this->is_username_locked( $username ) ) {
301 $this->error = __( 'This account is temporarily locked due to too many failed login attempts. Please try again later.', 'woocommerce-pos' );
302 $this->log_auth_attempt( $username, 'locked_out' );
303
304 return;
305 }
306
307 // Authenticate user directly (bypasses wp_authenticate filter chain).
308 $user = $this->authenticate_direct( $username, $password );
309
310 if ( is_wp_error( $user ) ) {
311 $this->record_failed_attempt( $username );
312 $this->increment_ip_attempts();
313 $this->log_auth_attempt( $username, 'failed', $user->get_error_code() );
314 $this->error = $user->get_error_message();
315
316 return;
317 }
318
319 // Check if user has access to POS.
320 if ( ! user_can( $user, 'access_woocommerce_pos' ) ) {
321 $this->log_auth_attempt( $username, 'no_permission' );
322 $this->error = __( 'You do not have permission to access the POS.', 'woocommerce-pos' );
323
324 return;
325 }
326
327 // Clear failed attempts on successful login.
328 $this->clear_failed_attempts( $username );
329
330 // Clean up auth session.
331 $session_key = 'wcpos_auth_session_' . md5( $this->state . $this->redirect_uri );
332 delete_transient( $session_key );
333
334 // Log successful auth.
335 $this->log_auth_attempt( $username, 'success' );
336
337 // Generate JWT token using Services/Auth.
338 $auth_service = AuthService::instance();
339 $redirect_data = $auth_service->get_redirect_data( $user );
340
341 if ( empty( $redirect_data ) ) {
342 $this->error = __( 'Failed to generate authentication tokens.', 'woocommerce-pos' );
343
344 return;
345 }
346
347 // On success, redirect back to app (or fallback to dashboard).
348 $redirect_params = array(
349 'access_token' => rawurlencode( $redirect_data['access_token'] ),
350 'refresh_token' => rawurlencode( $redirect_data['refresh_token'] ),
351 'token_type' => rawurlencode( $redirect_data['token_type'] ),
352 'expires_at' => \intval( $redirect_data['expires_at'] ),
353 'id' => \intval( $redirect_data['id'] ),
354 'uuid' => rawurlencode( $redirect_data['uuid'] ),
355 'display_name' => rawurlencode( $redirect_data['display_name'] ),
356 );
357
358 // Include state parameter if it was provided.
359 if ( ! empty( $this->state ) ) {
360 $redirect_params['state'] = rawurlencode( $this->state );
361 }
362
363 $target = $this->redirect_uri
364 ? add_query_arg( $redirect_params, $this->redirect_uri )
365 : admin_url();
366
367 wp_redirect( $target );
368 exit;
369 }
370
371 /**
372 * Authenticate user directly, bypassing the authenticate filter chain.
373 *
374 * This intentionally bypasses 2FA, captcha, and other security plugin hooks
375 * because this is a non-interactive authentication flow for mobile/desktop apps.
376 *
377 * Security is maintained through:
378 * - Rate limiting
379 * - Account lockout
380 * - Auth session expiration
381 * - Honeypot fields
382 *
383 * @param string $username The username.
384 * @param string $password The password.
385 *
386 * @return WP_Error|WP_User
387 */
388 private function authenticate_direct( string $username, string $password ) {
389 if ( empty( $username ) || empty( $password ) ) {
390 return new WP_Error(
391 'empty_credentials',
392 __( 'Please enter both username and password.', 'woocommerce-pos' )
393 );
394 }
395
396 // Get user by login or email.
397 $user = get_user_by( 'login', $username );
398 if ( ! $user ) {
399 $user = get_user_by( 'email', $username );
400 }
401
402 if ( ! $user ) {
403 // Use generic message to prevent username enumeration.
404 return new WP_Error(
405 'invalid_credentials',
406 __( 'Invalid username or password.', 'woocommerce-pos' )
407 );
408 }
409
410 // Check if password is correct.
411 if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) {
412 // Use same generic message.
413 return new WP_Error(
414 'invalid_credentials',
415 __( 'Invalid username or password.', 'woocommerce-pos' )
416 );
417 }
418
419 /*
420 * Allow plugins to block authentication if absolutely necessary.
421 *
422 * This is a WCPOS-specific filter that runs AFTER password validation.
423 * Use this sparingly - the purpose of this auth flow is to bypass
424 * interactive security measures.
425 *
426 * @param WP_Error|WP_User $user The authenticated user or WP_Error.
427 * @param string $username The username used.
428 *
429 * @return WP_Error|WP_User
430 *
431 * @since 1.8.0
432 *
433 * @hook woocommerce_pos_authenticate_user
434 */
435 return apply_filters( 'woocommerce_pos_authenticate_user', $user, $username );
436 }
437
438 /**
439 * Check if IP is rate limited.
440 *
441 * @return bool
442 */
443 private function is_ip_rate_limited(): bool {
444 $ip = $this->get_client_ip();
445 $transient_key = 'wcpos_auth_ip_' . md5( $ip );
446 $attempts = (int) get_transient( $transient_key );
447
448 return $attempts >= self::MAX_ATTEMPTS_PER_IP;
449 }
450
451 /**
452 * Increment IP attempt counter.
453 *
454 * @return void
455 */
456 private function increment_ip_attempts(): void {
457 $ip = $this->get_client_ip();
458 $transient_key = 'wcpos_auth_ip_' . md5( $ip );
459 $attempts = (int) get_transient( $transient_key );
460
461 set_transient( $transient_key, $attempts + 1, self::RATE_LIMIT_WINDOW );
462 }
463
464 /**
465 * Check if username is locked out.
466 *
467 * @param string $username The username to check.
468 *
469 * @return bool
470 */
471 private function is_username_locked( string $username ): bool {
472 if ( empty( $username ) ) {
473 return false;
474 }
475
476 $transient_key = 'wcpos_auth_lock_' . md5( strtolower( $username ) );
477
478 return false !== get_transient( $transient_key );
479 }
480
481 /**
482 * Record a failed login attempt for a username.
483 *
484 * @param string $username The username that failed.
485 *
486 * @return void
487 */
488 private function record_failed_attempt( string $username ): void {
489 if ( empty( $username ) ) {
490 return;
491 }
492
493 $username_key = md5( strtolower( $username ) );
494 $attempts_key = 'wcpos_auth_fail_' . $username_key;
495 $attempts = (int) get_transient( $attempts_key );
496 $attempts++;
497
498 set_transient( $attempts_key, $attempts, self::LOCKOUT_DURATION );
499
500 // Lock account after max attempts.
501 if ( $attempts >= self::MAX_FAILED_ATTEMPTS ) {
502 $lock_key = 'wcpos_auth_lock_' . $username_key;
503 set_transient( $lock_key, time(), self::LOCKOUT_DURATION );
504
505 // Log the lockout.
506 Logger::log(
507 \sprintf(
508 'WCPOS Auth: Account locked - username: %s, IP: %s, attempts: %d',
509 $username,
510 $this->get_client_ip(),
511 $attempts
512 )
513 );
514 }
515 }
516
517 /**
518 * Clear failed attempts after successful login.
519 *
520 * @param string $username The username to clear.
521 *
522 * @return void
523 */
524 private function clear_failed_attempts( string $username ): void {
525 if ( empty( $username ) ) {
526 return;
527 }
528
529 $username_key = md5( strtolower( $username ) );
530 delete_transient( 'wcpos_auth_fail_' . $username_key );
531 delete_transient( 'wcpos_auth_lock_' . $username_key );
532 }
533
534 /**
535 * Get client IP address.
536 *
537 * @return string
538 */
539 private function get_client_ip(): string {
540 $headers = array(
541 'HTTP_CF_CONNECTING_IP', // Cloudflare.
542 'HTTP_X_FORWARDED_FOR',
543 'HTTP_X_REAL_IP',
544 'REMOTE_ADDR',
545 );
546
547 foreach ( $headers as $header ) {
548 if ( ! empty( $_SERVER[ $header ] ) ) {
549 $ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
550 // Handle comma-separated IPs.
551 if ( false !== strpos( $ip, ',' ) ) {
552 $parts = explode( ',', $ip );
553 $ip = trim( $parts[0] );
554 }
555
556 if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
557 return $ip;
558 }
559 }
560 }
561
562 return 'unknown';
563 }
564
565 /**
566 * Log authentication attempt.
567 *
568 * @param string $username The username attempted.
569 * @param string $status The status: success, failed, rate_limited, locked_out, honeypot_triggered, no_permission.
570 * @param string $error_code Optional error code for failed attempts.
571 *
572 * @return void
573 */
574 private function log_auth_attempt( string $username, string $status, string $error_code = '' ): void {
575 $log_entry = \sprintf(
576 'WCPOS Auth: %s - username: %s, IP: %s, state: %s',
577 $status,
578 $username ? $username : 'unknown',
579 $this->get_client_ip(),
580 substr( $this->state, 0, 8 ) . '...' // Truncate state for logs.
581 );
582
583 if ( $error_code ) {
584 $log_entry .= ', error: ' . $error_code;
585 }
586
587 Logger::log( $log_entry );
588 }
589 }
590