Rails Login Security
Originally published in Hakiri Blog.
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 cause 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
- login and password correct?
- CAPTCHA incorrect?
- cancel login
- CAPTCHA correct?
- CAPTCHA not required?
- login and password correct?
- login user
- login and password incorrect?
- increase the number of failed attempts
- cancel login
- login and password correct?
- CAPTCHA required?
- account locked?
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
:
config.lock_strategy = :failed_attempts
config.maximum_attempts = 10
config.unlock_keys = [ :email ]
config.unlock_strategy = :both
config.unlock_in = 1.hours
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:
class AddLockableToUsers < ActiveRecord::Migration
def change
add_column :users, :failed_attempts, :integer, default: 0
add_column :users, :unlock_token, :string
add_column :users, :locked_at, :datetime
add_index :users, :unlock_token, unique: true
end
end
After the initial setup of the :lockable
module is complete, let’s look at CAPTCHA.
CAPTCHA
There are many options for CAPTCHAs out there with Recaptcha being my personal favorite. It supports a great cause, it’s easy to setup, and it’s free.
To use Recaptcha add the Recaptcha gem to your gemfile:
gem 'recaptcha', require: 'recaptcha/rails'
and run bundle install
. Then get private and public keys from the Recaptcha control panel and setup an initializer in config/initializers/recaptcha.rb
:
Recaptcha.configure do |config|
config.public_key = ENV['CAPTCHA_PUBLIC_KEY']
config.private_key = ENV['CAPTCHA_PRIVATE_KEY']
config.use_ssl_by_default = true
end
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:
- if User.find_by_email(resource.email)
- if User.find_by_email(resource.email).failed_attempts > User.logins_before_captcha
= recaptcha_tags
What’s User.logins_before_captcha
? This is just a reusable value that defines how many logins can be attempted before Recaptcha kicks in. One way to do it is in the model class method:
class User < ActiveRecord::Base
...
def self.logins_before_captcha
3
end
...
end
Now let’s add a custom SessionsController
to controllers/users/sessions_controller.rb
where the magic of the flow happens.
class Users::SessionsController < Devise::SessionsController
def create
flash.clear
user = User.find_by_email(sign_in_params['email'])
super and return unless user
adjust_failed_attempts user
super and return if (user.failed_attempts < User.logins_before_captcha)
super and return if user.access_locked? or verify_recaptcha
# Don't increase failed attempts if Recaptcha was not passed
decrement_failed_attempts(user) if recaptcha_present?(params) and
!verify_recaptcha
# Recaptcha was wrong
self.resource = resource_class.new(sign_in_params)
sign_out
flash[:error] = 'Captcha was wrong, please try again.'
respond_with_navigational(resource) { render :new }
end
private def adjust_failed_attempts(user)
if user.failed_attempts > user.cached_failed_attempts
user.update cached_failed_attempts: user.failed_attempts
else
increment_failed_attempts(user)
end
end
private def increment_failed_attempts(user)
user.increment :cached_failed_attempts
user.update failed_attempts: user.cached_failed_attempts
end
private def decrement_failed_attempts(user)
user.decrement :cached_failed_attempts
user.update failed_attempts: user.cached_failed_attempts
end
private def recaptcha_present?(params)
params[:recaptcha_challenge_field]
end
end
To activate your custom SessionsController
modify your routes.rb
:
AppName::Application.routes.draw do
devise_for :users, controllers: { sessions: 'users/sessions' }, ...
...
end
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:
add_column :users, :cached_failed_attempts, :integer, default: 0
And add this attribute to the user model:
class User < ActiveRecord::Base
attr_accessible :cached_failed_attempts, ...
...
end
Finally, let’s reset failed attempt counters in SessionsController
once the user logs in:
def after_sign_in_path_for(resource)
resource.update cached_failed_attempts: 0, failed_attempts: 0
root_path
end
Now we have a working secure login form that not only provides protection against attackers and scripts but also makes for a much nicer user experience.
Please let me know your thoughts about the article! I’d also like to know if there is any interest in having a standalone gem that extends Devise with the :captcha_protected
module.