Railsチュートリアル(第6版)　第8章　基本的なログイン機構

#第8章
この章では、ログインやログアウト機能を追加する。また、認証システムや認可モデルも導入する。

##セッション
HTTPはステートレス(状態がない)プロトコルのため、以前の情報を利用できない。つまり、別のページに遷移する時に情報を保持することが出来ない。
そのため、セッション(Session)を使うことで、状態を保持することができる。

Railsでセッションを実装する方法として、**cookies**を使うのが一般的。

Ｑ：**cookies**とは？
Ａ：ブラウザに保存される小さなテキストデータのこと。そのため、別ページに遷移しても情報が保持される。

セッションを利用するには、こんな下記のように
・ログインページで、新しいセッションを出力し、ログインするとセッションを実際に作成し保存。

・ログアウトするとセッションを破棄する。

こんな感じでやる。

###Sessionsコントローラ
セッションも、Sessionsコントローラを作成して、特定のRESTアクションにそれぞれ結びつけることにする。
Sessionsコントローラー作成

```
$ rails generate controller Sessions new
```

なお、newアクションを生成すると、それに対応したビューも同時に作られるのは知っての通りだが、無駄なビューを作成しないため、newだけ指定する。

`resources`メソッドを使ってフルセットを利用しないため、名前付きルーティングだけを使う。

```ruby:config/routes.rb
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
```

次にテストを更新する。

```ruby:test/controllers/sessions_controller_test.rb
require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest

  test "should get new" do
    get login_path
    assert_response :success
  end
end
```

ここのテストは、セッションコントローラーであることに気を付ける。


ルーティング図表

| HTTPリクエスト | URL | 名前付きルート |アクション名|用途|
|:-----------|:------------|:------------|:------------|:------------|
| GET | /login | login_path|new|新しいセッションのページ（ログイン）|
| POST | /login | login_path |create|新しいセッションの作成（ログイン）|
| DELETE| /logout| logout_path |destroy|セッションの削除（ログアウト）|

因みに、`rails routes`とコマンド実行すると現状のルーティングを確認できる。

演習
1. `GET login_path`は、ログイン用のフォームを受け取り、`POST login_path`はフォームの入力値をPOSTリクエストで送信する。

2.

```
$ rails routes | grep sessions
                         sessions_new GET    /sessions/new(.:format)                                                                  sessions#new
                                login GET    /login(.:format)                                                                         sessions#new
                                      POST   /login(.:format)                                                                         sessions#create
                               logout DELETE /logout(.:format)                                                                        sessions#destroy
```

###ログインフォーム
ここでは、セッションで使うビュー(ログインフォーム)を整える。
前回のユーザー作成ではActive Recordによって自動生成されるエラーメッセージがあったが、今回扱うセッションはActive Recordオブジェクトがないので、エラーメッセージが表示されない。
なので、実装としては、フラッシュでのエラーを表示する。

今までフォーム実装では、`form_with`ヘルパーを使って、インスタンス変数`@user`を引数に取ってた。

しかし、ユーザーリソースを作成するわけではなく、セッションを作成するだけなので、セッションにはSessionモデルがない。そのため、セッションフォームを作成するときは下記のようになる。

`form_with(url: login_path, scope: :session, local: true)`


セッションの場合はリソースの**スコープ**をとURLを明示的に指定する。
scope: :sessionと書いたことで、params[:session]に各種属性が入る。
メールアドレスなら
`params[:session][:email]`
パスワードなら
`params[:session][:email]`
に格納される。

因みにユーザー登録フォームの`form_with`はこんなん
`form_with(model: @user, local: true)`


ログインフォームのコード

```ruby:app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(url: login_path, scope: :session, local: true) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>
```

###ユーザーの検索と認証
ユーサー登録の時と同様に、最初に行うのは、入力が無効な場合の処理を実装する。

Sessionコントローラ(暫定版)

```ruby:app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new'
  end

  def destroy
  end
end
```

フォームを送信したら、render 'new'でnewページが描画される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/79b2f7cc-1408-3043-b02f-ce30b02b74d5.png)
デバッグ情報にメールアドレスとパスワードが入った。

```
  session: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
    email: aaa@example.com
    password: aaaaaa
```

これから分かるように、ネスト構造になっている。
paramsはこんな感じの入れ子ハッシュになっている。
`{ session: { password: "foobar", email: "user@example.com" } }`

なので、`params[:session]`の中に

`{ password: "foobar", email: "user@example.com" }`が入っている。

従って、`params[:session][:email]`これでデータにアクセスできる。

