- Read Tutorial
So our Post
feature is coming along, however we have an issue, right now all posts can be edited by anyone visiting the site. This means that if you create a post anyone can come along and change it, if you don't fix this bug...
We also have another issue, un-registered users can create posts, this is really bad because our post
creation workflow requires that a post belongs_to
a user, without that value you're app is going to have errors all over the place. So before we get started let's list what needs to be done:
A user should be required to be logged in to access the
new
form viewA user should be required to be logged in to access the
edit
viewA user should only be able to edit a post that they created
Ok, I feel better now that we know what we need to do, let's start off with the new
form authorization and create a spec to test this behavior. Inside of the creation
block in the features/post_spec
file add in the following expectation:
# spec/features/post_spec.rb it 'only lets signed in users view the new form' do logout(:user) visit new_post_path expect(current_path).to eq(new_user_session_path) end
Agh, more test methods! Before running the tests let's run through what this test is doing step by step:
Using the
Warden
logout
method it's killing the user session, essentially mocking what would happen if the user signed out (remember that ourbefore
block signed the user in, so we need to call this to mimc what would happen if the user wasn't signed in at all.Then we're trying to re-visit the
posts/new
path (we have to do this because to ensure that we're working with a user who is not signed in)Then we're using the
current_path
method fromCapybara
to check to see what URL path the mockedghost
user lands on
Running rspec
will give us the following failure:
Failures: 1) post creation only lets registered users view the new form Failure/Error: expect(current_path).to eq(new_user_session_path) expected: "/users/sign_in" got: "/posts/new" (compared using ==) # ./spec/features/post_spec.rb:80:in `block (3 levels) in <top (required)>' Finished in 1.38 seconds (files took 2.53 seconds to load) 34 examples, 1 failure, 3 pending Failed examples: rspec ./spec/features/post_spec.rb:77 # post creation only lets registered users view the new form
That means that it thought they were going to be sent to users/sign_in
but still went to the posts/new
path. Now we could do something ugly, like checking on the view if the user is signed in and then use JavaScript to redirect them or something horrible like that. But there's another way, Devise ships with a very handy method called authenticate_user!
that we can place in the controller action we want to protect and the method checks to see if a user is signed in and if not it redirects them to the login path, let's implement that:
# app/controllers/topics/posts_controller.rb def new authenticate_user! end
That's it! Now run rspec
again and see that the test is passing, I always love it when a single method call implements a full feature. Now we have two final features for this lesson:
A user should be required to be logged in to access the
edit
viewA user should only be able to edit a post that they created
If you assumed that we can take care of #2 the same way we did with the new
action you'd be 100% right, let's implement the spec first though (make sure to place this spec inside of the editing
block:
# spec/features/post_spec.rb # Update the before block so it creates a @post instance variable so we can use it in the new spec before do user = FactoryGirl.create(:user) second_user = FactoryGirl.create(:second_user) login_as(user, :scope => :user) @post = Post.create(title: "starter title", content: "starter content", topic_id: @topic.id, user_id: user.id) visit edit_topic_post_path(topic_id: @topic.id, id: @post.id) end # Now we can implement the spec it 'does not allow a user to access the edit page if they are not signed in' do logout(:user) visit edit_topic_post_path(topic_id: @topic.id, id: @post.id) expect(current_path).to eq(new_user_session_path) end
This expectation will look very similar to the new
spec, running the specs will give us the same type of failure, let's integrate the authenticate_user!
method inside of the edit
action in the controller:
# app/controllers/topics/posts_controller.rb def edit authenticate_user! end
This gets all of the specs passing, let's perform a small refactor, we're going to want the user to be logged in for every action except the index
and show
action, so let's have the authenticate_user!
method run as a before filter in this controller:
# app/controllers/topics/posts_controller.rb before_action :authenticate_user!, except: [:index, :show] def new end def edit end
Running the specs now will show that everything is still passing, let's see what this looks like in the browser by navigating to one of the show pages in incognito mode (to ensure we don't have a user session). Going to http://localhost:3000/topics/my-title-99/posts/2
works, so that's good, it's not blocking regular site visitors, if I click on the Edit Post
button it redirects me to the sign in page and let's us know that we need to be signed in to access that page, cool, so everything is working.
Let's fix one thing, I don't like that all users see the Edit Post
button, that should only be shown to the post author. We can fix this by updating the view with the following code:
<!-- app/views/topics/posts/show.html.erb --> <h1><%= @post.title %></h1> <p><%= @post.topic.title %></p> <p><%= @post.user.full_name %></p> <% if current_user && @post.user_id == current_user.id %> <%= link_to "Edit Post", edit_topic_post_path(topic_id: @topic.id, id: @post.id) %> <% end %>
Now if you go back in the browser to the show page and click refresh you'll see the button disappears, pretty cool, right?
The Devise gem has some great built in methods that let us implement basic authorization very efficiently. Now let's implement the final expectation:
# spec/features/post_spec.rb it 'does not allow a user to edit a post they did not create' do logout(:user) login_as(@second_user, :scope => :user) visit edit_topic_post_path(topic_id: @topic.id, id: @post.id) expect(current_path).to eq(topic_post_path(topic_id: @topic.id, id: @post.id)) end
This test is pretty similar to the other specs:
It first logs out the initial user (who is also the
@post
creator)Logs in another user (make sure to update the
before
action so that the@second_user = FactoryGirl.create(:second_user)
is an instance variable instead of a local variableTries to visit the
edit
path for@post
Expects to see the
show
path
Running the spec will complain that the mocked user is still able to reach the edit
page, let's update this in the controller:
# app/controllers/topics/posts_controller.rb def edit if @post.user_id != current_user.id redirect_to topic_post_path(topic_id: @post.topic_id, id: @post), notice: 'Your are not authorized to edit this post.' end end
This implementation code checks to see if the user trying to access the page is the same user who created the post and if not it redirect the user back to the show page. This ensures that a savvy user can't append /edit
at the end of the URL to change the post.
Nice work, you now know how to implement basic authorization into a Rails application. The methods we've covered are good for basic applications, however if you need an advanced permission you'll need a more comprehensive authentication implementation. In a future lesson we'll integrate the pundit
gem to give our application a scalable permission structure.
The Post
feature is where I want it to be right now, in the next lesson we're going to walk through how to create a pull request and merge the branch into the master branch using the GitHub web tools (as promised). For right now simply push the latest feature up to the remote branch like we have been doing:
git add . git ci -m 'Implemented basic authorization system for posts' git push