| @@ -13,8 +13,14 @@ | ||
| 13 | 13 | * window (transient-backed). At/after the threshold the IP is locked out |
| 14 | 14 | * for the window; a SUCCESSFUL auth clears the counter immediately so a |
| 15 | 15 | * legitimate client that fixed a typo isn't punished. |
| 16 | 16 | * |
| 17 | + * A new credential starts every client from zero (reset_all()). Failures | |
| 18 | + * earned with the old token say nothing about the new one, and the client | |
| 19 | + * that most needs to get through then is xSpeed Hub: its checks with the | |
| 20 | + * old token are what locked it out, and its check of the new token is what | |
| 21 | + * would clear the lock. | |
| 22 | + * | |
| 17 | 23 | * Threshold + window are overridable: |
| 18 | 24 | * - XSPEED_MCP_MAX_FAILS / XSPEED_MCP_LOCKOUT_SECONDS constants, and |
| 19 | 25 | * - the `xspeed_mcp_rate_limit` filter ( [ max_fails, lockout_seconds ] ). |
| 20 | 26 | * |
| @@ -31,8 +37,11 @@ | ||
| 31 | 37 | |
| 32 | 38 | /** Transient key prefix; the client-IP hash is appended. */ |
| 33 | 39 | private const PREFIX = 'xspeed_mcp_rl_'; |
| 34 | 40 | |
| 41 | + /** Option holding the lockout generation; part of every transient key. */ | |
| 42 | + private const GENERATION_OPTION = 'xspeed_mcp_rl_gen'; | |
| 43 | + | |
| 35 | 44 | /** Default: lock out after this many failed attempts. */ |
| 36 | 45 | private const DEFAULT_MAX_FAILS = 10; |
| 37 | 46 | |
| 38 | 47 | /** Default: lockout / rolling-window length, in seconds. */ |
| @@ -69,8 +78,20 @@ | ||
| 69 | 78 | public static function clear(): void { |
| 70 | 79 | delete_transient( self::key() ); |
| 71 | 80 | } |
| 72 | 81 | |
| 82 | + /** | |
| 83 | + * Clear every client's counter at once. Call when the site hands out a | |
| 84 | + * credential: a valid attach, or a token sent to the Hub. | |
| 85 | + * | |
| 86 | + * The counters are per-IP transients, which cannot be listed, so this | |
| 87 | + * moves every key to a new generation instead. The old transients expire | |
| 88 | + * on their own within the window. | |
| 89 | + */ | |
| 90 | + public static function reset_all(): void { | |
| 91 | + update_option( self::GENERATION_OPTION, self::generation() + 1, false ); | |
| 92 | + } | |
| 93 | + | |
| 73 | 94 | /** Seconds a locked client must wait (approximate; the window length). */ |
| 74 | 95 | public static function retry_after(): int { |
| 75 | 96 | return self::limits()[1]; |
| 76 | 97 | } |
| @@ -82,11 +103,19 @@ | ||
| 82 | 103 | $v = get_transient( self::key() ); |
| 83 | 104 | return is_numeric( $v ) ? (int) $v : 0; |
| 84 | 105 | } |
| 85 | 106 | |
| 86 | - /** Transient key bound to the (hashed) client IP. */ | |
| 107 | + /** Transient key bound to the (hashed) client IP and the generation. */ | |
| 87 | 108 | private static function key(): string { |
| 88 | - return self::PREFIX . md5( self::client_ip() ); | |
| 109 | + $generation = self::generation(); | |
| 110 | + $suffix = md5( self::client_ip() ); | |
| 111 | + return self::PREFIX . ( $generation > 0 ? $generation . '_' : '' ) . $suffix; | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** Current lockout generation (0 until the first reset_all()). */ | |
| 115 | + private static function generation(): int { | |
| 116 | + $v = get_option( self::GENERATION_OPTION, 0 ); | |
| 117 | + return is_numeric( $v ) ? (int) $v : 0; | |
| 89 | 118 | } |
| 90 | 119 | |
| 91 | 120 | /** |
| 92 | 121 | * Resolve [ max_fails, lockout_seconds ] from constants, then filter. |