0
1

Railsチュートリアル第9章学習まとめ

Posted at

9章

最近のWebサービスでは、ユーザーのログイン情報を任意で記憶しておき、ブラウザを再起動した後でもすぐにログインできる機能をremember meと言う。

永続cookie(permanent cookies)を使ってこの機能を実現していく。

ユーザーのログイン情報を長期間記憶する方法にを学ぶ。
その後、[remember me]チェックボックスを使って、ユーザーの任意でログイン情報を記憶する方法について学ぶ。

ということはレイヤーとして
・長期保存する機能
・ユーザーの任意で保存するかどうか決める機能
この2つがあるということですね。

9.1Remember me 機能

この機能を使うと、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになる。
ログインフォームにログインを継続するかどうかのチェックボックスも入れる。

記憶トークンと暗号化

Railsのsessionメソッドを使ってユーザーIDを保存しましたが、この情報はブラウザを閉じると消える。本節では、セッションの永続化の第一歩として記憶トークン(remember token)を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)によるトークン認証にこの記憶トークンを活用する。

パスワードとトークンとの一般的な違いは、パスワードはユーザーが作成・管理する情報であるのに対し、トークンはコンピューターが作成・管理する情報。

スマホのユーザー登録の情報でトークンというものを見たことがありましたが
そういうことだったんですね。

盗まれないような対策
1.TLSを全体的用。
2.記憶トークンのハッシュ化
3.Railsによるオート対策
4.物理的に盗まれた場合はセキュリティや2段階認証、パスワードの難易度をあげる、パスワードを使いまわさないといったところでしょうか。

セキュリティ事項の仕様設計で下記のような形をつくる
1.ランダムな文字列を生成して、それを記憶トークンとして使う。
2.記憶トークンは、ハッシュ化してデータベースに保存する。
3.記憶トークンをブラウザのcookiesに保存するときは、有効期限を設定する。
4.記憶トークンをブラウザのcookiesに保存するときは、ユーザーIDを暗号化する。
5.以降、もしブラウザのcookiesに暗号化されたユーザーIDがあったら、複合したユーザーIDでデータベースを検索し、データベース内のハッシュ値と一致するか確認する。(一致したらセッションを復元する)

というわけで早速設定に入ります。

remember_digest属性をUserモデルに追加
$ rails generate migration add_remember_digest_to_users remember_digest:string

マイグレーションの対象がデータベースのusersテーブルであることをRailsに指示

db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :remember_digest, :string
  end
end

記憶トークンとしてないを使うのか決める。
Ruby標準ライブラリにあるSecureRandomのurlsafe_base64を使う。

$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"

トークン生成用メソッドを追加

app/models/user.rb
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

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

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

続いての作業を要約させました。
Userモデルにremember_token属性を追加し、ユーザーを認証するためのrememberメソッドを実装。
具体的には、ユーザーのremember_tokenを生成し、それに関連するremember_digestをデータベースに保存するプロセスです。remember_tokenは仮想属性として実装され、update_attributeメソッドを用いてremember_digestがデータベースに保存されることになります。これにより、ユーザー認証のセキュリティが向上し、バリデーションをスキップすることで直接的なデータ更新が可能となります。

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

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

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

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

9.1.2ログイン状態の保持

user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続的なセッションを作成する準備が完了した。
実際に行うにはcookiesメソッドを使います。このメソッドは、sessionのときと同様にハッシュとして扱える。
個別のcookiesは、1つのvalue(値)と、オプションのexpires(有効期限)からできている。
(有効期限は省略可)。
例えば次のように、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができる。

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

さらにpermentメソッドを使うことにより省略ができる。

cookies.permanent[:remember_token] = remember_token
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }


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

  # ランダムなトークンを返す
  def User.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

つづいて

app/controllers/sessions_controller.rb
#ログインしてユーザーを保持する
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      reset_session
      remember user
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new', status: :unprocessable_entity
    end
  end

  def destroy
    log_out
    redirect_to root_url, status: :see_other
  end
end

remember userを追加。

