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, " Sara@Example.COM " becomes "sara@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: " Me@Example.com ", password: "secret123", password_confirmation: "secret123") user.email_address # => "me@example.com" user.password_digest # => "$2a$12$..." (a hash, not the password) User.authenticate_by(email_address: "me@example.com", password: "secret123") # => the user User.authenticate_by(email_address: "me@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: Sign up 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 Dashboard 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: " Me@Example.COM ", password: "secret123") assert_equal "me@example.com", user.email_address end test "requires a valid, unique email address" do User.create!(email_address: "me@example.com", password: "secret123") assert_not User.new(email_address: "not-an-email", password: "secret123").valid? assert_not User.new(email_address: "ME@example.com", password: "secret123").valid? end test "authenticate_by accepts the right password only" do user = User.create!(email_address: "me@example.com", password: "secret123") assert_equal user, User.authenticate_by(email_address: "me@example.com", password: "secret123") assert_nil User.authenticate_by(email_address: "me@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: "me@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: "me@example.com", password: "secret123" } assert_redirected_to root_url follow_redirect! assert_response :success end test "rejects invalid credentials" do post session_path, params: { email_address: "me@example.com", password: "wrong" } assert_redirected_to new_session_path assert_equal "Try another email address or password.", flash[:alert] end test "signs out and removes the session record" do post session_path, params: { email_address: "me@example.com", password: "secret123" } assert_difference -> { @user.sessions.count }, -1 do delete session_path end assert_redirected_to new_session_path end end And test/controllers/registrations_controller_test.rb: require "test_helper" class RegistrationsControllerTest < ActionDispatch::IntegrationTest test "creates an account and signs the user in" do assert_difference("User.count", 1) do post registration_path, params: { user: { email_address: "new@example.com", password: "password", password_confirmation: "password" } } end assert_redirected_to root_path assert_equal 1, User.find_by!(email_address: "new@example.com").sessions.count end test "re-renders the form for invalid input" do assert_no_difference("User.count") do post registration_path, params: { user: { email_address: "not-an-email", password: "password" } } end assert_response :unprocessable_content end end Run them: bin/rails test You'll see them all pass. We don't test rate_limit here because the test environment uses a null cache store, so the counter never accumulates. In Part 12 we'll talk about caches and revisit it. Common mistakes Logout does nothing or raises a routing error. You used link_to ..., method: :delete. Turbo ignores that option. Use button_to, or link_to with data: { turbo_method: :delete }. bcrypt error on user creation. The gem is still commented out in the Gemfile. Enable it and run bundle install. Redirect loops on the login page. A controller that shows the login form must call allow_unauthenticated_access, otherwise it asks for a login to show the login. Recap bin/rails generate authentication gives you users, sessions, login, logout and password reset, as plain readable code. has_secure_password handles hashing; normalizes cleans emails; authenticate_by checks credentials safely. A login creates a Session row and a signed cookie; Current exposes the user for one request. Controllers are locked by default; allow_unauthenticated_access opens specific actions; rate_limit guards against guessing. params.expect is the modern, safer strong parameters. Next up In Part 3 we'll build our first real CRUD: study plans. You'll learn resourceful routes, migrations, associations and validations, and how to make sure users only see their own data.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to