このような形でデータを取り出せるので、`User.find_by`メソッドでユーザーを探し、`authenticate`メソッドでパスワードの認証が出来た場合、セッションを作成するという流れでOK

```ruby:app/controllers/sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      # エラーメッセージを作成する
      render 'new'
    end
  end
```

`user = User.find_by(email: params[:session][:email].downcase)`

①メールアドレスは小文字で保存していたので、送信されたメールアドレスを`downcase`メソッドを使って小文字化する。

②`find_by`メソッドでメールアドレスがあるか検索する。

③メールアドレスがあれば、`user`に格納

`if user && user.authenticate(params[:session][:password])`

④&&は論理積で、ユーザーがいて、パスワードの認証が通ればOKということ。つまりは、どちらも真であるなら、if文の中を実行する。

###フラッシュメッセージを表示する
ログイン失敗時にエラーメッセージを表示する。

`flash[:danger] = 'Invalid email/password combination' # 本当は正しくない`

このままでは、一度フラッシュメッセージが表示されると消えずに残ってしまう。
`render`メソッドで強制的に再レンダリングしているため、リクエストとならない。従って、メッセージが常に表示されたままとなる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/709a7989-2b71-34c5-dafe-ff0feb795381.png)
サンプルページでも表示されたまま

###フラッシュのテスト
フラッシュ問題があったので、ログイン関係の簡単な統合テストを作成する。
いつもの通りTDDで行う。

```
$ rails generate integration_test users_login
```

テストコードの手順

①ログイン用のパスを開く
②新しいセッションのフォームが正しく表示されているか
③わざと無効な`params`ハッシュを使ってセッション用パスにPOST
④新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されているか
⑤別のページ(Homeページなど)にいったん移動
⑥移動先のページでフラッシュにメッセージが表示されてないことを確認

この内容をテストコードに落とし込む

```ruby:test/integration/users_login_test.rb
  test "login with invalid information" do
    get login_path
    assert_template 'sessions/new'
    post login_path, params: { session: { email: "", password: "" } }
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
```

テスト実施

```
$ rails test test/integration/users_login_test.rb
1 runs, 4 assertions, 1 failures, 0 errors, 0 skips
```

この問題を解決するには、`flash`を`flash.now`に置き換えればOK
`flash.now`はリクエストが発生した時に消える。
なので、`redirect_to`で使うとフラッシュが表示されないので、`render`と合わせて覚えるのがよさげかな？

```ruby:app/controllers/sessions_controller.rb
flash.now[:danger] = 'Invalid email/password combination'
```

テストしたらOKだった

演習

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/202f82d1-0935-ef13-3b10-8deb501d8288.png)
エラーメッセージが表示されたので別のページに遷移すると、、、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/8a385f3e-4e23-053c-31b1-1e022fef11d0.png)
エラーメッセージが消えたのでOK

##ログイン
cookiesを使った一時セッションでユーザーをログインできるようにする。このcookiesはブラウザを閉じると自動的に有効期限が切れるものだが、第9章ではブラウザを閉じても保持されるセッションを追加する。

Railsの全コントローラーの親クラスであるApplicationコントローラーに`SessionsHelper`を読み込ませれば、どのコントローラーでも使えるようになる。

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include SessionsHelper
end
```

###`log_in`メソッド
`session`メソッドを使って、単純なログインを行えるようにする。
`session`メソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。(`cookies`メソッドは、対照的に永続性がある)

ログインは様々な場所で使うので、Sessionsヘルパーに`log_in`という名前のメソッドを定義する。

```ruby:app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
end
```

`session`メソッドで作成した一時cookiesは自動的に暗号化され、保護される。
また、攻撃者がこのcookies情報を盗み出せたとしても、本物のユーザーとしてログインできない。しかし、これは一時セッションのみしか該当せず、永続的セッションでは断言できない。

`log_in`というヘルパーメソッドを定義したので、セッションの`create`アクションを完了させて、プロフィールページにリダイレクトさせる。

```ruby:app/controllers/sessions_controller.rb
    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user
    else
```

###現在のユーザー
別のページでもユーザーIDを使えるようにするため、`current_user`メソッドを定義し、セッションIDに対応するユーザー名をデータベースから取り出せるようにする。なお、`current_user`メソッドの目的は、次のようなコードを書く為
`<%= current_user.name %>`
これで現在のユーザー名を表示できる。
また、プロフィールページにリダイレクトさせることも
`redirect_to current_user`

ユーザーIDが存在しない状態で`find`メソッドを使うと例外が発生するため、`find_by`メソッドを使うことにする。

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

次に、IDが無効な場合(ユーザーが存在しない場合)にも例外を発生させず、`nil`を返すので、`current_user`を定義しなおす。

```ruby
def current_user
  if session[:user_id]
    User.find_by(id: session[:user_id])
  end
