Building a Web Application with PushAuth™

post

Welcome back to the Power of PushAuth™ blog series! This is the fifth post of the Power of PushAuth™ blog series. The first post of the series was a comprehensive guide to push authentication. The subsequent three posts comprised an end-to-end sample implementation of PushAuth™ in a simple user login flow:

  1. Web Server tutorial
  2. iOS Mobile App tutorial
  3. Android Mobile App tutorial

In this post, we will create a sample website from scratch using Rails, and will integrate PushAuth™ APIs into the user login flow. Along the way, we will also provide technical details on how the website interacts with PushAuth™ APIs, which can help readers incorporate PushAuth™ into any existing website. The end result of this tutorial will be similar to the sample web server we deployed in Web Server tutorial.

Setup

To follow this tutorial, you will need:

This tutorial assumes a basic familiarity with the Rails framework.

Step 1: Make basic Rails app with simple session-based authentication

First, we will create website with a simple username/password authentication, without incorporating UnifyID PushAuth™. Step 2 will integrate PushAuth™ into the website we create in Step 1.

Project Initialization

$ rails new push_auth_demo
$ cd push_auth_demo

Let’s add the bcrypt gem to our Gemfile by uncommenting this line in Gemfile:

gem 'bcrypt', '~> 3.1.7'

Now, we will run bundle install to update the Gemfile.lock.

Next, we can add the User model to our database; each User will have a username and a password hash.

$ rails generate model user username:uniq password:digest 
$ rails db:migrate

Now, we can generate the controller for handling sessions.

$ rails generate controller sessions new create destroy

Controller Logic

The basic idea of session-based authentication is pretty simple:

  • When a user logs in, session[:user_id] is set to be the unique index of the corresponding User in the database.
  • When no one is logged in, session[:user_id] should be unset.

