[Rails] セッション管理をベタで実装してみる



Ruby on Rails Tutorialのエッセンスを自分なりに整理11

[Rails][RSpec] Capybaraでフォーム入力をシミュレートしてテストする
http://qiita.com/kidachi_/items/b0e607c83e9da9380d7e
の続き。

Ruby on Rails Tutorial（chapter8）
http://railstutorial.jp/chapters/sign-in-sign-out?version=4.0#top

## 概要

普通はDeviseなどの便利なgemやライブラリを使うのだろうが、
内部動作を知るためにまとめる。

※ あくまでRails上での「ベタ」です。

## セッション管理の流れ

#### 新規登録時

1. ユーザ作成にあわせて、remember_token（セッショントークン）生成
- 暗号化の上dbに保管

#### ログイン時

1. 新たにremember_token生成
- cookieにremember_tokenを保管
- 暗号化の上dbにも保管
- コントローラからもビューからもアクセスできる@current_userを準備
- ユーザアクセスの度、cookieの中身とdbの中身を擦り合わせて照合
- サインアウトでセッションを破棄する

※ 新規登録時は、連続してログインフローに乗る

## セッションリソースの扱い

#### 通常（Usersなど）のリソースと同様、RESTfulに管理する

|HTTPリクエスト	|URL|	名前付きルート|	アクション|	用途|
|---|---|---|---|---|
|GET	|/signin	|signin_path	|new	|新しいセッション用 (サインイン)|
|POST	|/sessions|	sessions_path	|create|	新しいセッションを作成する|
|DELETE	|/signout	|signout_path|	destroy|	セッションを削除する (サインアウト)|

```ruby:config/routes.rb
SampleApp::Application.routes.draw do
  resources :users
  resources :sessions, only: [:new, :create, :destroy]
  root  'static_pages#home'
  match '/signup',  to: 'users#new',            via: 'get'
  match '/signin',  to: 'sessions#new',         via: 'get'
  match '/signout', to: 'sessions#destroy',     via: 'delete'
  ~
end
```

#### ただし、ストレージはCookie

通常のリソース（Users）などがバックエンドにDBを持つのに対して、
Sessionsリソースはcookiesを利用する。


## サインイン（ログイン）機能実装

1. サインインフォーム準備
- フォーム値の受け取り（createメソッド）
 - バリデーション
- サインイン実行（セッションを張る）
 - sign_in（ほか関連）メソッドの実装

### サインインフォームの作成

```ruby:erb
<% form_for(:session, url: sessions_path) %>
<% end %>
```

セッションはモデルが存在しないため、リソース名と対応するURLを指定する必要がある。

```ruby:erb
    <%= form_for(:session, url: sessions_path) do |f| %>

      <%= f.label :email %>
      <%= f.text_field :email %>

      <%= f.label :password %>
      <%= f.password_field :password %>

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

### フォーム値の受け取り（createメソッド）

```ruby:app/controllers/sessions_controller.rb
def create
  user = User.find_by(email: params[:session][:email].downcase)
  if user && user.authenticate(params[:session][:password])
    # ユーザーをサインインさせ、ユーザーページ (show) にリダイレクトする。
  else
    # エラーメッセージを表示し、サインインフォームを再描画する。
  end
end
```
### 値のバリデーション

- 該当emailのユーザが存在するかどうか

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

- パスワードが適切かどうか

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



### サインイン実行（セッションを張る）


```ruby:app/controllers/sessions_controller.rb
def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      sign_in user
      redirect_to user    # userを解析し、'/users/:id'にリダイレクト
    else
      flash.now[:error] = 'Invalid email/password combination'
      render 'new'
    end
  end
```
## sign_in（ほか関連）メソッドの実装

- サインイン状態を永続化
 - ユーザが明示的にサインアウトしたときのみ破棄

#### 流れ

1. 新たにremember_token生成
- cookieにremember_tokenを保管
- 暗号化の上dbにも保管
- コントローラからもビューからもアクセスできる@current_userを準備
- ユーザアクセスの度、cookieの中身とdbの中身を擦り合わせて照合
- サインアウトでセッションを破棄する


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

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.encrypt(remember_token))
    self.current_user = user
  end

  def current_user=(user)
    @current_user = user
  end

  # signed_in?を経由して、セッションが張られているかどうかを確認する
  # つまり、（ほとんどの）リクエストの度に呼ぶようにする。
  def current_user
    remember_token = User.encrypt(cookies[:remember_token])
    @current_user ||= User.find_by(remember_token: remember_token)
  end

  def signed_in?
    !current_user.nil?
  end

  def destroy
    self.current_user = nil
    cookies.delete(:remember_token)
  end

end
```


### どこに定義するか

セッション周りのメソッドはコントローラ/ビューのどちらからも使用できるよう、
<strong>SessionsHelper</strong>に定義（モジュール化する）


#### 例えば
ビューでsigned_in?でログイン判定し、リンクを出し分けるサンプル

```app/views/layouts/_header.html.erb
  <% if signed_in? %>
    <li><%= link_to "Users", '#' %></li>
    <li><%= link_to "Profile", current_user %></li>
    <li><%= link_to "Settings", '#' %></li>
    <li><%= link_to "Sign out", signout_path, method: "delete" %></li>
  <% else %>
    <li><%= link_to "Sign in", signin_path %></li>
  <% end %>
```


### コントローラにinclude

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end
```
すべてのヘルパーはビューではデフォルトで使用できる。
コントローラ側で使うには、明示的にapplication_controllerでインクルードしてやる。

## remember_token生成ロジックの実装

### Userモデルにremember_token属性を追加

```sh
$ rails generate migration add_remember_token_to_users
```

```ruby:db/migrate/[ts]_add_remember_token_to_users.rb
class AddRememberTokenToUsers < ActiveRecord::Migration
  def change
    add_column :users, :remember_token, :string
    add_index  :users, :remember_token
  end
end
```
```sh
$ rake db:migrate
```


### token生成

- 一意性が担保できる、長くてランダムな文字列を用いる

SecureRandomモジュールのurlsafe_base64メソッドを利用

```ruby
  def User.new_remember_token
    SecureRandom.urlsafe_base64
  end
```
- 万一dbが攻撃されて漏洩された場合に備えて暗号化

```ruby
  def User.encrypt(token)
    Digest::SHA1.hexdigest(token.to_s)
  end
```

### どこに定義するか

remember_tokenの利用シーンは以下ふたつ。

- サインイン時
- 新規登録時

※新規作成されたユーザは連続してサインインさせるため
サインイン時のみremember_token作成すれば良い様にも思うが、
分離された役割である以上片方に依存すべきではない。
すべてのユーザが必ず最初から有効なremember_tokneを持つ様にすべき。

よって、（新規登録時に生成出来るよう）<strong>Userモデルに定義</strong>する。
→before_create（コールバック）を用いて、User登録のタイミングで生成する。


```ruby:app/models/user.rb
class User < ActiveRecord::Base
  ~
  # User生成のタイミングでコールバック
  before_create :create_remember_token
  ~

  # ログイン時はSessionHelperから呼び出すことになるのでpublic
  def User.new_remember_token
    SecureRandom.urlsafe_base64
  end

  # 同上
  def User.encrypt(token)
    Digest::SHA1.hexdigest(token.to_s)
  end

  private

    # User生成時、before_createコールバックから呼び出す。
    def create_remember_token
      self.remember_token = User.encrypt(User.new_remember_token)
    end

end
```

これでサインイン時セッションを張り、サインアウトで破棄する一連のロジックが完成した。