1212from roundup .anypy .strings import StringIO
1313from roundup .cgi import exceptions , templating
1414from roundup .cgi .timestamp import Timestamped
15- from roundup .exceptions import Reject , RejectRaw
15+ from roundup .exceptions import RateLimitExceeded , Reject , RejectRaw
1616from roundup .i18n import _
1717from roundup .mailgw import uidFromAddress
1818from roundup .rate_limit import Gcra , RateLimit
2626
2727
2828class Action :
29+ loginLimit = None
30+
2931 def __init__ (self , client ):
3032 self .client = client
3133 self .form = client .form
@@ -37,8 +39,6 @@ def __init__(self, client):
3739 self .base = client .base
3840 self .user = client .user
3941 self .context = templating .context (client )
40- self .loginLimit = RateLimit (client .db .config .WEB_LOGIN_ATTEMPTS_MIN ,
41- timedelta (seconds = 60 ))
4242
4343 def handle (self ):
4444 """Action handler procedure"""
@@ -149,7 +149,7 @@ def examine_url(self, url):
149149 if not allowed_pattern .match (parsed_url_tuple .fragment ):
150150 raise ValueError (self ._ ("Fragment component (%(url_fragment)s) in %(url)s is not properly escaped" ) % info )
151151
152- return ( urllib_ .urlunparse (parsed_url_tuple ) )
152+ return urllib_ .urlunparse (parsed_url_tuple )
153153
154154 name = ''
155155 permissionType = None
@@ -1298,36 +1298,6 @@ def handle(self):
12981298 redirect_url_tuple .fragment ))
12991299
13001300 try :
1301- # Implement rate limiting of logins by login name.
1302- # Use prefix to prevent key collisions maybe??
1303- # set client.db.config.WEB_LOGIN_ATTEMPTS_MIN to 0
1304- # to disable
1305- if self .client .db .config .WEB_LOGIN_ATTEMPTS_MIN : # if 0 - off
1306- rlkey = "LOGIN-" + self .client .user
1307- limit = self .loginLimit
1308- gcra = Gcra ()
1309- otk = self .client .db .Otk
1310- try :
1311- val = otk .getall (rlkey )
1312- gcra .set_tat_as_string (rlkey , val ['tat' ])
1313- except KeyError :
1314- # ignore if tat not set, it's 1970-1-1 by default.
1315- pass
1316- # see if rate limit exceeded and we need to reject the attempt
1317- reject = gcra .update (rlkey , limit )
1318-
1319- # Calculate a timestamp that will make OTK expire the
1320- # unused entry 1 hour in the future
1321- ts = otk .lifetime (3600 )
1322- otk .set (rlkey , tat = gcra .get_tat_as_string (rlkey ),
1323- __timestamp = ts )
1324- otk .commit ()
1325-
1326- if reject :
1327- # User exceeded limits: find out how long to wait
1328- status = gcra .status (rlkey , limit )
1329- raise Reject (_ ("Logins occurring too fast. Please wait: %s seconds." ) % status ['Retry-After' ])
1330-
13311301 self .verifyLogin (self .client .user , password )
13321302 except exceptions .LoginError as err :
13331303 self .client .make_user_anonymous ()
@@ -1347,6 +1317,28 @@ def handle(self):
13471317 raise exceptions .Redirect (redirect_url )
13481318 # if no __came_from, send back to base url with error
13491319 return
1320+ except RateLimitExceeded as err :
1321+ self .client .make_user_anonymous ()
1322+ for arg in err .args :
1323+ self .client .add_error_message (arg )
1324+
1325+ if '__came_from' in self .form :
1326+ # usually web only. If API uses this they will get
1327+ # confused as the 429 isn't returned. Without this
1328+ # a web user will get redirected back to the home
1329+ # page rather than stay on the page where they tried
1330+ # to login.
1331+ # set a new error message
1332+ query ['@error_message' ] = err .args
1333+ redirect_url = urllib_ .urlunparse (
1334+ (redirect_url_tuple .scheme ,
1335+ redirect_url_tuple .netloc ,
1336+ redirect_url_tuple .path ,
1337+ redirect_url_tuple .params ,
1338+ urllib_ .urlencode (list (sorted (query .items ())), doseq = True ),
1339+ redirect_url_tuple .fragment ))
1340+ raise exceptions .Redirect (redirect_url )
1341+ raise
13501342
13511343 # now we're OK, re-open the database for real, using the user
13521344 self .client .opendb (self .client .user )
@@ -1370,7 +1362,139 @@ def handle(self):
13701362
13711363 raise exceptions .Redirect (redirect_url )
13721364
1373- def verifyLogin (self , username , password ):
1365+ def rateLimitLogin (self , username , is_api = False , update = True ):
1366+ """Implement rate limiting of logins by login name.
1367+
1368+ username - username attempting to log in. May or may not
1369+ be valid.
1370+ is_api - set to False for login via html page
1371+ set to "xmlrpc" for xmlrpc api
1372+ set to "rest" for rest api
1373+ update - if False will raise RateLimitExceeded without
1374+ updating the stored value. Default is True
1375+ which updates stored value. Used to deny access
1376+ when user successfully logs in but the user
1377+ doesn't have a valid attempt available.
1378+
1379+ Login rates for a user are split based on html vs api
1380+ login.
1381+
1382+ Set self.client.db.config.WEB_LOGIN_ATTEMPTS_MIN to 0
1383+ to disable for web interface. Set
1384+ self.client.db.config.API_LOGIN_ATTEMPTS to 0
1385+ to disable for web interface.
1386+
1387+ By setting LoginAction.limitLogin, the admin can override
1388+ the HTML web page rate limiter if they need to change the
1389+ interval from 1 minute.
1390+ """
1391+ config = self .client .db .config
1392+
1393+ if not is_api :
1394+ # HTML web login. Period is fixed at 1 minute.
1395+ # Override by setting self.loginLimit. Yech.
1396+ allowed_attempts = config .WEB_LOGIN_ATTEMPTS_MIN
1397+ per_period = 60
1398+ rlkey = "LOGIN-" + username
1399+ else :
1400+ # api login. Both Rest and XMLRPC use this.
1401+ allowed_attempts = config .WEB_API_FAILED_LOGIN_LIMIT
1402+ per_period = config .WEB_API_FAILED_LOGIN_INTERVAL_IN_SEC
1403+ rlkey = "LOGIN-API" + username
1404+
1405+ if not allowed_attempts : # if allowed_attempt == 0 - off
1406+ return
1407+
1408+ if self .loginLimit and not is_api :
1409+ # provide a way for user (via interfaces.py) to
1410+ # change the interval on the html login limit.
1411+ limit = self .loginLimit
1412+ else :
1413+ limit = RateLimit (allowed_attempts ,
1414+ timedelta (seconds = per_period ))
1415+
1416+ gcra = Gcra ()
1417+ otk = self .client .db .Otk
1418+
1419+ try :
1420+ val = otk .getall (rlkey )
1421+ gcra .set_tat_as_string (rlkey , val ['tat' ])
1422+ except KeyError :
1423+ # ignore if tat not set, tat is 1970-1-1 by default.
1424+ pass
1425+
1426+ # see if rate limit exceeded and we need to reject
1427+ # the attempt
1428+ reject = gcra .update (rlkey , limit )
1429+
1430+ # Calculate a timestamp that will make OTK expire the
1431+ # unused entry in twice the period defined for the
1432+ # rate limiter.
1433+ if update :
1434+ ts = otk .lifetime (per_period * 2 )
1435+ otk .set (rlkey , tat = gcra .get_tat_as_string (rlkey ),
1436+ __timestamp = ts )
1437+ otk .commit ()
1438+
1439+ # User exceeded limits: find out how long to wait
1440+ limitStatus = gcra .status (rlkey , limit )
1441+
1442+ if not reject :
1443+ return
1444+
1445+ for header , value in limitStatus .items ():
1446+ self .client .setHeader (header , value )
1447+
1448+ # User exceeded limits: tell humans how long to wait
1449+ # Headers above will do the right thing for api
1450+ # aware clients.
1451+ try :
1452+ retry_after = limitStatus ['Retry-After' ]
1453+ except KeyError :
1454+ # handle race condition. If the time between
1455+ # the call to grca.update and grca.status
1456+ # is sufficient to reload the bucket by 1
1457+ # item, Retry-After will be missing from
1458+ # limitStatus. So report a 1 second delay back
1459+ # to the client. We treat update as sole
1460+ # source of truth for exceeded rate limits.
1461+ retry_after = 1
1462+ self .client .setHeader ('Retry-After' , '1' )
1463+
1464+ # make rate limiting headers available to javascript
1465+ # even for non-api calls.
1466+ self .client .setHeader (
1467+ "Access-Control-Expose-Headers" ,
1468+ ", " .join ([
1469+ "X-RateLimit-Limit" ,
1470+ "X-RateLimit-Remaining" ,
1471+ "X-RateLimit-Reset" ,
1472+ "X-RateLimit-Limit-Period" ,
1473+ "Retry-After"
1474+ ])
1475+ )
1476+
1477+ raise RateLimitExceeded (_ ("Logins occurring too fast. Please wait: %s seconds." ) % retry_after )
1478+
1479+ def verifyLogin (self , username , password , is_api = False ):
1480+ """Authenticate the user with rate limits.
1481+
1482+ All logins (valid and failing) from a web page calling the
1483+ LoginAction method are rate limited to the config.ini
1484+ configured value in 1 minute. (Interval can be changed see
1485+ rateLimitLogin method.)
1486+
1487+ API logins are only rate limited if they fail. Successful
1488+ api logins are rate limited using the
1489+ api_calls_per_interval and api_interval_in_sec settings in
1490+ config.ini.
1491+
1492+ Once a user receives a rate limit notice, they must
1493+ wait the recommended time to try again as the account is
1494+ locked out for the recommended time. If a user tries to
1495+ log in while locked out, they will get a 429 rejection
1496+ even if the username and password are correct.
1497+ """
13741498 # make sure the user exists
13751499 try :
13761500 # Note: lookup only searches non-retired items.
@@ -1381,10 +1505,24 @@ def verifyLogin(self, username, password):
13811505 # delay caused by checking password only on valid
13821506 # users.
13831507 _discard = self .verifyPassword ("2" , password ) # noqa: F841
1508+
1509+ # limit logins to an acceptable rate. Do it for
1510+ # invalid usernames so attempts to break
1511+ # an invalid user will also be rate limited.
1512+ self .rateLimitLogin (username , is_api = is_api )
1513+
1514+ # this is not hit if rate limit is exceeded
13841515 raise exceptions .LoginError (self ._ ('Invalid login' ))
13851516
1517+ # if we are rate limited and the user tries again,
1518+ # reject the login. update=false so we don't count
1519+ # a potentially valid login.
1520+ self .rateLimitLogin (username , is_api = is_api , update = False )
1521+
13861522 # verify the password
13871523 if not self .verifyPassword (self .client .userid , password ):
1524+ self .rateLimitLogin (username , is_api = is_api )
1525+ # this is not hit if rate limit is exceeded
13881526 raise exceptions .LoginError (self ._ ('Invalid login' ))
13891527
13901528 # Determine whether the user has permission to log in.
0 commit comments