Mercurial > p > roundup > code
diff roundup/cgi/actions.py @ 7556:273c8c2b5042
fix(api): - issue2551063 - Rest/Xmlrpc interfaces needs failed login protection.
Failed API login rate limiting with expiring lockout added.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Wed, 19 Jul 2023 20:37:45 -0400 |
| parents | 804cc66692ac |
| children | 978285986b2c |
line wrap: on
line diff
--- a/roundup/cgi/actions.py Tue Jul 18 23:18:09 2023 -0400 +++ b/roundup/cgi/actions.py Wed Jul 19 20:37:45 2023 -0400 @@ -12,7 +12,7 @@ from roundup.anypy.strings import StringIO from roundup.cgi import exceptions, templating from roundup.cgi.timestamp import Timestamped -from roundup.exceptions import Reject, RejectRaw +from roundup.exceptions import RateLimitExceeded, Reject, RejectRaw from roundup.i18n import _ from roundup.mailgw import uidFromAddress from roundup.rate_limit import Gcra, RateLimit @@ -26,6 +26,8 @@ class Action: + loginLimit = None + def __init__(self, client): self.client = client self.form = client.form @@ -37,8 +39,6 @@ self.base = client.base self.user = client.user self.context = templating.context(client) - self.loginLimit = RateLimit(client.db.config.WEB_LOGIN_ATTEMPTS_MIN, - timedelta(seconds=60)) def handle(self): """Action handler procedure""" @@ -149,7 +149,7 @@ if not allowed_pattern.match(parsed_url_tuple.fragment): raise ValueError(self._("Fragment component (%(url_fragment)s) in %(url)s is not properly escaped") % info) - return(urllib_.urlunparse(parsed_url_tuple)) + return urllib_.urlunparse(parsed_url_tuple) name = '' permissionType = None @@ -1298,36 +1298,6 @@ redirect_url_tuple.fragment)) try: - # Implement rate limiting of logins by login name. - # Use prefix to prevent key collisions maybe?? - # set client.db.config.WEB_LOGIN_ATTEMPTS_MIN to 0 - # to disable - if self.client.db.config.WEB_LOGIN_ATTEMPTS_MIN: # if 0 - off - rlkey = "LOGIN-" + self.client.user - limit = self.loginLimit - gcra = Gcra() - otk = self.client.db.Otk - try: - val = otk.getall(rlkey) - gcra.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 = gcra.update(rlkey, limit) - - # Calculate a timestamp that will make OTK expire the - # unused entry 1 hour in the future - ts = otk.lifetime(3600) - otk.set(rlkey, tat=gcra.get_tat_as_string(rlkey), - __timestamp=ts) - otk.commit() - - if reject: - # User exceeded limits: find out how long to wait - status = gcra.status(rlkey, limit) - raise Reject(_("Logins occurring too fast. Please wait: %s seconds.") % status['Retry-After']) - self.verifyLogin(self.client.user, password) except exceptions.LoginError as err: self.client.make_user_anonymous() @@ -1347,6 +1317,28 @@ raise exceptions.Redirect(redirect_url) # if no __came_from, send back to base url with error return + except RateLimitExceeded as err: + self.client.make_user_anonymous() + for arg in err.args: + self.client.add_error_message(arg) + + if '__came_from' in self.form: + # usually web only. If API uses this they will get + # confused as the 429 isn't returned. Without this + # a web user will get redirected back to the home + # page rather than stay on the page where they tried + # to login. + # set a new error message + query['@error_message'] = err.args + redirect_url = urllib_.urlunparse( + (redirect_url_tuple.scheme, + redirect_url_tuple.netloc, + redirect_url_tuple.path, + redirect_url_tuple.params, + urllib_.urlencode(list(sorted(query.items())), doseq=True), + redirect_url_tuple.fragment)) + raise exceptions.Redirect(redirect_url) + raise # now we're OK, re-open the database for real, using the user self.client.opendb(self.client.user) @@ -1370,7 +1362,139 @@ raise exceptions.Redirect(redirect_url) - def verifyLogin(self, username, password): + def rateLimitLogin(self, username, is_api=False, update=True): + """Implement rate limiting of logins by login name. + + username - username attempting to log in. May or may not + be valid. + is_api - set to False for login via html page + set to "xmlrpc" for xmlrpc api + set to "rest" for rest api + update - if False will raise RateLimitExceeded without + updating the stored value. Default is True + which updates stored value. Used to deny access + when user successfully logs in but the user + doesn't have a valid attempt available. + + Login rates for a user are split based on html vs api + login. + + Set self.client.db.config.WEB_LOGIN_ATTEMPTS_MIN to 0 + to disable for web interface. Set + self.client.db.config.API_LOGIN_ATTEMPTS to 0 + to disable for web interface. + + By setting LoginAction.limitLogin, the admin can override + the HTML web page rate limiter if they need to change the + interval from 1 minute. + """ + config = self.client.db.config + + if not is_api: + # HTML web login. Period is fixed at 1 minute. + # Override by setting self.loginLimit. Yech. + allowed_attempts = config.WEB_LOGIN_ATTEMPTS_MIN + per_period = 60 + rlkey = "LOGIN-" + username + else: + # api login. Both Rest and XMLRPC use this. + allowed_attempts = config.WEB_API_FAILED_LOGIN_LIMIT + per_period = config.WEB_API_FAILED_LOGIN_INTERVAL_IN_SEC + rlkey = "LOGIN-API" + username + + if not allowed_attempts: # if allowed_attempt == 0 - off + return + + if self.loginLimit and not is_api: + # provide a way for user (via interfaces.py) to + # change the interval on the html login limit. + limit = self.loginLimit + else: + limit = RateLimit(allowed_attempts, + timedelta(seconds=per_period)) + + gcra = Gcra() + otk = self.client.db.Otk + + try: + val = otk.getall(rlkey) + gcra.set_tat_as_string(rlkey, val['tat']) + except KeyError: + # ignore if tat not set, tat is 1970-1-1 by default. + pass + + # see if rate limit exceeded and we need to reject + # the attempt + reject = gcra.update(rlkey, limit) + + # Calculate a timestamp that will make OTK expire the + # unused entry in twice the period defined for the + # rate limiter. + if update: + ts = otk.lifetime(per_period*2) + otk.set(rlkey, tat=gcra.get_tat_as_string(rlkey), + __timestamp=ts) + otk.commit() + + # User exceeded limits: find out how long to wait + limitStatus = gcra.status(rlkey, limit) + + if not reject: + return + + for header, value in limitStatus.items(): + self.client.setHeader(header, value) + + # User exceeded limits: tell humans how long to wait + # Headers above will do the right thing for api + # aware clients. + try: + retry_after = limitStatus['Retry-After'] + except KeyError: + # handle race condition. If the time between + # the call to grca.update and grca.status + # is sufficient to reload the bucket by 1 + # item, Retry-After will be missing from + # limitStatus. So report a 1 second delay back + # to the client. We treat update as sole + # source of truth for exceeded rate limits. + retry_after = 1 + self.client.setHeader('Retry-After', '1') + + # make rate limiting headers available to javascript + # even for non-api calls. + self.client.setHeader( + "Access-Control-Expose-Headers", + ", ".join([ + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + "X-RateLimit-Limit-Period", + "Retry-After" + ]) + ) + + raise RateLimitExceeded(_("Logins occurring too fast. Please wait: %s seconds.") % retry_after) + + def verifyLogin(self, username, password, is_api=False): + """Authenticate the user with rate limits. + + All logins (valid and failing) from a web page calling the + LoginAction method are rate limited to the config.ini + configured value in 1 minute. (Interval can be changed see + rateLimitLogin method.) + + API logins are only rate limited if they fail. Successful + api logins are rate limited using the + api_calls_per_interval and api_interval_in_sec settings in + config.ini. + + Once a user receives a rate limit notice, they must + wait the recommended time to try again as the account is + locked out for the recommended time. If a user tries to + log in while locked out, they will get a 429 rejection + even if the username and password are correct. + """ # make sure the user exists try: # Note: lookup only searches non-retired items. @@ -1381,10 +1505,24 @@ # delay caused by checking password only on valid # users. _discard = self.verifyPassword("2", password) # noqa: F841 + + # limit logins to an acceptable rate. Do it for + # invalid usernames so attempts to break + # an invalid user will also be rate limited. + self.rateLimitLogin(username, is_api=is_api) + + # this is not hit if rate limit is exceeded raise exceptions.LoginError(self._('Invalid login')) + # if we are rate limited and the user tries again, + # reject the login. update=false so we don't count + # a potentially valid login. + self.rateLimitLogin(username, is_api=is_api, update=False) + # verify the password if not self.verifyPassword(self.client.userid, password): + self.rateLimitLogin(username, is_api=is_api) + # this is not hit if rate limit is exceeded raise exceptions.LoginError(self._('Invalid login')) # Determine whether the user has permission to log in.
