12
12
from roundup .anypy .strings import StringIO
13
13
from roundup .cgi import exceptions , templating
14
14
from roundup .cgi .timestamp import Timestamped
15
- from roundup .exceptions import Reject , RejectRaw
15
+ from roundup .exceptions import RateLimitExceeded , Reject , RejectRaw
16
16
from roundup .i18n import _
17
17
from roundup .mailgw import uidFromAddress
18
18
from roundup .rate_limit import Gcra , RateLimit
26
26
27
27
28
28
class Action :
29
+ loginLimit = None
30
+
29
31
def __init__ (self , client ):
30
32
self .client = client
31
33
self .form = client .form
@@ -37,8 +39,6 @@ def __init__(self, client):
37
39
self .base = client .base
38
40
self .user = client .user
39
41
self .context = templating .context (client )
40
- self .loginLimit = RateLimit (client .db .config .WEB_LOGIN_ATTEMPTS_MIN ,
41
- timedelta (seconds = 60 ))
42
42
43
43
def handle (self ):
44
44
"""Action handler procedure"""
@@ -149,7 +149,7 @@ def examine_url(self, url):
149
149
if not allowed_pattern .match (parsed_url_tuple .fragment ):
150
150
raise ValueError (self ._ ("Fragment component (%(url_fragment)s) in %(url)s is not properly escaped" ) % info )
151
151
152
- return ( urllib_ .urlunparse (parsed_url_tuple ) )
152
+ return urllib_ .urlunparse (parsed_url_tuple )
153
153
154
154
name = ''
155
155
permissionType = None
@@ -1298,36 +1298,6 @@ def handle(self):
1298
1298
redirect_url_tuple .fragment ))
1299
1299
1300
1300
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
-
1331
1301
self .verifyLogin (self .client .user , password )
1332
1302
except exceptions .LoginError as err :
1333
1303
self .client .make_user_anonymous ()
@@ -1347,6 +1317,28 @@ def handle(self):
1347
1317
raise exceptions .Redirect (redirect_url )
1348
1318
# if no __came_from, send back to base url with error
1349
1319
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
1350
1342
1351
1343
# now we're OK, re-open the database for real, using the user
1352
1344
self .client .opendb (self .client .user )
@@ -1370,7 +1362,139 @@ def handle(self):
1370
1362
1371
1363
raise exceptions .Redirect (redirect_url )
1372
1364
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
+ """
1374
1498
# make sure the user exists
1375
1499
try :
1376
1500
# Note: lookup only searches non-retired items.
@@ -1381,10 +1505,24 @@ def verifyLogin(self, username, password):
1381
1505
# delay caused by checking password only on valid
1382
1506
# users.
1383
1507
_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
1384
1515
raise exceptions .LoginError (self ._ ('Invalid login' ))
1385
1516
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
+
1386
1522
# verify the password
1387
1523
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
1388
1526
raise exceptions .LoginError (self ._ ('Invalid login' ))
1389
1527
1390
1528
# Determine whether the user has permission to log in.
0 commit comments