第5回 Ruby on Rails〜タスク管理アプリケーション 機能追加③ (ログイン機能 part1)〜

Bootstrap

はじめに

今回から、以前作ったタスク管理アプリケーションにログイン機能を何回かに分けて実装していきます。

現段階のタスク管理アプリは、誰もが自由にタスクを追加、編集、削除できます。

しかし、このままでは自分のプライベートなタスクを他の人に見られたり、削除されてします恐れがあります。
このような事態を防ぐためには、認証機能を実装する必要があります。
アプリにユーザーが具体的に誰なのか把握させ、ユーザーが扱えるタスクに制限をかけます。

認証には、IDとパスワードを使うものや、外部サービスを使うものなど様々な方法があります。
タスク管理アプリにはメールアドレスとパスワードの組み合わせによる認証を実装します。

第一回となる今回は、以下の内容をまとめます。

  • ・セッションとCookie
  • ・Userモデルの作成
  • ・パスワードのHash化
  • ・ユーザー管理機能の実装

セッションとCookie

ログイン機能を実装する前に、セッションとCookieについて簡単に理解します。

セッション

WebアプリケーションではブラウザからサーバへHTTPリクエストを送り、HTTPレスポンスを受け取り画面として表示します。
このHTTPリクエストを繰り返し行うことで、順番に操作を行なっていきます。
しかしHTTPはステートレスなプロトコルであり、同じユーザから送られた一つ目のリクエスト二つ目のリクエストに情報を引き継げません。
一つのブラウザから連続して送られる一連のリクエストの中で状態を共有するサーバ側の仕組みを「セッション」と言います。

Railsではコントローラからsessionメソッドを呼び出すことで、セッションにアクセスできます。

セッションにデータを入れるには任意のキーを指定して値を格納します。

session[:user_id] = @user_id

値は以下のように取り出します。

@user_id = session[:user_id]

Cookie

セッションと似た仕組みとしてCookieがあります。セッションがアプリケーションサーバで独自に実現される仕組みであるのに対して、Cookieはブラウザとサーバ間でやり取りされる仕組みです。

Cookieでは、まずWebサーバからブラウザへHTTPレスポンスを返す際、何らかのCookie情報を含めて送ります。ブラウザはCookie情報をサーバのドメインなどの情報に紐付けて保存します。
次に同じドメインに対してHTTPリクエストを送る際に、保管していたCookie情報を添えて送ります。これによりWebサーバは以前どのようなCookie情報を受け取ったブラウザからリクエストが送られて来たのかを把握できます。

このような複数のリクエスト間で共有したい「状態」をブラウザ側に保存する仕組みをCookieといいます。

Railsでは、cookiesメソッドでCookie情報にアクセスし、データに対して操作を行えますが、基本的にはセッションを使えば済むので、直接Cookieを操作することはあまりありません。

RailsにおけるセッションとCookieの関係

RailsではセッションはCookieによってやりとりされるセッションIDをキーにして保管されます。
セッションデータの保管場所は複数の選択肢から選べますが、デフォルトではCookieとなっています。

このようにRailsのセッションの仕組みの一部はCookieによって実現されています。ブラウザ側で対応するCookieを消すとセッションもリセットされます。

Userモデルの作成

認証機能を実装するには、タスク管理アプリを利用するユーザーという概念を加えます。そこでユーザーを表すUserモデルを作成します。

Userモデルのデータ構造

Userクラスのデータ構造は以下のようにします。

  • 属性名:name データ型:string(文字列)
  • 属性名:email データ型:string(文字列)
  • 属性名:password_digest データ型:string(文字列)

パスワードの属性名をpassword_digestとしています。
次節でパスワードをHash化する際にRailsに標準で付いているhas_secure_passwordという機能を使います。
password_digestはこの機能の命名ルールに沿った名前となっています。

Userモデルの作成

以下のコマンドを実行してUserモデルとuserテーブルを作るためのマイグレーションファイルを作成します。

$ bin/rails g model user name:string email:string password_digest:string
Running via Spring preloader in process 79327
      invoke  active_record
      create    db/migrate/20210321172502_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml

生成したマイグレーションファイルを以下のように編集します。

class CreateUsers < ActiveRecord::Migration[6.1]
  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

これで名前、メールアドレス、パスワードに何かしらの文字列が入り、同じメールアドレスを持つユーザーが複数存在することはなくなりました。

