diff roundup/cgi/actions.py @ 5717:cad18de2b988

issue2550949: Rate limit password guesses/login attempts. Generic rate limit mechanism added. Deployed for web page logins. Default is 3 login attempts/minute for a user. After which one login attempt every 20 seconds can be done. Uses gcra algorithm so all I need to store is a username and timestamp in the one time key database. This does mean I don't have a list of all failed login attempts as part of the rate limiter. Set up config setting as well so admin can tune the rate. Maybe 1 every 10 seconds is ok at a site with poor typists who need 6 attempts to get the password right 8-). The gcra method can also be used to limit the rest and xmlrpc interfaces if needed. The mechanism I added also supplies a status method that calculates the expected values for http headers returned as part of rate limiting. Also tests added to test all code paths I hope.
author John Rouillard <rouilj@ieee.org>
date Sat, 11 May 2019 17:24:58 -0400
parents 9689d1bf9bb0
children 842252c3ee22
line wrap: on
line diff
--- a/roundup/cgi/actions.py	Sun Apr 28 18:44:05 2019 -0400
+++ b/roundup/cgi/actions.py	Sat May 11 17:24:58 2019 -0400
@@ -5,11 +5,15 @@
 from roundup.i18n import _
 from roundup.cgi import exceptions, templating
 from roundup.mailgw import uidFromAddress
+from roundup.rate_limit import Store, RateLimit
 from roundup.exceptions import Reject, RejectRaw
 from roundup.anypy import urllib_
 from roundup.anypy.strings import StringIO
 import roundup.anypy.random_ as random_
 
+import time
+from datetime import timedelta
+
 # Also add action to client.py::Client.actions property
 __all__ = ['Action', 'ShowAction', 'RetireAction', 'RestoreAction', 'SearchAction',
            'EditCSVAction', 'EditItemAction', 'PassResetAction',
@@ -31,6 +35,7 @@
         self.base = client.base
         self.user = client.user
         self.context = templating.context(client)
+        self.loginLimit = RateLimit(self.db.config['WEB_LOGIN_ATTEMPTS_MIN'], timedelta(seconds=60))
 
     def handle(self):
         """Action handler procedure"""
@@ -1226,7 +1231,34 @@
                                            )
 
         try:
-            self.verifyLogin(self.client.user, password)
+            # Implement rate limiting of logins by login name.
+            # Use prefix to prevent key collisions maybe??
+            rlkey="LOGIN-" + self.client.user
+            limit=self.loginLimit
+            s=Store()
+            otk=self.client.db.Otk
+            try:
+                val=otk.getall(rlkey)
+                s.set_tat_as_string(rlkey, val['tat'])
+            except KeyError:
+                # ignore if tat not set, it's 1970-1-1 by default.
+                pass
+            # see if rate limit exceeded and we need to reject the attempt
+            reject=s.update(rlkey, limit)
+
+            # Calculate a timestamp that will make OTK expire the
+            # unused entry 1 hour in the future
+            ts = time.time() - (60 * 60 * 24 * 7) + 3600
+            otk.set(rlkey, tat=s.get_tat_as_string(rlkey),
+                                   __timestamp=ts)
+            otk.commit()
+
+            if reject:
+                # User exceeded limits: find out how long to wait
+                status=s.status(rlkey, limit)
+                raise Reject(_("Logins occurring too fast. Please wait: %d seconds.")%status['Retry-After'])
+            else:
+                self.verifyLogin(self.client.user, password)
         except exceptions.LoginError as err:
             self.client.make_user_anonymous()
             for arg in err.args:

Roundup Issue Tracker: http://roundup-tracker.org/