Rails Login Security

Posted almost 3 years
ago
by Vasily

Web security is hard. There are so many little things that developers have to keep track of and remember. Take login forms, for example: they are an initial point of entry in almost any web app and are exposed not just to individuals but also to scripts and web crawlers. It means that login forms can be exploited automatically.

So, what can go wrong? For starters, an attacker can write a brute force script that will try millions of login and password combinations in a very short period of time, eventually hacking into some user accounts.

What if you have basic account locking implemented that some authentication libraries, like Devise, offer out of the box? The attacker can still wreak havoc by attempting to connect to multiple accounts N times, where N is the number of attempts after which an account gets locked. Imagine a script that tries to login to your app with millions of stolen email addresses. After a while many users will be locked out of their accounts. Not a great experience especially if the script is looped.

In this article I am going to address those issues and show how to implement a CAPTCHA-protected lockable login form with Rails 4 and Devise 3.

Before we dive in, I’d like to point out that this solution is not ideal in the sense that it relies on the Devise lockable module. Ideally, I’d like to create a decoupled Devise module (e.g., :captcha_protected or captchable). If there is enough interest from the community, I will implement it in the form of a gem.

General Flow

First off, let’s figure out what we want the login flow to be. I am making an assumption that the reader uses email and password as login credentials and that she implemented standard authentication with Devise and Rails.

In the ideal case, the user inputs their email and password in the login form fields and accesses the app. What happens if she can’t remember the exact password and tries different combinations? Devise’s default behavior is to allow as many attempts as possible. It’s not a secure behavior. To make it secure and not let the user make an infinite number of login attempts we’ll have to enable the lockable behavior that locks the account after N unsuccessful login attempts.

But what if it wasn’t the user who was making unsuccessful login attempts but a script whose target is to lock as many accounts as possible? CAPTCHA to the rescue! We want our CAPTCHA to kick in before account locking, which will give the attacker’s script a hard time locking an account: if CAPTCHA is not passed, then the login attempt stops mid-process and the user is asked to try again.

So, what’s the final path for a secure login? Here is my take:

receive user credentials

account locked?

cancel login

account not locked?

CAPTCHA required?

CAPTCHA correct?

login and password correct?

login user

login and password incorrect?

increase the number of failed attempts

failed attempts > max login attempts?

lock account

cancel login

CAPTCHA incorrect?

cancel login

CAPTCHA not required?

login and password correct?

login user

login and password incorrect?

increase the number of failed attempts

cancel login

Phew! What a flow. Let’s dig into the implementation details, shall we?

Account Locking

First things first: let’s setup the Devise :lockable module. It’s very simple. Add :lockable to the user model like this:

class User < ActiveRecord::Base
devise :lockable, ...
...
end

Now go to the Devise initializer under config/initializers/devise.rb and uncomment the configuration for :lockable:

So, what’s going on here? First, we set the default :failed_attempts strategy for account locking and :maximum_attempts that sets the number of attempts after which the account is locked. Then we specify the unlock keys that are used to lock and unlock the account. Since we don’t have any extra username fields let’s use email. Next up is the unlock strategy. Depending on your requirements you can choose :email, :time, :both, or :none. The email unlock strategy locks the account until it’s unlocked by the user. It sends a notification email to the user saying that her account was locked. It will also include a link for unlocking. The time unlock strategy locks the account temporarily and unlocks it automatically in :unlock_in time.

You can use either one of those strategies depending on what you need. For example, if you have to follow PCI Data Security Standards then the following should be setup in your app:

8.5.13 Limit repeated access attempts by locking out the user ID after not more than six attempts.

8.5.14 Set the lockout duration to thirty minutes or until administrator enables the user ID.

These requirements are pretty draconian towards the user and are the opposite of good UX but sometimes you have to do it.

The last thing we need to setup is the users table. Either uncomment the appropriate fields in your original Devise migration before running it or add the following standalone migration:

That’s it! Now you can use Recaptcha gem built-in recaptcha_tags and verify_recaptcha methods. If you want to learn more about how they work, please refer to the docs.

It’s finally time to combine the power of Devise with Recaptcha and setup a secure login form for your app!

Putting it all Together

In order for everything to work together, we’ll need to write a custom SessionsController class and add a custom view to views/sessions/new.html.*. The latter will overwrite the default Devise view for logins. You can copy-paste the whole view from GitHub and modify it (e.g., rewrite in haml). The only extra thing that we have to add is the Recaptcha helper that will render a CAPTCHA if the number of login attempts exceeds some threshold:

The new controller should be pretty self explanatory. The only bit that requires extra explanation is the cached_failed_attempts user attribute. Since the technique that I am describing in this article is not completely decoupled from the :lockable Devise module, we can’t rely on the failed_attempts user attribute to base our logic off of. There are a couple of edge cases where Devise resets this attribute to zero, breaking the Recaptcha flow. This is why we need a “cached” copy of this field that Devise won’t have access to. Create a migration that adds this field to the users table: