← 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 → 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 ) ) { |