!Magic

Written by Bryan Powell, a hybrid developer building a better web with Pakyow. Founded Metabahn. Follow me @bryanp or email me here. Subscribe to the feed for updates.

Handling user creation & auth with Pakyow.

April 17, 2015

User creation and auth is a simple concept but can be tricky to implement because it consists of so many moving parts. Today I want to try and make it a bit more approachable by outlining aspects of the approach we use at Metabahn.

As a precursor to this article, please read Connecting a Pakyow app to Postgres with Sequel. You'll need this knowledge once we start implementing the backend code. I'd even recommend that uou could even use that project as a starting point for this project.

For reference, you can find the complete app here.

Building the prototype.

Let's start by building the frontend. This will help us get a feel for what we're building before we start writing any backend code. Sort of a roadmap, if you will. We'll begin with the signup form.

The signup form.

Create a new view named app/views/users/new.html. In it, create a form that looks something like this:

<h2>
  Please Signup
</h2>

<form data-scope="user" data-prop="action">
  <input type="submit" value="Sign me up!">
</form>

This creates a form scoped as user. Later, our backend code will be able to bind user data to this form as well as receive user data from the form submission. It will also handle setting the action and method of the form for us.

We need three fields to create a user: email, password, and password confirmation. Let's add those next:

<h2>
  Please Signup
</h2>

<form data-scope="user" data-prop="action">
  <label>Email</label>
  <input type="text" data-prop="email">

  <label>Password</label>
  <input type="password" data-prop="password">

  <label>Confirm Password</label>
  <input type="password" data-prop="password_confirmation">

  <input type="submit" value="Sign me up!">
</form>

Notice that these are labeled as props that coorespond to the fields we know our user will have. When the backend is hooked up, Pakyow will handle setting the field names for these fields.

We also need a way of handling validation errors. Let's do that by adding an errors scope:

<h2>
  Please Signup
</h2>

<form data-scope="user" data-prop="action">
  <ul data-scope="errors">
    <li data-prop="message">
      Error message goes here.
    </li>
  </ul>

  <label>Email</label>
  <input type="text" data-prop="email">

  <label>Password</label>
  <input type="password" data-prop="password">

  <label>Confirm Password</label>
  <input type="password" data-prop="password_confirmation">

  <input type="submit" value="Sign me up!">
</form>

Now we have a user creation form that's ready to be tied inot the backend. Let's do the same with the login form.

The login form.

We'll take a similar approach as we did with the signup form. First, let's create a form; this time it will be scoped as session. This should be created as app/views/sessions/new.html:

<h2>
  Please Log In
</h2>

<form data-scope="session" data-prop="action">
  <input type="submit" value="Log in!">
</form>

Now we need fields for email and password:

<h2>
  Please Log In
</h2>

<form data-scope="session" data-prop="action">
  <label>Email</label>
  <input type="text" data-prop="email">

  <label>Password</label>
  <input type="password" data-prop="password">

  <input type="submit" value="Log in!">
</form>

As well as a way to handle errors:

<h2>
  Please Log In
</h2>

<form data-scope="session" data-prop="action">
  <ul data-scope="errors">
    <li data-prop="message">
      Error message goes here.
    </li>
  </ul>

  <label>Email</label>
  <input type="text" data-prop="email">

  <label>Password</label>
  <input type="password" data-prop="password">

  <input type="submit" value="Log in!">
</form>

And that's it. Start the server with pakyow s, open up the user form and session form in your browser, and bask in the glory of view composition without backend code!

Make a mental note that anyone with basic frontend skills can create these views. If you work on a team with dedicated designers and developers, it's handy to have your own areas to work in without stepping on each other's toes. Even if not, it's still helpful to think of the frontend and backend as completely separate steps. For me, anyway :-)

Onward to the backend!

Setting up Rack session middleware.

Before we get into building the backend, we need to tell Pakyow to use the Rack session middleware. Open app.rb and add this bit of code within the app definition:

middleware do |builder|
  builder.use Rack::Session::Cookie, key: 'myapp.session', secret: 'secretgoeshere'
end

