🦓

[Rails]ユーザ登録・ログイン 2/20

2023/06/15に公開

はじめに

Railsアプリにシンプルなユーザ登録・ログイン機能を作成してみました。

環境

ruby: 3.0.0
rails: 6.1.7

流れ

  1. Userモデルを作成する
  2. Userモデルにバリデーションルールをかける
  3. routes.rbに新規登録用urlを追加する
  4. signupコントローラーを作成する
  5. 新規登録フォームを作成する
  6. 登録されたユーザをDBに送る
  7. ログイン機能を作る
  8. ログアウト機能を作る
  9. ログインフォームを作る
  10. Currentモデルを作成する
  11. エラーメッセージをテンプレート化する

Userモデルを作成する

bin/rails generate model User user_name:string email:string password_digest:string
invoke  active_record
create    db/migrate/20230615044726_create_users.rb
create    app/models/user.rb

bin/rails db:migrate
Running via Spring preloader in process 45777
== 20230615044726 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0021s
== 20230615044726 CreateUsers: migrated (0.0022s) =============================

User.rbを編集する

user.rb
class User < ApplicationRecord
    has_secure_password
end

Userモデルにhas_secure_passwordを追加しています。この1行を追加するだけで、Userモデルはパスワードのハッシュ化、検証、認証に関する機能を利用できるようになります。
モデルにhas_secure_passwordを追加すると、パスワードを格納するためのpassword_digestという名前の属性が自動的に追加されます。また、パスワードの検証や認証に関連するメソッドとして、authenticateメソッドが提供されます。

has_secure_password

has_secure_passwordは、Railsで提供される機能の一つです。この機能を使用すると、ユーザーのパスワードを簡単にハッシュ化して保存し、パスワードの検証や認証に関連する機能を提供することができます。

  1. パスワードのハッシュ化: パスワードをモデルの属性として扱い、自動的にハッシュ化してデータベースに保存します。ハッシュ化されたパスワードは安全に保存され、元のパスワードは取得できません。
  2. パスワードの検証: パスワードのバリデーションを自動的に行い、必要に応じてエラーメッセージを追加します。また、パスワードの長さや複雑さの要件を指定することもできます。
  3. パスワードの認証: ユーザーが入力したパスワードと保存されたハッシュ化されたパスワードを照合し、正しいパスワードかどうかを確認します。認証が成功した場合、対応するユーザーオブジェクトを返します。

bcryptをインストールする

has_secure_passwordを使うにはbcryptというgemが必要です。
Gemfileの23行目あたりにコメントアウトされていますので、コメントアウトを解除してbundle installを実行します。

Gemfile
# Use Active Model has_secure_password
gem 'bcrypt', '~> 3.1.7'

bundle install
...
Installing bcrypt 3.1.18 with native extensions

https://github.com/bcrypt-ruby/bcrypt-ruby

rails consoleで確認する

$ rails console
Running via Spring preloader in process 46097
Loading development environment (Rails 6.1.7.3)
irb(main):001:0> User
=> User (call 'User.connection' to establish a connection)
irb(main):002:0> User.all
   (1.3ms)  SELECT sqlite_version(*)
  User Load (0.4ms)  SELECT "users".* FROM "users" /* loading for inspect */ LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation []> # userがまだいないので空の配列
# testユーザを作る
irb(main):003:0> User.create({user_name:"test_user", email:"user@test.com", password:"password", password_confirmation:"password"})
  TRANSACTION (0.1ms)  begin transaction
  User Create (1.6ms)  INSERT INTO "users" ("user_name", "email", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["user_name", "test_user"], ["email", "user@test.com"], ["password_digest", "$2a$12$5Cc5BA1GnKH2Dsh/sfhgCuCPgUHyNYqkyEL82xDdyaKtCwjP1lEo."], ["created_at", "2023-06-15 05:26:52.943768"], ["updated_at", "2023-06-15 05:26:52.943768"]]
  TRANSACTION (0.8ms)  commit transaction
=> #<User id: 1, user_name: "test_user", email: "user@test.com", password_digest: [FILTERED], created_at: "2023-06-15 05:26:52.943768000 +0000", updated_at: "2023-06-15 05:26:52.943768000 +0000">
irb(main):004:0> User.first
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
  # passwordとpassword_confirmationがDBに保存されないことを確認する