編集できたらマイグレーションを実行してusersテーブルを作成します。

$ bin/rails db:migrate
== 20210321172502 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0281s
== 20210321172502 CreateUsers: migrated (0.0281s) =============================

これでusersテーブルを作成できました。

パスワードのHash化

現段階ではユーザーの名前やメールアドレスをデータベースに保存する仕組みは出来上がっておりますが、パスワードをdigestに変換する仕組みはありません。

digestとは、元の値に戻すことのできない一方的なHash化を行なった文字列のことです。
digest自体は無意味に見える文字列であり、同じパスワードから生成すると常に同じ値になります。逆変換はできないため、digestだけを保存するようにすれば、データベースの値が漏洩してもパスワード自体が漏洩する事態には陥りません。

そこでパスワードの入力を受け付けてdigestを生成・保存するようモデルに実装します。

ここでhas_secure_passwordを使います。has_secure_passwordを使ってパスワードをハッシュ化するためにbcryptというハッシュ関数を提供するgemが必要になります。

Gemfileから以下の記述のコメントアウトを外して保存します。

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

bundleコマンドを実行します。

$ bundle

これでbcryptをインストールできたので、has_secure_passwordを使えます。

Userモデルに以下のように変更します。

class User < ApplicationRecord
  has_secure_password
end

has_secure_passwordを記述するとデータベースのカラムには対応しない以下の二つの属性が追加されます。

  • ・属性名:password 役割:ユーザーが入力した生のパスワードを一時的に格納
  • ・属性名:password_confirmation 役割:ユーザー登録時に確認用パスワードを一時的に格納

password属性とpassword_confirmation属性の値が一致しない場合、検証は失敗します。

ユーザー管理機能の実装

アプリケーションの利用者をどのようにデータベースに登録するかですが、今回は管理者がユーザを登録するタイプのユーザー管理機能を実装します。

adminフラグの追加

このユーザー管理機能は、/adminで始まるURLで提供し、adminというフラグがtrueのユーザー(管理者)だけが利用できるようにします。

$ bin/rails g migration add_admin_to_users
Running via Spring preloader in process 80529
      invoke  active_record
      create    db/migrate/20210321204250_add_admin_to_users.rb

生成したマイグレーションファイルを以下の通りに編集します。
usersテーブルにadminカラムを追加します。

/db/migrate/20210321204250_add_admin_to_users.rb

class AddAdminToUsers < ActiveRecord::Migration[6.1]
  def change
    add_column :users, :admin, :boolean, default: false, null: false
  end
end

編集したら以下のコマンドを実行します。

$ bin/rails db:migrate

コントローラの実装

CRUD機能を持ったAdmin::UsersControllerという名前のコントローラを作成します。

Admin::UsersControllerには以下のアクションを持たせます。

以下のコマンドを実行してapp/controllers/admin/users_controller.rbを画面を伴うGETアクションのビューとともに作成します。

$ bin/rails g controller Admin::Users new edit show index
Running via Spring preloader in process 81279
      create  app/controllers/admin/users_controller.rb
       route  namespace :admin do
  get 'users/new'
  get 'users/edit'
  get 'users/show'
  get 'users/index'
end
      invoke  slim
      create    app/views/admin/users
      create    app/views/admin/users/new.html.slim
      create    app/views/admin/users/edit.html.slim
      create    app/views/admin/users/show.html.slim
      create    app/views/admin/users/index.html.slim
      invoke  test_unit
      create    test/controllers/admin/users_controller_test.rb
      invoke  helper
      create    app/helpers/admin/users_helper.rb
      invoke    test_unit
      invoke  assets
      invoke    scss
      create      app/assets/stylesheets/admin/users.scss

new、edit、show、indexのからのアクションとデフォルトのビューが用意できました。

次に、想定したURLで適切なアクションにリクエストが飛ぶようにroute.rbを以下のように編集します。

config/route.rb

Rails.application.routes.draw do
  namespace :admin do
    resources :users
  end
  resources :tasks
  root to: 'tasks#index'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

各機能のURLを取得するにはadmin_users_pathやadmin_user_pathのようにadmin_のついたヘルパーメソッドを使います。

登録機能の実装

登録機能を実装します。コントローラ(app/controllers/admin/users_controller.rb)とビュー(app/views/admin/users/new.html.slim)を以下の通りに編集します。