end
```

セッションにユーザーIDが存在しない場合、`nil`が返ってくるのがポイント。
`current_user`が呼び出された回数分だけ、データベースへ問い合わせが発生する。
なので、`User.find_by`で得た実行結果をインスタンス変数に格納すれば、最初の一回のリクエストだけになる。以後の呼び出しでは、インスタンス変数の結果を再利用すればOK

```ruby
if @current_user.nil?
  @current_user = User.find_by(id: session[:user_id])
else
  @current_user
end
```

or演算子である「||」を使えば、下のように書き換えられる。

`@current_user = @current_user || User.find_by(id: session[:user_id])`

とてもすっきりしたコードになった。さらにこのコードは、`@current_user`に何も代入されていなければ、`find_by`を呼び出すため、無駄にデータベースへの読み出しが行われない。

さらに短縮形の書き方がある。

`@current_user ||= User.find_by(id: session[:user_id])`

この書き方は、プログラミングやっていたらよく見かけるだろう。
`x += 1`とかでおなじみの奴だ。元は、`x = x + 1`
さらにいいことは、論理値の||は左から順に評価していくのだが、trueになった時点で処理を終える。また、論理値の&&は、左から右に評価していく際に、falseになった時点で処理を終える。

`current_user`メソッドに適用させると以下のコードになる。

```ruby:app/helpers/sessions_helper.rb
  # 現在ログイン中のユーザーを返す（いる場合）
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end
```

###レイアウトリンクを変更する
ユーザーがログインしている時とログインしていない時で、レイアウトを変更する。
レイアウトのリンクを変更させる方法は、ERBコードの中でif文を使えばOK

```erb
<% if logged_in? %>
  # ログインユーザー用のリンク
<% else %>
  # ログインしていないユーザー用のリンク
<% end %>
```

また、ログインの有無は`logged_in?`メソッドを作成して、行う。

ユーザーがログイン中の状態＝sessionにユーザーIDが存在している。
つまり、`current_user`が`nil`でない状態のこと。
チェックには、否定演算子の`!`を使用する。これで論理値の逆転が起こる。

```ruby:app/helpers/sessions_helper.rb
  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  end
```

続いて、ヘッダーのパーシャル部分を変更する。

```ruby:app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", '#' %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>
```

`<li><%= link_to "Profile", current_user %></li>`
これは短縮形の書き方で、短縮しないと下記のコードになる。
`<%= link_to "Profile", user_path(current_user) %>`
どちらもリンク先はshowページになる。

レイアウトに新しいリンクを追加したので、Bootstrapのドロップダウンメニュー機能が適用できる状態になった。
BootstrapにあるCSSの`dropdown`クラスや`dropdown-menu`が該当する。
また、bootstrapを有効にするため、application.jsファイルを通して、Bootstrapに同行されているJavaScriptライブラリとjQueryも読み込む。

jQueryとbootstrapを読み込む
`$ yarn add jquery@3.4.1 bootstrap@3.4.1`

アプリケーションでjQueryを有効にするため、Webpackの環境ファイルを編集、追加する。

```ruby:config/webpack/environment.js
const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

module.exports = environment
```

最後に`application.js`ファイルでjQueryを`require`して、Bootstrapを`import`する。

```ruby:app/javascript/packs/application.js
require("jquery")
import "bootstrap"
```


これでログイン中とログアウトで別々のヘッダーが表示されるようになった。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/c1e8ae63-fb2a-0d8f-baa8-155cfc003cd0.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/b395bb2d-5472-647b-16ec-68193913c56d.png)

###レイアウトの変更をテストする
先ほどレイアウトの変更を行ったので、今回はその変更に対して統合テストをする。
手順
①ログイン用のパスを開く
②セッション用パスに有効な情報をpostする
③ログイン用リンクが表示されなくなったことを確認する
④ログアウト用リンクが表示されていることを確認する
⑤プロフィール用リンクが表示されていることを確認する

このテストをするには、登録済みユーザーとしてログインしておく必要があるため、テスト用ユーザとしてRailsではテスト用データを**fixture**で作成できる。

テストユーザーに有効なパスワードを用意するが、パスワードは`password_digest`にハッシュ化され保存されるため、fixtureでも同様にハッシュ化したパスワードを保存するための、`digest`メソッドを独自に定義する。

`has_secure_password`でbcryptパスワードが作成されるので、同様の方法でfixuture用のパスワードを作成する。

`secure_password`のソースコードでパスワードが生成されている。

`BCrypt::Password.create(string, cost: cost)`

・`string`はハッシュ化する文字列
・`cost`はコストパラメータと呼ばれる。これは、ハッシュを算出するのに掛かる計算コストを指定するが、この値を高くすればするほど、より堅牢なパスワードになるが重くなり、低いコストだと簡単だが軽くなる。
　この特性があるため、テストにおいては低いコストの方が軽くて望ましい。
なお、コストを指定するコードは、下のようになる。

```ruby
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                              BCrypt::Engine.cost
```

`digest`メソッドは、今後様々な場所で利用するため、Userモデルに配置する。
fixtureファイルがわざわざユーザーオブジェクトにアクセスする必要性はないので、クラスメソッドで定義したほうが良い。

```ruby:app/models/user.rb
  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
