Issues (4)

Service/RateLimitHandler.php (2 issues)

1
<?php
2
3
/*
4
 * This file is part of the ApiRateLimitBundle
5
 *
6
 * (c) Indra Gunawan <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Indragunawan\ApiRateLimitBundle\Service;
13
14
use Doctrine\Common\Annotations\AnnotationReader;
15
use Indragunawan\ApiRateLimitBundle\Annotation\ApiRateLimit;
16
use Psr\Cache\CacheItemPoolInterface;
17
use Symfony\Component\HttpFoundation\Request;
18
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
19
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
20
21
/**
22
 * @author Indra Gunawan <[email protected]>
23
 */
24
class RateLimitHandler
25
{
26
    /**
27
     * @var CacheItemPoolInterface
28
     */
29
    private $cacheItemPool;
30
31
    /**
32
     * @var TokenStorageInterface
33
     */
34
    private $tokenStorage;
35
36
    /**
37
     * @var AuthorizationCheckerInterface
38
     */
39
    private $authorizationChecker;
40
41
    /**
42
     * @var array
43
     */
44
    private $throttleConfig;
45
46
    /**
47
     * @var int
48
     */
49
    private $limit;
50
51
    /**
52
     * @var int
53
     */
54
    private $remaining;
55
56
    /**
57
     * @var int
58
     */
59
    private $reset;
60
61
    /**
62
     * @var
63
     */
64
    private $enabled = true;
65
66
    /**
67
     * @var bool
68
     */
69
    private $rateLimitExceeded = false;
70
71
    /**
72
     * RateLimitHandler constructor.
73
     */
74 8
    public function __construct(
75
        CacheItemPoolInterface $cacheItemPool,
76
        TokenStorageInterface $tokenStorage,
77
        AuthorizationCheckerInterface $authorizationChecker,
78
        array $throttleConfig
79
    ) {
80 8
        $this->cacheItemPool = $cacheItemPool;
81 8
        $this->tokenStorage = $tokenStorage;
82 8
        $this->authorizationChecker = $authorizationChecker;
83 8
        $this->throttleConfig = $throttleConfig;
84 8
    }
85
86
    /**
87
     * @return bool
88
     */
89 8
    public function isEnabled()
90
    {
91 8
        return $this->enabled;
92
    }
93
94
    /**
95
     * @return bool
96
     */
97 5
    public function isRateLimitExceeded()
98
    {
99 5
        return $this->rateLimitExceeded;
100
    }
101
102 7
    public function getRateLimitInfo(): array
103
    {
104
        return [
105 7
            'limit' => $this->limit,
106 7
            'remaining' => $this->remaining,
107 7
            'reset' => $this->reset,
108
        ];
109
    }
110
111 8
    public static function generateCacheKey(string $ip, string $username = null, string $userRole = null): string
112
    {
113 8
        if (!empty($username) && !empty($userRole)) {
114 2
            return sprintf('_api_rate_limit_metadata$%s', sha1($userRole.$username));
115
        }
116
117 6
        return sprintf('_api_rate_limit_metadata$%s', sha1($ip));
118
    }
119
120
    /**
121
     * @throws \Doctrine\Common\Annotations\AnnotationException
122
     * @throws \Psr\Cache\InvalidArgumentException
123
     * @throws \ReflectionException
124
     */
125 8
    public function handle(Request $request)
126
    {
127 8
        $reflectionClass = new \ReflectionClass($request->attributes->get('_api_resource_class'));
128 8
        if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionClass->getAttributes(ApiRateLimit::class)) {
129
            $annotation = $attributes[0]->newInstance();
130
        } else {
131 8
            $annotationReader = new AnnotationReader();
132
            /** @var ApiRateLimit $annotation */
133 8
            $annotation = $annotationReader->getClassAnnotation($reflectionClass, ApiRateLimit::class);
134
        }
135
136 8
        if (null !== $annotation) {
0 ignored issues
show
The condition null !== $annotation is always true.
Loading history...
137 8
            $this->enabled = $annotation->enabled;
138
            if ((
139 8
                    !\in_array($request->getMethod(), array_map('strtoupper', $annotation->methods), true)
140
                    &&
141 8
                    !\in_array($request->attributes->get('_api_item_operation_name'), array_map('strtolower', $annotation->methods), true)
142
                )
143 8
                && !empty($annotation->methods)
144
            ) {
145
                // The annotation is ignored as the method is not corresponding
146 8
                $annotation = new ApiRateLimit();
147
            }
148
        } else {
149
            $annotation = new ApiRateLimit();
150
        }
151
152 8
        list($key, $limit, $period) = $this->getThrottle($request, $annotation);
153
154 8
        if ($this->enabled) {
155 7
            $this->decreaseRateLimitRemaining($key, $limit, $period);
156
        }
157 8
    }
