If you've dealt with authentication in a Rails app before, you've probably used a gem like devise
to add the feature in a couple of hours and move on to the next thing.
However, the release of Rails 8 came with many changes that philosophically nudge the framework to developer empowerment to reduce the need for third-party code.
That's why Rails 8 comes with an authentication generator to help us add the feature to any application pretty quickly while following Rails conventions and owning the authentication code.
In this article, we will add authentication to a Rails app using the generator and some custom code on top of it to improve the default provided auth.
What we will build
We will build a simple job listing application where listing details can only be accessed by authenticated users.
Beyond e-mail & password authentication, we will also add an email and password sign up flow
Even though this is not as feature-complete as devise, it is a step closer to showing that we can add as many features as we require as long as we build upon the foundations and concepts provided by the auth generator.
The final result should look something like this:
Application set up
The first step is to create a new Rails app using Tailwind:
$ rails new joblist --css=tailwind
Next, we run the db setup and migrations:
$ bin/rails db:setup db:migrate
We will have a single model called JobListing
so we add it:
$ bin/rails generate scaffold JobListing name description:text
Next, we migrate the database:
$ bin/rails db:migrate
After running this, and adding some custom styling, we end up with something like this:
Of course, this is just a placeholder for the home page. We will be adding the rest of the functionality as we progress with the tutorial.
Now, we will analyze the generated code to better understand the auth flow. You can skip to the tutorial itself if you already know about the code.
Understanding the generator
The first thing to understand is that Rails 8 authentication is not meant to be a stand-in replacement for something like devise, at least not for now.
On the contrary, it's meant to kickstart our app's authentication and leave us with a solid foundation to build starting from some solid architectural choices.
Out of the box, it comes with two main features: user sign in and password recovery.
This leaves us with the task of building our sign-up flow and any other feature we want like social login, magic links login, phone authentication, account confirmation, account locking, etc.
If this is a dealbreaker for you, you might consider using a gem.
But, consider that 'owning' the authentication flow means that customization will be easier, and believe me: it's very probable that your auth flows will require customization down the road.
Now that we have that out of the way, let's focus on the architecture:
The generator's architecture
When we run the bin/rails generate authentication
command, Rails generates quite a bit of code for us: controllers, controller concerns, views, mailers, and even a Connection
channel to use with ActionCable.
However, to understand how everything works we must focus on the models it generates: User
, Session
and Current
. The first two are db-backed:
Out of the box, the users
table stores an e-mail address and a password_digest
which is used to compare the password provided at sign-in with the password used to create the user.
On the other hand, the sessions
table belongs to a User
and, out of the box, stores the ip_address
and user_agent
information for every session started by the User
which is useful by itself but also allows us to build upon this concept in the future if we need to.
The Current
model is a singleton that inherits from ActiveSupport::CurrentAttributes
that lets us access the user associated with the session present in a given request cookies:
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
Whenever we call Current.user
we access the User
instance associated with the session via the user_id
foreign key in the sessions
table.
This is very similar to the view-accessible current_user
global method defined by devise
so we can use it in our views or controllers whenever we need to check whether a request comes from a logged-in user or not.
The Authentication concern
The heart of the authentication flow with the Rails 8 generator is definitely the Authentication
controller concern.
This module, which is 52 lines of code, adds everything we need to authenticate users within our application.
It defines two important methods which are require_authentication
and the helper method authenticated?
.
As its name suggests, the require_authentication
method makes sure the request has a signed cookie that defines the session_id
which is used to retrieve a session or return the Current.session
in case the session was previously defined in the request lifecycle.
On the other hand, the authenticated?
method is an alias to the resume_session
method, which returns the current session or finds a session by cookie.
There's also a private terminate_session
method which basically destroys the Current.session
and deletes the session_id
cookie to make sure the user is currently signed out of our application.
One thing to consider with this concern is that it handles the typical redirect after sign in: it keeps track of the URL that generated the sign in request in the return_to_after_authenticating
cookie and saves us from manually defining that common redirection flow.
The sessions controller
This controller is responsible for creating and destroying sessions. In other words: it's responsible for signing users in and out of our application.
It defines three actions:
- The
new
action, which is responsible for rendering the sign-in form. - The
create
action, which authenticates a user and persists the session in the database. - The
destroy
action, which is responsible for terminating a session whenever a user wants to sign out of our application.
Note that, to make this all work, a single resource :session
is defined in the routes file which defines 6 endpoints. However, the show
and edit
actions are not defined in the controller, so we can modify the route to resource :session, only: [:new, :create, :destroy]
to keep our routes cleaner.
The passwords controller
This controller is responsible for the password recovery flow.
It defines four actions:
- The
new
action: responsible for rendering the password recovery form. - The
create
action: responsible for sending the password reset email using thePasswordsMailer.reset
. - The
edit
action: renders the password edit form if the correcttoken
is present in the URL. The token is a signed JWT which contains the user's ID, and it has a 15-minute expiration date. If the token is not present or is invalid, the user gets redirected to thenew
action. - The
update
action is where the actual update happens given the password and its confirmation match.
The routes
The generator adds the following to our routes.rb
file:
# config/routes.rb
resource :session, only: %i[new create destroy]
resources :passwords, param: :token
This generates the necessary routes for the sign-in and password reset flows.
However, to remove unused routes, we can modify the passwords
routes:
resources :passwords, only: %i[new create edit update], param: :token
We don't really need to access the index, show or destroy actions for this resource in particular.
The views
The generator creates the necessary views for the sign-in and password recovery flows.
Namely, it generates views for the sign in at app/views/sessions/new.html.erb
and another for the password recover at app/views/passwords/new.html.erb
.
After minimal styling, adding a padding, borders and centering them, is applied they look like this:
We also get a reset mailer view generated at app/views/password_mailer/reset.html.erb
which might not the prettiest, but it does its job:
Sign in flow
The first step to add authentication with the Rails generator is to actually run it:
$ bin/rails generate authentication
This will generate all the necessary code and database migrations to add a sign-in feature to our application.
Note that if we created our app with the --css=tailwind
argument, the views will be decently styled to kickstart the process.
The command will create a User
and Session
models and a Current
class to access the current user whenever we need it.
It will also add endpoints, controllers and their corresponding views to handle session creation (sign-in) and the password recovery flows.
Unless we need to modify the custom sign-in flow with the addition of extra fields or any other personalization, we're basically good to go: users can log in to our application using their existing account information.
However, consider that, by default, every route in your application will be protected, and you might not want that.
To allow a specific endpoint or controller to be accessed without authentication, we can add the following to the controller file:
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
allow_unauthenticated_access only: [:home]
before_action :resume_session, only: [:home]
def home
@job_listings = JobListing.all
end
end
Notice that the resume_session
method call is necessary for this to work because it's the method required to retrieve the current session. Otherwise, even if we were signed in the view wouldn't be able to access the Current.user
.
To add a sign-out action we need to add a button that makes a DELETE
request to the /session
endpoint.
Somewhere in our app, we should add something like this:
<% if Current.user.present? %>
<%= button_to "Sign out", session_path, method: :delete, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-black underline focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% else %>
<%= link_to "Sign in", new_session_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %>
Notice that we have to add the button_to
helper for it to work correctly.
Flash messages
Even though some actions like sessions#create
include a flash message for the scenario where no user is found, there are a couple of places were flash messages can help our users.
The first place is in the request_authentication
method that resides in the Authentication
concern:
# app/controllers/concerns/authentication.rb
def request_authentication
session[:return_to_after_authenticating] = request.url
flash[:alert] = "You must be logged in to access this page"
redirect_to new_session_path
end
We add the flash message to notify our users that they must be logged to access a given page.
The other place is in the destroy
action of the SessionsController
:
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def destroy
terminate_session
flash[:notice] = "You have been signed out."
redirect_to new_session_path
end
end
We then add a partial to display flash messages to give better feedback to our users.
After adding the flash messages, we should have a working authentication flow:
Password recovery flow
The password reset flow also comes out of the box with the Rails 8 auth generator.
To test it in development, we can add the letter_opener
gem to our Gemfile and configure ActionMailer to intercept emails:
# Gemfile
group :development do
gem "letter_opener"
end
# config/development.rb
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true
We can access the password reset screen at /passwords/new
and a link to it is actually present in the views provided by the generator.
When a user clicks in the link received in the password reset email, which contains a signed and expiring token, the password edit form gets rendered, and it allows the user to update the password.
The password flow looks like the following:
Sign up flow
The Rails authenticator doesn't include a sign-up flow, mostly because it's a very application-dependent flow.
But, adding a user sign-up flow to our app is actually not that difficult.
Before anything, we need to add validations to our User
model, considering that the default auth generator doesn't any.
By default, we need an email_address
and a password
to sign in to our application, so we need to make sure both of those fields are required and valid.
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
Then, we add a registration
endpoint:
# config/routes.rb
resource :registrations, only: [:new, :create]
Next, we define a RegistrationsController
and define an action to render the sign-up form:
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
resume_session only: :new
def new
@user = User.new
end
end
We then add a view to handle user sign up, we will have fields for email, password, and password confirmation:
# app/views/registrations/new.html.erb
<div class="mt-24">
<div class="max-w-2xl mx-auto md:w-2/3 w-full">
<div class="px-6 py-4 border border-gray-300 rounded-xl">
<h1 class="font-bold text-2xl">Sign up</h1>
<p class="text-base text-gray-500">
Sign up to create your account and start posting jobs.
</p>
<%= form_with url: registrations_url, model: @user, method: :post, class: "contents" do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.password_field :password_confirmation, required: true, autocomplete: "current-password", placeholder: "Password confirmation", maxlength: 72, class: "block shadow rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
<div class="flex w-full items-center justify-between">
<div class="inline">
<%= form.submit "Sign up", class: "rounded-lg py-2 px-6 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<div class="mt-4 text-sm text-gray-500 sm:mt-0">
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline" %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
Then, we add the sign-up logic to the create
action of the registrations controller:
class RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
before_action :resume_session, only: %i[new create]
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
start_new_session_for @user
redirect_to root_path, notice: "You've successfully signed up to Joblister. Welcome!"
else
populate_flash_with_errors(@user)
render :new, status: :unprocessable_entity
end
end
private
def user_params
params.expect(user: [:email_address, :password, :password_confirmation])
end
def populate_flash_with_errors(user)
if password_confirmation_matches? && user.errors.any?
flash[:alert] = user.errors.full_messages.join(", ")
elsif !password_confirmation_matches?
flash[:alert] = "Password and password confirmation don't match"
end
end
def password_confirmation_matches?
user_params[:password] == user_params[:password_confirmation]
end
end
Now, if we go through the sign-up flow, we can register users within our application.
Of course, this flow can be vastly different depending on your application's requirements, I just wanted to demonstrate how a basic devise-like sign-up flow would work.
You can modify the view or the controller action as you wish in case your application calls for more fields or a different sign-up flow.
If you've been following, the sign-up flow should look something like this:
TL;DR
Rails 8 added an authentication generator that, in principle, allows us to get rid of gems like devise
and build auth by our own.
It generates two db-backed models: User
and Session
which help us keep track of the actual users and their session
It also generates a Current
class that inherits from ActiveSupport::CurrentAttributes
that allows us to access the currently logged-in user using the Current.user
method.
Besides the models it also adds an Authentication
concern that handles most of the logic and the corresponding controllers, endpoints, and views to handle the basic sign-in and password recovery flows.
After we run the generator, we should be able to sign in or reset passwords for any existing user in our application.
To have a better UX, we should also add flash messages to the request_authentication
method in the Authentication
concern and to the destroy
session endpoint.
To add a sign-up flow, we just need to create a RegistrationsController
with its corresponding endpoint and view to render the form where users can input their data and sign-up to our application.
After creating the user we just call the start_new_session_for
method and pass the User
instance to log the user in.
As you can see, the Rails 8 auth generator produces a practical way to handle user authentication and allows us to replace gems like devise
which can be pretty heavy, especially if our auth needs are not that advanced.
Having to manage the code by ourselves can seem daunting at first but, eventually, it probably results in a more maintainable application because we are forced to understand what's happening behind the scenes.
I hope this article helped you understand and improve your authentication code. Stay in tune for more tutorials like this.
Have a good one and happy coding!