=> #<User id: 1, user_name: "test_user", email: "user@test.com", password_digest: [FILTERED], created_at: "2023-06-15 05:26:52.943768000 +0000", updated_at: "2023-06-15 05:26:52.943768000 +0000">

Userモデルにバリデーションルールをかける

ユーザ名とメアドにバリデーションをかけて空のままだとユーザを作成できないようにします。

app/models/user.rb
class User < ApplicationRecord
    has_secure_password

    validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
    validates :user_name, presence: true, length: { minimum: 3 }

end

xxx_create_user.rbマイグレーションファイルを編集する

バリデーションを通らない場合DBにsaveされないようにします。

db/migrate/xxx_create_user.rb
class CreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :users do |t|
      t.string :user_name, null: false
      t.string :email, null: false
      t.string :password_digest

      t.timestamps
    end
  end
end
# 変更を反映させる
rails db:migrate:redo

rails consoleで確認する

メアドを空にしてユーザを作成してみます。

$ rails console
Running via Spring preloader in process 46777
Loading development environment (Rails 6.1.7.3)
irb(main):001:0> user = User.create(user_name:user, password:"password", password_confirmation:"password")
   (0.5ms)  SELECT sqlite_version(*)
=> #<User id: nil, user_name: nil, email: nil, password_digest: [FILTERED], created_at: nil, updated_at: nil>
irb(main):002:0> user.errors.any?
=> true
irb(main):003:0> user.errors.first
# メアドがblankのエラーが発生
=> #<ActiveModel::Error attribute=email, type=blank, options={}>

routes.rbに新規登録用urlを追加する

config/routes.rb
Rails.application.routes.draw do
  root to: "main#index"
  get "signup", to: "signup#new"
  post "signup", to: "signup#create"
end

signupコントローラーを作成する

$ rails g controller signup
Running via Spring preloader in process 47334
      create  app/controllers/signup_controller.rb
      invoke  erb
      create  app/views/signup
app/controller/signup_controller.rb
class SignupController < ApplicationController
    def new
        @user = User.new
    end
end

新規登録フォームを作成する

app/views/signupnew.html.erbを作成します。

app/views/signup/new.html.erb
# form_withヘルパーを使って登録フォームを作る
<%= form_with model: @user, url: signup_path do |form| %>
  <%= form.text_field :user_name %>
  <%= form.text_field :email %>
  <%= form.password_field :password %>
  <%= form.password_field :password_confirmation %>
  <%= form.submit %>
<% end %>

# bootstrapでスタイリングする
<%= form_with model: @user, url: signup_path do |form| %>
  <div class="form-group">
    <%= form.label :user_name %>
    <%= form.text_field :user_name, placeholder: "Please enter your user name (minimum 3 characters)", class: "form-control mb-3" %>
  </div>
  <div class="form-group">
    <%= form.label :email %>
    <%= form.text_field :email, placeholder: "user@example.com", class: "form-control mb-3"  %>
  </div>
  <div class="form-group">
    <%= form.label :password %>
    <%= form.password_field :password, placeholder: "Please enter your password", class: "form-control mb-3"  %>
  </div>
  <div class="form-group">
    <%= form.label :password_confirmation %>
    <%= form.password_field :password_confirmation, placeholder: "Please enter your password again", class: "form-control mb-3"  %>
  </div>
  <%= form.submit class:'btn btn-primary' %>
<% end %>

デベロッパーツールで確認します:

フォームを送信してみます。

Started POST "/signup" for ::1 at 2023-06-15 16:46:17 +0900
Processing by SignupController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"user_name"=>"", "email"=>"test@user.com", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Create User"}
  Rendering text template
  Rendered text template (Duration: 0.1ms | Allocations: 28)
Completed 200 OK in 3ms (Views: 3.0ms | ActiveRecord: 0.0ms | Allocations: 571)

フォームヘルパー:
https://guides.rubyonrails.org/form_helpers.html#relying-on-record-identification

エラーメッセージを表示させる

