Example: Adding authentication

Amadeus Folego edited this page Apr 26, 2015 · 17 revisions

In this entry we'll explain how to use warden and sequel_secure_password gems to add authentication to your app.

We'll start with a vanilla yogurt template and create a login page at /user_sessions/new. If the user is already authenticated or successfully authenticates he can visit the /communities routes, otherwise he gets a 403 status code and markup requesting login.

If you just wanna grab the code, check out the warden-auth branch: https://github.com/badosu/Yogurt/tree/warden-auth or commit: 977271e

Model

First of all, let's create an User model with email and password:

# db/migrations/2_create_users.rb
Sequel.migration do
  change do
    create_table(:users) do
      primary_key :id

      column :email,           String,    null: false
      column :password_digest, String

      column :created_at,      DateTime,  null: false
      column :updated_at,      DateTime
    end
  end
end

We use the password_digest as it is the convention on sequel_secure_password for the column name, but that can be configured.

Run the migration: bin/bs bundle exec rake db:migrate.

With a simple validation for email presence:

# models/user.rb
class User < Sequel::Model
  def validate
    super
    errors.add(:email, 'must be present') if !email || email.empty?
  end
end

Adding Password check

Add sequel_secure_password to your Gemfile and run the usual bundling process.

This is a dead-simple gem and if you don't want to use it, just make sure that your model responds to #authenticate(password) with itself if it passes and nil if not. See the source code.

As this is a model plugin, and should be available to all models, append require 'secure_sequel_password' to the ./models.rb file header if you're not using Bundler.

Now you can simply use the Sequel convention for including plugins to a class, in this case for our User class:

diff --git a/models/user.rb b/models/user.rb
index ea8787d..8b8ce88 100644
--- a/models/user.rb
+++ b/models/user.rb
@@ -1,4 +1,6 @@
 class User < Sequel::Model
+  plugin :secure_password
+
   def validate
     super
     errors.add(:email, 'must be present') if !email || email.empty?

This .plugin call can accept configuration that is described on sequel_secure_password README. In particular we'll use the default one, which includes validations for the password presence and confirmation.

This is what's available for us now:

user = User.new
user.password = "foo"
user.password_confirmation = "bar"
user.valid? # => false

user.password_confirmation = "foo"
user.valid? # => true

user.authenticate("foo") # => user
user.authenticate("bar") # => nil

Let's seed the database (and skip validations):

User.new(email: 'elduderino@lebowski.com',
         password: 'youropinionman').
     save(validate: false)

Now we have implemented password validation logic into our User model, it was pretty straightforward and you can see what's happening under the hood here.

It's that simple!

Signin Page

Let's create a sign-in page, for this we'll use a nice Bootstrap example.

We'll use the concept of a user session as a resource to be created to abstract the signin process, as it's done with Devise.

Markup

Copy the html of that example and put it in on the views/user_sessions/new.html.erb file.

Put the signin.css file on assets/css/signin.css and add it to the yogurt.min.css bundle:

diff --git a/yogurt.rb b/yogurt.rb
index 2dbf94c..6b978cf 100644
--- a/yogurt.rb
+++ b/yogurt.rb
@@ -12,7 +12,7 @@ class Yogurt < Roda
   plugin :multi_route
   plugin :assets, group_subdirs: false,
          css: { home:   %w[lib/bootstrap.css jumbotron.css],
-                yogurt: %w[lib/bootstrap.css yogurt.css] },
+                yogurt: %w[lib/bootstrap.css yogurt.css signin.css] },
          js:  { yogurt: %w[lib/jquery-2.1.3.js lib/bootstrap.js] }
   plugin(:not_found) { view '/http_404' }

Adjust style.css and load it on user_sessions/new.html.erb by replacing the stylesheet links with <%= assets([:css, :yogurt]) %>.

Also add <%= assets([:js, :yogurt]) %> to load the relevant javascript.

Routing

Create the following file:

# routes/user_sessions.rb
class Yogurt
  route 'user_sessions' do |r|
    r.get 'new' do
      render '/user_sessions/new'
    end
  end
