PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / myyoast-client / infrastructure / token / user-token-storage.php

user-token-storage.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/myyoast-client/infrastructure/token/user-token-storage.php

253 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3
4 namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Token;
5
6 use Exception;
7 use Yoast\WP\SEO\Helpers\User_Helper;
8 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception;
9 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\User_Token_Storage_Interface;
10 use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
11 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
12 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
13 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
14 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
15 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
16 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
17 use YoastSEO_Vendor\Psr\Log\NullLogger;
18
19 /**
20 * Stores and retrieves encrypted user-level tokens in wp_usermeta.
21 *
22 * Used for authorization code flow tokens (user-specific). Tokens are
23 * bucketed per RFC 8707 resource indicator so a user can hold one token per
24 * resource server. Each (issuer, user, resource bucket) maps to a separate
25 * usermeta row.
26 *
27 * Key layout:
28 * - Default bucket: _wpseo_myyoast_user_tokens_{issuer_key}
29 * - Resource bucket: _wpseo_myyoast_user_tokens_{issuer_key}_{sha1_prefix}
30 */
31 class User_Token_Storage implements User_Token_Storage_Interface, LoggerAwareInterface {
32 use LoggerAwareTrait;
33
34 private const META_KEY_PREFIX = '_wpseo_myyoast_user_tokens_';
35 private const ENCRYPTION_CONTEXT = 'yoast-myyoast-user-tokens';
36
37 /**
38 * The user helper.
39 *
40 * @var User_Helper
41 */
42 private $user_helper;
43
44 /**
45 * The encryption service.
46 *
47 * @var Encryption
48 */
49 private $encryption;
50
51 /**
52 * The issuer configuration.
53 *
54 * @var Issuer_Config
55 */
56 private $issuer_config;
57
58 /**
59 * User_Token_Storage constructor.
60 *
61 * @param User_Helper $user_helper The user helper.
62 * @param Encryption $encryption The encryption service.
63 * @param Issuer_Config $issuer_config The issuer configuration.
64 */
65 public function __construct( User_Helper $user_helper, Encryption $encryption, Issuer_Config $issuer_config ) {
66 $this->user_helper = $user_helper;
67 $this->encryption = $encryption;
68 $this->issuer_config = $issuer_config;
69 $this->logger = new NullLogger();
70 }
71
72 /**
73 * Stores a token set for a user (encrypted). The resource bucket is derived from the token's own resource indicator.
74 *
75 * @param int $user_id The user ID.
76 * @param Token_Set $token_set The token set to store.
77 *
78 * @return void
79 *
80 * @throws Token_Storage_Exception If encryption fails.
81 */
82 public function store( int $user_id, Token_Set $token_set ): void {
83 try {
84 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for encrypted storage, not user-facing output.
85 $json = \wp_json_encode( $token_set->to_array() );
86 if ( $json === false ) {
87 throw new Token_Storage_Exception( 'Failed to JSON-encode token set for storage.' );
88 }
89
90 $encrypted = $this->encryption->encrypt( $json, self::ENCRYPTION_CONTEXT );
91 } catch ( Encryption_Exception $e ) {
92 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
93 throw new Token_Storage_Exception( 'Failed to encrypt token set for storage: ' . $e->getMessage(), 0, $e );
94 }
95
96 $this->user_helper->update_meta( $user_id, $this->get_meta_key( $token_set->get_resource_indicator() ), $encrypted );
97 }
98
99 /**
100 * Retrieves the stored token set for a user and resource bucket.
101 *
102 * @param int $user_id The user ID.
103 * @param Resource_Indicator $resource_indicator The resource indicator (use Resource_Indicator::default() for the default bucket).
104 *
105 * @return Token_Set|null The token set, or null if not stored or decryption fails.
106 */
107 public function get( int $user_id, Resource_Indicator $resource_indicator ): ?Token_Set {
108 return $this->decrypt_and_decode( $user_id, $this->user_helper->get_meta( $user_id, $this->get_meta_key( $resource_indicator ), true ) );
109 }
110
111 /**
112 * Deletes the stored token set for a user and resource bucket.
113 *
114 * @param int $user_id The user ID.
115 * @param Resource_Indicator $resource_indicator The resource indicator (use Resource_Indicator::default() for the default bucket).
116 *
117 * @return void
118 */
119 public function delete( int $user_id, Resource_Indicator $resource_indicator ): void {
120 $this->user_helper->delete_meta( $user_id, $this->get_meta_key( $resource_indicator ) );
121 }
122
123 /**
124 * Returns every stored token set across resource buckets for a user.
125 *
126 * @param int $user_id The user ID.
127 *
128 * @return Token_Set[] The stored token sets.
129 */
130 public function get_all( int $user_id ): array {
131 $tokens = [];
132 $all_meta = $this->user_helper->get_meta( $user_id );
133 if ( ! \is_array( $all_meta ) ) {
134 return $tokens;
135 }
136 $prefix = $this->get_meta_key_prefix_for_current_issuer();
137 foreach ( $all_meta as $key => $values ) {
138 if ( \strpos( (string) $key, $prefix ) !== 0 ) {
139 continue;
140 }
141 $stored = \is_array( $values ) ? ( $values[0] ?? '' ) : $values;
142 $token = $this->decrypt_and_decode( $user_id, $stored );
143 if ( $token !== null ) {
144 $tokens[] = $token;
145 }
146 }
147
148 return $tokens;
149 }
150
151 /**
152 * Deletes every stored user token set across all users and resource buckets for the current issuer.
153 *
154 * @return void
155 */
156 public function delete_all(): void {
157 $this->bulk_delete_by_prefix( $this->get_meta_key_prefix_for_current_issuer() );
158 }
159
160 /**
161 * Deletes every stored user token set across all users, issuers, and resource buckets.
162 *
163 * @return void
164 */
165 public function delete_all_issuers(): void {
166 $this->bulk_delete_by_prefix( self::META_KEY_PREFIX );
167 }
168
169 /**
170 * Deletes every usermeta row whose meta_key starts with the given prefix.
171 *
172 * @param string $prefix The meta_key prefix.
173 *
174 * @return void
175 */
176 private function bulk_delete_by_prefix( string $prefix ): void {
177 global $wpdb;
178
179 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk cleanup.
180 $wpdb->query(
181 $wpdb->prepare(
182 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Bulk cleanup.
183 "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
184 $wpdb->esc_like( $prefix ) . '%',
185 ),
186 );
187 }
188
189 /**
190 * Decrypts and decodes a stored meta value into a Token_Set.
191 *
192 * @param int $user_id The user ID (for logging context).
193 * @param string|false|null $stored The stored value.
194 *
195 * @return Token_Set|null The token set, or null on absence/failure.
196 */
197 private function decrypt_and_decode( int $user_id, $stored ): ?Token_Set {
198 if ( ! \is_string( $stored ) || $stored === '' ) {
199 return null;
200 }
201
202 try {
203 $decrypted = $this->encryption->decrypt( $stored, self::ENCRYPTION_CONTEXT );
204 $data = \json_decode( $decrypted, true, 512, \JSON_THROW_ON_ERROR );
205
206 if ( ! \is_array( $data ) || empty( $data['access_token'] ) ) {
207 return null;
208 }
209
210 return Token_Set::from_array( $data );
211 } catch ( Exception $e ) {
212 $this->logger->error(
213 'Failed to decrypt stored user token for user {user_id}: {error}',
214 [
215 'user_id' => $user_id,
216 'error' => $e->getMessage(),
217 ],
218 );
219
220 return null;
221 }
222 }
223
224 /**
225 * Returns the meta key prefix for the current issuer.
226 *
227 * @return string The meta key prefix.
228 */
229 private function get_meta_key_prefix_for_current_issuer(): string {
230 return self::META_KEY_PREFIX . $this->issuer_config->get_issuer_key();
231 }
232
233 /**
234 * Returns the meta key for a resource bucket.
235 *
236 * The default bucket has no suffix and shares its key with pre-RFC-8707
237 * installs. Explicit resource indicators get a sha1-hash suffix joined
238 * by an underscore.
239 *
240 * @param Resource_Indicator $resource_indicator The resource indicator.
241 *
242 * @return string The meta key.
243 */
244 private function get_meta_key( Resource_Indicator $resource_indicator ): string {
245 $key = $this->get_meta_key_prefix_for_current_issuer();
246 if ( $resource_indicator->is_default() ) {
247 return $key;
248 }
249
250 return $key . '_' . \substr( \sha1( $resource_indicator->value() ), 0, 12 );
251 }
252 }
253