```

fixtureファイルにテストユーザーを追記

```yml:test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
```

fixtureではERbを利用できるため、`digest`メソッドに`password`という文字列を渡して、ハッシュ化された戻り値を`password_digest`に入れるという流れ。
また、テスト用のfixtureにいるユーザーは、全員同じパスワードにするのが一般的。

有効なテストユーザーのfixtureができたら、fixtureのデータを下のように参照できるようにする。
`user = users(:michael)`
`users`は、`users.yml`を表し、シンボルの`:michael`は先ほど作ったユーザーにアクセスするためのキー。

```ruby:test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "login with valid information" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
  end
end
```

・`assert_redirected_to @user`は、リダイレクト先が@userか。(前回やったやつ)

・`follow_redirect!`は、実際にリダイレクト先に遷移する。

・`assert_template 'users/show'`で、showビューが表示されているか。

・`assert_select "a[href=?]", login_path, count: 0`は、`count: 0`というオプションを渡すことで、渡したパターンに一致するリンクが0かを確認している。今回は、ログイン後のshowビューなので、ログインパスがない状態、ログアウトパスがある状態、showページへのリンクがある状態としている。

演習
ぼっち演算子
`obj &&` obj.methodという記法があったが、これは短く書ける。
`obj&.method`とすればOK
なので
`user&.authenticate(params[:session][:password])`
は
`user&.authenticate(params[:session][:password])`
と書ける。

###ユーザー登録時にログイン
現状は、登録終了時にユーザーがデフォルトでログインしないため、ユーザー登録と同時にログインをできるようにする。

Usersコントローラーの`create`アクションに`log_in`メソッドを追記すればOK

```
  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end
```

ユーザー作成時にログインしているかどうかをチェックするため、`is_logged_in?`ヘルパーメソッドを定義する。これは、テストのセッションにユーザーがあれば、`true`を返し、それ以外は`false`を返す。
しかし、ヘルパーメソッドはテストから呼び出せないため、テストヘルパーにコードを書く。

```ruby:test/test_helper.rb
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end
```


```ruby:test/integration/users_signup_test.rb
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
```

ユーザー登録後のログインテストに
`assert is_logged_in?`
を入れればOK

##ログアウト
ログアウト機能を追加する。
セッションからユーザーIDを削除すれば良い。
`delete`メソッドを使って
`session.delete(:user_id)`
とすればOK

```ruby:app/helpers/sessions_helper.rb
  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
```

ヘルパーモジュールに`log_out`メソッドを入れた。

次に、`log_out`メソッドを呼び出す`destroy`アクションを追加する。

```ruby:app/controllers/sessions_controller.rb
  def destroy
    log_out
    redirect_to root_url
  end
```

ログアウト後ルートURLにリダイレクトさせる。

次にテストにいくつかテスト項目を追加
・ログインして、`delete`メソッドで`DELETE`リクエストをログアウト用パスに発行し、ユーザーがログアウトしてルートURLにリダイレクトすること
・ログイン用リンクが再表示されること
・ログアウト用リンクとプロフィール用リンクが非表示になる事

```ruby:
  test "login with valid email/invalid password" do
    get login_path
    assert_template 'sessions/new'
    post login_path, params: { session: { email:    @user.email,
                                          password: "invalid" } }
    assert_not is_logged_in?
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end

  test "login with valid information followed by logout" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in?
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end
```

`is_logged_in?`ヘルパーメソッドを定義したおかげで、`assert is_logged_in?`で簡単にテストできるようになった。

#最後に
ログイン機構の実装は、一回で理解しようとせず、二回目あたりから理解していく認識でも十分だと思う。
少しややこしい話になってきたが、より快適に使えるようUXを高めていくことは大事だ。

あとは、GithubとHerokuにあげて終了