11<?php
2+
3+ declare (strict_types=1 );
24/**
35 * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
46 *
3436use OCP \IConfig ;
3537use OCP \IDBConnection ;
3638use OCP \ILogger ;
39+ use OCP \Security \Bruteforce \MaxDelayReached ;
3740
3841/**
3942 * Class Throttler implements the bruteforce protection for security actions in
5053 */
5154class Throttler {
5255 public const LOGIN_ACTION = 'login ' ;
56+ public const MAX_DELAY = 25 ;
57+ public const MAX_DELAY_MS = 25000 ; // in milliseconds
58+ public const MAX_ATTEMPTS = 10 ;
5359
5460 /** @var IDBConnection */
5561 private $ db ;
@@ -82,7 +88,7 @@ public function __construct(IDBConnection $db,
8288 * @param int $expire
8389 * @return \DateInterval
8490 */
85- private function getCutoff ($ expire ) {
91+ private function getCutoff (int $ expire ): \ DateInterval {
8692 $ d1 = new \DateTime ();
8793 $ d2 = clone $ d1 ;
8894 $ d2 ->sub (new \DateInterval ('PT ' . $ expire . 'S ' ));
@@ -92,11 +98,12 @@ private function getCutoff($expire) {
9298 /**
9399 * Calculate the cut off timestamp
94100 *
101+ * @param float $maxAgeHours
95102 * @return int
96103 */
97- private function getCutoffTimestamp (): int {
104+ private function getCutoffTimestamp (float $ maxAgeHours = 12.0 ): int {
98105 return (new \DateTime ())
99- ->sub ($ this ->getCutoff (43200 ))
106+ ->sub ($ this ->getCutoff (( int ) ( $ maxAgeHours * 3600 ) ))
100107 ->getTimestamp ();
101108 }
102109
@@ -108,9 +115,9 @@ private function getCutoffTimestamp(): int {
108115 * @param array $metadata Optional metadata logged to the database
109116 * @suppress SqlInjectionChecker
110117 */
111- public function registerAttempt ($ action ,
112- $ ip ,
113- array $ metadata = []) {
118+ public function registerAttempt (string $ action ,
119+ string $ ip ,
120+ array $ metadata = []): void {
114121 // No need to log if the bruteforce protection is disabled
115122 if ($ this ->config ->getSystemValue ('auth.bruteforce.protection.enabled ' , true ) === false ) {
116123 return ;
@@ -150,15 +157,14 @@ public function registerAttempt($action,
150157 * @param string $ip
151158 * @return bool
152159 */
153- private function isIPWhitelisted ($ ip ) {
160+ private function isIPWhitelisted (string $ ip ): bool {
154161 if ($ this ->config ->getSystemValue ('auth.bruteforce.protection.enabled ' , true ) === false ) {
155162 return true ;
156163 }
157164
158165 $ keys = $ this ->config ->getAppKeys ('bruteForce ' );
159166 $ keys = array_filter ($ keys , function ($ key ) {
160- $ regex = '/^whitelist_/S ' ;
161- return preg_match ($ regex , $ key ) === 1 ;
167+ return 0 === strpos ($ key , 'whitelist_ ' );
162168 });
163169
164170 if (filter_var ($ ip , FILTER_VALIDATE_IP , FILTER_FLAG_IPV4 )) {
@@ -215,18 +221,19 @@ private function isIPWhitelisted($ip) {
215221 *
216222 * @param string $ip
217223 * @param string $action optionally filter by action
224+ * @param float $maxAgeHours
218225 * @return int
219226 */
220- public function getDelay ( $ ip , $ action = '' ) {
227+ public function getAttempts ( string $ ip , string $ action = '' , float $ maxAgeHours = 12 ): int {
221228 $ ipAddress = new IpAddress ($ ip );
222229 if ($ this ->isIPWhitelisted ((string )$ ipAddress )) {
223230 return 0 ;
224231 }
225232
226- $ cutoffTime = $ this ->getCutoffTimestamp ();
233+ $ cutoffTime = $ this ->getCutoffTimestamp ($ maxAgeHours );
227234
228235 $ qb = $ this ->db ->getQueryBuilder ();
229- $ qb ->select ('* ' )
236+ $ qb ->select ($ qb -> func ()-> count ( '* ' , ' attempts ' ) )
230237 ->from ('bruteforce_attempts ' )
231238 ->where ($ qb ->expr ()->gt ('occurred ' , $ qb ->createNamedParameter ($ cutoffTime )))
232239 ->andWhere ($ qb ->expr ()->eq ('subnet ' , $ qb ->createNamedParameter ($ ipAddress ->getSubnet ())));
@@ -235,34 +242,47 @@ public function getDelay($ip, $action = '') {
235242 $ qb ->andWhere ($ qb ->expr ()->eq ('action ' , $ qb ->createNamedParameter ($ action )));
236243 }
237244
238- $ attempts = count ($ qb ->execute ()->fetchAll ());
245+ $ result = $ qb ->execute ();
246+ $ row = $ result ->fetch ();
247+ $ result ->closeCursor ();
248+
249+ return (int ) $ row ['attempts ' ];
250+ }
239251
252+ /**
253+ * Get the throttling delay (in milliseconds)
254+ *
255+ * @param string $ip
256+ * @param string $action optionally filter by action
257+ * @return int
258+ */
259+ public function getDelay (string $ ip , string $ action = '' ): int {
260+ $ attempts = $ this ->getAttempts ($ ip , $ action );
240261 if ($ attempts === 0 ) {
241262 return 0 ;
242263 }
243264
244- $ maxDelay = 25 ;
245265 $ firstDelay = 0.1 ;
246- if ($ attempts > ( 8 * PHP_INT_SIZE - 1 ) ) {
266+ if ($ attempts > self :: MAX_ATTEMPTS ) {
247267 // Don't ever overflow. Just assume the maxDelay time:s
248- $ firstDelay = $ maxDelay ;
249- } else {
250- $ firstDelay *= pow (2 , $ attempts );
251- if ($ firstDelay > $ maxDelay ) {
252- $ firstDelay = $ maxDelay ;
253- }
268+ return self ::MAX_DELAY_MS ;
254269 }
255- return (int ) \ceil ($ firstDelay * 1000 );
270+
271+ $ delay = $ firstDelay * 2 **$ attempts ;
272+ if ($ delay > self ::MAX_DELAY ) {
273+ return self ::MAX_DELAY_MS ;
274+ }
275+ return (int ) \ceil ($ delay * 1000 );
256276 }
257277
258278 /**
259279 * Reset the throttling delay for an IP address, action and metadata
260280 *
261281 * @param string $ip
262282 * @param string $action
263- * @param string $metadata
283+ * @param array $metadata
264284 */
265- public function resetDelay ($ ip , $ action , $ metadata ) {
285+ public function resetDelay (string $ ip , string $ action , array $ metadata ): void {
266286 $ ipAddress = new IpAddress ($ ip );
267287 if ($ this ->isIPWhitelisted ((string )$ ipAddress )) {
268288 return ;
@@ -303,9 +323,28 @@ public function resetDelayForIP($ip) {
303323 * @param string $action optionally filter by action
304324 * @return int the time spent sleeping
305325 */
306- public function sleepDelay ($ ip , $ action = '' ) {
326+ public function sleepDelay (string $ ip , string $ action = '' ): int {
307327 $ delay = $ this ->getDelay ($ ip , $ action );
308328 usleep ($ delay * 1000 );
309329 return $ delay ;
310330 }
331+
332+ /**
333+ * Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes
334+ * In this case a "429 Too Many Request" exception is thrown
335+ *
336+ * @param string $ip
337+ * @param string $action optionally filter by action
338+ * @return int the time spent sleeping
339+ * @throws MaxDelayReached when reached the maximum
340+ */
341+ public function sleepDelayOrThrowOnMax (string $ ip , string $ action = '' ): int {
342+ $ delay = $ this ->getDelay ($ ip , $ action );
343+ if (($ delay === self ::MAX_DELAY_MS ) && $ this ->getAttempts ($ ip , $ action , 0.5 ) > self ::MAX_ATTEMPTS ) {
344+ // If the ip made too many attempts within the last 30 mins we don't execute anymore
345+ throw new MaxDelayReached ('Reached maximum delay ' );
346+ }
347+ usleep ($ delay * 1000 );
348+ return $ delay ;
349+ }
311350}
0 commit comments