We’ll start with writing the Application controller, where we have a couple of simple page actions:

  • GET / (application#home) renders a page that shows links to other pages and actions
  • GET /restricted (application#restricted) renders a page that is only accessible when logged in.

A few helper functions will live here as well:

  • current_user should either return a User object, or nil if there’s no one logged in.
  • logged_in? should return whether someone is logged in
  • authorized is an action that is called before loading pages that require the a user to be logged in.
# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :authorized
  helper_method :current_user
  helper_method :logged_in?

  skip_before_action :authorized, only: [:home]

  def current_user
    User.find_by(id: session[:user_id])
  end

  def logged_in?
    !current_user.nil?
  end

  def authorized
    unless logged_in?
      redirect_to login_path, alert: "You must be logged in to perform that action."
    end
  end
end

Now, we’ll handle users logging in or out through the Sessions controller.

  • GET /login (sessions#new) action displays a login page unless the user is already logged in
  • POST /login (sessions#create) will authenticate the user and set the session.
  • DELETE /logout (sessions#destroy) action will clear the user’s session cookie.
# app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  skip_before_action :authorized, except: [:destroy]

  def new
    redirect_to root_path if logged_in?
  end

  def create
    @user = User.find_by("lower(username) = ?", params[:username].downcase)
    if @user && @user.authenticate(params[:password])
      session[:user_id] = @user.id
      redirect_to root_path, notice: "Successfully logged in!"
    else
      redirect_to login_path, alert: "Sorry, that didn't work."
    end
  end

  def destroy
    session[:user_id] = nil
    redirect_to root_path, notice: "Successfully logged out."
  end
end

We also need to add routes for these actions.

# config/routes.rb

Rails.application.routes.draw do
  root "application#home"

  get "restricted", to: "application#restricted"

  get "login", to: "sessions#new"

  post "login", to: "sessions#create"

  delete "logout", to: "sessions#destroy"
end

Views

home page where you can click on the link to log in/out:

<!-- app/views/application/home.html.erb -->

<h1> Welcome! </h1>

<% if logged_in? %>
  Welcome, <%= current_user.username %>. <br />
  <%= link_to "Log out", logout_path, method: :delete %> <br />
  Click <%= link_to "here", restricted_path %> to see a super secret page!
<% else %>
  Please <%= link_to "log in", login_path %>.
<% end %>

restricted page to test access control:

<!-- app/views/application/restricted.html.erb -->

Shhh, this page is a secret!

sessions#new renders simple login form:

<!-- app/views/sessions/new.html.erb -->

<h1>Login</h1>

<%= form_tag "/login", {class: "form-signin"} do %>
  <%= label_tag :username, nil, class: "sr-only" %>
  <%= text_field_tag :username, nil, class: "form-control", placeholder: "Username", required: true, autofocus: true %>

  <%= label_tag :password, nil, class: "sr-only" %>
  <%= password_field_tag :password, nil, class: "form-control", placeholder: "Password", required: true%>

  <%= submit_tag "Log in", {class: ["btn", "btn-lg", "btn-primary", "btn-block"]} %>
<% end %>

Lastly, we’ll modify the default template to include a navigation bar at the top and flash messages for notice and alert from controllers:

<!-- Replace the contents of the <body> tag in app/views/layouts/application.html.erb with the following -->

    <nav class="navbar navbar-dark bg-dark">
      <a class="navbar-brand" href="/">
        <span class="logo d-inline-block align-top"></span>
        UnifyID PushAuth Sample
      </a>
    </nav>
    <% if flash[:notice] %>
      <div class="alert alert-primary alert-dismissible fade show" role="alert">
        <%= flash[:notice] %>
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
          <span aria-hidden="true">×</span>
        </button>
      </div>
    <% end %>
    <% if flash[:alert] %>
      <div class="alert alert-danger alert-dismissible fade show" role="alert">
        <%= flash[:alert] %>
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
          <span aria-hidden="true">×</span>
        </button>
      </div>
    <% end %>

    <main role="main" class="container">
      <div class="main-content">
        <%= yield %>
      </div>
    </main>

Styling

We can add styling to our website by simply adding bootstrap. The views we created above already use class names that are recognized by bootstrap.

First, add bootstrap and some of its dependencies.

$ yarn add bootstrap jquery popper.js

Then, add the following to the end of app/javascript/packs/application.js:

import 'bootstrap'
import 'stylesheets/application.scss'

Next, make a file called app/javascript/stylesheets/application.scss and add this:

@import "~bootstrap/scss/bootstrap";

Optionally, you may add your own custom CSS files as well. See an example in our sample code.

At this point, you should be able to run rails server and navigate to http://localhost:3000 to interact with the basic authentication server! You can create sample users as follows:

$ bundle exec rails console
> User.create(:username => "<your_username>", :password => "<your_password>").save
> exit

Step 2 – Integrate with UnifyID PushAuth™ APIs

Now that we have a website with a simple username/password authentication, let’s incorporate UnifyID PushAuth™ APIs to further enhance security.

Interface to PushAuth™ APIs

First, let’s tell Rails about your UnifyID API key.

Run rails credentials:edit and add the following

unifyid:
  server_api_key: <Your UnifyID API Key created from dashboard>

Next, add the following to config/application.rb, after the config.load_defaults line:

config.x.pushauth.base_uri = "https://api.unify.id"

Let’s also add the httparty gem to easily make HTTP/S requests. To do this, add the following to your Gemfile and run bundle install:

gem 'httparty', '~> 0.18.0'

Now, we will make a file called app/services/push_auth.rb which contains the interface for our Rails app to interact with the PushAuth™ APIs:

  • create_session method calls POST /v1/push/sessions to initiate PushAuth™ session (API doc).
  • get_session_status method calls GET /v1/push/sessions/{id} to retrieve the status of PushAuth™ session (API doc).
# app/services/push_auth.rb

class PushAuth
  include HTTParty
  base_uri Rails.configuration.x.pushauth.base_uri

  @@options = {
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": Rails.application.credentials.unifyid[:server_api_key]
    }
  }

  def self.create_session(user_id, notification_title, notification_body)
    body = {
      "user" => user_id,
      "notification" => {
        "title" => notification_title,
        "body" => notification_body
      }
    }
    post("/v1/push/sessions", @@options.merge({body: body.to_json}))
  end

  def self.get_session_status(api_id)
    get("/v1/push/sessions/#{api_id}", @@options)
  end
end

Controller Logic Modification

Now, we will modify the login flow to incorporate PushAuth™ as the second factor authentication. The new login flow will consist of the following:

  1. The client submits the username and password via a POST request to /login (sessions#create)
  2. The controller validates that the user exists and the password matches. If not, it displays an error message.
  3. Upon successful username/password authentication, the controller creates a PushAuth™ session and redirects to GET /mfa (sessions#init_mfa)
  4. The Javascript in /mfa page repeatedly queries GET /mfa/check (sessions#check_mfa), which checks the PushAuth™ session status until the session status is no longer pending.
  5. Upon receiving a non-pending session status, the client submits a request to PATCH /mfa/finalize (sessions#finalize_mfa) that completes the login process.

First, let’s replace the create action in app/controllers/sessions_controller.rb:

  def create
    @user = User.find_by("lower(username) = ?", params[:username].downcase)
    if @user && @user.authenticate(params[:password])
      session[:pre_mfa_user_id] = @user.id

      pushauth_title = "Authenticate with #{Rails.application.class.module_parent.to_s}?"
      pushauth_body = "Login request from #{request.remote_ip}"

      response = PushAuth.create_session(@user.username, pushauth_title, pushauth_body)

      session[:pushauth_id] = response["id"]

      redirect_to mfa_path
    else
      redirect_to login_path, alert: "Sorry, that didn't work."
    end
  end

Next, let’s also add check_mfa and finalize_mfa actions in this controller:

  def check_mfa
    status = PushAuth.get_session_status(session[:pushauth_id])["status"]

    render plain: status
  end

  def finalize_mfa
    case PushAuth.get_session_status(session[:pushauth_id])["status"]
    when "accepted"
      session[:user_id] = session[:pre_mfa_user_id]
      session[:pushauth_id] = nil
      session[:pre_mfa_user_id] = nil
      flash.notice = "Successfully logged in!"
    when "rejected"
      session[:pre_mfa_user_id] = nil
      flash.alert = "Your request was denied."
    end
  end

We also want to make sure that only users who completed the password authentication are able to access actions for the PushAuth™ authentication. Thus, within sessions_controller we will add:

# app/controllers/sessions_controller.rb

# Add this just under the skip_before_action line
  before_action :semi_authorized!, only: [:init_mfa, :check_mfa, :finalize_mfa]

# And add this after action methods
  private
  def semi_authorized
    session[:pre_mfa_user_id] && session[:pushauth_id]
  end

  def unauthorized
    redirect_to login_path, alert: "You are not authorized to view this page."
  end

  def semi_authorized!
    unauthorized unless semi_authorized
  end

Views

Now, we need a page that uses AJAX to determine whether the PushAuth™ request has been completed.

First, let’s add a line in our application template that allows us to add content inside the <head> tag.
Add this to app/views/layouts/application.html.erb, right before the </head> tag:

<%= yield :head %>

Next, let’s add the Javascript code we want to run on the init_mfa page:

// app/javascript/packs/init_mfa.js

import Rails from "@rails/ujs";
let check_status = window.setInterval(function() {
  Rails.ajax({
    type: "GET",
    url: "/mfa/check",
    success: function(r) {
      if (r !== "sent") {
        Rails.ajax({
          type: "PATCH",
          url: "/mfa/finalize",
          success: function() {
            window.clearInterval(check_status);
            window.location.href = "/";
          },
          error: function() {
            console.log("Promoting PushAuth status failed.");
          }
        });
      }
    },
    error: function() {
      console.log("Checking for PushAuth status failed.");
    }
  });
}, 2000);

This will poll /mfa/check every 2 seconds, until the Rails app reports that the PushAuth™ request has been accepted, rejected, or expired. At this point, the browser will ask the Rails app to complete the login process by submitting a /mfa/finalize request.

Now, let’s add a view file for init_mfa that includes the Javascript above.

<!-- app/views/sessions/init_mfa.html.erb -->

<% content_for :head do %>
  <%= javascript_pack_tag 'init_mfa' %>
<% end %>

<div class="spinner-border" role="status" ></div>

Waiting for a response to the push notification...

Finally, we will add new mfa routes.

# Add these to config/routes.rb

  get "mfa", to: "sessions#init_mfa"

  get "mfa/check", to: "sessions#check_mfa"

  patch "mfa/finalize", to: "sessions#finalize_mfa"

Congratulations! We have now integrated UnifyID’s PushAuth™. The final result should function just like the pushauth-sample-server project, which we introduced in our How to Implement PushAuth™: Web Server post. Please reach out to us if you have any questions, comments or suggestions, and feel free to share this post.

Share This Post