Source code for authnzerver.ratelimit

# -*- coding: utf-8 -*-
# ratelimit.py - Waqas Bhatti (waqas.afzal.bhatti@gmail.com) - Jul 2020
# License: MIT - see the LICENSE file for the full text.

"""This module contains RequestHandler mixins that do rate-limiting for the
authnzerver's own API, handle throttling of incorrect password attempts, and
do user locking/unlocking for repeated password check failures.

None of these will work without bits already defined in handlers.AuthHandler or
close derivatives.

"""

#############
## LOGGING ##
#############

import logging
from typing import Union

# get a logger
LOGGER = logging.getLogger(__name__)

#############
## IMPORTS ##
#############

from datetime import datetime, timedelta
from functools import partial
import json
import asyncio

# this replaces the default encoder and makes it so Tornado will do the right
# thing when it converts dicts to JSON when a
# tornado.web.RequestHandler.write(dict) is called.
from .jsonencoder import FrontendEncoder

json._default_encoder = FrontendEncoder()

import tornado.web
import tornado.ioloop

from .permissions import pii_hash
from . import actions

# rate-limit sensitive actions more agressively (these are all per minute)
# these can be overriden by specifying these specific request types in the
# server's ratelimits config variable
AGGRESSIVE_RATE_LIMITS = {
    "user-new": 5,
    "user-login": 10,
    "user-logout": 10,
    "user-edit": 10,
    "user-resetpass": 5,
    "user-changepass": 5,
    "user-sendemail-signup": 2,
    "user-sendemail-forgotpass": 2,
    "user-set-emailsent": 2,
    "apikey-new": 30,
    "apikey-new-nosession": 30,
    "apikey-refresh-nosession": 30,
}


