Rails #5: Security
In my previous articles, I have showed you how to create a simple blog application with articles, comments, rss feeds and formatting. However, as it is currently written, the application allows for anyone to create or edit an article. This is a serious security issue, and we better fix it.
In this tutorial, I will show you how to make sure that only logged in users can create articles, and that nobody else can edit an article that you created.
The task at hand
This is basically what I want to say:
class ArticlesController < ApplicationController
before\_filter :verify\_logged\_in, :except => [ :index, :show ]
def new
@article = current\_user.articles.build
end
def edit
@article = current\_user.articles.find(params[:id])
end
def create
@article = current\_user.articles.build(params[:article])
# save it...
end
def update
@article = current\_user.articles.find(params[:id])
# save it...
end
def destroy
@article = current\_user.articles.find(params[:id])
# delete it...
end
private
def verify\_logged\_in
unless current\_user
# redirect to login dialogue
end
end
end
In plain English: Verify that the user is logged in before any other actions than index and show are performed. This boils down to the method current_user returning a non-nil result. Use the current_user as starting point when creating or updating articles. Now we just need to create the login functionality. This means some way of creating users and making these users log in. There is actually a plugin called restful_authentication that does this for you, but it’s not that hard, so I though we could do it on our own.
The users model
The key to this business is obviously the verify_logged_in
method. Let’s try this:
def verify\_logged\_in
unless current\_user
redirect\_to new\_login\_session\_path
end
end
What is this current_user
thing?
def current\_user
User.find(session[:user\_id]) if session[:user\_id]
end
Here, we use a session variable to store the user_id, and look up the a User model using this session variable. You should never store ActiveRecord objects in session variables. By now, it is becoming blindingly obvious that we need a User model. Let’s use the generator: ruby script/generate model User username:string password:string
We also want to associate the articles with users. Add has_many :articles
in app/models/user.rb
, and belongs_to :user
in app/models/article.rb
. Also add the following to your newly generated migration in db/migrate/003_create_users.rb
:
class CreateUsers < ActiveRecord::Migration
def self.up
# ...
add\_column :articles, :user\_id, :integer
end
def self.down
remove\_column :articles, :user\_id
# ...
end
end
You can now execute rake db:migrate
to update the database structure.
Logging in
Next order of business: verify_logged_in
contained the line redirect_to new_login_session_path
. If you guessed that we are going to have a resource called “login_session”, and that this is just like when we said “new_comment_path”, you guessed right! This means we have to add the following to config/routes.rb
: map.resource :login_session
. A user should only be aware of one login_session, so we say map.resource
(singular) instead of map.resources
plural. A typo here can be very confusing. Now, create the LoginSessionsController by executing ruby script/generate controller LoginSessions new create show destroy
on the command line. We can now update our new app/controllers/login_sessions_controller.rb
. Notice how we use the conventions from other restful controllers of standardized action names for new, create, show and destroy, even though there is no login_session model:
class LoginSessionsController < ApplicationController
def new
end
def create
@user = User.authenticate(params[:login][:username], params[:login][:password])
if @user
session[:user\_id] = @user.id
redirect\_to :action => "show"
else
render :action => "new"
end
end
def show
@user = current\_user
if !@user
redirect\_to :action => :new
end
end
def destroy
session[:user\_id] = nil
redirect\_to :action => :new
end
private
def current\_user
User.find(session[:user\_id]) if session[:user\_id]
end
end
The form for logging in is located in app/views/login_sessions/new.html.erb
and looks like this:
Log in
======
< % form\_for :login, :url => { :action => :create } do |f| %>
< %= f.label :username %>: < %= f.text\_field :username %>
< %= f.label :password %>: < %= f.password\_field :password %>
< %= f.submit "Log in" %>
< % end %>
I have also made it possible to log out from the show-view by having a logout “form” in app/views/login_sessions/show.html.erb
:
Current session
===============
You are logged in as < %= @user.username %>
< % form\_tag '/login\_session', :method => :delete do %>
< %= submit\_tag "Log out" %>
< % end %>
(I’m not totally happy with this - suggestions for improvements are welcome!) Everything now works - trust me!
Testing the code
Okay, don’t trust me. God knows I wouldn’t trust me. You should obviously test your code. Normally, you would test this sort of logic using test classes, but I haven’t covered this yet, so we’ll have to be satisfied with manual testing for now. In article 4 in this series, I introduced fixtures. Fixtures are located under test/fixtures
, and when you used the generator to create the user model, a fixture was generated for you as well in test/fixtures/users.yml
. Update this to contain two test users with passwords. I use the following:
user1:
username: user1
password: password
user2:
username: user2
password: password
Also, make sure that the article fixtures in test/fixtures/articles.yml
contain reference to the users. Here is one of my articles:
two:
user: user1
title: Romans, Brothers, Fellowmen
author: Marcus Aurelius
content: I stand here before you bearing witness of a **miracle**
You can now reload the fixtures with rake db:fixtures:load
. Then test as follows:
- Go to an article and press the “edit” link. You should now be redirected to the login page
- Enter the username and password to your user who own the article from
test/fixtures/users.yml
and press “Log in”. You should now be taken to a page that shows your login status - Navigate back to the article edit page. You should now be able to edit the article.
- Navigate back to the login status page (http://localhost:3000/login_session) and press “Log out”. You should now be returned to the login form.
- Log in as a user who does not own the article.
- Navigate back to the article edit page. You should now be presented with an error message stating that there is no article with this id and user_id.
Our rudimentary security now works correctly, and we can rest soundly. Or we can polish it to make it nicer. Which one is it going to be? You know me by now: Let’s get out the belt sander.
Making it nice
There are many things we need to do to make this actually usable. We have our work cut out for us. Let’s get started:
Show current login status on all pages. In
app/views/layouts/articles.html.erb
, add<% if current_user %><p>Logged in as <%= link_to current_user.username, :controller => :login_sessions, :action => :show %></p><% end %>
, and copycurrent_user
from the articles_controller toapp/helpers/login_sessions_helper.rb
Most importantly: Let’s remove the “edit” link when the user can’t edit the current article. This avoid confusion, and means that we don’t have to fix the ugly error message for people trying to edit other people’s articles. Here’s how: In
app/views/articles/show.html.erb
, put the following around the edit link:<% if current_user_can_edit @article %> ... <% end %>
. Define thecurrent_user_can_edit
method inapp/helpers/login_sessions_helper.rb
as follows:article.user == current_user
Give a friendly message when we redirect someone to log in. We do this using the flash. The flash is a special session which is cleared after a the next request. We use it when we’re redirecting, and normally would put something in “@” instance variables, as instance variables don’t survive a redirect. In
app/controllers/articles_controller.rb
, add the following inverify_logged_in
before redirecting:flash[:notice] = "You must login in to do this"
. Inapp/views/login_sessions/new.html.erb
, add the following to the top of the page:<p style="color: green"><%= flash[:notice] %></p>
Add a message if login failed. In
app/controllers/login_sessions_controller.rb
, add the following to theelse
block ofcreate
:flash[:error] = "Login failed"
. Inapp/views/login_sessions/new.html.erb
, add the following to the top of the page:<p style="color: red"><%= flash[:error] %></p>
. As an extra exercise: Can you create a generic construct to list all items in the flash (useflash.each_pair
) and display them with proper formatting (updatepublic/stylesheet/scaffold.css
)?If a user was diverted to the login page, redirect the user back to the page he wanted to go to after a successful login. In
app/controllers/articles_controller.rb
, add the following inverify_logged_in
before the redirect:session[:redirect_after_login] = request.request_uri
. Inapp/controllers/login_sessions_controller.rb
, change the redirect increate
to the following:redirect_to(session[:redirect_after_login] || { :action => 'show'})
Allow for http authentication to allow web service clients to access restricted operations without having to simulate a login dialog. If you have Cygwin, or you’re working on a UNIX platform, you can test this by using the command line tool “curl”:
curl http://localhost:3000/articles/_id_/edit
will redirect, butcurl --user _username_:_password_ http://localhost:3000/articles/_id_/edit
will work if we make the following change: Inapp/controllers/articles_controller.rb
, add the following to the start ofcurrent_user
:if !session[:user_id] && user = authenticate_with_http_basic { |u, p| User.authenticate(u,p) }; session[:user_id] = user.id; end
Never store clear text passwords in the database. We can hash the passwords first. In
app/models/user.rb
, make the following changes:class User < ActiveRecord::Base has\_many :articles def password=(value) write\_attribute :hashed\_password, User.hash\_string(value) end def password "" end def self.authenticate(username, password) find\_by\_username\_and\_hashed\_password(username, hash\_string(password)) end PASSWORD\_SALT = "slgn3woihtrwoe1902" def self.hash\_string(string) Digest::SHA1.hexdigest(string + PASSWORD\_SALT) end end
Also update the migration by replacing the
password
column withhashed_password
, and rerun the last migration withrake db:migrate:redo
. Finally update thetest/fixture/users.yml
and replace the password lines with the following:hashed_password: <%= User.hash_string "password" %>
. Reload the fixtures withrake db:fixtures:load
. Everything will behave as before, but the passwords are no longer stored in clear text.Fix the tests: All the tests in
test/functional/articles_controller_test.rb
of methods that require login will now fail. Theget
,put
,post
anddelete
methods that the tests use are all designed with this problem in mind. They all take up to three parameters: The action, the arguments, and the current session. So, for example, intest_should_create_article
the linepost :create, :article => { }
should be replaced bypost :create, { :article => { } }, { :user_id => users(:user1).id }
, and the first line intest_should_update_article
should beput :update, { :id => articles(:one).id, :article => { } }, { :user_id => articles(:one).user_id }
. You can find more information about testing controllers in A Guide to Testing the Rails, by Steve Kellock and Chris Carter.Allow for the creation of new users. Whoops! Currently, only the users already in the database are available. That won’t really work, will it? First, let’s add a few details to the user model:
ruby script/generate migration AddDetailsToUser email:string full_name:string bio:text
andrake db:migrate
. Then, create the controller and views for the user scaffold:ruby script/generate scaffold --skip-migration user username:string password:string email:string full_name:string bio:text
. You will need to make a few simple changes: Removepassword
fromshow.html.erb
andindex.html.erb
, and replacetext_field
for password inedit.html.erb
andnew.html.erb
withpassword_field
. You will probably also want to have abefore_filter :verify_logged_in, :only => [ :edit, :update, :destroy ]
inapp/controllers/users_controller.rb
. Moveverify_logged_in
andcurrent_user
toapp/controllers/application.rb
to reuse these methods between the different controllers.There are still many things left to do with the users to make them secure and well-functioning. But that’s going to be the subject of a future post.
Don’t mess with my articles!
We have implemented functionality for logging in, and for restricting access to an article. It turns out that using the relationships between the user object and articles is a good way to ensure that a user is only allowed to edit his own articles. Using ActiveRecord to execute find
on an association instead of a class minimizes the risk of leaving security holes open by accident. There’s still a lot to learn about security however. In my next post, I will show you how you can let users grant access for other users to edit their posts. I might also touch on a few odds and ends regarding validation. Stay turned.