"Should I roll my own authentication?"
Given how easy it is to build an authentication system with Rails' has_secure_password
and the authenticate
method (as shown in Hartl's tutorial), why would you jump straight to a gem like Devise, which is hard to understand and customize?
In this article, I hope to lay down the case for why I think the answer to my first question is, "No, you shouldn't roll your own authentication for a production app."
First, let’s take a look at the authentication code from the Hartl tutorial. In app/controllers/sessions_controller.rb, we see the create
action defined like so:
def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) # Log the user in and redirect to the user's show page. else # Create an error message (similar to 'invalid email/password combo') render 'new' end end
In the 'happy case', this works by first ensuring a User
record can be found for the given email and then calling the #authenticate
method on the User
object.
Now because the #authenticate
method relies on the bcrypt
hashing algorithm, it will take a decent amount of time (from a computer's perspective) to run. This is a good thing. If you have a good password, it'll dissuade malicious actors from brute-forcing their way in.
What Happens If user Is Nil?
Because of how the if
statement is constructed, #authenticate
won't actually be called if User.find_by(...)
returns nil
. If #authenticate
isn't called, we can be reasonably sure that the server will render a response quicker than it would if #authenticate
was called.
In other words, if a given email doesn't exist in your database, your server response time when you post to the #create
action will be measurably faster. Thus, by analyzing the time it takes for your server to render a response, a malicious actor can begin to assemble a list of valid emails on your system.
Let’s try it out! We're going to get the tutorial app running locally and see if we can discern any difference in server response times between a valid and invalid email address.
Clone the tutorial app or any app that uses this particular style of authentication.
Start the server with
rails s
.Create a user account if you need to via the sign-up form. For our example, we’ll use
sid@example.com
as the email.In a separate terminal window, do:
curl localhost:3000/signin --cookie-jar cookie | grep csrf
When we post data to the #create
action in the next step, Rails' protect_from_forgery
method will ensure that we also post a valid authenticity token and cookie. So with the above curl
and subsequent grep
, we're downloading the sign-in page, storing the cookie in a file named cookie
, and then searching for the authenticity token. The output should look something like this:
% Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 4361 100 4361 0 0 24525 0 --:--:-- --:--:-- --:--:-- 24638 <meta content="authenticity_token" name="csrf-param" /> <meta content="jMeQnPStWoDP5Y5CcTCWgf7nBnja6fKYH7CO427WCZQ=" name="csrf-token" />
Now that we have the cookie and an authenticity token, we can post data. In this step, we'll measure how long it takes for the server to respond to a valid email/invalid password combination. In your terminal window, do:
curl -X POST -F 'session[email]=sid@example.com' -F 'session[password]=something' -F 'authenticity_token=jMeQnPStWoDP5Y5CcTCWgf7nBnja6fKYH7CO427WCZQ=' localhost:3000/sessions --cookie cookie
As you can see, I've used sid@example.com
as the email address, and I'm also passing in the authenticity token from step 4.
Per our discussion earlier, because this is a valid email address, we expect that User.find_by(...)
will not return nil
and that #authenticate
will be called. In your server log, note the response time. In my case, it was around 130ms. It won’t be the same every time, but it should have a pretty well-defined range, which you can average.
Let's see how long it takes for the server to respond to an invalid email.
curl -X POST -F 'session[email]=nonexistent-user@example.com' -F 'session[password]=something' -F 'authenticity_token=jMeQnPStWoDP5Y5CcTCWgf7nBnja6fKYH7CO427WCZQ=' localhost:3000/sessions --cookie cookie
Again, note the average response time. You should notice that in this instance, the response time for this request is consistently lower. As expected, #authenticate
is not called because User.find_by(email: 'nonexistent-user@example.com')
returns nil.
If you don't want to look at the server logs, you can also estimate response time by prepending time
to your curl
commands. For example:
time curl -X POST -F 'session[email]=nonexistent-user@example.com' -F 'session[password]=something' -F 'authenticity_token=jMeQnPStWoDP5Y5CcTCWgf7nBnja6fKYH7CO427WCZQ=' localhost:3000/sessions --cookie cookie
Using this method, I can guess if a given email address exists in the database.
In a real site that implements this, you won’t be able to check server logs. On top of that, internet response times are pretty random. But that doesn’t mean timing analysis is not possible. You can use statistics to filter out random noise and ascertain with a reasonable degree of confidence that one given request is slower than another.
The way to fix this is to ensure that the controller takes a fixed amount of time to render a response irrespective of whether the user exists or not. So you could do something like this:
user = User.find_by(email: params[:session][:email) || FakeUser.new
The FakeUser
class implements #authenticate
by comparing the hash of a passed-in password to a default hash and returns false.
Is This Good Enough?
Even if you implement the above securely, you'll probably need to add a few more features as your app grows in order toimprove the authentication UI and/or further secure your authentication:.
Allow the user to recover from a lost/forgotten password.
Let’s take a look at Railscasts episode 274 where we learn how to implement the reset password functionality. In
PasswordResetsController#create
, we see a familiar pattern:user.send_password_reset if user # equivalent to user && user.send_password_reset
If the user doesn’t exist, then the
#send_password method
, which generates a token and saves it to the database, is not called. This would likely result in a time difference between a valid and an invalid form input, again allowing someone to guess if a given user exists or not.Implement a lock out system, to prevent automated attacks, either from a single IP or a set of IPs.
Implement an API authentication scheme.
Implement OAUTH.
Implement two (or more) factor authentication, password complexity requirements and more.
Each one of the above features represents a potential security hole in your system once it is implemented. If your primary concern is the business logic of your app and not its security, I think it's in your best interests to go with a proven solution, like Devise
.
The Pros and Cons of Using a Gem like Devise
People are always looking for vulnerabilities. When vulnerabilities are found, a patch is released. Because of this, you can reduce the amount of time and thought you put into security and focus on the business logic of your app.
Devise implements many of the authentication-related features described above, and moreover does so in a secure manner. As an exercise, I'd recommending trying the above timing analysis on an app that uses Devise.
That being said, Devise != rainbows and unicorns. To customize Devise, you have to spend time to understand how it works. The documentation might not be the best, which can be super frustrating. You’ll also have to ensure that you and your team have a habit of updating the gem when patches are released. Plus, it’s one more dependency in your codebase.
And of course, you still have to be vigilant about potential security issues. Just because authentication is secure doesn't mean sensitive information can't be extracted from your app another way. For example, a “sign up” or "registration" page sometimes offers an easier path to guessing an existing user’s email, because of the common message which says ‘email address already exists’ or something similar. Captcha systems can help in prevent automating this sort of guessing game.
Another example I recently saw was a piece of code that was using MD5 to generate a token.
Conclusion
To recap, it's easy to introduce holes when it comes to authentication and authorization if you’re not familiar with security or don’t spend much time looking for weaknesses. Rolling your own authentication is still a wonderful learning experience though, as well as a way to explore your and others' ideas.
But if you want a proven solution, use Devise or a similar gem or at least one that's under active development and regularly poked, prodded, and patched.
Otherwise, keep a few general security tips in mind:
Install and regularly use bundler audit.
Install and regularly use brakeman.
Sign up to mailing lists where vulnerabilities for each of your gems are announced (this is the one for Rails).
Be informed of OWASP recommendations.
If you roll or have rolled your own authentication, I'd like to hear from you. What drove you to make the choices you did? How did it work out? And would you do it again?