BioTorrents.de’s version of Gazelle
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

loginwatch.class.php 7.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. <?php
  2. declare(strict_types = 1);
  3. /**
  4. * Adapted from
  5. * https://github.com/OPSnet/Gazelle/blob/master/app/LoginWatch.php
  6. *
  7. * Unknown status as of 2020-12-12
  8. */
  9. class LoginWatch
  10. {
  11. protected $watchId;
  12. /**
  13. * Set the context of a watched IP address (to save passing it in to each method call).
  14. * On a virgin login with no previous errors there may not even be a watch yet
  15. * @param int ID of the watch
  16. */
  17. public function setWatch($watchId)
  18. {
  19. if (!is_null($watchId)) {
  20. $this->watchId = $watchId;
  21. }
  22. return $this;
  23. }
  24. /**
  25. * Find a login watch by IP address
  26. * @param string IPv4 address
  27. * @return array [watchId, nrAttemtps, nrBans, bannedUntil]
  28. */
  29. public function findByIp(string $ipaddr): ?array
  30. {
  31. # Default: remote host
  32. if (empty($ipaddr)) {
  33. $ipaddr = $_SERVER['REMOTE_ADDR'];
  34. }
  35. return G::$DB->row("
  36. SELECT
  37. `ID`,
  38. `Attempts`,
  39. `Bans`,
  40. `BannedUntil`
  41. FROM
  42. `login_attempts`
  43. WHERE
  44. `IP` = '$ipaddr'
  45. ");
  46. }
  47. /**
  48. * Create a new login watch on an userid/username/ipaddress
  49. * @param string IPv4 address
  50. * @param string|null $capture The username captured on the form
  51. * @param int $userId
  52. * @return int ID of watch
  53. */
  54. public function create(string $ipaddr, ?string $capture, int $userId = 0)
  55. {
  56. G::$DB->prepare_query("
  57. INSERT INTO `login_attempts`(`IP`, `Capture`, `UserID`)
  58. VALUES('$ipaddr', '$capture', '$userId')
  59. ");
  60. G::$DB->exec_prepared_query();
  61. return ($this->watchId = G::$DB->inserted_id());
  62. }
  63. /**
  64. * Record another failure attempt on this watch. If the user has not
  65. * logged in recently from this IP address then subsequent logins
  66. * will be blocked for increasingly longer times, otherwise 1 minute.
  67. *
  68. * @param int $userId The ID of the user
  69. * @param string $ipaddr The IP the user is coming from
  70. * @param string $capture The username captured on the form
  71. * @return int 1 if the watch was updated
  72. */
  73. public function increment(int $userId, string $ipaddr, ?string $capture): int
  74. {
  75. $seen = G::$DB->query("
  76. SELECT
  77. 1
  78. FROM
  79. `users_history_ips`
  80. WHERE
  81. (
  82. `EndTime` IS NULL
  83. OR `EndTime` > NOW() - INTERVAL 1 WEEK
  84. )
  85. AND `UserID` = '$userId'
  86. AND `IP` = '$ipaddr'
  87. ");
  88. $delay = $seen ? 60 : LOGIN_ATTEMPT_BACKOFF[min($this->nrAttempts(), count(LOGIN_ATTEMPT_BACKOFF)-1)];
  89. G::$DB->prepare_query("
  90. UPDATE `login_attempts` SET
  91. `Attempts` = `Attempts` + 1,
  92. `LastAttempt` = now(),
  93. `BannedUntil` = now() + INTERVAL '$delay' SECOND,
  94. `UserID` = '$userId',
  95. `Capture` ='$capture'
  96. WHERE `ID` = '$this->watchId'
  97. ");
  98. return G::$DB->affected_rows();
  99. }
  100. /**
  101. * Ban subsequent attempts to login from this watched IP address for 6 hours
  102. * @param int $attempts How many attempts so far?
  103. * @param string the username captured on the form (which may not even be a valid user)
  104. * @param int $userId user ID of a valid user (or 0 if invalid username)
  105. * @return int 1 if the watch was banned
  106. */
  107. public function ban(int $attempts, ?string $capture, int $userId = 0): int
  108. {
  109. G::$DB->prepare_query("
  110. UPDATE
  111. `login_attempts`
  112. SET
  113. `Bans` = `Bans` + 1,
  114. `LastAttempt` = NOW(),
  115. `BannedUntil` = NOW() + INTERVAL 6 HOUR,
  116. `Attempts` = '$attempts',
  117. `Capture` = '$capture',
  118. `UserID` = '$userId'
  119. WHERE
  120. `ID` = '$this->watchId'
  121. ");
  122. G::$DB->exec_prepared_query();
  123. return G::$DB->affected_rows();
  124. }
  125. /**
  126. * When does the login ban expire?
  127. * @return string datestamp of expiry
  128. */
  129. public function bannedUntil(): ?string
  130. {
  131. return G::$DB->scalar("
  132. SELECT
  133. `BannedUntil`
  134. FROM
  135. `login_attempts`
  136. WHERE
  137. `ID` = '$this->watchId'
  138. ");
  139. }
  140. /**
  141. * If the login ban was in the past then they get 6 more shots
  142. * @return int 1 if a prior ban was cleared
  143. */
  144. public function clearPriorBan(): int
  145. {
  146. G::$DB->prepare_query("
  147. UPDATE
  148. `login_attempts`
  149. SET
  150. `BannedUntil` = NULL,
  151. `Attempts` = 0
  152. WHERE
  153. `BannedUntil` < NOW()
  154. AND `ID` = '$this->watchId'
  155. ");
  156. G::$DB->exec_prepared_query();
  157. return G::$DB->affected_rows();
  158. }
  159. /**
  160. * If the login was successful, clear prior attempts
  161. * @return int 1 if an update was made
  162. */
  163. public function clearAttempts(): int
  164. {
  165. G::$DB->prepare_query("
  166. UPDATE
  167. `login_attempts`
  168. SET
  169. `Attempts` = 0
  170. WHERE
  171. `ID` = '$this->watchId'
  172. ");
  173. G::$DB->exec_prepared_query();
  174. return $this->db->affected_rows();
  175. }
  176. /**
  177. * How many attempts have been made on this watch?
  178. * @return int Number of attempts
  179. */
  180. public function nrAttempts(): int
  181. {
  182. return (int) G::$DB->scalar("
  183. SELECT
  184. `Attempts`
  185. FROM
  186. `login_attempts`
  187. WHERE
  188. `ID` = '$this->watchId'
  189. ") ?? 0;
  190. }
  191. /**
  192. * Get the list of login failures
  193. * @return array list [ID, ipaddr, userid, LastAttempt (datetime), Attempts, BannedUntil (datetime), Bans]
  194. */
  195. public function activeList(string $orderBy, string $orderWay): array
  196. {
  197. G::$DB->prepare_query("
  198. SELECT
  199. w.`ID` AS id,
  200. w.`IP` AS ipaddr,
  201. w.`UserID` AS user_id,
  202. w.`LastAttempt` AS last_attempt,
  203. w.`Attempts` AS attempts,
  204. w.`BannedUntil` AS banned_until,
  205. w.`Bans` AS bans,
  206. w.`Capture`,
  207. um.`Username` AS username,
  208. (ip.`FromIP` IS NOT NULL) AS banned
  209. FROM
  210. `login_attempts` w
  211. LEFT JOIN `users_main` um ON
  212. (um.`ID` = w.`UserID`)
  213. LEFT JOIN `ip_bans` ip ON
  214. (ip.`FromIP` = INET_ATON(w.`IP`))
  215. WHERE
  216. (
  217. w.`BannedUntil` > NOW()
  218. OR w.`LastAttempt` > NOW() - INTERVAL 6 HOUR
  219. )
  220. ORDER BY
  221. '$orderBy' '$orderWay'
  222. ");
  223. G::$DB->exec_prepared_query();
  224. return G::$DB->to_array('id', MYSQLI_ASSOC, false);
  225. }
  226. /**
  227. * Ban the IP addresses pointed to by the IDs that are on login watch.
  228. * @param array list of IDs to ban.
  229. * @return number of addresses banned
  230. */
  231. public function setBan(int $userId, string $reason, array $list): int
  232. {
  233. if (!$list) {
  234. return 0;
  235. }
  236. $reason = trim($reason);
  237. $n = 0;
  238. foreach ($list as $id) {
  239. $ipv4 = G::$DB->scalar("
  240. SELECT
  241. INET_ATON(`IP`)
  242. FROM
  243. `login_attempts`
  244. WHERE
  245. `ID` = '$id'
  246. ");
  247. G::$DB->prepared_query("
  248. INSERT IGNORE
  249. INTO `ip_bans`(`UserID`, `Reason`, `FromIP`, `ToIP`)
  250. VALUES('$userId', '$reason', '$ipv4', '$ipv4')
  251. ");
  252. G::$DB->exec_prepared_query();
  253. $n += $this->db->affected_rows();
  254. }
  255. return $n;
  256. }
  257. /**
  258. * Clear the list of IDs that are on login watch.
  259. * @param array list of IDs to clear.
  260. * @return number of rows removed
  261. */
  262. public function setClear(array $list): int
  263. {
  264. if (!$list) {
  265. return 0;
  266. }
  267. G::$DB->prepare_query("
  268. DELETE
  269. FROM
  270. `login_attempts`
  271. WHERE
  272. `ID` IN(".placeholders($list).")
  273. ", ...$list);
  274. G::$DB->exec_prepared_query();
  275. return G::$DB->affected_rows();
  276. }
  277. }