PluginProbe
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP / trunk
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP vtrunk
3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 3.0.0 2.4.13 2.4.12 2.4.11 2.4.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 110 releases
betterlinks / includes / CLEToken.php

CLEToken.php in BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP trunk, at includes/CLEToken.php

413 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit; }
6
7 /**
8 * Quick Link Creation (CLE) API tokens.
9 *
10 * Replaces the old `md5( AUTH_KEY )` shared secret. That value was:
11 * - derived from a WordPress core secret, so rotating it logged every user out;
12 * - not bound to any user, so it carried no accountability;
13 * - never expiring and impossible to revoke from within the plugin.
14 *
15 * Tokens issued here are plugin-specific, bound to the WordPress user that
16 * created them, expire, and can be revoked individually or in bulk without
17 * touching `wp-config.php`.
18 *
19 * Storage note: the token is kept in the option in clear text (rather than only
20 * as a digest) because the Quick Link settings screen has to be able to re-render
21 * the bookmarklet on every page load. Comparison still runs through
22 * `hash_equals()` so verification is constant time. Anyone who can read this
23 * option already has database access.
24 *
25 * @package BetterLinks
26 */
27 class CLEToken {
28
29 /**
30 * Option holding the issued tokens.
31 */
32 const OPTION = 'betterlinks_cle_api_keys';
33
34 /**
35 * Option flagging whether the deprecated md5( AUTH_KEY ) key is still accepted.
36 */
37 const LEGACY_OPTION = 'betterlinks_cle_allow_legacy_key';
38
39 /**
40 * Default token lifetime.
41 */
42 const DEFAULT_TTL = YEAR_IN_SECONDS;
43
44 /**
45 * Hard cap on stored tokens, oldest evicted first.
46 *
47 * One token per user who can manage BetterLinks, so this has to sit well above
48 * the administrator count of a large site — evicting a token silently breaks
49 * that person's bookmarklet.
50 */
51 const MAX_TOKENS = 100;
52
53 /**
54 * Token prefix, so a leaked value is recognisable in logs / secret scanners.
55 */
56 const PREFIX = 'blk';
57
58 /**
59 * All stored token records.
60 *
61 * @return array
62 */
63 public static function all() {
64 $tokens = get_option( self::OPTION, array() );
65
66 return is_array( $tokens ) ? $tokens : array();
67 }
68
69 /**
70 * Persist the token set.
71 *
72 * @param array $tokens Token records keyed by id.
73 * @return void
74 */
75 private static function save( $tokens ) {
76 update_option( self::OPTION, $tokens, false );
77 }
78
79 /**
80 * Drop expired records and enforce the storage cap.
81 *
82 * @param array|null $tokens Optional pre-loaded set.
83 * @return array The pruned set.
84 */
85 public static function prune( $tokens = null ) {
86 $tokens = ( null === $tokens ) ? self::all() : $tokens;
87 $now = time();
88 $kept = array();
89
90 foreach ( $tokens as $id => $record ) {
91 if ( ! is_array( $record ) || empty( $record['token'] ) ) {
92 continue;
93 }
94
95 if ( ! empty( $record['expires'] ) && (int) $record['expires'] <= $now ) {
96 continue;
97 }
98
99 $kept[ $id ] = $record;
100 }
101
102 if ( count( $kept ) > self::MAX_TOKENS ) {
103 uasort(
104 $kept,
105 function ( $a, $b ) {
106 return (int) $a['created'] - (int) $b['created'];
107 }
108 );
109 $kept = array_slice( $kept, -self::MAX_TOKENS, null, true );
110 }
111
112 return $kept;
113 }
114
115 /**
116 * Issue a fresh token.
117 *
118 * @param int $user_id Owner. Defaults to the current user.
119 * @param int $ttl Lifetime in seconds. 0 for no expiry.
120 * @param string $label Human readable note.
121 * @return array|false The token record (including the plaintext `token`), or false.
122 */
123 public static function issue( $user_id = 0, $ttl = null, $label = '' ) {
124 $user_id = $user_id ? (int) $user_id : get_current_user_id();
125
126 if ( ! $user_id ) {
127 return false;
128 }
129
130 $ttl = ( null === $ttl ) ? self::DEFAULT_TTL : (int) $ttl;
131
132 $id = bin2hex( self::random_bytes( 6 ) );
133 $secret = bin2hex( self::random_bytes( 24 ) );
134
135 $record = array(
136 'id' => $id,
137 'token' => self::PREFIX . '_' . $id . '_' . $secret,
138 'user_id' => $user_id,
139 'label' => sanitize_text_field( $label ),
140 'created' => time(),
141 'expires' => $ttl > 0 ? time() + $ttl : 0,
142 'last_used' => 0,
143 );
144
145 $tokens = self::prune();
146 $tokens[ $id ] = $record;
147 self::save( $tokens );
148
149 return $record;
150 }
151
152 /**
153 * Return the caller's current token, issuing one on first use.
154 *
155 * @param int $user_id Owner. Defaults to the current user.
156 * @return array|false Token record, or false when there is no user.
157 */
158 public static function get_or_issue_for_user( $user_id = 0 ) {
159 $user_id = $user_id ? (int) $user_id : get_current_user_id();
160
161 if ( ! $user_id ) {
162 return false;
163 }
164
165 $tokens = self::prune();
166 $changed = ( count( $tokens ) !== count( self::all() ) );
167
168 foreach ( $tokens as $record ) {
169 if ( (int) $record['user_id'] === $user_id ) {
170 if ( $changed ) {
171 self::save( $tokens );
172 }
173
174 return $record;
175 }
176 }
177
178 if ( $changed ) {
179 self::save( $tokens );
180 }
181
182 return self::issue( $user_id );
183 }
184
185 /**
186 * Rotate: revoke every token owned by a user and issue a replacement.
187 *
188 * @param int $user_id Owner. Defaults to the current user.
189 * @return array|false New token record.
190 */
191 public static function rotate( $user_id = 0 ) {
192 $user_id = $user_id ? (int) $user_id : get_current_user_id();
193
194 if ( ! $user_id ) {
195 return false;
196 }
197
198 $tokens = self::prune();
199
200 foreach ( $tokens as $id => $record ) {
201 if ( (int) $record['user_id'] === $user_id ) {
202 unset( $tokens[ $id ] );
203 }
204 }
205
206 self::save( $tokens );
207
208 return self::issue( $user_id );
209 }
210
211 /**
212 * Revoke one token.
213 *
214 * @param string $id Token id.
215 * @return bool
216 */
217 public static function revoke( $id ) {
218 $tokens = self::all();
219 $id = (string) $id;
220
221 if ( ! isset( $tokens[ $id ] ) ) {
222 return false;
223 }
224
225 unset( $tokens[ $id ] );
226 self::save( $tokens );
227
228 return true;
229 }
230
231 /**
232 * Revoke every issued token.
233 *
234 * @return void
235 */
236 public static function revoke_all() {
237 self::save( array() );
238 }
239
240 /**
241 * Validate a presented token.
242 *
243 * @param string $token Presented token.
244 * @return array|false The matching record, or false.
245 */
246 public static function verify( $token ) {
247 if ( ! is_string( $token ) || '' === $token ) {
248 return false;
249 }
250
251 $parts = explode( '_', $token );
252
253 if ( count( $parts ) !== 3 || self::PREFIX !== $parts[0] ) {
254 return false;
255 }
256
257 $tokens = self::all();
258 $id = $parts[1];
259
260 if ( ! isset( $tokens[ $id ] ) || empty( $tokens[ $id ]['token'] ) ) {
261 return false;
262 }
263
264 $record = $tokens[ $id ];
265
266 if ( ! hash_equals( (string) $record['token'], $token ) ) {
267 return false;
268 }
269
270 if ( ! empty( $record['expires'] ) && (int) $record['expires'] <= time() ) {
271 self::revoke( $id );
272
273 return false;
274 }
275
276 if ( ! get_userdata( (int) $record['user_id'] ) ) {
277 // Owner was deleted — the token dies with them.
278 self::revoke( $id );
279
280 return false;
281 }
282
283 return $record;
284 }
285
286 /**
287 * Verify a token, switch the request to its owner, and confirm that owner may
288 * still use Quick Link Creation.
289 *
290 * The capability check runs *after* the switch because Pro answers
291 * `betterlinks/admin/current_user_can_edit_settings` from the current user's
292 * role, and there is no reliable way to evaluate a delegated role matrix for
293 * an arbitrary user id. The original user is restored if the check fails, so a
294 * rejected token never leaves the request running as someone else.
295 *
296 * @param string $token Presented token.
297 * @return array|false The record on success.
298 */
299 public static function authenticate( $token ) {
300 $record = self::verify( $token );
301
302 if ( ! $record ) {
303 return false;
304 }
305
306 $previous_user = get_current_user_id();
307
308 wp_set_current_user( (int) $record['user_id'] );
309
310 if ( ! self::current_user_can_create() ) {
311 wp_set_current_user( $previous_user );
312
313 return false;
314 }
315
316 self::touch( $record['id'] );
317
318 return $record;
319 }
320
321 /**
322 * May the *current* user use Quick Link Creation?
323 *
324 * Deliberately the same audience that could previously read `md5( AUTH_KEY )`
325 * out of the localized admin data and use the bookmarklet — administrators
326 * plus any role Pro has delegated settings access to. Narrowing it to
327 * `manage_options` would silently break the Quick Link settings tab for those
328 * delegated roles, and it would not close anything: the old shared key was
329 * usable by anyone who ever saw it, with no user attached at all.
330 *
331 * @return bool
332 */
333 public static function current_user_can_create() {
334 $can = current_user_can( 'manage_options' );
335
336 if ( ! $can ) {
337 $can = (bool) apply_filters( 'betterlinks/admin/current_user_can_edit_settings', $can );
338 }
339
340 return (bool) apply_filters( 'betterlinks/cle/user_can_create', $can, wp_get_current_user() );
341 }
342
343
344 /**
345 * Record last-used time (throttled to one write per hour per token).
346 *
347 * @param string $id Token id.
348 * @return void
349 */
350 private static function touch( $id ) {
351 $tokens = self::all();
352
353 if ( ! isset( $tokens[ $id ] ) ) {
354 return;
355 }
356
357 $now = time();
358
359 if ( $now - (int) $tokens[ $id ]['last_used'] < HOUR_IN_SECONDS ) {
360 return;
361 }
362
363 $tokens[ $id ]['last_used'] = $now;
364 self::save( $tokens );
365 }
366
367 /**
368 * Is the deprecated md5( AUTH_KEY ) key still accepted?
369 *
370 * Existing sites that already have Quick Link Creation switched on keep it for
371 * one release so bookmarklets and the Chrome extension do not break on update;
372 * every other site (including all fresh installs) starts with it off. Site
373 * owners can force either answer with the
374 * `betterlinks/cle/allow_legacy_api_key` filter, and the plugin will drop the
375 * path entirely in a future release.
376 *
377 * @return bool
378 */
379 public static function legacy_key_allowed() {
380 $stored = get_option( self::LEGACY_OPTION, null );
381
382 if ( null === $stored ) {
383 global $betterlinks_settings;
384
385 $in_use = ! empty( $betterlinks_settings['cle']['enable_cle'] );
386 $stored = $in_use ? '1' : '0';
387
388 add_option( self::LEGACY_OPTION, $stored, '', false );
389 }
390
391 return (bool) apply_filters( 'betterlinks/cle/allow_legacy_api_key', '1' === (string) $stored );
392 }
393
394 /**
395 * Cryptographically secure random bytes.
396 *
397 * @param int $length Byte count.
398 * @return string
399 */
400 private static function random_bytes( $length ) {
401 if ( function_exists( 'random_bytes' ) ) {
402 try {
403 return random_bytes( $length );
404 } catch ( \Exception $e ) {
405 // Fall through to the WordPress generator below.
406 unset( $e );
407 }
408 }
409
410 return substr( wp_generate_password( $length * 2, false, false ), 0, $length );
411 }
412 }
413