Throttler.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
  4. *
  5. * @author Lukas Reschke <lukas@statuscode.ch>
  6. *
  7. * @license GNU AGPL version 3 or any later version
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. *
  22. */
  23. namespace OC\Security\Bruteforce;
  24. use OCP\AppFramework\Utility\ITimeFactory;
  25. use OCP\IConfig;
  26. use OCP\IDBConnection;
  27. use OCP\ILogger;
  28. /**
  29. * Class Throttler implements the bruteforce protection for security actions in
  30. * Nextcloud.
  31. *
  32. * It is working by logging invalid login attempts to the database and slowing
  33. * down all login attempts from the same subnet. The max delay is 30 seconds and
  34. * the starting delay are 200 milliseconds. (after the first failed login)
  35. *
  36. * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
  37. * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
  38. *
  39. * @package OC\Security\Bruteforce
  40. */
  41. class Throttler {
  42. const LOGIN_ACTION = 'login';
  43. /** @var IDBConnection */
  44. private $db;
  45. /** @var ITimeFactory */
  46. private $timeFactory;
  47. /** @var ILogger */
  48. private $logger;
  49. /** @var IConfig */
  50. private $config;
  51. /**
  52. * @param IDBConnection $db
  53. * @param ITimeFactory $timeFactory
  54. * @param ILogger $logger
  55. * @param IConfig $config
  56. */
  57. public function __construct(IDBConnection $db,
  58. ITimeFactory $timeFactory,
  59. ILogger $logger,
  60. IConfig $config) {
  61. $this->db = $db;
  62. $this->timeFactory = $timeFactory;
  63. $this->logger = $logger;
  64. $this->config = $config;
  65. }
  66. /**
  67. * Convert a number of seconds into the appropriate DateInterval
  68. *
  69. * @param int $expire
  70. * @return \DateInterval
  71. */
  72. private function getCutoff($expire) {
  73. $d1 = new \DateTime();
  74. $d2 = clone $d1;
  75. $d2->sub(new \DateInterval('PT' . $expire . 'S'));
  76. return $d2->diff($d1);
  77. }
  78. /**
  79. * Return the given subnet for an IPv4 address and mask bits
  80. *
  81. * @param string $ip
  82. * @param int $maskBits
  83. * @return string
  84. */
  85. private function getIPv4Subnet($ip,
  86. $maskBits = 32) {
  87. $binary = \inet_pton($ip);
  88. for ($i = 32; $i > $maskBits; $i -= 8) {
  89. $j = \intdiv($i, 8) - 1;
  90. $k = (int) \min(8, $i - $maskBits);
  91. $mask = (0xff - ((pow(2, $k)) - 1));
  92. $int = \unpack('C', $binary[$j]);
  93. $binary[$j] = \pack('C', $int[1] & $mask);
  94. }
  95. return \inet_ntop($binary).'/'.$maskBits;
  96. }
  97. /**
  98. * Return the given subnet for an IPv6 address and mask bits
  99. *
  100. * @param string $ip
  101. * @param int $maskBits
  102. * @return string
  103. */
  104. private function getIPv6Subnet($ip, $maskBits = 48) {
  105. $binary = \inet_pton($ip);
  106. for ($i = 128; $i > $maskBits; $i -= 8) {
  107. $j = \intdiv($i, 8) - 1;
  108. $k = (int) \min(8, $i - $maskBits);
  109. $mask = (0xff - ((pow(2, $k)) - 1));
  110. $int = \unpack('C', $binary[$j]);
  111. $binary[$j] = \pack('C', $int[1] & $mask);
  112. }
  113. return \inet_ntop($binary).'/'.$maskBits;
  114. }
  115. /**
  116. * Return the given subnet for an IP and the configured mask bits
  117. *
  118. * Determine if the IP is an IPv4 or IPv6 address, then pass to the correct
  119. * method for handling that specific type.
  120. *
  121. * @param string $ip
  122. * @return string
  123. */
  124. private function getSubnet($ip) {
  125. if (\preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip)) {
  126. return $this->getIPv4Subnet(
  127. $ip,
  128. 32
  129. );
  130. }
  131. return $this->getIPv6Subnet(
  132. $ip,
  133. 128
  134. );
  135. }
  136. /**
  137. * Register a failed attempt to bruteforce a security control
  138. *
  139. * @param string $action
  140. * @param string $ip
  141. * @param array $metadata Optional metadata logged to the database
  142. */
  143. public function registerAttempt($action,
  144. $ip,
  145. array $metadata = []) {
  146. // No need to log if the bruteforce protection is disabled
  147. if($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
  148. return;
  149. }
  150. $values = [
  151. 'action' => $action,
  152. 'occurred' => $this->timeFactory->getTime(),
  153. 'ip' => $ip,
  154. 'subnet' => $this->getSubnet($ip),
  155. 'metadata' => json_encode($metadata),
  156. ];
  157. $this->logger->notice(
  158. sprintf(
  159. 'Bruteforce attempt from "%s" detected for action "%s".',
  160. $ip,
  161. $action
  162. ),
  163. [
  164. 'app' => 'core',
  165. ]
  166. );
  167. $qb = $this->db->getQueryBuilder();
  168. $qb->insert('bruteforce_attempts');
  169. foreach($values as $column => $value) {
  170. $qb->setValue($column, $qb->createNamedParameter($value));
  171. }
  172. $qb->execute();
  173. }
  174. /**
  175. * Get the throttling delay (in milliseconds)
  176. *
  177. * @param string $ip
  178. * @param string $action optionally filter by action
  179. * @return int
  180. */
  181. public function getDelay($ip, $action = '') {
  182. $cutoffTime = (new \DateTime())
  183. ->sub($this->getCutoff(43200))
  184. ->getTimestamp();
  185. $qb = $this->db->getQueryBuilder();
  186. $qb->select('*')
  187. ->from('bruteforce_attempts')
  188. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  189. ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($this->getSubnet($ip))));
  190. if ($action !== '') {
  191. $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
  192. }
  193. $attempts = count($qb->execute()->fetchAll());
  194. if ($attempts === 0) {
  195. return 0;
  196. }
  197. $maxDelay = 30;
  198. $firstDelay = 0.1;
  199. if ($attempts > (8 * PHP_INT_SIZE - 1)) {
  200. // Don't ever overflow. Just assume the maxDelay time:s
  201. $firstDelay = $maxDelay;
  202. } else {
  203. $firstDelay *= pow(2, $attempts);
  204. if ($firstDelay > $maxDelay) {
  205. $firstDelay = $maxDelay;
  206. }
  207. }
  208. return (int) \ceil($firstDelay * 1000);
  209. }
  210. /**
  211. * Will sleep for the defined amount of time
  212. *
  213. * @param string $ip
  214. * @param string $action optionally filter by action
  215. * @return int the time spent sleeping
  216. */
  217. public function sleepDelay($ip, $action = '') {
  218. $delay = $this->getDelay($ip, $action);
  219. usleep($delay * 1000);
  220. return $delay;
  221. }
  222. }