Throttler.php 7.4 KB

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