Rails 6 でTodoApp作るぜ part3

今回はUserを作ってログインとかするぜ

Userモデル作成

テーブル設計

名称 カラム名 データ型
名前 name string
メールアドレス email string
パスワード password_digest string


% rails g model user name:string email:string password_digest:string


今回はしっかりnullつけます


[ db/migrate/?????????.create_user.rb ]

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
      t.index :email, unique: true
    end
  end
end


t.index :email, unique: trueは同じメールアドレスのユーザーが複数人保存されないためにつけています。

% rails db:migrate

パスワードをdigestで保存する

まずはbcryptというハッシュ関数を提供するgemをbundleします。
bcryptはもともとGemfileに記述されています。しかし、なぜかコメントアウトされています。なのでコメントインしてあげてbundleします

[ Gemfile ]

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.6.5'

# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 6.0.2', '>= 6.0.2.1'
# Use sqlite3 as the database for Active Record
gem 'sqlite3', '~> 1.4'
# Use Puma as the app server
gem 'puma', '~> 4.1'
# Use SCSS for stylesheets
gem 'sass-rails', '>= 6'
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem 'webpacker', '~> 4.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.7'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use Active Model has_secure_password
gem 'bcrypt', '~> 3.1.7'            <- これ!

# Use Active Storage variant
# gem 'image_processing', '~> 1.2'

# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.4.2', require: false

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end

group :development do
  # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
  gem 'web-console', '>= 3.3.0'
  gem 'listen', '>= 3.0.5', '< 3.2'
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

group :test do
  # Adds support for Capybara system testing and selenium driver
  gem 'capybara', '>= 2.15'
  gem 'selenium-webdriver'
  # Easy installation and use of web drivers to run system tests with browsers
  gem 'webdrivers'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

gem 'slim-rails'
gem 'html2slim'
% bundle


次にuser.rbを編集します

[ app/models/user.rb ]

class User < ApplicationRecord
  has_secure_password

  validates :name, presence: true
  validates :email, presence: true, uniqueness: true
end

has_secure_passwordbcryptがインストールされていないと使用できません。
has_secure_passwordを使うと先ほど作成したuserテーブルpasswordpassword_confirmationというカラムが作られます。目には見えませんが確実に作られます。
passwordに入力された値とpassword_confirmationに入力された値が等しかった場合はその値をハッシュ化してpassword_digestに登録されるという仕組みです。

それと、validatesです。 名前やメールアドレスが空のとき、メールアドレスが既に存在するときは保存に失敗してエラーが発生するようにしています。


users_controllerの作成

% rails g controller users index show new edit

routesの設定

users_controllerにもresourcesを使い、tasks_controllerと同じく一般的なCRUD機能を持たせます


[ config/routes.rb ]

Rails.application.routes.draw do

  resources :users

  root 'tasks#index'
  get 'tasks/index', to: 'tasks#index'
  resources :tasks
end


controllerの設定

routesで設定したとうり、シンプルなCRUD機能を作ります


[ app/controllers/users_controller.rb ]

class UsersController < ApplicationController
  def index
    @users = User.all
  end

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to users_url, notice: "ユーザー「#{@user.name}」を登録しました。"
    else
      render :new
    end
  end

  def edit
    @user = User.find(params[:id])
  end

  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      redirect_to users_url, notice: "ユーザー「#{@user.nsame}」を更新しました。"
    else
      render :edit
    end
  end

  def destroy
    @user = User.find(prams[:id])
    @user.destroy
    redirect_to root, notice: "ユーザー「#{@user.name}」を削除しました。"
  end

  private

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

end

viewsの作成

まず初めに、コンソールから適当にユーザーを作っておきます。

name panda
email panda@example.com
password password
password_confirmation password

めっちゃ適当!

% rails c   
Running via Spring preloader in process 60245
Loading development environment (Rails 6.0.2.1)
>> user = User.new(name: "panda", email: "panda@example.com", password: "password", password_confirmation: "password")
   (1.3ms)  SELECT sqlite_version(*)
=> #<User id: nil, name: "panda", email: "panda@example.com", password_digest: [FILTERED], created_at: nil, updated_at: nil>
>> user.save
   (0.1ms)  begin transaction
  User Create (1.8ms)  INSERT INTO "users" ("name", "email", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["name", "panda"], ["email", "panda@example.com"], ["password_digest", "$2a$12$UPS/sV/yXgtL1TJ8vStzJuuDRgTvqPPGbPJVkFpT2K6Pp.dnwSZiO"], ["created_at", "2020-01-18 11:37:54.068311"], ["updated_at", "2020-01-18 11:37:54.068311"]]
   (2.0ms)  commit transaction
=> true