app/helpers/sessions_helper.rb
module SessionsHelper

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

  # 永続的セッションのためにユーザーをデータベースに記憶する
  def remember(user)
    user.remember
    cookies.permanent.encrypted[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 現在ログインしているユーザーを返す(いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

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

  # 現在のユーザーをログアウトする
  def log_out
    reset_session
    @current_user = nil
  end
end

これを今回追加したわかですがいまいちコードがよくわかっていないので確認します。

# 永続的セッションのためにユーザーをデータベースに記憶する
  def remember(user)
    user.remember
    cookies.permanent.encrypted[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

user.rememberメソッドの呼び出し:
このメソッドは、先ほど説明したように、ユーザーのremember_token属性にランダムなトークンを生成して設定し、関連するremember_digestをデータベースに保存します。これにより、ユーザーを識別できる安全な方法が確保されます。
クッキーにユーザーIDを保存:
cookies.permanent.encrypted[:user_id] = user.idの行では、ユーザーのIDを暗号化された形でクッキーに保存します。permanentメソッドによって、このクッキーはブラウザが閉じられた後も持続し、特定の有効期限まで保存されます。暗号化されているため、クッキーが盗まれた場合でも、ユーザーIDは直接読み取られることはありません。
クッキーにremember_tokenを保存:
cookies.permanent[:remember_token] = user.remember_tokenの行で、生成されたremember_tokenをクッキーに保存します。このトークンは暗号化されていないため、保護する必要がありますが、これがサーバー側に保存されたremember_digestと対応している必要があります。ユーザーが再訪したときに、このトークンを使用してユーザーを認証します

とのこと。ちょっと難しいです。

ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されますが、current_userメソッドでは一時セッションしか扱っていないので、このままでは正常に動作しないとのこと。

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

永続的セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続的セッションにログインする必要とのこと。

if session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
elsif cookies.encrypted[:user_id]
  user = User.find_by(id: cookies.encrypted[:user_id])
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

セッション内にユーザーが保存されてあるかどうかを確認する。
current userがnilだった場合ユーザーを検索し代入
すでに変数がある場合はそれを避ける

クッキーにユーザー情報があるかどうか確認をする。
データベースからユーザーが見つかった場合、さらにそのユーザーのauthenticated?メソッドを呼び出して、クッキーに保存されているremember_tokenが正しいかを検証
検証されたユーザーをログインし、現在のユーザーを設定する。

めちゃくちゃ解釈が難しいです。笑

app/helpers/sessions_helper.rb
#永続的セッションのcurrent_userを更新する
module SessionsHelper

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

  # 永続的セッションのためにユーザーをデータベースに記憶する
  def remember(user)
    user.remember
    cookies.permanent.encrypted[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.encrypted[: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

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

  # 現在のユーザーをログアウトする
  def log_out
    reset_session
    @current_user = nil
  end
end

ここの箇所苦い思い出でちょくちょくエラーになったんですよね。
ユーザーがnilだった場合はなんともできなくて、nilだった場合は参照しないってコードを
個人的に足した覚えがあります。
もしここで詰まる方がいれば、nilだった場合の対処を記述することを推奨します。
(チュートリアルとはずれますが)

というわでdef currentuserに追記しました。

9.1.3ユーザーを忘れる

ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義する。

user.forgetメソッドによって、user.rememberが取り消され記憶ダイジェストをnilで更新する。

何言ってるのかよくわからないのでさらに見ていきます。

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

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

  # ランダムなトークンを返す
  def User.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

  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
end
 # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end

ここのコードを書けばいける準備ができました。
forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す。
forgetヘルパーメソッドではuser.forgetを呼んでからuser_idとremember_tokenのcookieを削除

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 永続的セッションを破棄する
  def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

  # 現在のユーザーをログアウトする
  def log_out
    forget(current_user)
    reset_session
    @current_user = nil
  end
end

ここでは先ほどのforgetでsessionヘルパーから呼び出し削除してるのがわかります。
どこでどこの部分を呼び出して削除させるの切り分けがめちゃくちゃ難しいです。

[Remember me]チェックボックス

チェックボックスを実装

<%= f.label :remember_me, class: "checkbox inline" do %>
  <%= f.check_box :remember_me %>
  <span>Remember me on this computer</span>
<% end %>

ラベルの内側に実装する。

app/views/sessions/new.html.erb
#[remember me]チェックボックスをログインフォームに追加する
<% 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) do |f| %>

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

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

      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

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

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


2つのCSSクラスcheckboxとinlineを使用している。
これらをチェックボックスとテキスト「Remember me on this computer」として同じ行に配置するとのこと。

app/assets/stylesheets/custom.scss
.
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}
.
.
.

オフのときは実装しないようにしていく。

params[:session][:remember_me]

オンのときに1になり、オフのときに0になる。

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

このコードを使うことにより実装ができる。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      reset_session
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new', status: :unprocessable_entity
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url, status: :see_other
  end
end

「三項演算子(ternary operator)」を使うと、このようなif-thenの分岐構造を1行で表すことができる。
→三項演算子(ternary operator)は、条件式を評価してその結果に基づき値を返すために使用される簡潔な方法です。この演算子は通常、条件 ? 真の場合の値 : 偽の場合の値 の形式で書かれます。この形式を用いることで、通常のif-then構造を一行で表現することができる。

めちゃくちゃ便利ですね。

本章のまとめ(引用)

・Railsでは、あるページから別のページに移動するときに状態を保持できる。ページの状態を長期間保持したいときは、cookiesメソッドを使って永続的セッションにする
・ユーザーごとに記憶トークンと記憶ダイジェストを関連付けることで、永続的セッションを実現できる
・cookiesメソッドを使うと、ユーザーのブラウザにcookiesなどを保存できる
・ログイン状態は、セッションもしくはcookiesの状態に基づいて決定される
・セッションとcookiesをそれぞれ削除すると、ユーザーのログアウトが実現できる
・三項演算子を使用すると、単純なif-then文をコンパクトに記述できる

感想

個人的にここの章はめちゃくちゃ理解が難しかったです。
今も理解していないところもあるので定期的に実践しながら理解を深めていかなければいけないなと思います。
特にセキュリティにも関わる事項なのでしっかり理解していかなかければいけないですね。

今回の目から鱗は三項演算子ですね。 要件定義をしっかり理解した上でコンパクトにする。
これがrailsの良さなんでしょうね。

引き続き学習していきます。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1