Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
81 / 84
93.33% covered (success)
93.33%
14 / 15
CRAP
50.00% covered (danger)
50.00%
1 / 2
RateLimiter
96.15% covered (success)
96.15%
75 / 78
90.91% covered (success)
90.91%
10 / 11
26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 initRedis
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 checkLimit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 recordFailure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 clearAttempts
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkRedisLimit
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 recordRedisFailure
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 checkDatabaseLimit
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 recordDatabaseFailure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
RateLimitResult
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
4
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isBlocked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttempts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRetryAfter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Rate Limiter Service
4 *
5 * Provides rate limiting functionality with Redis backend and database fallback.
6 * Used to protect against brute force attacks on login, password reset, and other sensitive endpoints.
7 *
8 * @package BuyerKiosk\Auth\Services
9 */
10
11namespace BuyerKiosk\Auth\Services;
12
13class RateLimiter
14{
15    /**
16     * @var \Predis\Client|null Redis client instance
17     */
18    private $redis;
19
20    /**
21     * @var \PDO|null Database connection for fallback
22     */
23    private $db;
24
25    /**
26     * @var bool Whether Redis is available
27     */
28    private $redisAvailable = false;
29
30    /**
31     * Default rate limit configurations
32     */
33    private const LIMITS = [
34        'login' => [
35            'max_attempts' => 10,
36            'window_seconds' => 1800,  // 30 minutes
37            'lockout_seconds' => 1800  // 30 minutes
38        ],
39        'password_reset' => [
40            'max_attempts' => 3,
41            'window_seconds' => 3600,  // 1 hour
42            'lockout_seconds' => 3600  // 1 hour
43        ],
44        'login_user' => [
45            'max_attempts' => 5,
46            'window_seconds' => 900,   // 15 minutes
47            'lockout_seconds' => 900   // 15 minutes
48        ],
49        'mfa_verify' => [
50            'max_attempts' => 5,
51            'window_seconds' => 300,   // 5 minutes
52            'lockout_seconds' => 900   // 15 minutes
53        ]
54    ];
55
56    /**
57     * Constructor
58     *
59     * @param \PDO|null $db Database connection for fallback storage
60     */
61    public function __construct(\PDO $db = null)
62    {
63        $this->db = $db;
64        $this->initRedis();
65    }
66
67    /**
68     * Initialize Redis connection
69     */
70    private function initRedis(): void
71    {
72        if (!empty($_ENV['REDIS_URL'])) {
73            try {
74                $this->redis = new \Predis\Client($_ENV['REDIS_URL']);
75                $this->redis->ping();
76                $this->redisAvailable = true;
77            } catch (\Exception $e) {
78                error_log("RateLimiter: Redis unavailable, falling back to database: " . $e->getMessage());
79                $this->redisAvailable = false;
80            }
81        }
82    }
83
84    /**
85     * Check if an action is rate limited
86     *
87     * @param string $action The action type (e.g., 'login', 'password_reset')
88     * @param string $identifier The identifier (e.g., IP address, email)
89     * @return RateLimitResult
90     */
91    public function checkLimit(string $action, string $identifier): RateLimitResult
92    {
93        $config = $this->getConfig($action);
94        $key = $this->buildKey($action, $identifier);
95
96        if ($this->redisAvailable) {
97            return $this->checkRedisLimit($key, $config);
98        }
99
100        return $this->checkDatabaseLimit($action, $identifier, $config);
101    }
102
103    /**
104     * Record a failed attempt
105     *
106     * @param string $action The action type
107     * @param string $identifier The identifier
108     */
109    public function recordFailure(string $action, string $identifier): void
110    {
111        $config = $this->getConfig($action);
112        $key = $this->buildKey($action, $identifier);
113
114        if ($this->redisAvailable) {
115            $this->recordRedisFailure($key, $config);
116        } else {
117            $this->recordDatabaseFailure($action, $identifier, $config);
118        }
119    }
120
121    /**
122     * Clear rate limit attempts (e.g., after successful login)
123     *
124     * @param string $action The action type
125     * @param string $identifier The identifier
126     */
127    public function clearAttempts(string $action, string $identifier): void
128    {
129        $key = $this->buildKey($action, $identifier);
130
131        if ($this->redisAvailable) {
132            $this->redis->del($key);
133        } elseif ($this->db) {
134            $stmt = $this->db->prepare("
135                DELETE FROM rateLimitAttempts
136                WHERE action = :action AND identifier = :identifier
137            ");
138            $stmt->execute(['action' => $action, 'identifier' => $identifier]);
139        }
140    }
141
142    /**
143     * Get rate limit configuration for an action
144     *
145     * @param string $action The action type
146     * @return array Configuration array
147     */
148    private function getConfig(string $action): array
149    {
150        return self::LIMITS[$action] ?? self::LIMITS['login'];
151    }
152
153    /**
154     * Build a cache key for rate limiting
155     *
156     * @param string $action The action type
157     * @param string $identifier The identifier
158     * @return string The cache key
159     */
160    private function buildKey(string $action, string $identifier): string
161    {
162        return "ratelimit:{$action}:" . md5($identifier);
163    }
164
165    /**
166     * Check rate limit using Redis
167     *
168     * @param string $key The cache key
169     * @param array $config The rate limit configuration
170     * @return RateLimitResult
171     */
172    private function checkRedisLimit(string $key, array $config): RateLimitResult
173    {
174        $data = $this->redis->get($key);
175
176        if ($data === null) {
177            return new RateLimitResult(false, 0, 0);
178        }
179
180        $attempts = json_decode($data, true);
181        $now = time();
182
183        // Clean old attempts outside the window
184        $attempts = array_filter($attempts, function($timestamp) use ($now, $config) {
185            return ($now - $timestamp) < $config['window_seconds'];
186        });
187
188        $attemptCount = count($attempts);
189        $isBlocked = $attemptCount >= $config['max_attempts'];
190
191        $retryAfter = 0;
192        if ($isBlocked && !empty($attempts)) {
193            $oldestAttempt = min($attempts);
194            $retryAfter = max(0, $config['window_seconds'] - ($now - $oldestAttempt));
195        }
196
197        return new RateLimitResult($isBlocked, $attemptCount, $retryAfter);
198    }
199
200    /**
201     * Record a failure in Redis
202     *
203     * @param string $key The cache key
204     * @param array $config The rate limit configuration
205     */
206    private function recordRedisFailure(string $key, array $config): void
207    {
208        $data = $this->redis->get($key);
209        $attempts = $data ? json_decode($data, true) : [];
210        $now = time();
211
212        // Clean old attempts and add new one
213        $attempts = array_filter($attempts, function($timestamp) use ($now, $config) {
214            return ($now - $timestamp) < $config['window_seconds'];
215        });
216        $attempts[] = $now;
217
218        $this->redis->setex($key, $config['window_seconds'], json_encode(array_values($attempts)));
219    }
220
221    /**
222     * Check rate limit using database fallback
223     *
224     * @param string $action The action type
225     * @param string $identifier The identifier
226     * @param array $config The rate limit configuration
227     * @return RateLimitResult
228     */
229    private function checkDatabaseLimit(string $action, string $identifier, array $config): RateLimitResult
230    {
231        if (!$this->db) {
232            // If no database connection, allow the request (fail open for availability)
233            return new RateLimitResult(false, 0, 0);
234        }
235
236        $stmt = $this->db->prepare("
237            SELECT attempts, lastAttemptAt
238            FROM rateLimitAttempts
239            WHERE action = :action AND identifier = :identifier
240        ");
241        $stmt->execute(['action' => $action, 'identifier' => $identifier]);
242        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
243
244        if (!$row) {
245            return new RateLimitResult(false, 0, 0);
246        }
247
248        $lastAttempt = strtotime($row['lastAttemptAt']);
249        $now = time();
250        $windowElapsed = $now - $lastAttempt;
251
252        // If outside the window, reset
253        if ($windowElapsed >= $config['window_seconds']) {
254            $this->clearAttempts($action, $identifier);
255            return new RateLimitResult(false, 0, 0);
256        }
257
258        $attempts = (int)$row['attempts'];
259        $isBlocked = $attempts >= $config['max_attempts'];
260        $retryAfter = $isBlocked ? max(0, $config['window_seconds'] - $windowElapsed) : 0;
261
262        return new RateLimitResult($isBlocked, $attempts, $retryAfter);
263    }
264
265    /**
266     * Record a failure in database
267     *
268     * @param string $action The action type
269     * @param string $identifier The identifier
270     * @param array $config The rate limit configuration
271     */
272    private function recordDatabaseFailure(string $action, string $identifier, array $config): void
273    {
274        if (!$this->db) {
275            return;
276        }
277
278        // Try to update existing record, or insert new one
279        $stmt = $this->db->prepare("
280            INSERT INTO rateLimitAttempts (action, identifier, attempts, lastAttemptAt, createdAt)
281            VALUES (:action, :identifier, 1, NOW(), NOW())
282            ON DUPLICATE KEY UPDATE
283                attempts = IF(
284                    TIMESTAMPDIFF(SECOND, lastAttemptAt, NOW()) >= :window,
285                    1,
286                    attempts + 1
287                ),
288                lastAttemptAt = NOW()
289        ");
290        $stmt->execute([
291            'action' => $action,
292            'identifier' => $identifier,
293            'window' => $config['window_seconds']
294        ]);
295    }
296}
297
298/**
299 * Result object for rate limit checks
300 */
301class RateLimitResult
302{
303    /**
304     * @var bool Whether the request is blocked
305     */
306    private $blocked;
307
308    /**
309     * @var int Number of attempts made
310     */
311    private $attempts;
312
313    /**
314     * @var int Seconds until the rate limit resets
315     */
316    private $retryAfter;
317
318    /**
319     * Constructor
320     *
321     * @param bool $blocked Whether the request is blocked
322     * @param int $attempts Number of attempts made
323     * @param int $retryAfter Seconds until the rate limit resets
324     */
325    public function __construct(bool $blocked, int $attempts, int $retryAfter)
326    {
327        $this->blocked = $blocked;
328        $this->attempts = $attempts;
329        $this->retryAfter = $retryAfter;
330    }
331
332    /**
333     * Check if the request is blocked
334     *
335     * @return bool
336     */
337    public function isBlocked(): bool
338    {
339        return $this->blocked;
340    }
341
342    /**
343     * Get the number of attempts
344     *
345     * @return int
346     */
347    public function getAttempts(): int
348    {
349        return $this->attempts;
350    }
351
352    /**
353     * Get seconds until retry is allowed
354     *
355     * @return int
356     */
357    public function getRetryAfter(): int
358    {
359        return $this->retryAfter;
360    }
361}