みてもらうとわかりますが、newではpassword: "password", password_confiramtion: "password"と入力しています。しかし、saveでは、["password_digest", "$2a$12$UPS/sV/yXgtL1TJ8vStzJuuDRgTvqPPGbPJVkFpT2K6Pp.dnwSZiO"]と保存されています。これがhas_secure_passwordbcryptのパワーです。このハッシュ化によりdbを悪者に覗かれても元のパスワードを盗むことは不可能。らしいです。


それでは、viewsを順番に紹介していきます。(taskとほぼ同じです。)

[ users/index.html.slim ]

f:id:yukitoku_sw:20200118211425p:plain

h1 アカウント一覧

.nav.justify-content-end
  = link_to '新規登録', new_user_path, class: 'nav-link'

table.table
  thead.thead-default
    tr
      th 名前
      th 登録日
      th 編集日
  tbody
    - @users.each do |user|
      tr
        td = link_to user.name, user
        td = user.created_at
        td = user.updated_at

ユーザー一覧となるのが嫌いなのでアカウントにしました。この気持ちわかります??

編集と削除のリンクはつけていません。自分のアカウントを他の人に勝手に消されたら困るので


[ users/show.html.slim ]

f:id:yukitoku_sw:20200118211842p:plain

h1 アカウントの詳細

.nav.justify-content-end
  = link_to '一覧', users_path, class: 'nav-link'

table.table.table-hover
  tbody
    tr
      th 名前
      td = @user.name
    tr
      th メールアドレス
      td = @user.email
    tr
      th 登録日
      td = @user.created_at
    tr
      th 更新日
      td = @user.updated_at

= link_to '編集', edit_user_path, class: 'btn btn-primary mr-3'

詳細ページでは編集だけ残しています。後で、自分のページでしか表示されないようにします。

基本的にtaskで作ったviewページをコピペしてます。


newとeditの前に_formを作ります。

[ users/_form.html.slim ]

- if user.errors.present?
  ul#error_explanation
    - user_errors.full_messages.each do |message|
      li = message
      
= form_with model: user, local: true do |f|
  .form-group
    = f.label :name, '名前'
    = f.text_field :name, class: 'form-control'
  .form-group
    = f.label :email, 'メールアドレス'
    = f.text_field :email, class: 'form-control'
  .form-group
    = f.label :password, 'パスワード'
    = f.password_field :password, class: 'form-control'
  .form-group
    = f.label :password_confirmation, 'パスワード(確認)'
    = f.password_field :password_confirmation, class: 'form-control'
  = f.submit '登録', class: 'btn btn-success'

formはtaskと中身が違うのでお気をつけて!


[ users/new.html.slim ]

f:id:yukitoku_sw:20200118212441p:plain

h1 アカウント新規登録

.nav.justify-content-end
  = link_to '一覧', users_path, class: 'nav-link'

= render partial: 'form', locals: { user: @user }


[ users/edit.html.slim ]

f:id:yukitoku_sw:20200118212619p:plain

ちょっと変ですが、とりあえずね・・

h1 アカウントの編集

.nav.justify-content-end
  = link_to '一覧', users_path, class: 'nav-link'

= render partial: 'form', locals: { user: @user }

= link_to 'アカウント削除', @user[f:id:yukitoku_sw:20200118215007p:plain], method: :delete, data: { confirm: "タスク「#{@user.name}」を削除します。よろしいですか?" }, class: 'btn btn-danger m-3'


headerにusers_pathのリンクを貼っておきます。

[ app/layouts/_header.html.slim ]

f:id:yukitoku_sw:20200118215007p:plain

ヘッダーの右端にちょこんとリンクがあります・・・

header.mb-3
  .app-title.navbar.navbar-expand-md.navbar-dark.bg-dark
    .navbar-brand TodoApp
    ul.navbar-nav.ml-auto
      li.nav-item = link_to 'User', users_path, class: 'nav-link'


ログイン機能を実装する

ControllerをGしてRoutsをdescribeする

[ コンソール ]

% rails g controller sessions new

コントローラーなのでsessionsと複数形です


[ config/routes.rb ]

Rails.application.routes.draw do

  get '/login', to: 'sessions#new'
  post '/login', to: 'sessions#create'
  delete '/logout', to: 'sessions#destroy'
  
  resources :users

  root 'tasks#index'
  resources :tasks
end

簡単に言うと、

  • getでログインのフォームを表示

  • postでログイン状態を作る

  • deleteでログイン状態を消す(ログアウト状態にする)


Viewの作成

今回はviewから作った方がわかりやすいのでviewを先に作ります

f:id:yukitoku_sw:20200118222129p:plain

ちょっと腹立つ仕様

[ app/views/sessions/new.html.slim ]

h1 ログインする?