app/controllers/admin/users_controller.rb

class Admin::UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to admin_user_url(@user), notice: "ユーザー「#(@user.name)」を登録しました。"
    else
      render :new
    end
  end

  ・・・

  private

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

app/views/admin/users/new.html.slim

h1 ユーザー登録

= form_with model: [:admin, @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-check
    = f.label :admin, class: 'form-check-label' do
      = f.check_box :admin, class: 'form-check-input'
      | 管理者権限
  .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-primary'

編集できたらUserクラスに検証を追加します。

app/models/user.rb

class User < ApplicationRecord
  has_secure_password

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

名前とメールアドレスが空のときやメールアドレスが重複するときは登録できないようにできました。

サーバを再起動し、/admin/users/newにアクセスすると以下の画面が表示されます。

その他機能の実装

残りのCRUD機能を以下の通りに実装します。

app/controllers/admin/users_controller.rb

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

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

  def new
    @user = User.new
  end

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

  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to admin_user_url(@user), notice: "ユーザー「#(@user.name)」を登録しました。"
    else
      render :new
    end
  end

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

    if @user.update(user_params)
      redirect_to admin_user_url(@user), notice: "ユーザー「#(@user.name)」を更新しました。"
    else
      render :edit
    end
  end

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

    @user.destroy
    redirect_to admin_user_url(@user), notice: "ユーザー「#(@user.name)」を削除しました。"
  end

  private

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

app/views/admin/users/index.html.slim

h1 ユーザー一覧

= link_to '新規登録', new_admin_user_path, class: 'btn btn-primary'

.mb-3
table.table.table-hover
  thread.thread-default
    tr
      th= User.human_attribute_name(:name)
      th= User.human_attribute_name(:email)
      th= User.human_attribute_name(:admin)
      th= User.human_attribute_name(:created_at)
      th= User.human_attribute_name(:updated_at)
      th
  tbody
    - @users.each do |user|
      tr
        td= link_to user.name, [:admin, user]
        td= user.email
        td= user.admin? ? 'あり' : 'なし'
        td= user.created_at
        td= user.updated_at
        td
          = link_to '編集', edit_admin_user_path(user), class: 'btn btn-primary mr-3'
          = link_to '削除', [:admin, user], method: :delete, data: { confirm: "ユーザー「#{user.name}」を削除します。よろしいですか?"}, class: 'btn btn-danger'

app/views/admin/users/new.html.slim

h1 ユーザー登録

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

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

app/views/admin/users/edit.html.slim

h1 ユーザーの編集

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

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

app/views/admin/users/_form.html.slim

- if user.errors.present?
  ul#error_explanation
    - user.errors.full_messages.each do |message|
      li= message

= form_with model: [:admin, @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-check
    = f.label :admin, class: 'form-check-label' do
      = f.check_box :admin, class: 'form-check-input'
      | 管理者権限
  .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-primary'

app/views/admin/users/show.html.slim

h1 ユーザーの詳細

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

table.tabel.table-hover
  tbody
    tr
      th= User.human_attribute_name(:id)
      td= @user.id
    tr
      th= User.human_attribute_name(:name)
      td= @user.name
    tr
      th= User.human_attribute_name(:email)
      td= @user.email
    tr
      th= User.human_attribute_name(:admin)
      td= @user.admin? ? 'あり' : 'なし'
    tr
      th= User.human_attribute_name(:created_at)
      td= @user.created_at
    tr
      th= User.human_attribute_name(:updated_at)
      td= @user.updated_at

= link_to '編集', edit_admin_user_path, class: 'btn btn-primary mr-3'
= link_to '削除', [:admin, @user], method: :delete, data: { confirm: "ユーザー「#{@user.name}」を削除します。よろしいですか?"}, class: 'btn btn-danger'

以上でユーザー管理機能の基礎ができました。
/admin/usersにアクセスすることで機能を利用することができます。
この機能へのリンクはのちにレイアウトに追加します。

今回はここまでです・

終わりに

タスク管理アプリケーションにログイン機能を追加する準備としてユーザー管理機能の基礎部分を実装しました。

しかしこのままでは管理者でなくてもユーザー管理機能を使えてしまいます。ログインしている管理者のみ使えるように制限をかける必要がありますが、そのためには先にログイン機能を実装する必要があります。

次回はログイン機能とログアウト機能を実装します。

コメント

タイトルとURLをコピーしました