Authentication is one of the key aspects of many web applications. It is the process of identifying a person before granting them access to the application. It is very important that the authentication approach is secure and easy to use for all users.
What is passwordless authentication?
Passwordless authentication is a verification process that determines whether an individual is who they claim to be, without coercion. You do not require credentials to log in. All you need is an email address or phone number associated with an account and you will get a magic link or one-time password each time you want to log in. Once you click on the link, you will be redirected to the application and you will already be logged in. After that, the magic link or one-time password will no longer be valid and no one else can use it.
There are several ways of implementing passwordless authentication.
- Magic Link
- SMS/Email One Time Pin Code
- Biometric Verification
In this article we will be focusing on Passwordless Authentication using Magic Link.
What are the Benefits of Passwordless Authentication?
Improve user experiences – Users do not have to remember and manage usernames & passwords for different applications.
Strengthen security – Applications do not have the overhead of storing the credentials. This also eliminates data breaches, password thefts, brute-force attacks and other password-hacking attempts.
Recently I worked on an application that allowed a user to share the contract links with other users. I had to track the identity(email) of the user who accessed the shared link without introducing the traditional approach of email/password. So I got an opportunity to implement Passwordless Authentication via Magic Link. I'll be sharing the implementation in this article. The entire example code used in this article is available on GitHub, feel free to fork and play with it.
Authentication through Magic Link
The Application which I worked on, uses the API to serve the data to a Client application. The diagram below explains the User-Client-API-Email flow.
Let's write some code to Authenticate using Magic Link
Rails API application
-
Create Rails API App
$ rails new passwordless-authentication-api --api $ cd passwordless-authentication-api
-
Add User Model
$ rails g model user name email:uniq login_token login_token_verified_at:datetime $ rails db:create && rails db:migrate
name
field is optionalemail
field is required and unique, which enables users to authenticate with the application.login_token
field is used to store the token which is shared with the user to verify their identity.login_token_verified_at
field for storing the time when the user proves their identity.
Let's add validation for the
email
field.# app/models/user.rb validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { case_sensitive: false }, presence: true
Also, let's downcase the emails before saving them to the database.
# app/models/user.rb before_save { self.email = email.downcase }
-
Integrate
jwt
Gem to generate token
We will be using thejwt
Gem to generate the token. Refer to Authenticate Your Rails API with JWT from Scratch for a thorough explanation.Let's add
jwt
Gem to Gemfile and runbundle install
.# Gemfile gem 'jwt'
Once done, create a file called
json_web_token.rb
underlib
and add following code snippet:# lib/json_web_token.rb require 'jwt' class JsonWebToken # Encodes and signs JWT Payload with expiration def self.encode(payload) payload.reverse_merge!(meta) JWT.encode(payload, Rails.application.secrets.secret_key_base) end # Decodes the JWT with the signed secret def self.decode(token) JWT.decode(token, Rails.application.secrets.secret_key_base) end # Validates the payload hash for expiration and meta claims def self.valid_payload(payload) !(expired(payload) || payload['iss'] != meta[:iss] || payload['aud'] != meta[:aud]) end # Default options to be encoded in the token def self.meta { exp: 1.day.from_now.to_i, iss: 'issuer_name', aud: 'client', } end # Validates if the token is expired by exp parameter def self.expired(payload) Time.at(payload['exp']) < Time.now end end
-
Add an endpoint to accept the user's email and send them a magic link to access the application.
$ rails g controller api/v1/authentication
Add the below routes
# config/routes.rb: namespace :api do namespace :v1 do post 'authentication/create' end end
# app/controllers/api/v1/authentication_controller.rb class Api::V1::AuthenticationController < ApplicationController def create # the user might already exist in our db or it might be a new user user = User.find_or_create_by!(email: params[:user][:email]) user.send_magic_link head :ok end end
# app/models/user.rb def send_magic_link generate_login_token UserMailer.magic_link(self, login_link).deliver_now end # generates login token to authorize user def generate_login_token # create a login_token and set it up to expire in 60 minutes payload = { email: email, exp: 1.hour.from_now.to_i } # set login_token to validate last sent login token self.login_token = generate_token(payload) save! end # returns the magic link which is to be included in the email def login_link Rails.application.routes.url_helpers.api_v1_sessions_create_url(login_token: login_token, host: 'localhost:3000') end private def generate_token(token_payload) JsonWebToken.encode(token_payload) end
UserMailer
to send a magic link to the user's email# app/mailers/user_mailer.rb class UserMailer < ApplicationMailer def magic_link(user, login_link) @user = user @login_link = login_link mail to: @user.email, subject: 'Sign In to mywebsite.com' end end
# app/views/user_mailer/magic_link.html.erb <p>Hello <%= @user.email %>,</p> <%= link_to "Confirm", @login_link %>
-
Add sessions endpoint to verify the magic link and return the auth token. Then the client application can use the auth token to access authenticated API endpoints.
$ rails g controller api/v1/sessions
Add the below routes
# config/routes.rb: namespace :api do namespace :v1 do post 'sessions/create' end end
# app/controllers/api/v1/sessions_controller.rb class Api::V1::SessionsController < ApplicationController def create login_token = params[:login_token].to_s decoded_token = JsonWebToken.decode(login_token) if decoded_token && JsonWebToken.valid_payload(decoded_token.first) user = User.find_by(login_token: login_token) if user render json: { auth_token: user.generate_auth_token } else render json: { error: 'Invalid Request' }, status: :unauthorized end else render json: { error: 'Invalid Request' }, status: :unauthorized end end end
# app/models/user.rb # generates auth token to authenticate the further request once user is authorized def generate_auth_token self.login_token = nil self.login_token_verified_at = Time.now self.save payload = { user_id: id, login_token_verified_at: login_token_verified_at, exp: 1.day.from_now.to_i } generate_token(payload) end
-
Add logic to verify the token before accessing the request
# app/controllers/application_controller.rb class ApplicationController < ActionController::API private # Validates the token and user and sets the @current_user scope def authenticate_request! if !payload || !JsonWebToken.valid_payload(payload.first) return invalid_authentication end load_current_user! invalid_authentication unless @current_user end # Returns 401 response. To handle malformed / invalid requests. def invalid_authentication render json: { error: 'Invalid Request' }, status: :unauthorized end # Deconstructs the Authorization header and decodes the JWT token. def payload auth_header = request.headers['Authorization'] token = auth_header.split(' ').last JsonWebToken.decode(token) rescue nil end # Sets the @current_user with the user_id from the payload def load_current_user! @current_user = User.find_by(id: payload[0]['user_id']) end end
We can add the
authenticate_request!
method in thebefore_action
of any controller, for the actions that we want to be authenticated.Now, we have added the authentication layer to the application.
-
Add contract endpoints for which we need users to be authorized.
$ rails g model contract title terms:text
Let's add API to return the contract details.
$ rails g controller api/v1/contracts
# config/routes.rb: namespace :api do namespace :v1 do resources :contracts, only: :show end end
# app/controllers/api/v1/contracts_controller.rb class Api::V1::ContractsController < ApplicationController before_action :authenticate_request! def show @contract = Contract.find(params[:id]) render json: @contract end end
-
Now, let's explore the endpoints with curl
First, check the response for
contracts
API without authentication$ curl -X GET http://localhost:3000/api/v1/contracts/1 { "error" : "Invalid Request" }
As expected, since we did not pass the token, it returned an error message.
Now, let's authenticate ourselves in order to access the
contracts
API.First, we will need to provide an email address where we can receive the verification email.
$ curl http://localhost:3000/api/v1/authentication/create -H 'content-type: multipart/form-data' -F 'user[email]=sampat.badhe@kiprosh.com'
For this example, I am checking email content from the logs.
Delivered mail 60bd9e6b4368c_bcf346c709f7@Sampat.local.mail (8.3ms) Date: Mon, 05 Jun 2021 09:49:55 +0530 From: passwordless_authentication@example.com To: sampat.badhe@kiprosh.com Message-ID: <60bd9e6b4368c_bcf346c709f7@Sampat.local.mail> Subject: Sign in into mywebsite.com Mime-Version: 1.0 Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 7bit <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <style> </style> </head> <body> <p>Hello sampat.badhe@kiprosh.com,</p> <a href="http://localhost:3000/api/v1/sessions/create?login_token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMwNDMxOTUsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwiZW1haWwiOiJzYW1wYXQuYmFkaGVAZXhhbXBsZS5jb20ifQ.uWG-Nl8wxiI75OcK8asdHO-mTTm5JMZapB0GqqpAwSY">Confirm</a> </body> </html>
Now, as we have received the email, let's access the magic link URL using curl.
$ curl -X POST http://localhost:3000/api/v1/sessions/create?login_token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMwNDMxOTUsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwiZW1haWwiOiJzYW1wYXQuYmFkaGVAZXhhbXBsZS5jb20ifQ.uWG-Nl8wxiI75OcK8asdHO-mTTm5JMZapB0GqqpAwSY { "auth_token" : "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMxMjY0MzYsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwidXNlcl9pZCI6MiwibG9naW5fdG9rZW5fdmVyaWZpZWRfYXQiOiIyMDIxLTA2LTA3IDA0OjI3OjE2IFVUQyJ9.Z3eZSxJsWYpLLvSNFVggwYzPQ-oqoBkfJBXgVXQcEXc" }
Once the magic link is verified, we will get an
auth_token
which can be stored by the Client applications to access authenticated endpoints.Now, let's access the
contracts
API again withauth_token
in the headers.$ curl http://localhost:3000/api/v1/contracts/1 -H 'Authorization: eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MjMxMjY0MzYsImlzcyI6Imlzc3Vlcl9uYW1lIiwiYXVkIjoiY2xpZW50IiwidXNlcl9pZCI6MiwibG9naW5fdG9rZW5fdmVyaWZpZWRfYXQiOiIyMDIxLTA2LTA3IDA0OjI3OjE2IFVUQyJ9.Z3eZSxJsWYpLLvSNFVggwYzPQ-oqoBkfJBXgVXQcEXc' { "created_at" : "2021-06-04T11:57:15.590Z", "id" : 1, "updated_at" : "2021-06-04T11:57:15.590Z", "terms" : "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "title" : "Sample Contract" }
Conclusion
There are a lot of different ways you can implement an authentication system for your application and passwordless is one of those. I hope you enjoyed this article and learned how to implement Passwordless Authentication via Magic Link using the Rails API.
Thank you for reading.