= form_with scope: :session, local: true do |f|
  .form-group
    = f.label :email, 'メールアドレス'
    = f.text_field :email, class: 'form-control', id: 'session_email'
  .form-group
    = f.label :password, 'パスワード'
    = f.password_field :password, class: 'form-control', id: 'session_password'
  = f.submit 'ログイン', class: 'btn btn-success'

formでメールアドレスとパスワードを受け取ってユーザーを判別します


Controllerを実装する

[ app/controllers/sessions_controller.rb ]

class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: session_params[:email])

    if user&.authenticate(session_params[:password])
      session[:user_id] = user.id
      redirect_to root_url, notice: 'ログインしました'
    else
      render :new
    end
  end

  def destroy
    reset_session
    redirect_to login_url, notice: 'ログアウトしました'
  end

  private

    def session_params
      params.require(:session).permit(:email, :password)
    end
end

new

viewを表示するだけなので何も記述しません。 Simple is the Best

create

まず、session_paramsから受け取ったメールアドレスに該当するユーザーを探します。
ユーザーが見つかった場合 その見つけたユーザーとsession_paramsから受け取ったパスワードを持つユーザーが同一なのか判断します。 authenticatehas_secure__passwordに付随するメソッドのことです。
引数で受け取ったパスワードをハッシュ化してUser内部に保存されているpassword_digestと一致するかを調べてくれます。一致していたらそのUserオブジェクトを返します。一致していなければfalseを返します。
user.authenticate(session_params[:password])とすると、最初のメールアドレス該当者がいなかった場合にuserがnilになります。するとauthenticateメソッドがundefined method `authenticate' for nil:NilClass (NoMethodError)が発生してしまうので&.(ボッチ演算子)を使っています。

そして、生き残ったUserがいた場合はSessionに生き残ったUserのidを格納しています。

destroy

reset_sessionをすることでsession[:user_id]から自分のuser_idを取り出すこと(ログアウト)ができます。


ログイン情報の取得をメソッド化する

現状、下記コードでログインしているユーザーを取得することができます。

User.find_by(id: session[:user_id])

これを何度も書くのは面倒です。

なので、メソッドとして定義します。

[ app/controllers/application_controller.rb ]

class ApplicationController < ActionController::Base
  helper_method :current_user

  private

    def current_user
      @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
    end
end

application_controllercurrent_userとしてhelper_methodに定義しました。 これによりcurrent_userは全てのビューで利用できます。

ヘッダーにリンクを貼って試してみます。

[ app/views/layouts/_header.html.slim ]

header.mb-3
  .app-title.navbar.navbar-expand-md.navbar-dark.bg-dark
    .navbar-brand TodoApp
    ul.navbar-nav.ml-auto
      - if current_user
        li.nav-item = link_to 'Tasks', tasks_path, class: 'nav-link'
        li.nav-item = link_to 'Users', users_path, class: 'nav-link'
        li.nav-item = link_to 'Logout', logout_path, method: :delete, class: 'nav-link'
      - else
        li.nav-item = link_to 'Login', login_path, class: 'nav-link'

ログイン前は、ログインしか表示されていない

f:id:yukitoku_sw:20200119082120p:plain

ログイン後はログイン以外が表示されています。

f:id:yukitoku_sw:20200119082202p:plain


ログインユーザーしかタスク管理ができない??

ようにします。

アカウントを登録して、ログインしないとタスク管理まで辿り着けなくするってこと!

[ app/controllers/application_controller.rb ]

class ApplicationController < ActionController::Base
  helper_method :current_user
  before_action :login_required

  private

    def current_user
      @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
    end

    def login_required
      redirect_to login_url unless current_user
    end
end

今回もapplication_controller.rbに、login_requiredってフィルタを書いていきます。

強制的にログインページに吹っ飛ばす。ログインしていなければな って感じ

しかし、このままだとログインページにもlogin_requiredが適用されて永遠に異空間を彷徨うことになってしまいます。

それは避けましょう

[ /sessions_controller.rb ]

class SessionsController < ApplicationController
  skip_before_action :login_required     #追加

  def new
  end

  (省略)
end

sessions_controllerskip_before_action :login_requiredを追加します。 これにより、セッション機能にはlogin_requiredが適用されなくなり、無事にログインページにたどり着くことができます。


ログインすれば他のユーザーのデータも扱えるクネ??

これはあかん

ログインしているユーザー "の" タスクのみ管理できるようにする

  • UserとTaskを紐付ける。

  • ログインUserに紐づいたTaskを登録する

  • ログインUserのTaskのみ一覧、詳細、変更を行えるようにする

よし!?

UserとTaskを紐付ける

% rails g migration AddUserIdToTasks

内容を編集

class AddUserIdToTasks < ActiveRecord::Migration[6.0]
  def change
    execute 'DELETE FROM tasks;'
    add_reference :tasks, :user, forign_key: true
  end
end

execで既存のTaskを削除してから、forign_keyをつけます 既存のTaskにはuser_idが設定されていないのでエラーになります。

% rails db:migrate

関連付けも必要です

[ models/user.rb ]

class User < ApplicationRecord
  has_secure_password

  validates :name, presence: true
  validates :email, presence: true, uniqueness: true

  has_many :tasks  # 追加
end

Userは複数のTaskを持っている

[ models/task.rb ]

class Task < ApplicationRecord
  validates :title, presence: true, length: { maximum: 30 }
  validates :description, presence: true

  belongs_to :user  # 追加
end

Taskは各Userに所属している


ログインUserに紐づいたTaskを登録する

tasks_controllercreateを修正します

@task = Task.new(task_params)の部分を @task = current_user.tasks.new(task_params)へと修正

これによりtaskにcurrent_userのuser_idが登録されます。 各Task毎にどのUserの所有物なのかわかるってことよ

[ app/controllers/tasks_controller.rb ]

def create
    @task = current_user.tasks.new(task_params)  # 変更箇所
    if @task.save
      redirect_to tasks_url, notice: "タスク「#{@task.title}」を登録しました。"
    else
      render :new
    end
  end


ログインUserのTaskのみ一覧、詳細、変更を行えるようにする

先ほど使った@task = current_user.tasksを乱用します

ちなみに、

@tasks = current_user.tasks

@tasks = Task.where(user_id: current_user.id)

と同じです。

[ tasks_controller.rb ]

class TasksController < ApplicationController
  def index
    @tasks = current_user.tasks
  end

  def new
    @task = Task.new
  end
  
  def create
    @task = current_user.tasks.new(task_params)
    if @task.save
      redirect_to tasks_url, notice: "タスク「#{@task.title}」を登録しました。"
    else
      render :new
    end
  end

  def show
    @task = current_user.tasks.find(params[:id])
  end

  def edit
    @task = current_user.tasks.find(params[:id])
  end

  def update
    @task = current_user.tasks.find(params[:id])
    if @task.update(task_params)
      redirect_to tasks_url, notice: "タスク「#{@task.title}」を更新しました!"
    else
      render :edit
    end
  end

  def destroy
    @task = current_user.tasks.find(params[:id])
    @task.destroy
    redirect_to tasks_url, notice: "タスク「#{@task.title}」を削除しました。"
  end

  private

    def task_params
      params.require(:task).permit(:title, :description)
    end
end

これで自分のタスクは自分でしか操作できないようになりました。


コードをわかりやすくする

ごちゃごちゃ書いたのでちょっと綺麗にしたい

tasks_controllerで乱用した@task = current_user.tasks.find(params[:id])をまとめる

[ tasks_controller.rb ]

class TasksController < ApplicationController
  before_action :set_task, only: [:show, :edit, :update, :destroy]

  def index
    @tasks = current_user.tasks
  end

  def new
    @task = Task.new
  end
  
  def create
    @task = current_user.tasks.new(task_params)
    if @task.save
      redirect_to tasks_url, notice: "タスク「#{@task.title}」を登録しました。"
    else
      render :new
    end
  end

  def show
  end

  def edit
  end

  def update
    if @task.update(task_params)
      redirect_to tasks_url, notice: "タスク「#{@task.title}」を更新しました!"
    else
      render :edit
    end
  end

  def destroy
    @task.destroy
    redirect_to tasks_url, notice: "タスク「#{@task.title}」を削除しました。"
  end

  private

    def task_params
      params.require(:task).permit(:title, :description)
    end

    def set_task
      @task = current_user.tasks.find(params[:id])
    end
end

privateの下にset_taskを定義します。中身はさっき乱用した@task = current_user.tasks.find(params[:id])
これをbefore_action :set_task, only: [:show, :edit, :update, :destroy]とすることで、指定したアクションの前に@task = current_user.tasks.find(params[:id])を実行してくれます。なので各アクションからは記述を削除してokです。

ちょっとすっきりしました。


一覧ページでの表示順を設置していなかったのでscopeを使い指定します。

[ models/task.rb ]

class Task < ApplicationRecord
  validates :title, presence: true, length: { maximum: 30 }
  validates :description, presence: true

  belongs_to :user

  scope :recent, -> { order(created_at: :desc) }  # 追加
end

登録日の新しい順に設定しました。 これをコントローラーに伝えます。

[ tasks_contorller.rb ]

 def index
    @tasks = current_user.tasks.recent
  end

これだけです!


今回はここまで・・・


_次回、機能武装