Sessions
Now that our users have the possibility to register and confirm on our page, we need to make it possible for our users to sign in. For handling login, we need to create a session controller:
$ padrino-gen controller Sessions new create destroy
create app/controllers/sessions.rb
create app/views/sessions
apply tests/rspec
create spec/app/controllers/sessions_controller_spec.rb
create app/helpers/sessions_helper.rb
apply tests/rspec
create spec/app/helpers/sessions_helper_spec.rb
We made a mistake during the generation - we forget to add the right action for our request. Before making the mistake to delete the generated files by hand with a couple of rm's
, you can run a generator to destroy a controller:
$ padrino-gen controller Sessions -d
remove app/controllers/sessions.rb
remove app/views/sessions
apply tests/rspec
remove spec/app/controllers/sessions_controller_spec.rb
remove app/helpers/sessions_helper.rb
apply tests/rspec
remove spec/app/helpers/sessions_helper_spec.rb
And run the generate command with the correct actions:
$ padrino-gen controller Sessions get:new post:create get:destroy
Our session controller is naked:
# app/controllers/sessions_controller.rb
JobVacancy:App.controllers :sessions do
# get :index, :map => '/foo/bar' do
# session[:foo] = 'bar'
# render 'index'
# end
# get :sample, :map => '/sample/url', :provides => [:any, :js] do
# case content_type
# when :js then ...
# else ...
# end
# get :foo, :with => :id do
# 'Maps to url '/foo/#{params[:id]}''
# end
# get '/example' do
# 'Hello world!'
# end
get :new do
end
post :create do
end
get :destroy do
end
end
\begin{aside} \heading{Test-First development}
Is a term from Extreme Programming (XP) and means that you first write down your tests before writing any code to solve it. This forces you to really think about what you are going to do. These tests prevent you from over engineering a problem because you have to make these tests green.
\end{aside}
We write our tests first before the implementation:
# spec/app/controllers/sessions_controller_spec.rb
require 'spec_helper'
RSpec.describe "SessionsController" do
describe "GET /login" do
it "load the login page" do
get "/login"
expect(last_response).to be_ok
end
end
describe "POST :create" do
let(:user) { build(:user)}
let(:params) { attributes_for(:user)}
it "stay on page if user is not found" do
expect(User).to receive(:find_by_email).and_return(false)
post 'sessions/create'
expect(last_response).to be_ok
end
it "stay on login page if user is not confirmed" do
user.confirmation = false
expect(User).to receive(:find_by_email).and_return(user)
post 'sessions/create'
expect(last_response).to be_ok
end
it "stay on login page if user has wrong password" do
user.confirmation = true
user.password = "fake"
expect(User).to receive(:find_by_email).and_return(user)
post 'sessions/create', {:password => 'correct'}
expect(last_response).to be_ok
end
it "redirects to home for confirmed user and correct password" do
user.confirmation = true
user.password = 'real'
expect(User).to receive(:find_by_email).and_return(user)
post 'sessions/create', {:password => 'real', :remember_me => false}
expect(last_response).to be_redirect
end
end
describe "GET /logout" do
xit "empty the current session"
xit "redirect to homepage if user is logging out"
end
end
We are using method stubs to make test what we want with the expect(User).to receive(:find_by_email).and_return(false)
method. At first I was thinking at that mocking is something very difficult. Read it the method out loud ten times and you can guess whats going on. If our User
object gets call from it's class method find_by_email
it should return false. So we stimulate the actual application call find_by_email
in our application and preventing our tests from hitting the database and making it faster. Beside we are using xit to temporarily disable tests.
Here is the code for our session controller to make the test "green":
# app/controllers/session.rb
JobVacancy::App.controllers :sessions do
get :new, :map => "/login" do
render 'new'
end
post :create do
@user = User.find_by_email(params[:email])
if @user && @user.confirmation && @user.password == params[:password]
redirect '/'
else
render 'new'
end
end
get :destroy, :map => '/logout' do
end
end
When I started the tests I got some weird error messages of calling a method user.save
on a nil object and spend one hour till I found the issue. Do you remember the UserObserver
? Exactly, this tiny piece of code is also activated for our tests and since we disable sending mails with the set :delivery_method, :test
settings in app.rb
I never received an mails. The simple to this problem was to add an option to in the spec_helper.rb
to disable the observers:
# spec/spec_helper.rb
...
RSpec.configure do |conf|
conf.before do
ActiveRecord::Base.observers.disable :all
end
...
end
Running our tests:
$ rspec spec/app/controllers/sessions_controller_spec.rb
SessionsController
GET /login
load the login page
POST :create
stay on page if user is not found (FAILED - 1)
stay on login page if user is not confirmed (FAILED - 2)
stay on login page if user has wrong password (FAILED - 3)
redirects to home for confirmed user and correct password (FAILED - 4)
redirect if user is correct and has remember_me (FAILED - 5)
GET /logout
empty the current session (PENDING: Temporarily skipped with xit)
redirect to homepage if user is logging out (PENDING: Temporarily skipped with xit)
Pending: (Failures listed here are expected and do not affect your suite's status)
1) SessionsController GET /logout empty the current session
# Temporarily skipped with xit
# ./spec/app/controllers/sessions_controller_spec.rb:66
2) SessionsController GET /logout redirect to homepage if user is logging out
# Temporarily skipped with xit
# ./spec/app/controllers/sessions_controller_spec.rb:71
Failures:
1) SessionsController POST :create stay on page if user is not found
Failure/Error: expect(last_response).to be_ok
expected `#<Rack::MockResponse:0xacd2ddc @original_headers={"Content-Type"=>
"text/plain", "X-Content-Type-Options"=>"nosniff", "Set-Cookie"=>"rack.
session=BAh7CEkiD3Nlc3Npb25faWQGOgZFVEkiRTlkOWJjYWM3YmQ1MDg2ZmFmMzk3%
0AMmNmZTE4M2IyMmUyYjQ5YzRiYzNmZjg4ODNmYjcwODZkMTc5NjM4NTJh
M2MG%0AOwBGSSIJY3NyZgY7AEZJIiVhM2JhMWZmMjFkNjg1MDMzODczMjFjYWYxNTBi%
0AOWVkOAY7AEZJIg10cmFja2luZwY7AEZ7B0kiFEhUVFBfVVNFUl9BR0VOVAY7
%0AAFRJIi1kYTM5YTNlZTVlNmI0YjBkMzI1NWJmZWY5NTYwMTg5MGFmZDgwNzA5
%0ABjsARkkiGUhUVFBfQUNDRVBUX0xBTkdVQUdFBjsAVEkiLWRhMzlhM2VlNWU2%
0AYjRiMGQzMjU1YmZlZjk1NjAxODkwYWZkODA3MDkGOwBG%0A--
d6e98e46cbddb5ab1287ac6bc9fba47bcfb2724f; path=/; HttpOnly"},
@errors="", @body_string=nil, @status=403, @header={"Content-Type"=>"text/plain"
, "X-Content-Type-Options"=>"nosniff", "Set-Cookie"=>
"rack.session=BAh7CEkiD3Nlc3Npb25faWQGOgZFVEkiRTlkOWJjYWM3YmQ1MDg2ZmFmMzk3
%0AMmNmZTE4M2IyMmUyYjQ5YzRiYzNmZjg4ODNmYjcwODZkMTc5NjM4NTJhM2MG
%0AOwBGSSIJY3NyZgY7AEZJIiVhM2JhMWZmMjFkNjg1MDMzODczMjFjYWYxNTBi%
0AOWVkOAY7AEZJIg10cmFja2luZwY7AEZ7B0kiFEhUVFBfVVNFUl9BR0VOVAY7
%0AAFRJIi1kYTM5YTNlZTVlNmI0YjBkMzI1NWJmZWY5NTYwMTg5MGFmZDgwNzA5
%0ABjsARkkiGUhUVFBfQUNDRVBUX0xBTkdVQUdFBjsAVEkiLWRhMzlhM2VlNWU2
%0AYjRiMGQzMjU1YmZlZjk1NjAxODkwYWZkODA3MDkGOwBG%0A--
d6e98e46cbddb5ab1287ac6bc9fba47bcfb2724f; path=/; HttpOnly",
"Content-Length"=>"9"}, @chunked=false,
@writer=#<Proc:0xacd2cb0@/home/wm/.rvm/gems/ruby-2.2.1/gems
/rack-1.5.5/lib/rack/response.rb:27 (lambda)>, @block=nil,
@length=9, @body=["Forbidden"]>.ok?` to return true, got false
# ./spec/app/controllers/sessions_controller_spec.rb:18:in `block
(3 levels) in <top (required)>'
...
Finished in 0.38537 seconds (files took 0.74964 seconds to load)
8 examples, 5 failures, 2 pending
Failed examples:
rspec ./spec/app/controllers/sessions_controller_spec.rb:15 # SessionsController
# POST :create stay on page if user is not found
rspec ./spec/app/controllers/sessions_controller_spec.rb:21 # SessionsController
# POST :create stay on login page if user is not confirmed
rspec ./spec/app/controllers/sessions_controller_spec.rb:28 # SessionsController
# POST :create stay on login page if user has wrong password
rspec ./spec/app/controllers/sessions_controller_spec.rb:36 # SessionsController
# POST :create redirects to home for confirmed user and correct password
rspec ./spec/app/controllers/sessions_controller_spec.rb:44 # SessionsController
# POST :create redirect if user is correct and has remember_me
The part of the tests with POST :create.to be_ok
are failing because of Padrinos csrf token. To make the tests running, you need to disable them for the test environment:
# app/app.rb
module JobVacancy
class App < Padrino::Application
...
configure :test do
set :protect_from_csrf, false
end
end
end
Before going on with implementing the logout action we need to think what happened after we login. We have to find a mechanism to enable the information of the logged in user in all our controllers and views. We will do it with sessions helper. Let's look into this file:
# app/helpers/sessions_helper.rb
# Helper methods defined here can be accessed in any controller or view in
# the application
JobVacancy::App.helpers do
# def simple_helper_method
# ...
# end
end
Yeah, Padrino prints the purpose of this new file and it says what we want to do. Let's implement the main features:
# app/helpers/session_helper.rb
JobVacancy::App.helpers do
def current_user=(user)
@current_user = user
end
def current_user
@current_user ||= User.find_by_id(session[:current_user])
end
def current_user?(user)
user == current_user
end
def sign_in(user)
session[:current_user] = user.id
self.current_user = user
end
def sign_out
session.delete(:current_user)
end
def signed_in?
!current_user.nil?
end
end
There's a lot of stuff going on in this helper:
current_user
: Uses the||=
notation. If the left hand-side isn't initialized, initialize the left hand-side with the right hand-side.current_user?
: Checks if the passed in user is the currently logged in user.sign_in
: Uses the global session method use the user Id as login informationsign_out
: Purges the:current_user
field from our session.signed_in?
: We will use this small method within our whole application to display special actions which should only be available for authenticated users.
\begin{aside} \heading{Why Sessions and how does sign_out work?}
When you request an URL in your browser, you are using the HTTP/HTTPS protocol. This protocol is stateless that means that it doesn't save the state in which you are in your application. Web applications implement states with one of the following mechanisms: hidden variables in forms when sending data, cookies, or query strings (e.g. http://localhost:3000/login?user=test&password=test).
We are going to use cookies to save if a user is logged in and saving the user-Id in our session cookies under the current_user
key.
What the delete method does is the following: It will look into the last request in your application inside the session information hash and delete the current_user
key. If you want to explore more of the internal of an application I highly recommend you Pry. You can throw in at any part of your application binding.pry
and have full access to all variables.
\end{aside}
Now we are in a position to write tests for our :destroy
action:
# spec/app/controller/sessions_spec.rb
require 'spec_helper'
describe "SessionsController" do
...
describe "GET /logout" do
it "empty the current session" do
get '/logout'
expect(last_request.env['rack.session'][:current_user]).to be_nil
end
it "redirect to homepage if user is logging out" do
get '/logout'
expect(last_response).to be_redirect
end
end
end
We use the last_request method to access to Rack's SessionHash information.
And finally the implementation of the code that it make our tests green:
# app/controllers/session.rb
JobVacancy::App.controllers :sessions do
...
get :destroy, :map => '/logout' do
sign_out
flash[:notice] = "You have successfully logged out."
redirect '/'
end
end
What we forget due to this point is to make use of the sign_in(user)
method. We need this during our session :create
action:
# app/controller/session.rb
JobVacancy::App.controllers :sessions do
...
post :create do
...
if user && user.confirmation && user.password == params[:password]
sign_in(user)
redirect '/'
else
...
end
end
end
Where can we test now our logic? The main application layout of our application should have a "Login" and "Logout" link according to the status of the user:
<%# app/views/application.rb %>
<!DOCTYPE html>
<html lang="en-US">
<%= stylesheet_link_tag '../assets/application' %>
<%= javascript_include_tag '../assets/application' %>
</head>
<body>
<div class=="container">
<div class="row">
<nav id="navigation">
...
<% if signed_in? %>
<%= link_to 'Logout', url(:sessions, :destroy) %>
<% else %>
<div class="span2">
<%= link_to 'Login', url(:sessions, :new) %>
</div>
<% end %>
</nav>
</div>
...
</div>
</div>
</body>
With the change above we changed the default "Registration" entry in our header navigation to "Login". We will add the link to the registration form now in the 'session/new' view:
<%# app/views/sessions/new.erb %>
<h1>Login</h1>
<% form_tag '/sessions/create' do %>
<%= label_tag :email %>
<%= text_field_tag :email %>
<%= label_tag :password %>
<%= password_field_tag :password %>
<p>
<%= submit_tag "Sign up", :class => "btn btn-primary" %>
</p>
<% end %>
New on this platform? <%= link_to 'Register', url(:users, :new) %>
\begin{aside} \heading{No hard coded urls for controller routes}
The line above with <% form_tag '/sessions/create' do %>
is not a good solution. If you are changing the mapping inside the controller, you have to change all the hard coded paths manually. A better approach is to reference the controller and action within the url
method with url(:sessions, :create)
. Try it out and see if it's working.
\end{aside}
Here we are using the form_tag instead of the form_for
tag because we don't want to render information about a certain model. We want to use the information of the session form to find a user in our database. We can use the submitted inputs with params[:email]
and params[:password]
in the :create
action in our sessions controller. The basic idea is to pass a variable to the rendering of method which says if we have an error or not and display the message accordingly. To handle this we are using the :locals
option to create customized params for your views:
# app/controllers/sessions.rb
JobVacancy::App.controllers :sessions do
get :new, :map => "/login" do
render 'new', :locals => { :error => false }
end
post :create do
@user = User.find_by_email(params[:email])
if @user && @user.confirmation && @user.password == params[:password]
sign_in(@user)
redirect '/'
else
render 'new', :locals => { :error => true }
end
end
...
end
Now we can use the error
variable in our view:
<%# app/views/sessions/new.erb %>
<h1>Login</h1>
<% form_tag url(:sessions, :create) do %>
<% if error %>
<div class="alert alert-error">
<h4>Error</h4>
Your Email and/or Password is wrong!
</div>
<% end %>
...
<% end %>
New on this platform? <%= link_to 'Register', url(:users, :new) %>
The last thing we want to is to give the user feedback about what the recently action. Like that it would be nice to give feedback of the success of the logged and logged out action. We can do this with short flash messages above our application which will fade away after a certain amount of time. To do this we can use Padrino's flash mechanism is build on Rails flash message implementation.
And here is the implementation of the code:
<%# app/views/application.erb %>
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>Job Vacancy - find the best jobs</title>
<%= stylesheet_link_tag '../assets/application' %>
<%= javascript_include_tag '../assets/application' %>
</head>
<body>
<div class="container">
<% if !flash.empty? %>
<div class="row" id="flash">
<% if flash.key[:notice] %>
<div class="span9 offset3 alert alert-success">
<%= flash[:notice] %>
</div>
<% end %>
</div>
<% end %>
</div>
</body>
Next we need implement the flash messages in our session controller:
# app/controllers/sessions.rb
JobVacancy::App.controllers :sessions do
...
post :create do
user = User.find_by_email(params[:email])
if user && user.confirmation && user.password == params[:password]
flash[:notice] = "You have successfully logged out."
sign_in(user)
redirect '/'
else
render 'new', :locals => { :error => true }
end
end
...
end
If you now login successfully you will see the message but it will stay there forever. But we don't want to have this message displayed the whole time, we will use jQuery's fadeOut method to get rid of the message. Since we are first writing our own customized JavaScript, let's create the inline with the following content:
<%# app/views/application.erb %>
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>Job Vacancy - find the best jobs</title>
<%= stylesheet_link_tag '../assets/application' %>
<%= javascript_include_tag '../assets/application' %>
</head>
<body>
<div class=="container">
<% if flash[:notice] %>
<div class="row" id="flash">
<div class="span9 offset3 alert alert-success">
<%= flash[:notice] %></p>
</div>
<script type="text/javascript">
$(function(){
$("#flash").fadeOut(2000);
});
</script>
</div>
<% end %>
</div>
</body>