[docs]class RateLimitMixin: """ This class contains a method that rate-limits the authnzerver's own API. Requires: - self.cacheobj (from AuthHandler) - self.ratelimits (from AuthHandler) - self.pii_salt (from AuthHandler) - self.request.remote_ip (from tornado.web.RequestHandler) """
[docs] def ratelimit_request( self, reqid: Union[int, str], request_type: str, frontend_client_ipaddr: str, request_body: dict = None, ) -> None: """ This rate-limits the request based on the request type and the set ratelimits passed in the config object. """ # # rate limit per request_type:client_ipaddr key # client_ipaddr_key = f"{request_type}-{frontend_client_ipaddr}" client_req_count = self.cacheobj.counter_increment( client_ipaddr_key, ) # # email-tied request types are additionally checked per # email_addr:request_type pair # if "email" in request_type and request_body is not None: email_addr = request_body.get("email") if not email_addr: email_addr = request_body.get("email_address") if not email_addr: LOGGER.error( f"email-tied request type: {request_type} could " f"not be rate-limited because no " f"'email' or 'email_address' key found in " f"request body. failing this request..." ) raise tornado.web.HTTPError(status_code=429) client_email_key = f"{request_type}-{email_addr}" client_email_count = self.cacheobj.counter_increment( client_email_key ) ( client_email_reqrate, client_email_reqcount, client_email_reqcount0, client_email_req_tnow, client_email_req_t0, ) = self.cacheobj.counter_rate( client_email_key, 60.0, return_allinfo=True ) if request_type in AGGRESSIVE_RATE_LIMITS: limit_applied = AGGRESSIVE_RATE_LIMITS[request_type] if ( client_email_count > limit_applied and client_email_reqrate > limit_applied ): LOGGER.error( "[%s] request '%s' is being rate-limited. " "Cache token: '%s', count: %s. " "Rate: %.3f per minute > limit specified for '%s': %s" % ( reqid, request_type, pii_hash(client_email_key, self.pii_salt), client_email_reqcount, client_email_reqrate, request_type, limit_applied, ) ) raise tornado.web.HTTPError(status_code=429) else: limit_applied = self.ratelimits["user"] if ( client_email_count > limit_applied and client_email_reqrate > limit_applied ): LOGGER.error( "[%s] request '%s' is being rate-limited. " "Cache token: '%s', count: %s. " "Rate: %.3f per minute > limit specified for '%s': %s" % ( reqid, request_type, pii_hash(client_email_key, self.pii_salt), client_email_reqcount, client_email_reqrate, request_type, limit_applied, ) ) raise tornado.web.HTTPError(status_code=429) # # all other rate-limits are checked per IP address:request_type pair # # apply agressive rate limiting to sensitive actions if request_type in AGGRESSIVE_RATE_LIMITS: ( client_ipaddr_reqrate, client_ipaddr_reqcount, client_ipaddr_reqcount0, client_ipaddr_req_tnow, client_ipaddr_req_t0, ) = self.cacheobj.counter_rate( client_ipaddr_key, 60.0, return_allinfo=True ) limit_applied = AGGRESSIVE_RATE_LIMITS[request_type] if ( client_req_count > limit_applied and client_ipaddr_reqrate > limit_applied ): LOGGER.error( "[%s] request '%s' is being rate-limited. " "Cache token: '%s', count: %s. " "Rate: %.3f per minute > limit specified for '%s': %s" % ( reqid, request_type, pii_hash(client_ipaddr_key, self.pii_salt), client_ipaddr_reqcount, client_ipaddr_reqrate, request_type, limit_applied, ) ) raise tornado.web.HTTPError(status_code=429) # apply specific rate limiting to explicitly specified # API actions in the ratelimits config var elif request_type in self.ratelimits: ( client_ipaddr_reqrate, client_ipaddr_reqcount, client_ipaddr_reqcount0, client_ipaddr_req_tnow, client_ipaddr_req_t0, ) = self.cacheobj.counter_rate( client_ipaddr_key, 60.0, return_allinfo=True ) limit_applied = self.ratelimits[request_type] if ( client_req_count > limit_applied and client_ipaddr_reqrate > limit_applied ): LOGGER.error( "[%s] request '%s' is being rate-limited. " "Cache token: '%s', count: %s. " "Rate: %.3f per minute > limit specified for '%s': %s" % ( reqid, request_type, pii_hash(client_ipaddr_key, self.pii_salt), client_ipaddr_reqcount, client_ipaddr_reqrate, request_type, limit_applied, ) ) raise tornado.web.HTTPError(status_code=429) # all other ratelimits are applied according to the # API action groups defined in the ratelimits config var else: # only apply rate-limits after burst is exceeded if client_req_count > self.ratelimits["burst"]: ( client_ipaddr_reqrate, client_ipaddr_reqcount, client_ipaddr_reqcount0, client_ipaddr_req_tnow, client_ipaddr_req_t0, ) = self.cacheobj.counter_rate( client_ipaddr_key, 60.0, return_allinfo=True ) # # specific rate-limiting per request type # if request_type.startswith("user-"): limit_name, limit_applied = ( "user", self.ratelimits["user"], ) elif request_type.startswith("session-"): limit_name, limit_applied = ( "session", self.ratelimits["session"], ) elif request_type.startswith("apikey-"): limit_name, limit_applied = ( "apikey", self.ratelimits["apikey"], ) # internal- prefixed requests have a more generous ratelimit elif request_type.startswith("internal-"): limit_name, limit_applied = ("internal", 3000) # all other requests are limited by frontend client IP addr else: limit_name, limit_applied = ( "ipaddr", self.ratelimits["ipaddr"], ) if client_ipaddr_reqrate > limit_applied: LOGGER.error( "[%s] request '%s' is being rate-limited. " "Cache token: '%s', count: %s. " "Rate: %.3f per minute > limit specified for '%s': %s" % ( reqid, request_type, pii_hash(client_ipaddr_key, self.pii_salt), client_ipaddr_reqcount, client_ipaddr_reqrate, limit_name, limit_applied, ) ) raise tornado.web.HTTPError(status_code=429)
[docs]class UserLockMixin: """This class handles user locking/unlocking and slowing down repeated password failures. """
[docs] async def handle_failed_logins(self, payload: dict) -> tuple: """ This handles failed logins. - Adds increasing wait times to successive logins if they keep failing. - If the number of failed logins exceeds 10, the account is locked for one hour, and an unlock action is scheduled on the ioloop. Requires: - self.failed_passchecks (from AuthHandler) - self.config (from AuthHandler) """ # increment the failure counter and return it if payload["body"]["email"] in self.failed_passchecks: self.failed_passchecks[payload["body"]["email"]] += 1 else: self.failed_passchecks[payload["body"]["email"]] = 1 failed_pass_count = self.failed_passchecks[payload["body"]["email"]] if 0 < failed_pass_count <= self.config.userlocktries: # asyncio.sleep for an exponentially increasing period of time # until 40.0 seconds ~= 10 tries wait_time = 1.5 ** (failed_pass_count - 1.0) if wait_time > 40.0: wait_time = 40.0 await asyncio.sleep(wait_time) return "wait", failed_pass_count, wait_time elif failed_pass_count > self.config.userlocktries: return "locked", failed_pass_count, 0.0 else: return "ok", failed_pass_count, 0.0
[docs] async def lockuser_repeated_login_failures( self, payload: dict, unlock_after_seconds: int = 3600, ) -> dict: """ This locks the user account. Also schedules an unlock action for later. Requires: - self.config (from AuthHandler) - self.executor (from AuthHandler) - self.scheduled_user_unlock() (from UserLockMixin) """ # look up the user ID using the email address loop = tornado.ioloop.IOLoop.current() backend_func = partial( actions.get_user_by_email, { "email": payload["body"]["email"], "reqid": payload["body"]["reqid"], "pii_salt": payload["body"]["pii_salt"], }, config=self.config, ) user_info = await loop.run_in_executor(self.executor, backend_func) if not user_info["success"]: LOGGER.error( "Could not look up the user ID for email: %s to lock " "their account after repeated failed login attempts." % pii_hash( payload["body"]["email"], payload["body"]["pii_salt"] ) ) else: # attempt to lock the user using actions.internal_toggle_user_lock backend_func = partial( actions.internal_toggle_user_lock, { "target_userid": user_info["user_info"]["user_id"], "action": "lock", "reqid": payload["body"]["reqid"], "pii_salt": payload["body"]["pii_salt"], }, config=self.config, ) locked_info = await loop.run_in_executor( self.executor, backend_func ) if locked_info["success"]: unlock_after_dt = datetime.utcnow() + timedelta( seconds=unlock_after_seconds ) # schedule the unlock loop.call_later( unlock_after_seconds, self.scheduled_user_unlock, user_info["user_info"]["user_id"], payload["body"]["reqid"], payload["body"]["pii_salt"], ) LOGGER.warning( "Locked the account for user ID: %s, " "email: %s after repeated " "failed login attempts. " "Unlock scheduled for: %sZ" % ( pii_hash( user_info["user_info"]["user_id"], payload["body"]["pii_salt"], ), pii_hash( payload["body"]["email"], payload["body"]["pii_salt"], ), unlock_after_dt, ) ) # we'll return a failure here as the response no matter what happens # above to deny the login return { "success": False, "user_id": None, "messages": [ "Your user account has been locked " "after repeated login failures. " "Try again in an hour or " "contact the server admins." ], }
[docs] async def scheduled_user_unlock( self, user_id: int, reqid: Union[int, str], pii_salt: str ): """ This function is scheduled on the ioloop to unlock the specified user. """ LOGGER.warning( "[%s] Unlocked the account for user ID: %s after " "login-failure timeout expired." % (reqid, pii_hash(user_id, pii_salt)) ) loop = tornado.ioloop.IOLoop.current() backend_func = partial( actions.internal_toggle_user_lock, { "target_userid": user_id, "action": "unlock", "reqid": reqid, "pii_salt": pii_salt, }, config=self.config, ) await loop.run_in_executor(self.executor, backend_func)