Intro
I find passwordless authentication to be a better alternative to normal authentication with login and password because it really annoys me every time to generate and save a new password. So I am always looking for better alternatives. For example, social authentication. I wrote about it here.
This particular method is another way to request registration via email, inspired by the authentication-zero library, but I’ve made it simpler and more straightforward.
For this tutorial, I aim to provide an extensive explanation, addressing the questions I’ve posed to myself, much like I would ask a teacher while learning.
The Difference Between Authentication and Authorization
Actually, even I thought these two words were synonymous… But it turned out to be like bricks, layers of the security system of your application.
Authentication: Who Are You?
Imagine your application is a nightclub. Authentication is like checking IDs at the door. The bouncer verifies the ID (username and password) to confirm you are who you say you are. If your ID is valid, you’re authenticated and allowed to enter.
Authentication involves verifying user credentials during login or registration. Rails provides features like user models and password hashing to securely handle this process.
Authorization: What Can You Do?
Now that you’re inside the club (authenticated), authorization determines what privileges you have. In the club, maybe VIPs have a separate area. Here, authorization checks if you have the necessary permissions (VIP status) to access specific areas or features.
In Rails, authorization involves defining rules and assigning roles to users. You can then use middleware or gates to restrict access to routes or functionalities based on these permissions.
Analogy Together: Security Layers
Think of authentication and authorization as two security layers working together. Authentication verifies the user identity (like the ID check), and authorization determines what actions this authenticated user can take within the system (like VIP access). Both are crucial for securing your Rails application.
The algorithm
Let’s first understand the algorithm from both sides before we jump into implementation. For us, there is no difference between registration and login, which is another big plus for the passwordless method.
The logic of passwordless authentication from the user’s perspective is as follows:
- A user attempts to access a page restricted to unregistered users.
- The user is redirected to the “login” page.
- On the login page, the user enters their email address.
- After submitting the email address, the user receives an email containing a magic link.
- The user clicks on the magic link in the email, which authorizes them.
- Upon authorization, the user is redirected to the necessary page for registered users.
- Then we also need to handle the logout case.
From the Ruby on Rails developer perspective, the algorithm looks as follows:
- We need 3 models for our auth: User, SignInToken, and Session.
- The User model will only need an email (no password).
- SignInToken is a token needed only for the verification process. It will appear in the email in the form of a magic link and should be deleted after the user successfully uses this link for login.
- The Session model will handle the user’s current session once they’ve successfully authenticated using the magic link sent to their email.
- When a user requests to login (by clicking the button “Send email”), we generate a unique token (the SignInToken) and send it to the email address in the following format:
http://yourapp.com/verify?sid=SignInToken
. - The user clicks on the magic link in the email, which directs them back to our application with the token embedded in the URL.
- Upon receiving the request with the token, we validate it against the corresponding record in the SignInToken model. If the token is valid and hasn’t expired, we proceed to create a new session for the user.
- Once the session is created, the user is considered authenticated and is redirected to the page for authenticated users.
- Then we implement the method of authorization so that we can restrict unverified users from accessing certain pages.
- Similarly, we implement the logout method.
Implementation. Authentication and writing cookies
Let’s start by making necessary models for our auth: User, SignInToken and Session.
rails g model user email:string
rails g model session user:references
rails g model sign_in_token user:references
In User model we need to add the following code:
# models/user.rb
class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :sign_in_tokens, dependent: :destroy
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email, with: -> { _1.strip.downcase }
end
To start, we define the connections between users and their sessions, as well as their sign-in tokens. These associations ensure that when a user account is deleted, any associated sessions or tokens are also removed, keeping our database tidy and secure.
Next, we enforce email validation by checking for its presence, uniqueness, and correct format.
Lastly, we normalize the email addresses to a consistent format. By removing extra spaces and converting all characters to lowercase, we guarantee uniformity across user entries, simplifying future data management tasks.
Next we need to create a Session controller and here will be the main logic of our auth.
rails g controller sessions new create verify destroy
Now, before we begin updating the controller, let’s add a route to our login page get 'login' => 'sessions#new', as: :login
and your routes.rb file should look like this:
# routes.rb
Rails.application.routes.draw do
get 'sessions/new'
get 'sessions/create'
get 'sessions/verify'
get 'sessions/destroy'
# We added the route for login
get 'login' => 'sessions#new', as: :login
get 'up' => 'rails/health#show', as: :rails_health_check
end
Then let’s open our controller and add the auth magic(create method):
# controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = User.find_or_create_by(email: params[:email])
# TODO: Add send email logic
redirect_to login_path, notice: "Check your email for a link to sign in"
end
def verify
end
def destroy
end
end
The create
method is triggered when the user fills out the form with their email and clicks the “Verify email” button.
Firstly, using the find_or_create_by
method, we attempt to locate a user in the database based on the email address provided in the request parameters (params[:email]
). If a user with that email address already exists, it is assigned to the user variable.
If no user is found, a new user record is created. The new user’s email address is set based on the value in params[:email]
.
Next, we should send an email with a special link to the user’s email address. However, since we haven’t generated a Mailer for our user yet, we will add this to our TODO list for now.
Finally, we utilize the redirect_to
helper method to render a notice message if the email is sent correctly.
Now it’s time to create our user Mailer. In terminal generate:
rails g mailer User passwordless
There will be created 3 files: mailers/user_mailer.rb
, views/user_mailer/passwordless.html.erb
and views/user_mailer/passwordless.text.erb
Go to the UserMailer file and let’s write the email logic.
# mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def passwordless
@user = params[:user]
@signed_id = @user.sign_in_tokens.create.signed_id(expires_in: 1.day)
mail to: @user.email, subject: 'Your sign in link'
end
end
Here, we retrieve the user from the database based on the email provided in the request parameters (params[:user]
).
We also create a new instance of SignInToken for the user and hash it using the public method signed_id
. This signed id is tamper-proof, making it safe to send in an email or share with the outside world. It can be set to expire (the default is not to expire) and scoped down with a specific purpose. If the expiration date has been exceeded before find_signed
is called, the id won’t find the designated record.
This is essentially the magic of Ruby on Rails. It handles all encryption by itself, allowing us to simply use the signed_id
in our email. To verify this hashed token, we’ll need to use another public method, as you may guess, find_signed
.
If you have a question like, “Where is this signed_id
stored if our SignInToken model has nothing but user:references
?”
Well, I had the same question, and the answer is, “it’s not stored in the database at all.” In fact, it’s generated in the email itself and printed here just once.
Now that we’ve configured how our email should be generated, it’s time to fix its view.
<%# views/user_mailer/passwordless.html.erb %>
<p>Hey there,</p>
<p>You requested a magic sign-in link. Here you go:</p>
<p><%= link_to "Sign in without password", verify_sessions_url(sid: @signed_id) %></p>
In the email, as you can see, we generate the URL based on the verify method of our session controller (which is defined but has no logic inside yet, but it will soon). And at the end of the URL, there will be a hash that we defined in the previous step, (sid: @signed_id)
.
Now let’s go to our sessions_controller and write the verify method and fix our TODO!
# controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new; end
def create
User.find_or_create_by(email: params[:email])
# replace TODO with actual email
UserMailer.with(user:).passwordless.deliver_later
redirect_to login_path, notice: 'Check your email for a link to sign in'
end
# add this method
def verify
begin
user = SignInToken.find_signed!(params[:sid]).user
rescue StandardError
redirect_to(login_path, alert: 'Invalid or expired token')
return
end
session_record = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session_record.id, httponly: true }
user.sign_in_tokens.delete_all
redirect_to root_path, notice: 'Successfully signed in'
end
def destroy; end
end
First of all, we added UserMailer in the create
method since we added the mailer and the email view.
Secondly, we added the verify
method. Here is the breakdown of what is happening here line by line:
- First, we validate the token that was passed through email. As I already mentioned, to validate something that has a signed_id, we use the
find_signed
method. This way, we find the user associated with the token. - Also, we handle invalid and expired tokens under
rescue StandardError
. If the token is valid, a new Session record is created and associated with the user (session_record
). This session record will be used to identify the user’s authenticated state. - Now, cookies. We set a signed, permanent cookie named
session_token
with the value of the newly created session record’s ID. Thehttponly: true
option ensures that the cookie can only be accessed by the server, preventing client-side scripts from reading or modifying the cookie. - After checking the token and creating the session, we delete all the SignInToken records associated with the user using the
delete_all
method. This step is typically done for security reasons, as it ensures that any previously issued tokens are invalidated after successful login. - Finally, the user is redirected to the
root_path
(or any other path of your choice for logged-in users) with a notice message indicating that the sign-in process was successful.
I don’t have a root_path
just yet, so I created Home controller and home#index
path. To make this tutorial somewhat shorter, I’ll skip these steps with creating the Home controller and view. Hopefully, you won’t have a hard time going through this step on your own. Then, I add the home route to the routes.rb
file:
# routes.rb
Rails.application.routes.draw do
get 'sessions/new'
get 'sessions/create'
get 'sessions/verify'
get 'sessions/destroy'
get 'login' => 'sessions#new', as: :login
get 'up' => 'rails/health#show', as: :rails_health_check
# add root page
root 'home#index'
end
And the last thing we have to setup is :host parameter in config/environments/development.rb
and in the future specify the host in config/environments/production.rb
:
Rails.application.routes.default_url_options[:host] = 'localhost:3000'
So here we should stop and think… as we’ve basically created an authentication system with email, it should work, and we can check it somehow. The last thing is to create a view with the basic auth form:
<%# views/sessions/new.html.erb %>
<h1>Sign in</h1>
<%= form_with(url: sessions_path) do |form| %>
<div>
<%= form.label :email, style: 'display:block' %>
<%= form.email_field :email, required: true, autofocus: true %>
</div>
<div>
<%= form.submit "Verify email" %>
</div>
<% end %>
Now I think we’ve finished about 2/3 of our main task. We can now run the server and check the login page.
bin/rails s
Oops, we’ve just encountered an error.
It happens because we haven’t properly defined the routes in our application. Let’s fix it.
# routes.rb
Rails.application.routes.draw do
get 'login' => 'sessions#new', as: :login
get 'up' => 'rails/health#show', as: :rails_health_check
# define sessions_path
resources :sessions, only: %i[create destroy] do
get :verify, on: :collection
end
root 'home#index'
end
Here’s what each part of the code does:
resources :sessions, only: %i[create destroy]
: This line creates RESTful routes for the SessionsController, but it only creates the create and destroy actions. The only
option is used to limit the routes to just these two actions.
get :verify, on: :collection
: This line defines an additional route for the verify action in the SessionsController. The on: :collection
option specifies that this action is a collection route, meaning it doesn’t require an ID parameter.
By defining these routes, Rails automatically generates several route helpers, including:
sessions_path
: This helper corresponds to the path for the create action, which is typically used for submitting a new session (logging in).session_path(id)
: This helper corresponds to the path for the destroy action, which is typically used for logging out. It requires an id parameter.verify_sessions_path
: This helper corresponds to the path for the verify action, which you defined as a collection route.
When you use <%= form_with(url: sessions_path) %>
in your view, Rails knows to generate the correct URL for the create action of the SessionsController because you defined the resources :sessions
routes.
By properly defining the routes for your SessionsController, Rails can generate the necessary route helpers, including sessions_path
, which resolves the undefined local variable or method ‘sessions_path’ error you were encountering.
Now, if we go to 127.0.0.1:3000/login
, we should see our login page.
And now, if we enter an email and receive the verification link, we should be logged in and redirected to the path for authenticated users.
Authorization and reading cookies
For now, all our application is accessible by anyone by default. So our task for now is to restrict access to the root_path
and redirect the user to the login page until verification.
We need to know whether the user is authenticated or not. We can do this by checking the presence (or actually reading) of a session cookie. Navigate to the ApplicationController and add the following code:
# controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate
private
def authenticate
if (session_record = Session.find_by(id: cookies.signed[:session_token]))
Current.session = session_record
else
redirect_to login_path
end
end
end
On the application level, we define the method authenticate
, where we read cookies and assign a session if present; otherwise, the user will be redirected to the login page.
To make this work, I utilize the Current
class. This is a custom class used to store and manage global data (data that needs to be accessible from anywhere in the application).
You need to create a new file in the models
folder called current.rb
and add this code:
# models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
By assigning the session_record
to Current.session
in ApplicationController, you’re making that session object available throughout the application, thanks to the Current
class you defined earlier. Since you also delegated the user
method to the session
attribute in the Current
class (delegate :user, to: :session, allow_nil: true
), you can now access the user associated with the current session by calling Current.user
from anywhere in your application.
One last thing is to add before_action
where it is necessary.
# controllers/home_controller.rb
class HomeController < ApplicationController
before_action :authenticate
def index; end
end
And we also need to skip before_action where the authentication is required.
# controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :authenticate, only: %i[new create verify]
# rest of the code
Now, if we reload our Rails server and delete cookies, then try to access the home page, we will be redirected to the login page. Once we log in, we will be able to access the home page.
The last part of this article will be the shortest one: how to log out from our app.
Delete sessions and cookies
To delete the session from the database and log out the user, we need to write our destroy
method in our sessions_controller
.
# controllers/sessions_controller.rb
def destroy
Current.user.sessions.find(params[:id]).destroy
redirect_to(login_path, notice: "You're logged out")
end
As we now have a Current helper, we know who this current user is and whose session we should destroy.
Now let’s write a simple logout button right on our index#home page.
# views/home/index.html.erb
<%= button_to "Log Out", session_path(Current.session), method: :delete, class: "btn btn-danger" %>
That’s it! Test it out. Log in and log out should work as expected. Now with the same logic you can add a list of sessions and make it possible to delete any session of your user, not only the current one.
I also leave the authmagic code here for you reference.
Happy coding!