PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / gsc / GscOAuthHandler.php

GscOAuthHandler.php in 404 Solution trunk, at includes/gsc/GscOAuthHandler.php

199 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Google Search Console OAuth callback and revocation handler.
9 *
10 * Supports both custom-credentials flow (code exchange) and centralized
11 * flow (Worker has already exchanged tokens).
12 */
13 class ABJ_404_Solution_GscOAuthHandler {
14
15 /**
16 * AJAX handler: OAuth callback from Google (custom mode) or from the
17 * centralized Worker (centralized mode).
18 *
19 * @return void
20 */
21 public static function handleCallback() {
22 if (!ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin()) {
23 wp_die(__('Insufficient permissions.', '404-solution'), 403);
24 }
25
26 $logger = abj_service('logging');
27 $gsc = new ABJ_404_Solution_GoogleSearchConsole($logger);
28
29 $isCentralized = isset($_GET['abj404_gsc_centralized']) && $_GET['abj404_gsc_centralized'] === '1';
30
31 if ($isCentralized) {
32 self::handleCentralizedCallback($gsc);
33 return;
34 }
35
36 $code = ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => 'code'));
37 $state = ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => 'state'));
38
39 if (!wp_verify_nonce($state, 'abj404_gsc_oauth')) {
40 wp_die(__('Security check failed.', '404-solution'), 403);
41 }
42
43 if ($code === '') {
44 $gsc->oauthStore()->setLastOAuthError(__('Authorization was denied or cancelled.', '404-solution'));
45 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
46 exit;
47 }
48
49 $error = $gsc->oauthStore()->exchangeCodeForToken($code);
50
51 if ($error !== '') {
52 $gsc->oauthStore()->setLastOAuthError($error);
53 }
54 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
55 exit;
56 }
57
58 /**
59 * Handle the centralized OAuth callback.
60 *
61 * @param ABJ_404_Solution_GoogleSearchConsole $gsc
62 * @return void
63 */
64 private static function handleCentralizedCallback(ABJ_404_Solution_GoogleSearchConsole $gsc): void {
65 $nonce = ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => 'nonce'));
66
67 if (!wp_verify_nonce($nonce, 'abj404_gsc_oauth')) {
68 wp_die(__('Security check failed.', '404-solution'), 403);
69 }
70
71 $payload = self::verifiedCentralizedPayload($nonce);
72
73 $error = self::payloadString($payload, 'abj404_gsc_error');
74 if ($error !== '') {
75 $gsc->oauthStore()->setLastOAuthError($error);
76 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
77 exit;
78 }
79
80 $accessToken = self::payloadString($payload, 'access_token');
81 $refreshToken = self::payloadString($payload, 'refresh_token');
82 $expiresIn = self::payloadInt($payload, 'expires_in', 3600);
83
84 if ($accessToken === '') {
85 $gsc->oauthStore()->setLastOAuthError(__('No access token received from authorization.', '404-solution'));
86 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
87 exit;
88 }
89
90 $gsc->oauthStore()->storeCentralizedTokens($accessToken, $refreshToken, $expiresIn);
91
92 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
93 exit;
94 }
95
96 /**
97 * Verify and decode the centralized Worker's signed callback payload.
98 *
99 * @param string $nonce WordPress OAuth callback nonce.
100 * @return array<string, mixed>
101 */
102 private static function verifiedCentralizedPayload(string $nonce): array {
103 $encodedPayload = ABJ_404_Solution_RequestInputNormalizer::readText(
104 $_GET, array('name' => 'abj404_gsc_payload'));
105 $signature = ABJ_404_Solution_RequestInputNormalizer::readText(
106 $_GET, array('name' => 'abj404_gsc_signature'));
107
108 $secretKey = ABJ_404_Solution_GscConfig::centralizedCallbackSecretTransientKey($nonce);
109 $secret = get_transient($secretKey);
110
111 if ($encodedPayload === '' || $signature === '' || !is_string($secret) || $secret === '') {
112 wp_die(__('Security check failed.', '404-solution'), 403);
113 }
114
115 $expectedSignature = hash_hmac('sha256', $encodedPayload, $secret);
116 if (!hash_equals($expectedSignature, $signature)) {
117 wp_die(__('Security check failed.', '404-solution'), 403);
118 }
119
120 $json = self::base64UrlDecode($encodedPayload);
121 if ($json === '') {
122 wp_die(__('Security check failed.', '404-solution'), 403);
123 }
124
125 $payload = json_decode($json, true);
126 if (!is_array($payload)) {
127 wp_die(__('Security check failed.', '404-solution'), 403);
128 }
129
130 $normalizedPayload = array();
131 foreach ($payload as $key => $value) {
132 if (!is_string($key)) {
133 wp_die(__('Security check failed.', '404-solution'), 403);
134 }
135 $normalizedPayload[$key] = $value;
136 }
137
138 $payloadNonce = $normalizedPayload['nonce'] ?? null;
139 if (!is_scalar($payloadNonce) || (string)$payloadNonce !== $nonce) {
140 wp_die(__('Security check failed.', '404-solution'), 403);
141 }
142
143 delete_transient($secretKey);
144
145 return $normalizedPayload;
146 }
147
148 private static function base64UrlDecode(string $value): string {
149 $padding = strlen($value) % 4;
150 if ($padding !== 0) {
151 $value .= str_repeat('=', 4 - $padding);
152 }
153
154 $decoded = base64_decode(strtr($value, '-_', '+/'), true);
155 return is_string($decoded) ? $decoded : '';
156 }
157
158 /**
159 * @param array<string, mixed> $payload
160 */
161 private static function payloadString(array $payload, string $key): string {
162 $value = $payload[$key] ?? '';
163 return is_scalar($value) ? sanitize_text_field((string)$value) : '';
164 }
165
166 /**
167 * @param array<string, mixed> $payload
168 */
169 private static function payloadInt(array $payload, string $key, int $default): int {
170 $value = $payload[$key] ?? null;
171 if (is_int($value)) {
172 return $value;
173 }
174 if (is_string($value) && is_numeric($value)) {
175 return (int)$value;
176 }
177 return $default;
178 }
179
180 /**
181 * AJAX handler: revoke GSC authorization.
182 *
183 * @return void
184 */
185 public static function handleRevoke() {
186 if (!ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin() ||
187 !check_admin_referer('abj404_gsc_revoke')) {
188 wp_die(__('Security check failed.', '404-solution'), 403);
189 }
190
191 $logger = abj_service('logging');
192 $gsc = new ABJ_404_Solution_GoogleSearchConsole($logger);
193 $gsc->oauthStore()->revokeAuthorization();
194
195 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
196 exit;
197 }
198 }
199