You'll likely want to change the key and secret.

Installing bcrypt.

Next we need to add the bcrypt library to our app. This will let us encrypt the password in the database so it can't be stolen. Add the following to your Gemfile:

gem "bcrypt"

Now run bundle install to install the library.

Setting up the user migration.

We'll want to store users in the database, which means we need to create a migration that defines the schema for our users table. Create a file in the migrations directory named 001_create_users.rb (the leading number might be different if you already have migrations in your app). Add the following:

Sequel.migration do
  up do
    create_table :users do
      primary_key   :id
      String        :email
      String        :crypted_password
      Time          :created_at
      Time          :updated_at
    end
  end

  down do
    drop_table :users
  end
end

Notice the crypted_password field; this allows us to store a password securely. Even if the data was to leak, the passwords would be safe.

Run rake db:migrate to create the table in your configured database.

Setting up the User & Session models.

We need two models to handle user creation and auth. Let's start with the one for User.

The User model.

The User model will be built on Sequel so that users can be persisted to the database. Start by creating a file at app/lib/models/user_model.rb (note that the naming convention can be anything since all the files within lib are loaded for us).

Add the following bit of code to define our model:

require 'bcrypt'

class User < Sequel::Model
end

As we discussed when we defined the migrations, we'll only be storing the crypted password. This means that there is no password or password confirmation fields available on our User model. Let's define some attributes so that we can set these values from the submitted form:

attr_accessor :password, :password_confirmation

Now when we do User.new({ password: 'foo' }) the value for password will be available on the model (same goes for password_confirmation).

Let's handle encrypting our password by overriding the password setter:

def password=(password)
  @password = password

  return if password.nil? || password.empty?
  self.crypted_password = BCrypt::Password.create(password)
end

We should also normalize the value for email so that it is case-insensitive. One approach is to add a before_validation hook that's executed right before the model is validated and saved:

def before_validation
  @email = @email.to_s.downcase
  super
end

Now let's create our validations. Add the following code into the User class:

plugin :validation_helpers
EMAIL_REGEX = /^[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}$/i unless defined? EMAIL_REGEX

def validate
  super
end

The first validation we want to write is for email. Add the following code into the validate method:

# require a value for email address
validates_presence  :email

# require a valid email address
validates_format    EMAIL_REGEX, :email if email &amp;&amp; !email.empty?

# make sure the email address is unique
validates_unique    :email

Now let's define validations for password (again, to the validate method):

# require a value for password
validates_presence  :password

# make sure the password matches the confirmation
errors.add(:password, "and confirmation must match") if password &amp;&amp; password != password_confirmation

Finally, we need a method that performs the authentication check on login attempts. The way this will work is when a login attempt comes through, the sessions#create route will create a Session object with the submitted email and password. The route will then ask the User model if the session is valid.

We'll need two methods on the User model to make this work. First, a class-level method called auth:

def self.auth(session)
  user = first(email: session[:email])

  if user &amp;&amp; user.auth?(session.password)
    return user
  else
    return nil
  end
end

This method accepts a session object, finds the first user with a matching email address, and calls an auth?method on the instance of the found user. Let's define the auth? method now:

def auth?(password)
  BCrypt::Password.new(crypted_password) == password
end

The auth? method will return a truthy or falsey value depending on if the provided password matches the crypted_password stored on the user instance.

The Session model.

Fortunately the Session model is much simpler. Create a session_model.rb file within app/lib/models with the following code:

class Session
  attr_accessor :email, :password

  def initialize(params)
    @email, @password = params.values_at(:email, :password)
  end

  def [](key)
    send(key) if respond_to?(key)
  end
end

Here we simply provide a bit of code for initializing the session, along with a convenience method for fetching values with a Hash-style syntax (e.g. session[:email]).

Writing the routes.

Whew, okay we now have views and models. Our dependencies are also setup and ready for use. Let's tie it all together with a few routes. The first step is to define Restful resources for user and session. Open up app/lib/routes.rb and add the following code:

restful :user, '/users' do
  new do
  end

  create do
  end
end

