PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.16
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.9.16, at includes/Templates/Auth.php

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