PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / google-calendar / Controller / calendar-auth-controller.php

calendar-auth-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/module/google-calendar/Controller/calendar-auth-controller.php

742 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Calendar Auth Controller
4 *
5 * @package BookticsGoogleCalendar\Google\Controller
6 */
7
8 namespace Booktics\Module\Google_Calendar\Controller;
9
10 if ( ! defined( 'ABSPATH' ) ) exit;
11
12 use Booktics\Abstracts\Base_Rest_Controller;
13 use Exception;
14 use WP_REST_Request;
15 use WP_REST_Server;
16 use WP_Error;
17 use WP_HTTP_Response;
18
19 /**
20 * Class Calendar_Auth_Controller
21 */
22 class Calendar_Auth_Controller extends Base_Rest_Controller {
23
24 /**
25 * The namespace of this controller's route.
26 *
27 * @var string
28 */
29 protected $namespace = 'booktics/v1';
30
31 /**
32 * The base of this controller's route.
33 *
34 * @var string
35 */
36 protected $rest_base = 'settings/google';
37
38 /**
39 * User meta key for storing Google tokens
40 */
41 const BGC_USER_META_TOKEN = 'booktics_google_auth_token';
42
43 /**
44 * Transient prefix for one-time OAuth state tokens.
45 */
46 const BGC_OAUTH_STATE_PREFIX = 'booktics_gcal_oauth_';
47
48 /**
49 * Lifetime of an OAuth state token.
50 */
51 const BGC_OAUTH_STATE_TTL = 900; // 15 minutes.
52
53 /**
54 * Google OAuth configuration.
55 *
56 * @var array
57 */
58 private $oauth_config;
59
60 /**
61 * Initialize the class and set up hooks.
62 */
63 public function __construct() {
64 $this->oauth_config = $this->get_oauth_config();
65 add_action( 'template_redirect', array( $this, 'handle_google_auth_callback' ) );
66 add_action( 'admin_notices', array( $this, 'permalink_warning_notice' ) );
67
68 // In your addon's main file or a dedicated class
69 add_filter(
70 'booktics_team_member_data',
71 function ( $team_member, $team_member_id ) {
72 $team_member['google_calendar_connected'] = $this->team_member_is_connected( $team_member_id );
73
74 return $team_member;
75 },
76 10,
77 2
78 );
79 }
80
81 /**
82 * Warn the admin when the permalink structure breaks the OAuth callback.
83 *
84 * The Google callback lives at a pretty path ( booktics-integration/
85 * google-auth ), which WordPress only routes when permalinks are not set to
86 * Plain. On Plain that URL 404s and the connection silently fails, so a
87 * heads-up is shown — but only once Google is actually configured, to keep
88 * the notice off screens where it would be noise.
89 *
90 * @return void
91 */
92 public function permalink_warning_notice() {
93 if ( ! current_user_can( 'manage_options' ) ) {
94 return;
95 }
96
97 // Plain permalinks store an empty structure.
98 if ( get_option( 'permalink_structure' ) ) {
99 return;
100 }
101
102 if ( ! booktics_get_option( 'google_client_id' ) ) {
103 return;
104 }
105
106 printf(
107 '<div class="notice notice-warning"><p>%s</p></div>',
108 wp_kses(
109 sprintf(
110 /* translators: %s: link to the WordPress permalink settings screen. */
111 __( 'Booktics: Google Calendar sign-in needs a permalink structure other than <strong>Plain</strong>. Open <a href="%s">Settings &rarr; Permalinks</a> and pick <strong>Post name</strong>, or the Google connection will fail.', 'booktics' ),
112 esc_url( admin_url( 'options-permalink.php' ) )
113 ),
114 array(
115 'strong' => array(),
116 'a' => array( 'href' => array() ),
117 )
118 )
119 );
120 }
121
122 /**
123 * Get OAuth configuration for Google API
124 *
125 * @return array
126 */
127 private function get_oauth_config() {
128 return array(
129 'client_id' => booktics_get_option( 'google_client_id' ),
130 'client_secret' => booktics_get_option( 'google_secret_key' ),
131 'redirect_uri' => booktics_get_option( 'google_redirect_url' ),
132 'scope' => 'https://www.googleapis.com/auth/calendar',
133 'access_type' => 'offline',
134 'prompt' => 'consent',
135 );
136 }
137
138 /**
139 * Get valid access token for user, refreshing if necessary
140 *
141 * @param int $user_id
142 * @return string|false Access token or false on failure
143 */
144 private function get_valid_access_token( $user_id = null ) {
145 if ( null === $user_id ) {
146 $user_id = get_current_user_id();
147 }
148
149 $token = get_user_meta( $user_id, self::BGC_USER_META_TOKEN, true );
150 if ( ! $token ) {
151 return false;
152 }
153
154 // Handle both string (old format) and array (new format) tokens
155 if ( is_string( $token ) ) {
156 $token = json_decode( $token, true );
157 }
158
159 if ( ! is_array( $token ) || empty( $token['access_token'] ) ) {
160 return false;
161 }
162
163 // Check if token needs refreshing
164 if ( $this->is_token_expired( $token ) ) {
165 $refreshed_token = $this->refresh_access_token( $token, $user_id );
166 if ( ! $refreshed_token ) {
167 delete_user_meta( $user_id, self::BGC_USER_META_TOKEN );
168 return false;
169 }
170 return $refreshed_token['access_token'];
171 }
172
173 return $token['access_token'];
174 }
175
176 /**
177 * Check if token is expired
178 *
179 * @param array $token
180 * @return bool
181 */
182 private function is_token_expired( $token ) {
183 if ( empty( $token['expires_at'] ) ) {
184 return true;
185 }
186
187 $expires_at = is_numeric( $token['expires_at'] ) ? $token['expires_at'] : strtotime( $token['expires_at'] );
188 $buffer_time = 300; // 5 minutes buffer
189
190 return ( time() + $buffer_time ) >= $expires_at;
191 }
192
193 /**
194 * Refresh access token using refresh token
195 *
196 * @param array $token
197 * @param int $user_id
198 * @return array|false New token array or false on failure
199 */
200 private function refresh_access_token( $token, $user_id ) {
201 if ( empty( $token['refresh_token'] ) ) {
202 return false;
203 }
204
205 $response = wp_remote_post(
206 'https://oauth2.googleapis.com/token',
207 array(
208 'body' => array(
209 'client_id' => $this->oauth_config['client_id'],
210 'client_secret' => $this->oauth_config['client_secret'],
211 'refresh_token' => $token['refresh_token'],
212 'grant_type' => 'refresh_token',
213 ),
214 )
215 );
216
217 if ( is_wp_error( $response ) ) {
218 return false;
219 }
220
221 $body = wp_remote_retrieve_body( $response );
222 $new_token = json_decode( $body, true );
223
224 if ( isset( $new_token['error'] ) ) {
225 return false;
226 }
227
228 // Merge with existing token to preserve refresh_token
229 $refreshed_token = array_merge( $token, $new_token );
230
231 // Set expiry time
232 if ( isset( $refreshed_token['expires_in'] ) ) {
233 $refreshed_token['expires_at'] = time() + $refreshed_token['expires_in'];
234 }
235
236 // Save the refreshed token
237 update_user_meta( $user_id, self::BGC_USER_META_TOKEN, $refreshed_token );
238
239 return $refreshed_token;
240 }
241
242 /**
243 * Issue a one-time OAuth state token.
244 *
245 * The token is random and carries no caller-supplied data; the real
246 * payload (who started the flow and which team member it is for) is held
247 * server-side in a transient. This is what makes the callback trustworthy:
248 * the state cannot be forged to name an arbitrary user, and it cannot be
249 * replayed because it is deleted on first use.
250 *
251 * @param int $team_member_id Target team member/user id.
252 * @return string The opaque state token to hand to Google.
253 */
254 private function issue_oauth_state( $team_member_id ) {
255 $token = wp_generate_password( 40, false, false ); // URL-safe: letters + digits only.
256
257 set_transient(
258 self::BGC_OAUTH_STATE_PREFIX . $token,
259 array(
260 'initiator' => get_current_user_id(),
261 'team_member_id' => absint( $team_member_id ),
262 ),
263 self::BGC_OAUTH_STATE_TTL
264 );
265
266 return $token;
267 }
268
269 /**
270 * Validate and consume a one-time OAuth state token.
271 *
272 * @param string $token State token returned by Google.
273 * @return array|false Stored { initiator, team_member_id }, or false when
274 * the token is unknown, expired or already used.
275 */
276 private function consume_oauth_state( $token ) {
277 $key = self::BGC_OAUTH_STATE_PREFIX . sanitize_text_field( $token );
278 $data = get_transient( $key );
279
280 if ( false === $data || ! is_array( $data ) || ! isset( $data['initiator'], $data['team_member_id'] ) ) {
281 return false;
282 }
283
284 delete_transient( $key ); // Single use.
285
286 return $data;
287 }
288
289 /**
290 * Handle direct OAuth callback from Google.
291 */
292 public function handle_google_auth_callback() {
293 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback; CSRF is prevented by the signed, single-use state verified below.
294 if ( ! isset( $_GET['code'] ) ) {
295 return;
296 }
297
298 // A server-issued state is mandatory. It is a random, single-use token
299 // bound to the initiating user and the target team member. Without a
300 // valid state we cannot trust who this callback is for, so we refuse
301 // instead of falling back to the current user (which allowed login CSRF
302 // and let a forged state overwrite any user's Google tokens).
303 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
304 $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
305 $state_data = '' !== $state ? $this->consume_oauth_state( $state ) : false;
306
307 if ( ! $state_data ) {
308 wp_safe_redirect( $this->get_redirect_url_for_admin() );
309 exit;
310 }
311
312 $team_member_id = absint( $state_data['team_member_id'] );
313 $initiator = absint( $state_data['initiator'] );
314
315 // The browser finishing the OAuth round-trip must belong to the same
316 // logged-in user who started it, and that user must still be allowed to
317 // manage the target team member's connection.
318 if ( get_current_user_id() !== $initiator
319 || ( $initiator !== $team_member_id && ! current_user_can( 'edit_user', $team_member_id ) ) ) {
320 wp_safe_redirect( $this->get_redirect_url_for_admin() );
321 exit;
322 }
323
324 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
325 $code = sanitize_text_field( wp_unslash( $_GET['code'] ) );
326
327 try {
328 $tokens = $this->exchange_code_for_tokens( $code );
329
330 if ( ! $tokens || isset( $tokens['error'] ) ) {
331 throw new Exception( $tokens['error_description'] ?? 'Authentication failed' );
332 }
333
334 // Set expiry time
335 if ( isset( $tokens['expires_in'] ) ) {
336 $tokens['expires_at'] = time() + $tokens['expires_in'];
337 }
338
339 update_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, $tokens );
340
341 if ( current_user_can( 'manage_options' ) && $team_member_id === get_current_user_id() ) {
342 booktics_update_option( 'google_calendar_connected', 1 );
343 }
344
345 $redirect_url = ( $team_member_id !== get_current_user_id() ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
346
347 wp_safe_redirect( $redirect_url );
348 exit;
349
350 } catch ( Exception $e ) {
351 $redirect_url = ( $team_member_id !== get_current_user_id() ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
352
353 wp_safe_redirect( $redirect_url );
354 exit;
355 }
356 }
357
358 /**
359 * Exchange authorization code for access tokens
360 *
361 * @param string $code
362 * @return array|false
363 */
364 private function exchange_code_for_tokens( $code ) {
365 $response = wp_remote_post(
366 'https://oauth2.googleapis.com/token',
367 array(
368 'body' => array(
369 'client_id' => $this->oauth_config['client_id'],
370 'client_secret' => $this->oauth_config['client_secret'],
371 'code' => $code,
372 'grant_type' => 'authorization_code',
373 'redirect_uri' => $this->oauth_config['redirect_uri'],
374 ),
375 )
376 );
377
378 if ( is_wp_error( $response ) ) {
379 return false;
380 }
381
382 $body = wp_remote_retrieve_body( $response );
383 return json_decode( $body, true );
384 }
385
386 private function get_redirect_url_for_admin() {
387 return home_url( '/wp-admin/admin.php?page=booktics#/settings?tab=integration' );
388 }
389
390 private function get_redirect_url_for_team_member( $team_member_id ) {
391 return home_url( "/wp-admin/admin.php?page=booktics#/team-members/update/{$team_member_id}?tab=integration" );
392 }
393
394 /**
395 * Register the routes for the controller.
396 */
397 public function register_routes(): void {
398 // Get Google auth URL
399 register_rest_route(
400 $this->namespace,
401 $this->rest_base . '/connect',
402 array(
403 array(
404 'methods' => WP_REST_Server::READABLE,
405 'callback' => array( $this, 'get_auth_url' ),
406 'permission_callback' => array( $this, 'permission_check' ),
407 'args' => array(
408 'team_member_id' => array(
409 'description' => __( 'ID of the team member to connect', 'booktics' ),
410 'type' => 'integer',
411 'required' => false,
412 ),
413 ),
414 ),
415 )
416 );
417
418 // Revoke authentication
419 register_rest_route(
420 $this->namespace,
421 $this->rest_base . '/revoke',
422 array(
423 array(
424 'methods' => WP_REST_Server::DELETABLE,
425 'callback' => array( $this, 'revoke_auth' ),
426 'permission_callback' => array( $this, 'permission_check' ),
427 'args' => array(
428 'team_member_id' => array(
429 'description' => __( 'ID of the team member to disconnect', 'booktics' ),
430 'type' => 'integer',
431 'required' => false,
432 ),
433 ),
434 ),
435 )
436 );
437
438 // Google connect for admin
439 register_rest_route(
440 $this->namespace,
441 $this->rest_base . '/admin/connect',
442 array(
443 array(
444 'methods' => WP_REST_Server::CREATABLE,
445 'callback' => array( $this, 'get_admin_auth_url' ),
446 'permission_callback' => function () {
447 return current_user_can( 'manage_options' );
448 },
449 ),
450 )
451 );
452
453 // Check authentication status
454 register_rest_route(
455 $this->namespace,
456 $this->rest_base . '/check',
457 array(
458 array(
459 'methods' => WP_REST_Server::READABLE,
460 'callback' => array( $this, 'check_auth' ),
461 'permission_callback' => array( $this, 'permission_check' ),
462 'args' => array(
463 'team_member_id' => array(
464 'description' => __( 'ID of the team member to check', 'booktics' ),
465 'type' => 'integer',
466 'required' => false,
467 ),
468 ),
469 ),
470 )
471 );
472 }
473
474 /**
475 * Check if a given request has permission.
476 *
477 * @return bool|WP_Error
478 */
479 public function permission_check( $request ) {
480 if ( ! is_user_logged_in() ) {
481 return $this->error(
482 __( 'You must be logged in to access this endpoint.', 'booktics' ),
483 401,
484 'permission_error'
485 );
486 }
487
488 // Get team member ID from request
489 $team_member_id = $this->get_team_member_id( $request );
490
491 // If no team member ID specified, allow if user can manage options
492 if ( ! $team_member_id ) {
493 return current_user_can( 'manage_options' );
494 }
495
496 // If team member ID is specified, check if current user can edit that user
497 if ( get_current_user_id() !== $team_member_id && ! current_user_can( 'edit_user', $team_member_id ) ) {
498 return $this->error(
499 __( 'You do not have permission to access this user\'s Google Calendar settings.', 'booktics' ),
500 403,
501 'permission_error'
502 );
503 }
504
505 return true;
506 }
507
508 public function get_admin_auth_url( $request ) {
509 $data = json_decode( $request->get_body(), true );
510 $google_client_id = $data['google_client_id'];
511 $google_client_secret = $data['google_secret_key'];
512 $block_booking_during_google_events = isset( $data['block_booking_during_google_events'] ) ? $data['block_booking_during_google_events'] : false;
513
514 // save credentials in options
515 booktics_update_option( 'google_client_id', $google_client_id );
516 booktics_update_option( 'google_secret_key', $google_client_secret );
517 booktics_update_option( 'block_booking_during_google_events', $block_booking_during_google_events );
518
519 // Update OAuth config
520 $this->oauth_config['client_id'] = $google_client_id;
521 $this->oauth_config['client_secret'] = $google_client_secret;
522
523 // Bind the flow to this admin with a random, single-use state.
524 $state = $this->issue_oauth_state( get_current_user_id() );
525 $auth_url = $this->create_auth_url( $state );
526
527 return $this->response(
528 array( 'auth_url' => $auth_url ),
529 __( 'Google authentication URL generated successfully.', 'booktics' )
530 );
531 }
532
533 /**
534 * Get Google OAuth URL.
535 *
536 * @param WP_REST_Request $request
537 * @return WP_Error|WP_HTTP_Response
538 */
539 public function get_auth_url( $request ) {
540 try {
541 // permission_check() has already confirmed the caller may manage
542 // this team member; bind the flow to them with a random, single-use
543 // state rather than an encoded, forgeable user id.
544 $team_member_id = absint( $request->get_param( 'team_member_id' ) );
545 if ( ! $team_member_id ) {
546 $team_member_id = get_current_user_id();
547 }
548
549 $state = $this->issue_oauth_state( $team_member_id );
550
551 $auth_url = $this->create_auth_url( $state );
552
553 if ( empty( $auth_url ) ) {
554 return $this->error(
555 __( 'Failed to generate Google authentication URL.', 'booktics' ),
556 500,
557 'auth_url_error'
558 );
559 }
560
561 return $this->response(
562 array( 'auth_url' => $auth_url ),
563 __( 'Google authentication URL generated successfully.', 'booktics' )
564 );
565
566 } catch ( Exception $e ) {
567 return $this->error(
568 /* translators: %s: Error message */
569 sprintf( __( 'Failed to generate Google auth URL: %s', 'booktics' ), $e->getMessage() ),
570 500,
571 'auth_error'
572 );
573 }
574 }
575
576 /**
577 * Create OAuth authorization URL
578 *
579 * @param string $state Optional state parameter
580 * @return string
581 */
582 private function create_auth_url( $state = '' ) {
583 $params = array(
584 'client_id' => $this->oauth_config['client_id'],
585 'redirect_uri' => $this->oauth_config['redirect_uri'],
586 'scope' => $this->oauth_config['scope'],
587 'response_type' => 'code',
588 'access_type' => $this->oauth_config['access_type'],
589 'prompt' => $this->oauth_config['prompt'],
590 );
591
592 if ( ! empty( $state ) ) {
593 $params['state'] = $state;
594 }
595
596 return 'https://accounts.google.com/o/oauth2/auth?' . http_build_query( $params );
597 }
598
599 /**
600 * Revoke Google authentication.
601 *
602 * @param WP_REST_Request $request
603 * @return WP_Error|WP_HTTP_Response
604 */
605 public function revoke_auth( $request ) {
606 try {
607 $team_member_id = $this->get_team_member_id( $request );
608
609 if ( current_user_can( 'manage_options' ) && (int)$team_member_id == get_current_user_id() ) {
610 booktics_update_option( 'google_calendar_connected', 0 );
611 }
612
613 // Get the token before revoking
614 $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
615
616 if ( empty( $token ) ) {
617 return $this->response(
618 array( 'revoked' => true ),
619 __( 'Google account was not connected.', 'booktics' )
620 );
621 }
622
623 // Handle both string (old format) and array (new format) tokens
624 if ( is_string( $token ) ) {
625 $token = json_decode( $token, true );
626 }
627
628 // Revoke the token
629 if ( isset( $token['access_token'] ) ) {
630 $this->revoke_token( $token['access_token'] );
631 }
632
633 // Delete the token
634 delete_user_meta( $team_member_id, self::BGC_USER_META_TOKEN );
635
636 return $this->response(
637 array( 'revoked' => true ),
638 __( 'Google authentication revoked successfully.', 'booktics' )
639 );
640
641 } catch ( Exception $e ) {
642 return $this->error(
643 /* translators: %s: Error message */
644 sprintf( __( 'Failed to revoke Google authentication: %s', 'booktics' ), $e->getMessage() ),
645 500,
646 'revoke_error'
647 );
648 }
649 }
650
651 /**
652 * Revoke access token at Google
653 *
654 * @param string $access_token
655 * @return bool
656 */
657 private function revoke_token( $access_token ) {
658 $response = wp_remote_post(
659 'https://oauth2.googleapis.com/revoke',
660 array(
661 'body' => array(
662 'token' => $access_token,
663 ),
664 )
665 );
666
667 return ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200;
668 }
669
670 /**
671 * Check authentication status.
672 *
673 * @param WP_REST_Request $request
674 * @return WP_Error|WP_HTTP_Response
675 */
676 public function check_auth( $request ) {
677 try {
678 $team_member_id = $this->get_team_member_id( $request );
679 $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
680 $is_authenticated = false;
681
682 if ( $token ) {
683 // Handle both string (old format) and array (new format) tokens
684 if ( is_string( $token ) ) {
685 $token = json_decode( $token, true );
686 }
687
688 if ( is_array( $token ) && isset( $token['access_token'] ) ) {
689 $is_authenticated = ! $this->is_token_expired( $token );
690
691 // If token is expired but we have a refresh token, try to refresh
692 if ( ! $is_authenticated && isset( $token['refresh_token'] ) ) {
693 $new_token = $this->refresh_access_token( $token, $team_member_id );
694 if ( $new_token ) {
695 $is_authenticated = true;
696 }
697 }
698 }
699 }
700
701 return $this->response(
702 array( 'authenticated' => $is_authenticated ),
703 $is_authenticated
704 ? __( 'Google account is connected.', 'booktics' )
705 : __( 'Google account is not connected.', 'booktics' )
706 );
707
708 } catch ( Exception $e ) {
709 return $this->error(
710 /* translators: %s: Error message */
711 sprintf( __( 'Failed to check authentication status: %s', 'booktics' ), $e->getMessage() ),
712 500,
713 'auth_check_error'
714 );
715 }
716 }
717
718 private function get_team_member_id( $request ) {
719 $data = $request->get_method() === 'GET'
720 ? $request->get_query_params()
721 : json_decode( $request->get_body(), true );
722
723 if ( isset( $data['team_member_id'] ) ) {
724 return absint( $data['team_member_id'] );
725 }
726
727 return get_current_user_id();
728 }
729
730 /**
731 * Check if team member is connected
732 *
733 * @param int $team_member_id team member id or user id
734 * @return int 1 if connected, 0 if not
735 */
736 private function team_member_is_connected( $team_member_id ) {
737 $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
738
739 return empty( $token ) ? 0 : 1;
740 }
741 }
742