Ruby on Rails

【Railsチュートリアル】クッキーを使ったセッション管理方法まとめ

セッションの勉強のためにRailsチュートリアルの8章、9章をやりましてとても勉強になったので、このブログでアウトプットしたいと思います。

(注) このブログは筆者のまとめ記事になるので、まだやってない方はRailsチュートリアルを一度やることをおすすめします。Railsチュートリアルを一周した人が復習に読むことを想定して書いています。

セッションとは

HTTP通信とはステートレスです。ステートレスとは状態を持たないということです。

ただし、AmazonでのネットショッピングやLINEのメッセージのようにログインして使うサイトの場合、本人であることの確認を行うためにページ遷移するごとにユーザIDとパスワードが必要になってしまいます。これではあまりにも不便ですよね。なので、セッションを使ってログイン状態を保持するということをします。セッションの説明が下になります。

アプリケーションはセッションを用いて、多くのユーザーがアプリケーションとやりとりできるようにしつつ、各ユーザー固有のステートを維持します。たとえばセッションを用いることで、ユーザーが認証を1回行うだけで以後のリクエストでサインインしたままにできます。

https://railsguides.jp/security.html#%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3

クライアントとサーバーの通信状態をセッションと呼びます。アプリケーションはブラウザのクッキーを利用してクライアントごとに一意のセッションIDを保存します。

クッキーとは

クッキーの特徴は以下です。

  • HTTPヘッダーに格納できるテキストデータ
  • このデータはクライアントのブラウザに保存される

Cookieとは、Webサイトの提供者が、Webブラウザを通じて訪問者のコンピュータに一時的にデータを書き込んで保存させる仕組み。

https://e-words.jp/w/Cookie.html

この仕組みを使うことで、アプリケーションはセッションIDだけでなくユーザの買い物かごの状態や過去に訪れたページなどを保存することができます。クッキーにより本来のHTTP通信ではできなかった状態の維持ができ、ネットショッピングが利用できるということになります。

セッションを用いたログイン機能

ここからはRailsでログイン機能を実装する例を見ていきます。Railsではsessionメソッドを使って、ブラウザのcookieにユーザ情報を一時的に保存することができます。(モジュールをIncludeする必要があります。)

include SessionHelper

def log_in(user)
  session[:user_id] = user.id
end

sessionメソッドは次のような特徴を持っています。

  • ユーザのIDが暗号化された状態で、ブラウザのクッキーに保存される
  • ブラウザを閉じると、cookieに保存されたセッションIDも削除される
  • この後、session[:user_id]とすることで、ブラウザから送られてきたセッションIDからuser_idを復元することができる

ブラウザはリクエストを送る際、毎回クッキーをリクエストのヘッダーにつけて送る仕組みになっている(下記赤く囲った箇所)ので、Rails側で送られてきたセッションIDをチェックし、該当するユーザに対応するページを返します。

sessionメソッドは一時的なcookieを作成するメソッド

このcookieを利用してログイン状態を実現する

ここでブラウザを閉じるとブラウザでこのクッキーを削除するので、次回アクセスする際には再度ログインが必要になります。

永続的なログイン機能

これで簡単なログイン機能をつくることができました。しかし、これではブラウザを再起動するとクッキーが失われて再度ログインする必要があります。普段ログイン状態でブラウザを閉じ、再度起動してもログイン状態が保持されてますよね。これを実現するにはcookieにユーザ情報を永続的に保存しておかないといけません。

その場合、Railsで用意されているcookiesメソッドを使って、ユーザの暗号化済みのIDと記憶トークンをブラウザのクッキーに保存します。cookiesメソッドで以下のように1つの値と有効期限を設定できます。有効期限を20年に設定してすることで、永続的なログイン機能をつくることができます。

cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc }

permanentメソッドを使うと次のように書けます。

cookies.permanent[:remember_token] = remember_token

記憶トークンをユーザーに紐付けないといけないため、次のようにユーザーIDもクッキーに保存します。

cookies[:user_id] = user.id

signedをつけると、ユーザーIDを暗号化できます。生のユーザーIDをクッキーに保存するとアプリケーションの内部構造の推測を助けることになるので必ず暗号化します。

cookies.signed[:user_id] = user.id

また、今回は永続的なログイン状態を実現したいので永続化します。

cookies.permanent.signed[:user_id] = user.id

ユーザーを取り出す場合は、次のようにします。signed[:user_id]とすることでRials側で自動的に暗号が元に戻ります。

User.find_by(id: cookies.signed[:user_id])

それではこのメソッドを使って実際に永続的なログイン機能の実装方法を詳しく見ていきます。

実装方法

永続セッションはセキュリティー上重要なので、下のようにして安全に管理することとします。

  • 記憶トークンにはランダムな文字列を生成して用いる
  • トークンはハッシュ値に変換してからデータベースに保存する
  • ユーザIDを含むクッキーを受け取ったら、そのIDでデータベースを検索して、記憶トークンのクッキーがデータベース内のハッシュ値と一致することを確認する

これを実現するために、いくつか準備をするのと紹介するメソッドがあります。まずはremember_digest属性をUserモデルに追加しないといけません。ここには記憶トークンのハッシュ値を保存します。

usersテーブル

idinteger
namestring
emailstring
created_atdatetime
updated_atdatetime
password_digeststring
remember_digeststring

また、ランダムな記憶トークンを作成するメソッドも必要です。ここではSecureRandomモジュールのurlsafe_base64メソッドを使います。このメソッドはA-Z, a-z, 0-9, -, _のいずれかの文字からなる長さ22のランダムな文字列を返します。

