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
← All changes | core/module/google-calendar/Controller/calendar-auth-controller.php +138 -21 1.0.21 → 1.0.29 View file →
@@ -40,8 +40,18 @@
40 40 */
41 41 const BGC_USER_META_TOKEN = 'booktics_google_auth_token';
42 42
43 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 + /**
44 54 * Google OAuth configuration.
45 55 *
46 56 * @var array
47 57 */
@@ -52,8 +62,9 @@
52 62 */
53 63 public function __construct() {
54 64 $this->oauth_config = $this->get_oauth_config();
55 65 add_action( 'template_redirect', array( $this, 'handle_google_auth_callback' ) );
66 + add_action( 'admin_notices', array( $this, 'permalink_warning_notice' ) );
56 67
57 68 // In your addon's main file or a dedicated class
58 69 add_filter(
59 70 'booktics_team_member_data',
@@ -67,8 +78,49 @@
67 78 );
68 79 }
69 80
70 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 + /**
71 123 * Get OAuth configuration for Google API
72 124 *
73 125 * @return array
74 126 */
@@ -187,31 +239,92 @@
187 239 return $refreshed_token;
188 240 }
189 241
190 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 + /**
191 290 * Handle direct OAuth callback from Google.
192 291 */
193 292 public function handle_google_auth_callback() {
194 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback doesnt require nonce
293 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback; CSRF is prevented by the signed, single-use state verified below.
195 294 if ( ! isset( $_GET['code'] ) ) {
196 295 return;
197 296 }
198 297
199 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback doesnt require nonce
200 - if ( isset( $_GET['state'] ) ) {
201 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended
202 - $decoded_state = base64_decode( sanitize_text_field( wp_unslash( $_GET['state'] ) ) );
203 - $state_data = json_decode( $decoded_state, true );
204 - $team_member_id = isset( $state_data['team_member_id'] ) ? $state_data['team_member_id'] : null;
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;
205 310 }
206 311
207 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended
208 - $code = sanitize_text_field( wp_unslash( $_GET['code'] ) );
312 + $team_member_id = absint( $state_data['team_member_id'] );
313 + $initiator = absint( $state_data['initiator'] );
209 314
210 - if ( ! isset( $team_member_id ) ) {
211 - $team_member_id = get_current_user_id();
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;
212 322 }
213 323
324 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended
325 + $code = sanitize_text_field( wp_unslash( $_GET['code'] ) );
326 +
214 327 try {
215 328 $tokens = $this->exchange_code_for_tokens( $code );
216 329
217 330 if ( ! $tokens || isset( $tokens['error'] ) ) {
@@ -224,19 +337,19 @@
224 337 }
225 338
226 339 update_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, $tokens );
227 340
228 - if ( current_user_can( 'manage_options' ) ) {
341 + if ( current_user_can( 'manage_options' ) && $team_member_id === get_current_user_id() ) {
229 342 booktics_update_option( 'google_calendar_connected', 1 );
230 343 }
231 344
232 - $redirect_url = isset( $team_member_id ) && (int)$team_member_id != get_current_user_id() ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
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();
233 346
234 347 wp_safe_redirect( $redirect_url );
235 348 exit;
236 349
237 350 } catch ( Exception $e ) {
238 - $redirect_url = isset( $team_member_id ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
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();
239 352
240 353 wp_safe_redirect( $redirect_url );
241 354 exit;
242 355 }
@@ -406,9 +519,11 @@
406 519 // Update OAuth config
407 520 $this->oauth_config['client_id'] = $google_client_id;
408 521 $this->oauth_config['client_secret'] = $google_client_secret;
409 522
410 - $auth_url = $this->create_auth_url();
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 );
411 526
412 527 return $this->response(
413 528 array( 'auth_url' => $auth_url ),
414 529 __( 'Google authentication URL generated successfully.', 'booktics' )
@@ -422,15 +537,17 @@
422 537 * @return WP_Error|WP_HTTP_Response
423 538 */
424 539 public function get_auth_url( $request ) {
425 540 try {
426 - // Get any parameters you want to pass through
427 - $state_params = array(
428 - 'team_member_id' => absint( $request->get_param( 'team_member_id' ) ),
429 - );
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 + }
430 548
431 - // Convert to JSON and base64 encode to make it URL-safe
432 - $state = base64_encode( wp_json_encode( $state_params ) );
549 + $state = $this->issue_oauth_state( $team_member_id );
433 550
434 551 $auth_url = $this->create_auth_url( $state );
435 552
436 553 if ( empty( $auth_url ) ) {