Passed
Pull Request — master (#14)
by Indra
02:10
created

RateLimitHandler::isRateLimitExceeded()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
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 ReflectionClass;
18
use Symfony\Component\HttpFoundation\Request;
19
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
20
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
21
22
/**
23
 * @author Indra Gunawan <[email protected]>
24
 */
25
class RateLimitHandler
26
{
27
    /**
28
     * @var CacheItemPoolInterface
29
     */
30
    private $cacheItemPool;
31
32
    /**
33
     * @var TokenStorageInterface
34
     */
35
    private $tokenStorage;
36
37
    /**
38
     * @var AuthorizationCheckerInterface
39
     */
40
    private $authorizationChecker;
41
42
    /**
43
     * @var array
44
     */
45
    private $throttleConfig;
46
47
    /**
48
     * @var int
49
     */
50
    private $limit;
51
52
    /**
53
     * @var int
54
     */
55
    private $remaining;
56
57
    /**
58
     * @var int
59
     */
60
    private $reset;
61
62
    /**
63
     * @var
64
     */
65
    private $enabled = true;
66
67
    /**
68
     * @var bool
69
     */
70
    private $rateLimitExceeded = false;
71
72
    /**
73
     * RateLimitHandler constructor.
74
     *
75
     * @param CacheItemPoolInterface $cacheItemPool
76
     * @param TokenStorageInterface $tokenStorage
77
     * @param AuthorizationCheckerInterface $authorizationChecker
78
     * @param array $throttleConfig
79
     */
80 8
    public function __construct(
81
        CacheItemPoolInterface $cacheItemPool,
82
        TokenStorageInterface $tokenStorage,
83
        AuthorizationCheckerInterface $authorizationChecker,
84
        array $throttleConfig
85
    ) {
86 8
        $this->cacheItemPool = $cacheItemPool;
87 8
        $this->tokenStorage = $tokenStorage;
88 8
        $this->authorizationChecker = $authorizationChecker;
89 8
        $this->throttleConfig = $throttleConfig;
90 8
    }
91
92
    /**
93
     * @return bool
94
     */
95 8
    public function isEnabled()
96
    {
97 8
        return $this->enabled;
98
    }
99
100
    /**
101
     * @return bool
102
     */
103 5
    public function isRateLimitExceeded()
104
    {
105 5
        return $this->rateLimitExceeded;
106
    }
107
108
    /**
109
     * @return array
110
     */
111 7
    public function getRateLimitInfo(): array
112
    {
113
        return [
114 7
            'limit'     => $this->limit,
115 7
            'remaining' => $this->remaining,
116 7
            'reset'     => $this->reset,
117
        ];
118
    }
119
120
    /**
121
     * @param string $ip
122
     * @param string|null $username
123
     * @param string|null $userRole
124
     *
125
     * @return string
126
     */
127 8
    public static function generateCacheKey(string $ip, string $username = null, string $userRole = null): string
128
    {
129 8
        if (!empty($username) && !empty($userRole)) {
130 2
            return sprintf('_api_rate_limit_metadata$%s', sha1($userRole . $username));
131
        }
132
133 6
        return sprintf('_api_rate_limit_metadata$%s', sha1($ip));
134
    }
135
136
    /**
137
     * @param Request $request
138
     *
139
     * @throws \Doctrine\Common\Annotations\AnnotationException
140
     * @throws \Psr\Cache\InvalidArgumentException
141
     * @throws \ReflectionException
142
     */
143 8
    public function handle(Request $request)
144
    {
145 8
        $annotationReader = new AnnotationReader();
146
        /** @var ApiRateLimit $annotation */
147 8
        $annotation = $annotationReader->getClassAnnotation(
148 8
            new ReflectionClass($request->attributes->get('_api_resource_class')),
149 8
            ApiRateLimit::class
150
        );
151
152 8
        if (null !== $annotation) {
153 8
            $this->enabled = $annotation->enabled;
154 8
            if (!in_array($request->getMethod(), array_map('strtoupper', $annotation->methods), true) && !empty($annotation->methods)) {
155
                // The annotation is ignored as the method is not corresponding
156 8
                $annotation = new ApiRateLimit();
157
            }
158
        } else {
159
            $annotation = new ApiRateLimit();
160
        }
161
162 8
        list($key, $limit, $period) = $this->getThrottle($request, $annotation);
163
164 8
        if ($this->enabled) {
165 7
            $this->decreaseRateLimitRemaining($key, $limit, $period);
166
        }
167 8
    }
168
169
    /**
170
     * @param string $key
171
     * @param int $limit
172
     * @param int $period
173
     *
174
     * @throws \Psr\Cache\InvalidArgumentException
175
     */
176 7
    protected function decreaseRateLimitRemaining(string $key, int $limit, int $period)
177
    {
178 7
        $cost = 1;
179 7
        $currentTime = gmdate('U');
180
181 7
        $rateLimitInfo = $this->cacheItemPool->getItem($key);
182 7
        $rateLimit = $rateLimitInfo->get();
183 7
        if ($rateLimitInfo->isHit() && $currentTime <= $rateLimit['reset']) {
184
            // decrease existing rate limit remaining
185 2
            if ($rateLimit['remaining'] - $cost >= 0) {
186 1
                $remaining = $rateLimit['remaining'] - $cost;
187 1
                $reset = $rateLimit['reset'];
188 1
                $ttl = $rateLimit['reset'] - $currentTime;
189
            } else {
190 1
                $this->rateLimitExceeded = true;
191 1
                $this->reset = $rateLimit['reset'];
192 1
                $this->limit = $limit;
193 1
                $this->remaining = 0;
194
195 2
                return;
196
            }
197
        } else {
198
            // add / reset new rate limit remaining
199 5
            $remaining = $limit - $cost;
200 5
            $reset = $currentTime + $period;
201 5
            $ttl = $period;
202
        }
203
204
        $rateLimit = [
205 6
            'limit' => $limit,
206 6
            'remaining' => $remaining,
207 6
            'reset' => $reset,
208
        ];
209
210 6
        $rateLimitInfo->set($rateLimit);
211 6
        $rateLimitInfo->expiresAfter($ttl);
212
213 6
        $this->cacheItemPool->save($rateLimitInfo);
214
215 6
        $this->limit = $limit;
216 6
        $this->remaining = $remaining;
217 6
        $this->reset = $reset;
218 6
    }
219
220
    /**
221
     * @param Request $request
222
     *
223
     * @return array
224
     */
225 8
    private function getThrottle(Request $request, ApiRateLimit $annotation)
226
    {
227 8
        if (null !== $token = $this->tokenStorage->getToken()) {
228
            // no anonymous
229 2
            if (is_object($token->getUser())) {
230 2
                $rolesConfig = $this->throttleConfig['roles'];
231 2
                if (!empty($annotation->throttle['roles'])) {
232 1
                    $rolesConfig = $annotation->throttle['roles'];
233
                }
234
235 2
                foreach ($rolesConfig as $role => $throttle) {
236 2
                    if ($this->authorizationChecker->isGranted($role)) {
237 2
                        $username = $token->getUsername();
238 2
                        $userRole = $role;
239 2
                        $limit = $throttle['limit'];
240 2
                        $period = $throttle['period'];
241
242 2
                        return [self::generateCacheKey($request->getClientIp(), $username, $userRole), $limit, $period];
0 ignored issues
show
Bug introduced by
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

242
                        return [self::generateCacheKey(/** @scrutinizer ignore-type */ $request->getClientIp(), $username, $userRole), $limit, $period];
Loading history...
243
                    }
244
                }
245
            }
246
        }
247
248 6
        if (!empty($annotation->throttle['default'])) {
249 1
            $limit = $annotation->throttle['default']['limit'];
250 1
            $period = $annotation->throttle['default']['period'];
251
        } else {
252 5
            $limit = $this->throttleConfig['default']['limit'];
253 5
            $period = $this->throttleConfig['default']['period'];
254
        }
255
256 6
        return [self::generateCacheKey($request->getClientIp()), $limit, $period];
257
    }
258
}
259