Locking Users Out After Invalid Login Attempts: Django & a Cache

What & Why

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:

  • The user’s invalid login attempts. We don’t need to keep track of all of them indefinitely, we only need to keep track of the ones that were made within the time range, up to the number of invalid attempts they’re allowed to make. To be more specific, if we lock a user out after 5 invalid login attempts within a 10 minute time period, we don’t need to keep track of more than 5 attempts, and we don’t need to keep track of invalid attempts that were more than 10 minutes ago.

Pseudocode & the Logic Flow

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’ve been locked out, make sure the timestamp of the lockout start is within the duration of the lockout.

If they’re not locked out:

  • Process the request — if their credentials are correct, log them in.

If they’ve entered invalid credentials and already have an entry in the cache:

  • Go through the timestamps that are currently in their timestamp bucket and remove any that were longer ago than our time period (15 minutes, in our example)

Code!

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.

The Cache Object

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 loggingfrom django.core.cache import cachelogger = 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)
  • The _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.

Before Login is Processed

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

Wrong Credentials!

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)

Other Considerations

  • As you’ve probably noticed, this approach depends on a caching system. If an attacker could figure out how to bring down your caching backend, you wouldn’t be able to access your cache, and would therefore have no way to lock users out, presenting a security risk

Senior Software Engineer | www.adriennedomingus.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store