バリデーションに引っかかった場合フォームの一番上にエラーメッセージをユーザに表示させます

app/views/signup/new.html.erb
  <% if form.object.errors.any? %>
    <div class="alert alert-danger">
      <% form.object.errors.full_messages.each do |message|  %>
        <div>
          <%= message %>
        </div>
      <% end  %>
    </div>
  <% end %>

登録されたユーザをDBに送る

app/controller/signup_controller.rb
class SignupController < ApplicationController
    def new
        @user = User.new
    end
    # actionを追加する
    def create
        @user = User.new(user_params)
        if @user.save
            redirect_to root_path, notice: "アカウトを作成しました。"
        else
            render :new
        end
    end

    def user_params
        params.require(:user).permit(:user_name, :email, :password, :password_confirmation)
    end
end

ユーザを新規登録してDBにsaveされることを確認します。

Started POST "/signup" for ::1 at 2023-06-15 17:14:08 +0900
Processing by SignupController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"user_name"=>"test_user", "email"=>"user@sample.com", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Create User"}
   (0.1ms)  SELECT sqlite_version(*)
  ↳ app/controllers/signup_controller.rb:7:in `create'
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/signup_controller.rb:8:in `create'
  User Exists? (1.5ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "user@sample.com"], ["LIMIT", 1]]
  ↳ app/controllers/signup_controller.rb:8:in `create'
  User Create (0.6ms)  INSERT INTO "users" ("user_name", "email", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["user_name", "test_user"], ["email", "user@sample.com"], ["password_digest", "$2a$12$4I8KZCWM7lfwl80s7w/KxuyqFCBvTlFeaXz49ppv/gXtSI9711rx."], ["created_at", "2023-06-15 08:14:08.342498"], ["updated_at", "2023-06-15 08:14:08.342498"]]
  ↳ app/controllers/signup_controller.rb:8:in `create'
  TRANSACTION (0.8ms)  commit transaction
  ↳ app/controllers/signup_controller.rb:8:in `create'
Redirected to http://localhost:3000/
Completed 302 Found in 217ms (ActiveRecord: 3.6ms | Allocations: 8300)

新規登録機能ができました。ログイン機能も作っていきます。

ログイン機能を作成する

セッションを使ってログインしたユーザを管理します。

app/controllers/signup_controller.rb
class SignupController < ApplicationController
    def create
        @user = User.new(user_params)
        if @user.save
            session[:user_id] = @user.id
            flash[:success] = "アカウトを作成しました。"
            redirect_to root_path
        else
            render :new
        end
    end
..
end

ユーザを作成した時に発行されたセッションIDを受け取る

app/controllers/main_controller.rb
class MainController < ApplicationController
    def index
        if session[:user_id]
	    # findの場合はユーザを存在しないとエラーを返すのでfind_byを使う
            @user = User.find_by(id: session[:user_id])
        end
    end
end

DBからユーザを特定してビューに表示させる

DBからuser.idsessionIDと等しいユーザを取得します。

Started GET "/" for ::1 at 2023-06-15 20:29:46 +0900
Processing by MainController#index as HTML
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 9], ["LIMIT", 1]]
  ↳ app/controllers/main_controller.rb:4:in `index'
  Rendering layout layouts/application.html.erb
  Rendering main/index.html.erb within layouts/application
  Rendered main/index.html.erb within layouts/application (Duration: 0.1ms | Allocations: 38)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered shared/_header.html.erb (Duration: 0.2ms | Allocations: 88)
  Rendered shared/_message.html.erb (Duration: 0.1ms | Allocations: 50)
  Rendered layout layouts/application.html.erb (Duration: 8.3ms | Allocations: 6031)
Completed 200 OK in 11ms (Views: 8.6ms | ActiveRecord: 0.1ms | Allocations: 7016)
app/views/main/index.html.erb
<% if @user %>
  <h1>こんにちは、<%= @user.user_name %>さん</h1>
<% end %>

ログアウト機能を作成する

ユーザがログアウトしたらセッションIDを削除されます。

routes.rbを編集する

config/routes.rb
Rails.application.routes.draw do
...
  get "signup", to: "signup#new"
  post "signup", to: "signup#create"
  delete "logout", to: "sessions#destroy"