end

Visit /user_sessions/new, that's it.

Authenticating

We'll post the form at the sign in page to the user sessions collection route and redirect the user to the communities route if it's succesful, or re-render the page otherwise.

Change the markup on views/user_sessions/new.html.erb accordingly, the form part should look like this:

<!-- ... -->
      <form class="form-signin" action='/user_sessions' method='POST'>
        <h2 class="form-signin-heading">Please sign in</h2>
        <label for="email" class="sr-only">Email address</label>
        <input type="email" name='email' class="form-control" placeholder="Email address" required autofocus>
        <label for="password" class="sr-only">Password</label>
        <input type="password" name="password" class="form-control" placeholder="Password" required>
<!-- ... -->

And the authentication logic:

# routes/user_sessions.rb
# ...
  route 'user_sessions' do |r|
    r.is do
      r.post do
        user = User.first(email: r['email'])

        if user && user.authenticate(r['password'])
          r.redirect '/communities'
        else
          render '/user_sessions/new'
        end
      end
    end
# ...

Verify that our seed data passes and it works as intended, great!

There are lots of stuff to do yet, for example: we do not really create a user session nor persist it. Also we don't secure that the /communities pages check authentication. Hopefully, we'll be able to address these issues with Warden.

Warden

First include warden to your Gemfile and configure it:

# config/warden.rb
require 'warden'

Warden::Manager.serialize_into_session{|user| user.id }
Warden::Manager.serialize_from_session{|id| User[id] }

Warden::Strategies.add(:password) do
  def valid?
    params["email"] || params["password"]
  end

  def authenticate!
    user = User.first(email: params["email"])

    if user && user.authenticate(params["password"])
      success! user
    else
      fail! "Could not log in"
    end
  end
end

Configure your application to use warden as a middleware:

# ./yogurt.rb
#...
require './config/warden'

class Yogurt < Roda
  #...

  use Warden::Manager do |manager|
    manager.scope_defaults :default,
      strategies: [:password],
      action: 'user_sessions/unauthenticated'
    manager.failure_app = self
  end

Finally, let's update our authentication routes:

# routes/user_sessions.rb
class Yogurt
  route 'user_sessions' do |r|
    r.is do
      r.post do
        env['warden'].authenticate!

        r.redirect session[:return_to] || '/communities'
      end
    end

    r.get 'new' do
      render '/user_sessions/new'
    end

    r.is 'unauthenticated' do
      session[:return_to] = env['warden.options'][:attempted_path]
      response.status = 403

      render '/user_sessions/new'
    end
  end
end

Add the authentication check for all the communities routes:

diff --git a/routes/communities.rb b/routes/communities.rb
index f9a95fa..c056d4e 100644
--- a/routes/communities.rb
+++ b/routes/communities.rb
@@ -2,6 +2,8 @@ class Yogurt
   route 'communities' do |r|
     set_view_subdir 'communities'

+    env['warden'].authenticate!
+
     r.is do
       r.get do
         @communities = Community.order(Sequel.desc(:created_at)).all

Verify it works as expected.

Logging out

We'll send DELETE to /user_sessions to logout the application:

# routes/user_sessions.rb
class Yogurt
  route 'user_sessions' do |r|
    r.is do
      r.post do
        env['warden'].authenticate!

        r.redirect session[:return_to] || '/communities'
      end

      r.delete do
        env['warden'].logout

        r.redirect '/user_sessions/new'
      end
    end
#...

Let's add a logout button to the layout header:

# views/layout.html.erb
<!-- ... -->
            <li role="presentation"><a href="#">Contact</a></li>
            <% if env['warden'].authenticated? %>
              <li role="presentation">
                <form class='inline' action='/user_sessions' method='POST'>
                  <input type='hidden' name='_method' value='DELETE'>

                  <button type="submit" class="btn btn-warning">Logout</button>
                </form>
              </li>
            <% end %>
          </ul>
<!-- ... -->

Bonus: Add success/error messages

See commit: 8ef447e