Passed
Pull Request — master (#18)
by Indra
06:11
created

RateLimitHandler::handle()   B

Complexity

Conditions 8
Paths 12

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 8.0189

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 19
c 4
b 0
f 0
dl 0
loc 31
ccs 14
cts 15
cp 0.9333
rs 8.4444
cc 8
nc 12
nop 1
crap 8.0189
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
    public function __construct(
75 8
        CacheItemPoolInterface $cacheItemPool,
76
        TokenStorageInterface $tokenStorage,
77
        AuthorizationCheckerInterface $authorizationChecker,
78
        array $throttleConfig
79
    ) {
80
        $this->cacheItemPool = $cacheItemPool;
81 8
        $this->tokenStorage = $tokenStorage;
82 8
        $this->authorizationChecker = $authorizationChecker;
83 8
        $this->throttleConfig = $throttleConfig;
84 8
    }
85 8
86
    /**
87
     * @return bool
88
     */
89
    public function isEnabled()
90 8
    {
91
        return $this->enabled;
92 8
    }
93
94
    /**
95
     * @return bool
96
     */
97
    public function isRateLimitExceeded()
98 5
    {
99
        return $this->rateLimitExceeded;
100 5
    }
101
102
    public function getRateLimitInfo(): array
103 7
    {
104
        return [
105
            'limit' => $this->limit,
106 7
            'remaining' => $this->remaining,
107 7
            'reset' => $this->reset,
108 7
        ];
109
    }
110
111
    public static function generateCacheKey(string $ip, string $username = null, string $userRole = null): string
112 8
    {
113
        if (!empty($username) && !empty($userRole)) {
114 8
            return sprintf('_api_rate_limit_metadata$%s', sha1($userRole.$username));
115 2
        }
116
117
        return sprintf('_api_rate_limit_metadata$%s', sha1($ip));
118 6
    }
119
120
    /**
121
     * @throws \Doctrine\Common\Annotations\AnnotationException
122
     * @throws \Psr\Cache\InvalidArgumentException
123
     * @throws \ReflectionException
124
     */
125
    public function handle(Request $request)
126 8
    {
127
        $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 8
        } else {
131 8
            $annotationReader = new AnnotationReader();
132 8
            /** @var ApiRateLimit $annotation */
133
            $annotation = $annotationReader->getClassAnnotation($reflectionClass, ApiRateLimit::class);
134
        }
135 8
136 8
        if (null !== $annotation) {
0 ignored issues
show
introduced by
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
                    !\in_array($request->attributes->get('_api_item_operation_name'), array_map('strtolower', $annotation->methods), true)
142
                )
143
                && !empty($annotation->methods)
144
            ) {
145 8
                // The annotation is ignored as the method is not corresponding
146
                $annotation = new ApiRateLimit();
147 8
            }
148 7
        } else {
149
            $annotation = new ApiRateLimit();
150 8
        }
151
152
        list($key, $limit, $period) = $this->getThrottle($request, $annotation);
153
154
        if ($this->enabled) {
155 7
            $this->decreaseRateLimitRemaining($key, $limit, $period);
156
        }
157 7
    }
158 7
159
    /**
160 7
     * @throws \Psr\Cache\InvalidArgumentException
161 7
     */
162 7
    protected function decreaseRateLimitRemaining(string $key, int $limit, int $period)
163
    {
164 2
        $cost = 1;
165 1
        $currentTime = gmdate('U');
166 1
167 1
        $rateLimitInfo = $this->cacheItemPool->getItem($key);
168
        $rateLimit = $rateLimitInfo->get();
169 1
        if ($rateLimitInfo->isHit() && $currentTime <= $rateLimit['reset']) {
170 1
            // decrease existing rate limit remaining
171 1
            if ($rateLimit['remaining'] - $cost >= 0) {
172 1
                $remaining = $rateLimit['remaining'] - $cost;
173
                $reset = $rateLimit['reset'];
174 2
                $ttl = $rateLimit['reset'] - $currentTime;
175
            } else {
176
                $this->rateLimitExceeded = true;
177
                $this->reset = $rateLimit['reset'];
178 5
                $this->limit = $limit;
179 5
                $this->remaining = 0;
180 5
181
                return;
182
            }
183
        } else {
184 6
            // add / reset new rate limit remaining
185 6
            $remaining = $limit - $cost;
186 6
            $reset = $currentTime + $period;
187
            $ttl = $period;
188
        }
189 6
190 6
        $rateLimit = [
191
            'limit' => $limit,
192 6
            'remaining' => $remaining,
193
            'reset' => $reset,
194 6
        ];
195 6
196 6
        $rateLimitInfo->set($rateLimit);
197 6
        $rateLimitInfo->expiresAfter($ttl);
198
199
        $this->cacheItemPool->save($rateLimitInfo);
200
201
        $this->limit = $limit;
202 8
        $this->remaining = $remaining;
203
        $this->reset = $reset;
204 8
    }
205
206 2
    /**
207 2
     * @return array
208 2
     */
209 1
    private function getThrottle(Request $request, ApiRateLimit $annotation)
210
    {
211
        if (null !== $token = $this->tokenStorage->getToken()) {
212 2
            // no anonymous
213 2
            if (\is_object($token->getUser())) {
214 2
                $rolesConfig = $this->throttleConfig['roles'];
215 2
                if (!empty($annotation->throttle['roles'])) {
216 2
                    $rolesConfig = $annotation->throttle['roles'];
217 2
                }
218
219 2
                foreach ($rolesConfig as $role => $throttle) {
220
                    if ($this->authorizationChecker->isGranted($role)) {
221
                        $username = $token->getUsername();
222
                        $userRole = $role;
223
                        $limit = $throttle['limit'];
224
                        $period = $throttle['period'];
225 6
226 1
                        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

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