We’ve all done it. Forgotten our passwords and been locked out of an account. It’s irritating when it’s actually you, but it does serve a purpose — namely it will significantly slow down any malicious actors trying to use brute force attacks to access accounts on your system. So let’s jump in!
In order to do this we need to keep track of a few things. We’ll talk about the specifics of how we can store this information in the cache later, but for now, let’s assume we’re tracking this information:
Here are the things that could happen when a user hits the login view with a POST request, and what we should do (a fun flow chart + some words for folks who aren’t visual learners!):
If they’re not locked out:
If they’ve entered invalid credentials and already have an entry in the cache:
If you haven’t used Django’s caching system before, I recommend reading up on how it works a bit before we jump in. Basic usage examples can be found here, and the rest of that page has a ton more information if you need it!
All the examples below assume that a user will be locked out for 15 minutes after 5 invalid login attempts within a 10 minute period. Numbers are hardcoded accordingly. You probably want to handle these magic numbers however you handle magic numbers within your codebase.
I like to create an object for each different caching use case — that way I can just pass in the information I know it needs and let it generate the keys and values accordingly, without worrying about remembering what format I’ve used or repeating logic in various places. Here’s what this cache might look like, commentary to follow:
import logging
from django.core.cache import cache
logger = logging.getLogger(__name__)
class InvalidLoginAttemptsCache(object):
@staticmethod
def _key(email):
return 'invalid_login_attempt_{}'.format(email)
@staticmethod
def _value(lockout_timestamp, timebucket):
return {
'lockout_start': lockout_timestamp,
'invalid_attempt_timestamps': timebucket
}
@staticmethod
def delete(email):
try:
cache.delete(InvalidLoginAttemptsCache._key(email)) except Exception as e:
logger.exception(e.message)
@staticmethod
def set(email, timebucket, lockout_timestamp=None):
try:
key = InvalidLoginAttemptsCache._key(email)
value = InvalidLoginAttemptsCache._value(lockout_timestamp, timebucket) cache.set(key, value)
except Exception as e:
logger.exception(e.message)
@staticmethod
def get(email):
try:
key = InvalidLoginAttemptsCache._key(email)
return cache.get(key)
except Exception as e:
logger.exception(e.message)
_key
method returns a string used as the cache key — I use a base identifier as the beginning of the key (invalid_login_attempt_
) to differentiate between other things you keep in the cache, followed by a unique identifier for the user. If you have a multi-tenant application, you may need to include more than just the user’s email address in the cache key — such which domain they’re trying to log in on or which tenant they belong to (in case they access multiple domains, you wouldn’t want to lock them out of one for getting their password wrong on the other!). Just keep in mind as you’re passing this information around, it needs to be serializable, so pass around IDs or strings, not database objects._value
method returns the dictionary containing when they were locked out (if applicable), and the list of timestamps of their invalid login attempts. These values are passed into the .set
function from the view, and just assigned here.set
function does just what it sounds like — adds the key/value pair to your cache. You can optionally add a third argument to tell the cache when this key/value pair expires — I’d recommend either how long a user is locked out if they are, or the time period during which their invalid login attempts are counted, but make sure you use the longer of these two values!get
also does just what it sounds like — looks in the cache to see if that value exists and returns it if it does, and returns None
otherwise.delete
, as you might expect, removes the entry from the cache based on the key. You can use this in an admin page that allows administrators to unlock users, as well as to remove the cache entry after the requisite time period has lapsed and the user’s lockout has expired.Cool, let’s put our cache to use! When a user tries to log in, there are some things we can do before we even check to see if their credentials are correct. The below assumes a user is locked out for 15 minutes after their invalid login attempts, and covers this portion of our flow chart above:
import arrow
# get the email from the form or POST data
locked_out = False
cache_results = InvalidLoginAttemptsCache.get(email)
if cache_results and cache_results.get('lockout_start'):
lockout_start = arrow.get(cache_results.get('lockout_start'))
locked_out = lockout_start >= arrow.utcnow().shift(minutes=-15)
if not locked_out:
InvalidLoginAttemptsCache.delete(email, domain_id)
else:
# Add an error to the form to let the user know they're locked out and can't log in. Code to do this will vary depending on whether you're in a view or the form class itself
else:
# If they don't have an entry in the cache, we know they're not locked out, and we can process their request
I’m going to skip the step where they’re not currently locked out and their credentials are correct and assume you’ve already got a functioning success-path login flow. Let’s look at some code for this part instead:
cache_results = InvalidLoginAttemptsCache.get(email)
lockout_timestamp = None
now = arrow.utcnow()
invalid_attempt_timestamps = cache_results['invalid_attempt_timestamps'] if cache_results else []
# clear any invalid login attempts from the timestamp bucket that were longer ago than the range
invalid_attempt_timestamps = [timestamp for timestamp in invalid_attempt_timestamps if timestamp > now.shift(minutes=-15).timestamp]
# add this current invalid login attempt to the timestamp bucket
invalid_attempt_timestamps.append(now.timestamp)
# check to see if the user has enough invalid login attempts to lock them out
if len(invalid_attempt_timestamps) >= 5:
lockout_timestamp = now.timestamp
# This is also where you'll need to add an error to the form to both prevent their successful authentication and let the user know
# Add a cache entry. If they've already got one, this will overwrite it, otherwise it's a new one
InvalidLoginAttemptsCache.set(email, invalid_attempt_timestamps, lockout_timestamp)