restful :session, '/sessions' do
  new do
  end

  create do
  end

  remove do
  end
end

For Pakyow to setup our user and session forms for us, we'll also need to define restful bindings. Open app/lib/bindings.rb and add the following code:

scope :user do
  restful :user
end

scope :session do
  restful :session
end

Finally, we'll need a few helpers for handling errors within our forms. Add the following code to app/lib/helpers.rb:

def handle_errors(view)
  if @errors
    render_errors(view, @errors)
  else
    view.scope(:errors).remove
  end
end

def render_errors(view, errors)
  unless errors.is_a?(Array)
    errors = pretty_errors(errors.full_messages)
  end

  view.scope(:errors).with do
    prop(:message).repeat(errors) { |context, message|
      context.text = message
    }
  end
end

def pretty_errors(errors)
  Array(errors).map { |error|
    error.gsub('_', ' ').capitalize
  }
end

We won't walk through each line of this today, but I'll likely write a post in the future just on error handling.

Alright, now let's dive into each individual route.

users#new

In this route we want to setup the form and present any errors associated with a user being created. Add the following code to the new action of restful :user:

view.scope(:user).with do |view|
  view.bind(@user || User.new)
  handle_errors(view)
end

This code finds the user scope (which is the form in our case) and creates a working context with this scope using the with method. Within that context we do two things:

  1. Bind a user object (or a brand new object if one doesn't exist).
  2. Call the handle_errors helper method to present any errors.

It might seem a little odd that we're handling existing user instances and errors in this action, but it'll make sense after we define the users#create action.

users#create

Here we want to create the user object, so long as it's valid. If it isn't valid we want to take the user back to the form and present the errors. This is what the code within the create action should look like:

@user = User.new(params[:user])

if @user.valid?
  @user.save
  redirect router.path(:default)
else
  @errors = @user.errors
  reroute router.group(:user).path(:new), :get
end

Here we create a User instance from the submitted form values and check the validity. If it's valid, we save the user and redirect back to the default route. Otherwise we set the errors in an instance variable and reroute the user back to users#new. Rerouting is different than redirecting, as it happens within the current request/response lifecycle. It's simply a way of executing the logic tied to some other route.

Now it's time to hookup the session routes.

session routes

The session routes are so similar in structure to the user routes that I'll provide all of the necessary code at once:

restful :session, '/sessions' do
  new do
    view.scope(:session).with do |view|
      view.bind(@session || Session.new({}))
      handle_errors(view)
    end
  end

  create do
    @session = Session.new(params[:session])
    if user = User.auth(@session)
      session[:user] = user.id
      redirect router.path(:default)
    else
      @errors = ['Invalid email and/or password']
      reroute router.group(:session).path(:new), :get
    end
  end

  remove do
    session[:user] = nil
    redirect router.path(:default)
  end
end

A couple notes here. On sessions#create, If the authentication attempt is successful we'll create a user session. This allows us to keep track of the currently logged in user. Notice also in the remove action how we set the user session to nil.

Convenience Routes

I find it helpful to define convenience routes for login / logout. Add the following to your routes file:

get :login, '/login' do
  reroute router.group(:session).path(:new)
end

get :logout, '/logout' do
  reroute router.group(:session).path(:remove), :delete
end

Now your users can login and logout by going directly to /login or /logout.

Restricting Routes

Now that we have a fully baked user creation / auth system, let's put a route behind a privacy wall. We'll do this by defining a route function that can be mixed into other routes as a before hook. Define the following function in your routes file:

fn :require_auth do
  redirect(router.group(:session).path(:new)) unless session[:user]
end

Assuming you've used the pakyow-example-sequel repo as a starting point, define a before function on the default route like this:

default before: [:require_auth] do
  view.scope(:post).apply(Post.all)
end

Now when you navigate to / you'll be redirected to the login page unless you're an authenticated user. Handy!

Playing with our app.

It's now possible to do all kinds of things with our app. Try some things:

Conclusion

And there you have it! Hit problems or have questions? Post on Stack Overflow or ask us for help on Gitter. Thanks for reading!

PREVIOUSLY full archive