SecureRandom.urlsafe_64
=> "iP_w8x0M28HUztRtIRAo-A"

さらに紹介しないといけないのはupdate_attributeメソッドです。これは属性の一部を変更するメソッドです。また、バリデーションの検証を回避するといった効果もあります。

user
=> #<User:0x00007f89fb0fe560
 id: 1,
 name: "さかい",
 email: "test@example.com">
user.update_attribute(:name, "酒井")
=> true
user.name
=> "酒井"
user.update!(name: "さかい")
=> ActiveRecord::RecordInvalid: Validation failed: Password can't be blank, Password is too short (minimum is 6 characters)

また、user.remember_tokenメソッドを使ってトークンにアクセスできるようにしてかつ、トークンをデータベースに保存しないという状態を実現します。attr_accessorを使うと仮想のremember_token属性を作ることができます。

class User < ApplicationRecord
  attr_accessor :remember_token

  def remember
    self.remember_token = ...
      // 略
  end
end

ここまでで、準備を終了です。実際にコードを見ていきます。

Userモデル

Userモデルでは、記憶トークンをハッシュ値に変換してデータベースのremember_digestカラムに保存する実装を書きます。まずself.new_tokenメソッドで記憶トークンを作り仮想のremember_token属性に入れます。rememberメソッドでそれをハッシュ化してremember_digest属性に保存しています。少しややこしいので、実装を見たほうが早いです。

class User < ApplicationRecord
  attr_accessor :remember_token
  # 略

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

  # ランダムなトークンを返す
  def self.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

self.digestメソッドの中身はスルーします。文字列を渡すとハッシュ化してくれるんだな程度でいいと思います。詳しくはこちら。

このメソッド内ではrememberメソッドの中身は下記のように書いてもいいののですが、user.rb以外の実装でここで作ったremember_token属性を使うのでここでは上のようにします。

def remember
  update_attribute(:remember_digest, User.digest(User.new_token))
end
  • rememberメソッドで仮想のremember_token属性に記憶トークンを入れる
  • その後、ハッシュ化した記憶トークンを保存する

また、authenticate?メソッドでは、BCrypt::Passwordクラスのis_password?メソッドを使ってクッキーで送られてきた記憶トークンとデータベースの中のハッシュ化されたremember_digestが一致するかどうか調べています。

もぐくん
もぐくん
self.digest(string)の中身なに(泣)
さかい
さかい
ここでは考えなくていいよ

sessions_helper.rb

続いて、ヘルパーメソッドを定義していきます。ここでは色々なメソッドを定義しているのですが、永続ログインを行っているrememberメソッドを中心に見ていきます。

module SessionsHelper
  # ログインを行う
  def log_in(user)
    session[:user_id] = user.id
  end

  # ログイン中のユーザーを定義する
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: session[:user_id])
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

  # ユーザがログイン中か判定するメソッド
  def loged_in?
    !current_user.nil?
  end

  # 永続ログインを行うメソッド
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end
end

current_userメソッドでは、クッキーにセッションIDがあれば該当するユーザーを探し出して@current_userへ格納、なければクッキーのユーザIDから該当するユーザを探し出し、クッキーの記憶トークンが正しいかを確認した上でそのユーザをログインさせて@current_userへ格納します。

  def current_user
    if (user_id = session[:user_id])
      # セッションIDから復元したユーザIDを持つユーザを探していれば、@current_userに格納
      @current_user ||= User.find_by(id: session[:user_id])
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      # 送られてくる記憶トークンがデータベースに記憶されている記憶トークン(ハッシュ化されたもの)と合っているか確認
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

sessions_helper.rbでのrememberメソッドでは、モデルで定義したrememberメソッドを実行し、仮想のremember_token属性をつくってデータベースにハッシュ化した記憶トークンを保存しています。その後、クッキーにユーザーIDと記憶トークンを入れてブラウザに渡す処理をしています。

def remember(user)
  # userのremember_digestに暗号化トークンを保存する
  user.remember
  cookies.permanent.signed[:user_id] = user.id
  cookies.permanent[:remember_token] = user.remember_token
end

さて、最後にsessions_controllerです。

もぐくん
もぐくん
疲れたよ
さかい
さかい
もう少し!

sessions_controller.rb

これまで定義してきたメソッドを利用するのでコントローラーでの処理は至ってシンプルです。ここで注目してもらいたいのがrememberメソッドです。ログインするたびに実行され、データベース中のremember_digestカラムの中身が変わるので、悪意あるユーザにクッキーからユーザIDと記憶トークンが盗まれたとしても、本物のユーザがログアウトすれば、これを使ってログインすることはできなくなります。もし、記憶トークンがなくユーザIDのみでログイン機能をつくっているとユーザIDが盗まれると悪意あるユーザのやりたい放題になってしまいます。

class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    # user.anthenticateでは送られてきたパスワードとデータベースの中にあるハッシュ化されたパスワードが一致しているか確認する
    if user && user.authenticate(params[:session][:password])
      log_in(user)
      remember(user) # 注目
      redirect_to user
    else
      flash.now[:danger] = "Invalid email/password combination"
      render 'new'
    end
  end
end

さいごに

長くなりましたが、Railsチュートリアルで書かれているログイン機能実装が終わりです。自分も復習に記事を読み返して適宜修正や追加をしていきたいと思います。
ここまで読んでくださりありがとうございました。

ABOUT ME
sakai
三重出身の28歳。前職はメーカーで働いていて、プログラミングスクールに通って未経験からWeb業界に転職しました。Railsをメインで使っていて、AWSも少しできます。音楽を聞くこととYoutubeを見るのが好きです。最近はへきトラ劇場にハマってます