Learn Rails 8 by Building Study Flow, Part 2: Authentication with the Built-in Generator
In Part 1 we generated a Rails 8 app and followed a request from route to controller. Today Study Flow gets its first real feature: users.
Before this Rails version, most people added a gem like Devise to get login. Rails 8 ships its own authentication generator. It writes plain Ruby code into your app, so you can read all of it, and that's exactly what we'll do.
What you'll learn
- Running the Rails 8 authentication generator
- What
has_secure_passworddoes for you - How a login works: the Session model, signed cookies and Current
- How
allow_unauthenticated_accessandrate_limitprotect your controllers - Adding a signup page, which the generator doesn't create, using
params.expect
The idea in one minute
A website needs to remember who you are between requests, because HTTP forgets everything after each one. The usual trick is a cookie: You log in with email and password. The app creates a session record in the database and puts its id in a cookie. On every later request, the browser sends the cookie back, and the app looks up the session to find the user.
Think of a coat check. You hand over your coat (password) once, get a numbered ticket (cookie), and every time you show the ticket, they know which coat is yours.
Step 1: Run the generator
bin/rails generate authentication
bin/rails db:migrate
Make sure bcrypt is enabled in your Gemfile (the generator uncomments it, then run bundle install):
gem "bcrypt", "~> 3.1.7"
The generator created a lot. Here's what matters:
app/models/user.rbapp/models/session.rbapp/models/current.rbapp/controllers/concerns/authentication.rbapp/controllers/sessions_controller.rbapp/controllers/passwords_controller.rbapp/mailers/passwords_mailer.rbapp/views/sessions/new.html.erbapp/views/passwords/...db/migrate/..._create_users.rbdb/migrate/..._create_sessions.rb
Let's go through them.
Step 2: The two tables
# db/migrate/..._create_users.rb
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
# db/migrate/..._create_sessions.rb
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
Two details are worth noticing: Users store a password_digest, never the password itself. It's a one-way hash. A Session is a database row, one per login, recording the IP and browser. Don't confuse it with Rails' built-in session hash. If you log in on your laptop and your phone, you get two rows, and you can end one without the other.
Step 3: The models
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
class Session < ApplicationRecord
belongs_to :user
end
Three lines of the User model do a lot: has_secure_password adds password and password_confirmation attributes, hashes the password with bcrypt into password_digest, and gives you authenticate_by and a password reset token. normalizes cleans a value before saving or querying. With this, "S***@Example.COM " becomes "s***@example.com", so users can't sign up twice with different capitalization. dependent: :destroy deletes a user's sessions when the user is deleted.
You can try it in the console:
bin/rails console
user = User.create!(email_address: "M*@Example.com", password: "secret123", password_confirmation: "secret123")
user.email_address # => "m*@example.com"
user.password_digest # => "$2a$12$..." (a hash, not the password)
User.authenticate_by(email_address: "m*@example.com", password: "secret123") # => the user
User.authenticate_by(email_address: "m*@example.com", password: "wrong") # => nil
Step 4: Current: a request-wide global, done safely
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
Controllers, views and models often need to know who's logged in. Current stores the current session for the duration of one request and then resets automatically. That gives you Current.session and Current.user anywhere in your app without passing them around. It's safe because it's cleared between requests. A normal global variable would leak between users.
Step 5: The Authentication concern
This module is the heart of the system:
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end
Read it as a story: included do ... end runs in the controller that includes the module. Adding before_action :require_authentication means every controller is locked by default. That's a safe default: you must opt out, not remember to opt in. require_authentication tries to find the session from the cookie. If there isn't one, it remembers the page you wanted (return_to_after_authenticating) and sends you to the login page. cookies.signed means the cookie can't be tampered with. httponly: true hides it from JavaScript. same_site: :lax blocks it on most cross-site requests. start_new_session_for is called after a successful login. It creates the Session row and sets the cookie. after_authentication_url sends you back to the page you originally wanted, or to the root page.
ApplicationController includes the concern:
class ApplicationController < ActionController::Base
include Authentication
# ...
end
Step 6: The login controller
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end
allow_unauthenticated_access only: %i[new create] opens the login form and the login action to logged-out visitors. Everything else stays locked. rate_limit is new in Rails 8. It allows 10 login attempts every 3 minutes per IP address, which slows down password guessing. No extra gem needed. User.authenticate_by finds the user and checks the password in a way that takes the same time whether or not the email exists. That prevents attackers from discovering which emails are registered by measuring response times. The error message is the same for a wrong email and a wrong password, on purpose. status: :see_other (HTTP 303) after destroy makes the browser follow the redirect with a GET. This matters with Turbo, which we'll meet in Part 5.
Step 7: Password reset (already built)
The generator also gave you PasswordsController and PasswordsMailer. The flow: The user enters their email on /passwords/new. If the email exists, the app emails a link containing a signed, expiring token (user.password_reset_token, supplied by has_secure_password). The link opens /passwords/:token/edit, where the user picks a new password. On success, all the user's sessions are destroyed, which logs them out everywhere.
Notice the response in create is the same whether or not the email exists. Again, that avoids leaking who has an account. We'll look at how mailers work in Part 7. For now just know it works.
Step 8: Signup, which the generator doesn't give you
The generator creates login but no registration page. Let's add one.
Routes (config/routes.rb):
Rails.application.routes.draw do
root "dashboard#show"
resource :session
resources :passwords, param: :token
resource :registration, only: %i[new create]
get "up" => "rails/health#show", as: :rails_health_check
end
resource :registration (singular) creates /registration/new and POST /registration. There's no id, because a signed-in user has just one of these at a time. We'll compare singular and plural resources in Part 3.
Controller:
class RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_path, alert: "Try again later." }
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
start_new_session_for @user
redirect_to after_authentication_url, notice: "Welcome! Your account has been created."
else
flash[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_content
end
end
private
def user_params
params.expect(user: [:email_address, :password, :password_confirmation])
end
end
Three things to learn here: Signing up logs you in right away. We reuse start_new_session_for, the same method login uses. params.expect is new in Rails 8. Old Rails used params.require(:user).permit(...). With expect, if the user key is missing or has the wrong shape, Rails answers with a clean 400 Bad Request instead of crashing with an error. It also lists which fields you allow (this is called strong parameters, and it stops users from sending fields like admin: true). render :new, status: :unprocessable_content. When validation fails we show the form again, with a 422 status. Turbo needs this status to know the form failed, and it won't re-render the page otherwise.
View (app/views/registrations/new.html.erb). This is trimmed down, and the repo has the full styled version:
<h1 class="font-bold text-2xl">Sign up</h1>
<%= form_with model: @user, url: registration_url do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address" %>
<%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72, placeholder: "Enter your password" %>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", maxlength: 72, placeholder: "Password confirmation" %>
<%= form.submit "Sign up" %>
<% end %>
<%= link_to "Sign In", new_session_path %>
maxlength: 72 is there because bcrypt only reads the first 72 bytes of a password.
Step 9: A page to land on
A user who signs up needs somewhere to go, so we add a small dashboard.
bin/rails generate controller dashboard show
class DashboardController < ApplicationController
def show
end
end
<%# app/views/dashboard/show.html.erb %>
<h1 class="font-bold text-4xl">Dashboard</h1>
<%= button_to "Log out", session_path, method: :delete %>
We use button_to for logout instead of a plain link. A link sends a GET request, but logging out must be a DELETE, and button_to makes a tiny form that sends the right method. Because ApplicationController is locked by default and DashboardController doesn't call allow_unauthenticated_access, visitors who aren't logged in will be redirected to the sign-in page.
Try it: Run bin/dev. Visit http://localhost:3000. You're sent to the login page. Click Sign Up, create an account, and you land on the dashboard. Click Log out, and you're back at the login page. ✅
How to test this
Manual checks, as above, plus one extra: after logging in, open your browser's dev tools, then Application → Cookies. You'll see session_id, and its value is unreadable because it's signed.
Now automated tests. Create test/models/user_test.rb:
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "normalizes the email address" do
user = User.create!(email_address: "M*@Example.COM ", password: "secret123")
assert_equal "m*@example.com", user.email_address
end
test "requires a valid, unique email address" do
User.create!(email_address: "m*@example.com", password: "secret123")
assert_not User.new(email_address: "not-an-email", password: "secret123").valid?
assert_not User.new(email_address: "M*@example.com", password: "secret123").valid?
end
test "authenticate_by accepts the right password only" do
user = User.create!(email_address: "m*@example.com", password: "secret123")
assert_equal user, User.authenticate_by(email_address: "m*@example.com", password: "secret123")
assert_nil User.authenticate_by(email_address: "m*@example.com", password: "wrong")
end
end
Then test/controllers/sessions_controller_test.rb:
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = User.create!(email_address: "m*@example.com", password: "secret123")
end
test "redirects visitors to the login page" do
get root_path
assert_redirected_to new_session_path
end
test "signs in with valid credentials" do
post session_path, params: { email_address: "m*@example.com", password: "secret123" }
assert_redirecte
Comments
No comments yet. Start the discussion.