Part 1: User Signup
There are certainly a lot of amazing authentication options in the Rails ecosystems.
If I was building a large application with a lot of multi-user expectations, I would use Devise. It is a great solution and it has been tried and tested over a decade.
However, if I was building a small application that only needed a few users, I would probably build my own authentication system. That's what this post is about, just to teach you how to do it should you want to and to also explain how authentication works in Rails.
If you want to follow along, I've setup this application starting from Avis Rails Starter.
The User Model
The first step is to create your user model. This is a pretty standard model, but there are a few things to note. The first is that we are using the has_secure_password
method. This is a Rails method that will require a password_digest
column to store the encrypted user password.
rails g model user email:string password_digest:string
That will generate the appropriate migration
The next step is to add bcrypt
to your Gemfile. This is the gem that will handle the encryption of the password.
And then finally, to turn on has_secure_password
in our User
model. While we are here, we will also add a validation to make sure that the email is unique.
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
has_secure_password
end
The Signups Controller
I've seen people name the controller responsible for registration or signup a lot of things. I've seen UsersController
, RegistrationsController
, SignupsController
, and more. I personally like SignupsController
because it is the most descriptive. It is the controller that handles the signup process.
The RESTful way to do it would be the UsersControllers
but I like to leave that to actually managing the users, not the process of signing one up. I tend to think an application has a logical resource of a Signup
and should have a controller the deals specifically with that.
Let's make the controller and clean up the routes related to signup.
rails g controller signups new create
Rails.application.routes.draw do
resource :signup, only: [:new, :create]
end
We're using resource
as Signups
are a singular resource that require no show
or edit
type functionality.
New Signup Form
Let's now setup new#signups
and the registration form.
app/controllers/signups_controller.rb
class SignupsController < ApplicationController
def new
@user = User.new
end
end
Because we're using my rails starter which includes shadcn-ui, we can make a pretty style-ish registration form.
app/views/signups/new.html.erb
<%= render_form_for(@user, url: signup_path) do |f| %>
<div class="grid gap-2">
<div class="grid gap-1">
<div class="grid gap-1">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, placeholder: "name@example.com",
autocomplete: :email,
autocorrect: "email",
autocapitalize: "none" %>
</div>
<div class="grid gap-1">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password, placeholder: "Your password...",
autocomplete: "current-password" %>
</div>
<div class="grid gap-1">
<%= f.label :password_confirmation, class: "sr-only" %>
<%= f.password_field :password_confirmation, placeholder: "Confirm your password...",
autocomplete: "current-password" %>
</div>
<%= f.submit "Create Account" %>
</div>
</div>
<% end %>
render_form_for
is a thin wrapper on-top of form_for
that comes with the shadcn-ui library.
But because of Tailwind and shadcn-ui, or registration form looks like:
Create Signup Action
With the form complete, we can build out create#signups
. The responsibility of the rest of the signups
controller is to create the user by email and set the password information using the has_secure_password
helpers.
Note that even though the database has the column of password_digest
, the attributes we're writing too are password
and password_confirmation
because that's how has_secure_password
works. It uses those attributes to encrypt the password and make sure it matches the confirmation.
app/controllers/signups_controller.rb
class SignupsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
redirect_to root_path, notice: "You have successfully signed up!"
else
flash.now[:alert] = "There was a problem signing up."
render :new, status: 422
end
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
end
The happy path of registration should work now but lets handle the validation. The first thing is because our form works with Turbo, we have to send an error code status of 422, which is an unprocessable_entity
saying that if the User
instance fails validation, Turbo should re-render the response from the form submission within the current document.
Create Signup Form Validation
A lot of the frontend for validation is handled by render_form_for
decorating the fields with errors with an error class that the stylesheet will border red. But we can display the flash alert as a toast and spell out the validation errors.
The entire form ends up looking like:
<div class="container flex flex-row items-center justify-center">
<div class="my-20">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div class="flex flex-col space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Create an account</h1>
<p class="text-muted-foreground text-sm">Enter your email and your password below.</p>
<% if @user.errors.any? %>
<div class="text-left">
<%= render_alert variant: :error, title: "Failed to Create Account" do %>
<% @user.errors.full_messages.each do |message| %>
<p><%= message %></p>
<% end %>
<% end %>
</div>
<% end %>
</div>
<div class="grid gap-6">
<%= render_form_for(@user, url: signup_path) do |f| %>
<div class="grid gap-2">
<div class="grid gap-1">
<div class="grid gap-1">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, placeholder: "name@example.com",
autocomplete: :email,
autocorrect: "email",
autocapitalize: "none" %>
</div>
<div class="grid gap-1">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password, placeholder: "Your password...",
autocomplete: "current-password" %>
</div>
<div class="grid gap-1">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password_confirmation, placeholder: "Confirm your password...",
autocomplete: "current-password" %>
</div>
<%= f.submit "Create Account" %>
</div>
</div>
<% end %>
<p class="text-muted-foreground px-8 text-center text-sm">
By clicking continue, you agree to our
<a
class="hover:text-primary underline underline-offset-4"
href="/terms">Terms of Service</a>
and
<a
class="hover:text-primary underline underline-offset-4"
href="/privacy">Privacy Policy</a>.
</p>
</div>
</div>
</div>
</div>
<%= render_toast header: flash[:alert], variant: :destructive if flash[:alert] %>
Part 2: User Sessions
Now that users can signup for the application, let's let them login. I like to create a SessionsController
and draw the following routes to handle login and logout.
rails g controller sessions new create destroy
get "/login", to: "sessions#new", as: "login"
post "/sessions", to: "sessions#create"
get "/logout", to: "sessions#destroy", as: "logout"
Let's build the Login form.
Login Form
sessions#new
doesn't need anything special, a blank action is enough.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
end
The login from:
app/views/sessions/new.html.erb
<%= render_form_for(User.new, as: :user, url: sessions_path) do |f| %>
<div class="grid gap-2">
<div class="grid gap-1">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, placeholder: "name@example.com",
autocomplete: :email,
autocorrect: "email",
autocapitalize: "none" %>
</div>
<div class="grid gap-1">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password, placeholder: "Your password...",
autocomplete: "current-password" %>
</div>
<%= f.submit "Login" %>
</div>
<% end %>
The from is going to submit to sessions#create
. I'm only instantiating a User
to give my form something to wrap around and name fields correctly, but we're not creating a new User
. You could easily use form_tag
for this form.
Login Logic
The login logic is going to live in ApplicationController
and looks like this:
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
private
def sign_in(user)
session[:user_id] = user.id
end
def current_user
@current_user = User.find_by(id: session[:user_id]) if session[:user_id]
end
helper_method :current_user
def signed_in?
!!current_user
end
helper_method :signed_in?
end
These methods constitute the entirety of the login logic. sign_in
will store the user's id in the session. current_user
will load the user from the database from the session if it exists. You can check if a user is signed_in?
by the presence of current_user
.
This is why I like implementing my own authentication system. It's really not that much, that is the bulk of the key logic.
Logout Logic
We've already got a route ready for logging a user out. Let's implement the logic.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
# Rest of Controller
def destroy
session[:user_id] = nil # Or reset_session
redirect_to root_path, notice: "You have successfully logged out!"
end
end
Depending on whether you want to keep any of the other user session information or completely reset it, logging a user out is as simple as setting the session[:user_id]
to nil
or calling reset_session
.
Conclusion
That's a basic authentication system in rails, without any bells and whistles. In a follow up post I'll show you how you can build a password reset as well as implement omniauth for 3rd party OAuth login and magic login links.