PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Templates / Auth.php

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

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