end

sessions_controller.rbを作成する

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
    def destroy
        session[:user_id] = nil
        flash[:success] = "ログアウトしました。"
        redirect_to root_path
    end
end

ビューにログアウトボタンを追加する

app/views/main/index.html.erb
<% if @user %>
  <h1>こんにちは、<%= @user.user_name %>さん</h1>
  # 良い
  <%= button_to "ログアウト", logout_path, method: :delete, class: 'btn btn-primary' %>
  # 悪い
  <%= link_to "ログアウト", logout_path, method: :delete, class: 'btn btn-primary' %>
<% end %>

button_toがフォームを使ってpostリクエストを送るようになってます。

ログインフォームを作成する

routes.rbを編集する

config/routes.rb
Rails.application.routes.draw do
...
  get "signup", to: "signup#new"
  post "signup", to: "signup#create"
  get "login", to: "sessions#new"
  post "login", to: "sessions#create"
  delete "logout", to: "sessions#destroy"
end

sessions_controllerを編集する

newcreateアクションを追加します。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
    def new

    end

    def create
      user = User.find_by(email: params[:email])
        if user.present? && user.authenticate(params[:password])
            session[:user_id] = user.id
            flash[:success] = "ログインしました。"
            redirect_to root_path
        else
            flash[:danger] = "ログイン失敗しました。もう一度試してください。"
            render :new
        end
    end
end

ログインフォームを作成する

sessionsコントローラーには特定のモデルが関連付けられていないため、form.errorsメソッドを直接使用することはできません。
SessionsControllerや同様のモデルに関連付けられていないコントローラーの場合、エラーメッセージのハンドリング方法が異なります。
form.errorsの代わりに、コントローラーでフラッシュメッセージを設定してエラーを表示する方法を使用します。

app/views/sessions/new.html.erb
<h1>ログイン</h1>
<%= form_with url:login_path do |form| %>
  <div class="form-group">
    <%= form.label :email %>
    <%= form.text_field :email, placeholder: "user@example.com", class: "form-control mb-3"  %>
  </div>
  <div class="form-group">
    <%= form.label :password %>
    <%= form.password_field :password, placeholder: "Please enter your password", class: "form-control mb-3"  %>
  </div>
  <%= form.submit class:'btn btn-primary' %>
<% end  %>

ユーザセッションを全ページに共通するので、application_controller.rbに入れておきます。

Currentモデルを作成する

class Current < ActiveSupport::CurrentAttributes
    attribute :user
end

application_controller.rbに共通するコードをいれる

main_controller.rbindexアクションを削除します。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
    before_action :set_current_user
    
    def set_current_user
        if session[:user_id]
            Current.user = User.find_by(id: session[:user_id])
        end
    end
end

ビューの@userCurrent.userにする

app/views/main/index.html.erb
<% if Current.user %>
  <h1>こんにちは、<%= Current.user.user_name %>さん</h1>
<% end %>

_header.html.erbを編集する

ユーザがログインしてない場合、ログインと新規登録を表示させます。
ユーザがログインした場合、ユーザ名とログアウトを表示させます

app/views/shared/_header.html.erb
...
<ul class="navbar-nav ms-auto">
  <% if Current.user %>
      <li class="nav-item">
           <%= link_to Current.user.user_name, class: 'nav-link '%>
      </li>
      <li class="nav-item">
           <%= link_to "ログアウト", logout_path, method: :delete, class: 'nav-link' %>
      </li>
   <% else %>
      <li class="nav-item">
           <%= link_to "ログイン", login_path, class: 'nav-link' %></li>
      <li class="nav-item">
           <%= link_to "新規登録", signup_path, class: 'nav-link' %></li>
   <% end  %>
</ul>
...

エラーメッセージをテンプレート化する

shared/_message.html.erbを作成して、ヘッダーの直下に入れます。

app/views/shared/_message.html.erb
<% flash.each do |key, value| %>
  <div class="alert alert-<%= key %> alert-dismissible fade show" role="alert">
    <%= value %>
    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
  </div>
<% end %>

終わりに

ユーザ登録・ログイン機能ができました。
次回ではパスワードリセット機能を実装します。

Discussion