PageRenderTime 57ms CodeModel.GetById 27ms RepoModel.GetById 1ms app.codeStats 0ms

/OpenIdProvider/Helpers/IPBanner.cs

https://bitbucket.org/sparktree/stackexchange.stackid
C# | 228 lines | 136 code | 44 blank | 48 comment | 24 complexity | 450a8cd526cc2dd2842509c35b3d22db MD5 | raw file
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Web;
  5. using System.Collections.Concurrent;
  6. using OpenIdProvider.Models;
  7. using System.Data.SqlTypes;
  8. using ProtoBuf;
  9. namespace OpenIdProvider.Helpers
  10. {
  11. /// <summary>
  12. /// Helper for tracking infractions and IP bans that result from them.
  13. /// </summary>
  14. public static class IPBanner
  15. {
  16. // Distinct from LINQ class to avoid capturing a data context (also, don't need Id or Reason)
  17. class BanPeriod
  18. {
  19. public DateTime CreationDate;
  20. public DateTime ExpirationDate;
  21. }
  22. [ProtoContract]
  23. public class Infraction
  24. {
  25. public enum InfractionType { Login, XSRF, Recovery }
  26. [ProtoMember(1)]
  27. public InfractionType Type;
  28. [ProtoMember(2)]
  29. public int? RelatedId;
  30. [ProtoMember(3)]
  31. public DateTime Expires;
  32. }
  33. private static object UpdateLock = new object();
  34. private static ConcurrentDictionary<string, BanPeriod> BannedIPCache = new ConcurrentDictionary<string, BanPeriod>();
  35. private static DateTime NextRefresh { get; set; }
  36. static IPBanner() { NextRefresh = DateTime.MinValue; }
  37. /// <summary>
  38. /// Call to periodically slurp down new bans and the like.
  39. ///
  40. /// We want this stuff in the DB for persistence (so an App cycle doesn't lift bans),
  41. /// but we don't want an extra DB hit on every page either.
  42. ///
  43. /// Thus, we maintain a copy of the "important" parts of the IPBans table in memory.
  44. /// </summary>
  45. private static void TryRefreshCache()
  46. {
  47. if (Current.Now < NextRefresh) return;
  48. lock (UpdateLock)
  49. {
  50. if (Current.Now < NextRefresh) return;
  51. var now = Current.Now;
  52. var getNewBansFrom = BannedIPCache.Count == 0 ? SqlDateTime.MinValue.Value : BannedIPCache.Max(i => i.Value.CreationDate);
  53. var newBans = Current.ReadDB.IPBans.Where(b => b.CreationDate > getNewBansFrom);
  54. foreach(var b in newBans){
  55. var period = new BanPeriod{ CreationDate = b.CreationDate, ExpirationDate = b.ExpirationDate};
  56. BannedIPCache.AddOrUpdate(b.IP, period, (string key, BanPeriod old) => period);
  57. }
  58. var expiredBans = BannedIPCache.Where(i => i.Value.ExpirationDate < now).Select(i => i.Key);
  59. BanPeriod ignored;
  60. foreach (var expired in expiredBans) BannedIPCache.TryRemove(expired, out ignored);
  61. NextRefresh = Current.Now + TimeSpan.FromMinutes(5);
  62. }
  63. }
  64. /// <summary>
  65. /// Return true if the given IP is banned.
  66. /// </summary>
  67. public static bool IsBanned(string ip)
  68. {
  69. // Never enforce an internal IP ban
  70. if (Current.IsPrivateIP(ip)) return false;
  71. TryRefreshCache();
  72. BanPeriod ban;
  73. if (!BannedIPCache.TryGetValue(ip, out ban)) return false;
  74. if (ban.ExpirationDate > Current.Now) return true;
  75. return false;
  76. }
  77. /// <summary>
  78. /// Create a new ban for the given ip lasting for the given period.
  79. /// </summary>
  80. public static void Ban(string ip, TimeSpan @for, string reason)
  81. {
  82. // Never ban an internal IP
  83. if (Current.IsPrivateIP(ip)) return;
  84. var db = Current.WriteDB;
  85. var now = Current.Now;
  86. var newBan =
  87. new IPBan
  88. {
  89. CreationDate = now,
  90. ExpirationDate = now + @for,
  91. IP = ip,
  92. Reason = reason
  93. };
  94. db.IPBans.InsertOnSubmit(newBan);
  95. db.SubmitChanges();
  96. var period = new BanPeriod { CreationDate = newBan.CreationDate, ExpirationDate = newBan.ExpirationDate };
  97. BannedIPCache.AddOrUpdate(ip, period, (string key, BanPeriod old) => period);
  98. }
  99. /// <summary>
  100. /// Get the existing infractions for this IP.
  101. /// </summary>
  102. private static List<Infraction> GetInfractionList(string ip)
  103. {
  104. var key = "infraction-" + ip;
  105. var inCache = Current.GetFromCache<List<Infraction>>(key);
  106. if (inCache.RemoveAll(i => i.Expires < Current.Now) != 0) Current.AddToCache(key, inCache, TimeSpan.FromDays(1));
  107. return new List<Infraction>(inCache);
  108. }
  109. /// <summary>
  110. /// Add new infractions for this IP.
  111. /// </summary>
  112. private static void UpdateInfractionList(string ip, Infraction addToList)
  113. {
  114. var key = "infraction-" + ip;
  115. var inCache = Current.GetFromCache<List<Infraction>>(key) ?? new List<Infraction>();
  116. inCache.Add(addToList);
  117. Current.AddToCache(key, inCache, TimeSpan.FromDays(1));
  118. }
  119. /// <summary>
  120. /// Gives a black mark for a bad POST (as evidenced by a forged or missing XSRF token) request.
  121. /// </summary>
  122. public static void BadXSRFToken(string ip)
  123. {
  124. UpdateInfractionList(ip, new Infraction { Type = Infraction.InfractionType.XSRF, Expires = Current.Now.Add(TimeSpan.FromMinutes(5)) });
  125. var existingInfactions = GetInfractionList(ip);
  126. if (existingInfactions.Count(i => i.Type == Infraction.InfractionType.XSRF) > 10) Ban(ip, TimeSpan.FromMinutes(10), "Too many bad XSRF tokens.");
  127. }
  128. /// <summary>
  129. /// Gives a black mark to an IP for sending a recovery email.
  130. ///
  131. /// We want to cut these off after a while (faster in the face of other "iffy" behavior) since you can
  132. /// use recovery email error messages as a ghetto way to scan for usernames (registered email addresses).
  133. /// </summary>
  134. public static void AttemptedToSendRecoveryEmail(string ip)
  135. {
  136. UpdateInfractionList(ip, new Infraction { Type = Infraction.InfractionType.Recovery, Expires = Current.Now.Add(TimeSpan.FromMinutes(30)) });
  137. var existingInfractions = GetInfractionList(ip);
  138. if (existingInfractions.Count(i => i.Type == Infraction.InfractionType.Recovery) > 5) Ban(ip, TimeSpan.FromMinutes(60), "Too many attempts at recovering an account.");
  139. }
  140. /// <summary>
  141. /// Gives a black mark to an IP that just failed a login attempt.
  142. ///
  143. /// Passes the user, if the account exists at all.
  144. ///
  145. /// Repeated attempts to login to a single account can be very worrying past
  146. /// a certain number, but fat fingering does happen so we don't want to panic
  147. /// immediately.
  148. ///
  149. /// Repeated attempts to *different* accounts is almost certainly a sign of an
  150. /// attack, once the number of involved accounts grows to a certain size.
  151. /// </summary>
  152. public static void BadLoginAttempt(User user, string ip)
  153. {
  154. UpdateInfractionList(ip,
  155. new Infraction
  156. {
  157. Type = Infraction.InfractionType.Login,
  158. Expires = Current.Now.Add(TimeSpan.FromMinutes(5)),
  159. RelatedId = user != null ? user.Id : -1
  160. }
  161. );
  162. var existingInfractions = GetInfractionList(ip);
  163. var singleUser = existingInfractions.Where(i => i.Type == Infraction.InfractionType.Login && i.RelatedId != -1).GroupBy(i => i.RelatedId).Max(g => (int?)g.Count());
  164. if (singleUser > 10)
  165. {
  166. Ban(ip, TimeSpan.FromMinutes(5), "More than 10 attempts to login as a user.");
  167. return;
  168. }
  169. var noUser = existingInfractions.Count(i => i.Type == Infraction.InfractionType.Login && i.RelatedId == -1);
  170. if (noUser > 20)
  171. {
  172. Ban(ip, TimeSpan.FromMinutes(30), "More than 20 attempts to login.");
  173. return;
  174. }
  175. var total = existingInfractions.Count(i => i.Type == Infraction.InfractionType.Login);
  176. // This suggests they're trying to actually dodge our single and scan behavior; drop the hammer
  177. if (total > 30)
  178. {
  179. Ban(ip, TimeSpan.FromMinutes(60), "Appears to be spamming login attempts, while dodging throttles.");
  180. return;
  181. }
  182. }
  183. }
  184. }