PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.20
ووسلام – همگام سازی ووکامرس و باسلام v1.10.20
1.10.19 1.10.20 1.10.18 1.10.17 1.10.15 1.10.14 1.10.13 1.10.12 1.10.10 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.2 1.9.1 1.9.0 1.8.8 1.8.5 1.8.6 All 53 releases
← All changes | includes/Admin/Settings/OAuthManager.php +117 -15 1.10.61.10.20 View file →
@@ -8,10 +8,10 @@
8 8 defined('ABSPATH') || exit;
9 9
10 10 class OAuthManager
11 11 {
12 - /** Prefix for the per-user transient holding a pending OAuth authorization. */
13 - const OAUTH_STATE_TRANSIENT = 'sync_basalam_oauth_state_';
12 + /** Signed, browser-bound proof that an administrator started OAuth. */
13 + const OAUTH_STATE_COOKIE = 'sync_basalam_oauth_state';
14 14
15 15 /** Lifetime of a pending OAuth authorization — the SSO round-trip window. */
16 16 const OAUTH_STATE_TTL = 600; // 10 * MINUTE_IN_SECONDS
17 17
@@ -18,18 +18,30 @@
18 18 /**
19 19 * Remember that the current admin has just started an OAuth authorization.
20 20 *
21 21 * This is called only from the nonce-protected initiation flow, so the
22 - * marker it stores cannot be planted by a forged cross-site request. The
23 - * callback later requires (and consumes) this marker, which is what turns
22 + * cookie it stores cannot be planted by a forged cross-site request. The
23 + * callback later requires (and consumes) this cookie, which is what turns
24 24 * the token-saving callback from "always forgeable" into "only valid for a
25 25 * flow this admin actually started".
26 + *
27 + * The proof deliberately lives in a signed HttpOnly cookie instead of a
28 + * WordPress transient. Sites with a persistent object-cache drop-in route
29 + * transients through Redis/Memcached, where a failed write, eviction, or
30 + * cache flush during the OAuth round trip would otherwise invalidate a
31 + * legitimate callback.
26 32 */
27 33 public static function issueOauthState()
28 34 {
29 - $state = wp_generate_password(64, false);
30 - set_transient(self::OAUTH_STATE_TRANSIENT . get_current_user_id(), $state, self::OAUTH_STATE_TTL);
35 + $userId = get_current_user_id();
36 + if ($userId <= 0) return false;
31 37
38 + $state = wp_generate_password(64, false);
39 + $expiresAt = time() + self::OAUTH_STATE_TTL;
40 + $value = self::buildOauthStateCookieValue($state, $userId, $expiresAt);
41 +
42 + if (! self::writeOauthStateCookie($value, $expiresAt)) return false;
43 +
32 44 return $state;
33 45 }
34 46
35 47 /**
@@ -34,22 +46,112 @@
34 46
35 47 /**
36 48 * Validate and consume the pending OAuth authorization for the current user.
37 49 *
38 - * Single use: the marker is deleted whether or not it was present, so a
50 + * Single use: the cookie is deleted whether or not it was valid, so a
39 51 * replayed or forged callback cannot reuse it.
40 52 */
41 53 private static function verifyOauthState()
42 54 {
43 - $key = self::OAUTH_STATE_TRANSIENT . get_current_user_id();
44 - $expected = get_transient($key);
45 - delete_transient($key);
55 + $value = isset($_COOKIE[self::OAUTH_STATE_COOKIE])
56 + ? (string) wp_unslash($_COOKIE[self::OAUTH_STATE_COOKIE])
57 + : '';
46 58
47 - // The token exchange is routed back through the Hamsalam proxy, which
48 - // consumes the SSO "state" (the site URL) and does not forward a secret
49 - // we control. The single-use marker set during the authenticated
50 - // initiation is therefore the value that authorises the write.
51 - return ! empty($expected);
59 + self::clearOauthStateCookie();
60 +
61 + return self::isOauthStateCookieValid(
62 + $value,
63 + get_current_user_id(),
64 + time()
65 + );
66 + }
67 +
68 + private static function buildOauthStateCookieValue($state, $userId, $expiresAt)
69 + {
70 + $payload = json_encode([
71 + 'state' => (string) $state,
72 + 'user_id' => (int) $userId,
73 + 'expires_at' => (int) $expiresAt,
74 + ]);
75 +
76 + if (! is_string($payload)) return '';
77 +
78 + $encodedPayload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
79 + $signature = hash_hmac('sha256', $encodedPayload, wp_salt('auth'));
80 +
81 + return $encodedPayload . '.' . $signature;
82 + }
83 +
84 + private static function isOauthStateCookieValid($value, $userId, $now)
85 + {
86 + if (! is_string($value) || $value === '' || (int) $userId <= 0) return false;
87 +
88 + $parts = explode('.', $value, 2);
89 + if (count($parts) !== 2) return false;
90 +
91 + [$encodedPayload, $signature] = $parts;
92 + $expectedSignature = hash_hmac('sha256', $encodedPayload, wp_salt('auth'));
93 +
94 + if (! hash_equals($expectedSignature, $signature)) return false;
95 +
96 + $padding = strlen($encodedPayload) % 4;
97 + if ($padding !== 0) $encodedPayload .= str_repeat('=', 4 - $padding);
98 +
99 + $payload = base64_decode(strtr($encodedPayload, '-_', '+/'), true);
100 + $data = is_string($payload) ? json_decode($payload, true) : null;
101 +
102 + if (! is_array($data)) return false;
103 +
104 + return ! empty($data['state'])
105 + && (int) ($data['user_id'] ?? 0) === (int) $userId
106 + && (int) ($data['expires_at'] ?? 0) >= (int) $now;
107 + }
108 +
109 + private static function writeOauthStateCookie($value, $expiresAt)
110 + {
111 + if (! is_string($value) || $value === '' || headers_sent()) return false;
112 +
113 + $written = setcookie(self::OAUTH_STATE_COOKIE, $value, [
114 + 'expires' => (int) $expiresAt,
115 + 'path' => self::oauthStateCookiePath(),
116 + 'domain' => self::oauthStateCookieDomain(),
117 + 'secure' => is_ssl(),
118 + 'httponly' => true,
119 + 'samesite' => 'Lax',
120 + ]);
121 +
122 + // Keep the current request internally consistent for callers and tests.
123 + if ($written) $_COOKIE[self::OAUTH_STATE_COOKIE] = $value;
124 +
125 + return $written;
126 + }
127 +
128 + private static function clearOauthStateCookie()
129 + {
130 + unset($_COOKIE[self::OAUTH_STATE_COOKIE]);
131 +
132 + if (headers_sent()) return;
133 +
134 + setcookie(self::OAUTH_STATE_COOKIE, '', [
135 + 'expires' => time() - HOUR_IN_SECONDS,
136 + 'path' => self::oauthStateCookiePath(),
137 + 'domain' => self::oauthStateCookieDomain(),
138 + 'secure' => is_ssl(),
139 + 'httponly' => true,
140 + 'samesite' => 'Lax',
141 + ]);
142 + }
143 +
144 + private static function oauthStateCookiePath()
145 + {
146 + return defined('ADMIN_COOKIE_PATH') && ADMIN_COOKIE_PATH
147 + ? ADMIN_COOKIE_PATH
148 + : '/wp-admin';
149 + }
150 +
151 + private static function oauthStateCookieDomain()
152 + {
153 + return defined('COOKIE_DOMAIN') ? (string) COOKIE_DOMAIN : '';
52 154 }
53 155
54 156 public function getOauthData()
55 157 {