Learn Rails 8 by Building Study Flow, Part 2: Authentication with the Built-in Generator
DEV Community

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_password does for you
  • How a login works: the Session model, signed cookies and Current
  • How allow_unauthenticated_access and rate_limit protect 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.rb
  • app/models/session.rb
  • app/models/current.rb
  • app/controllers/concerns/authentication.rb
  • app/controllers/sessions_controller.rb
  • app/controllers/passwords_controller.rb
  • app/mailers/passwords_mailer.rb
  • app/views/sessions/new.html.erb
  • app/views/passwords/...
  • db/migrate/..._create_users.rb
  • db/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
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.