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:
- Web Server tutorial
- iOS Mobile App tutorial
- 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 actionsGET /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 inauthorized
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 inPOST /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:
- The client submits the username and password via a
POST
request to /login
(sessions#create
) - The controller validates that the user exists and the password matches. If not, it displays an error message.
- Upon successful username/password authentication, the controller creates a PushAuth™ session and redirects to
GET /mfa
(sessions#init_mfa
) - 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. - 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.