158
159
    /**
160
     * @throws \Psr\Cache\InvalidArgumentException
161
     */
162 7
    protected function decreaseRateLimitRemaining(string $key, int $limit, int $period)
163
    {
164 7
        $cost = 1;
165 7
        $currentTime = gmdate('U');
166
167 7
        $rateLimitInfo = $this->cacheItemPool->getItem($key);
168 7
        $rateLimit = $rateLimitInfo->get();
169 7
        if ($rateLimitInfo->isHit() && $currentTime <= $rateLimit['reset']) {
170
            // decrease existing rate limit remaining
171 2
            if ($rateLimit['remaining'] - $cost >= 0) {
172 1
                $remaining = $rateLimit['remaining'] - $cost;
173 1
                $reset = $rateLimit['reset'];
174 1
                $ttl = $rateLimit['reset'] - $currentTime;
175
            } else {
176 1
                $this->rateLimitExceeded = true;
177 1
                $this->reset = $rateLimit['reset'];
178 1
                $this->limit = $limit;
179 1
                $this->remaining = 0;
180
181 2
                return;
182
            }
183
        } else {
184
            // add / reset new rate limit remaining
185 5
            $remaining = $limit - $cost;
186 5
            $reset = $currentTime + $period;
187 5
            $ttl = $period;
188
        }
189
190
        $rateLimit = [
191 6
            'limit' => $limit,
192 6
            'remaining' => $remaining,
193 6
            'reset' => $reset,
194
        ];
195
196 6
        $rateLimitInfo->set($rateLimit);
197 6
        $rateLimitInfo->expiresAfter($ttl);
198
199 6
        $this->cacheItemPool->save($rateLimitInfo);
200
201 6
        $this->limit = $limit;
202 6
        $this->remaining = $remaining;
203 6
        $this->reset = $reset;
204 6
    }
205
206
    /**
207
     * @return array
208
     */
209 8
    private function getThrottle(Request $request, ApiRateLimit $annotation)
210
    {
211 8
        if (null !== $token = $this->tokenStorage->getToken()) {
212
            // no anonymous
213 2
            if (\is_object($token->getUser())) {
214 2
                $rolesConfig = $this->throttleConfig['roles'];
215 2
                if (!empty($annotation->throttle['roles'])) {
216 1
                    $rolesConfig = $annotation->throttle['roles'];
217
                }
218
219 2
                foreach ($rolesConfig as $role => $throttle) {
220 2
                    if ($this->authorizationChecker->isGranted($role)) {
221 2
                        $username = $token->getUsername();
222 2
                        $userRole = $role;
223 2
                        $limit = $throttle['limit'];
224 2
                        $period = $throttle['period'];
225
226 2
                        return [self::generateCacheKey($request->getClientIp(), $username, $userRole), $limit, $period];
0 ignored issues
show
It seems like $request->getClientIp() can also be of type null; however, parameter $ip of Indragunawan\ApiRateLimi...ler::generateCacheKey() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

226
                        return [self::generateCacheKey(/** @scrutinizer ignore-type */ $request->getClientIp(), $username, $userRole), $limit, $period];
Loading history...
227
                    }
228
                }
229
            }
230
        }
231
232 6
        if (!empty($annotation->throttle['default'])) {
233 1
            $limit = $annotation->throttle['default']['limit'];
234 1
            $period = $annotation->throttle['default']['period'];
235
        } else {
236 5
            $limit = $this->throttleConfig['default']['limit'];
237 5
            $period = $this->throttleConfig['default']['period'];
238
        }
239
240 6
        return [self::generateCacheKey($request->getClientIp()), $limit, $period];
241
    }
242
}
243