Throttler.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  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. $bitmask = 1 << (7 - ($i % 8));
  161. $part = $part & $bitmask;
  162. $orig = $orig & $bitmask;
  163. if ($part !== $orig) {
  164. $valid = false;
  165. break;
  166. }
  167. }
  168. if ($valid === true) {
  169. return true;
  170. }
  171. }
  172. return false;
  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. $ipAddress = new IpAddress($ip);
  183. if ($this->isIPWhitelisted((string)$ipAddress)) {
  184. return 0;
  185. }
  186. $cutoffTime = (new \DateTime())
  187. ->sub($this->getCutoff(43200))
  188. ->getTimestamp();
  189. $qb = $this->db->getQueryBuilder();
  190. $qb->select('*')
  191. ->from('bruteforce_attempts')
  192. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  193. ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
  194. if ($action !== '') {
  195. $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
  196. }
  197. $attempts = count($qb->execute()->fetchAll());
  198. if ($attempts === 0) {
  199. return 0;
  200. }
  201. $maxDelay = 25;
  202. $firstDelay = 0.1;
  203. if ($attempts > (8 * PHP_INT_SIZE - 1)) {
  204. // Don't ever overflow. Just assume the maxDelay time:s
  205. $firstDelay = $maxDelay;
  206. } else {
  207. $firstDelay *= pow(2, $attempts);
  208. if ($firstDelay > $maxDelay) {
  209. $firstDelay = $maxDelay;
  210. }
  211. }
  212. return (int) \ceil($firstDelay * 1000);
  213. }
  214. /**
  215. * Reset the throttling delay for an IP address, action and metadata
  216. *
  217. * @param string $ip
  218. * @param string $action
  219. * @param string $metadata
  220. */
  221. public function resetDelay($ip, $action, $metadata) {
  222. $ipAddress = new IpAddress($ip);
  223. if ($this->isIPWhitelisted((string)$ipAddress)) {
  224. return;
  225. }
  226. $cutoffTime = (new \DateTime())
  227. ->sub($this->getCutoff(43200))
  228. ->getTimestamp();
  229. $qb = $this->db->getQueryBuilder();
  230. $qb->delete('bruteforce_attempts')
  231. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  232. ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())))
  233. ->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)))
  234. ->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata))));
  235. $qb->execute();
  236. }
  237. /**
  238. * Will sleep for the defined amount of time
  239. *
  240. * @param string $ip
  241. * @param string $action optionally filter by action
  242. * @return int the time spent sleeping
  243. */
  244. public function sleepDelay($ip, $action = '') {
  245. $delay = $this->getDelay($ip, $action);
  246. usleep($delay * 1000);
  247. return $delay;
  248. }
  249. }