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に指示
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"
トークン生成用メソッドを追加
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がデータベースに保存されることになります。これにより、ユーザー認証のセキュリティが向上し、バリデーションをスキップすることで直接的なデータ更新が可能となります。
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
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
つづいて
#ログインしてユーザーを保持する
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を追加。
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が正しいかを検証
検証されたユーザーをログインし、現在のユーザーを設定する。
めちゃくちゃ解釈が難しいです。笑
#永続的セッションの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で更新する。
何言ってるのかよくわからないのでさらに見ていきます。
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を削除
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 %>
ラベルの内側に実装する。
#[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」として同じ行に配置するとのこと。
.
.
.
/* 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)
このコードを使うことにより実装ができる。
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の良さなんでしょうね。
引き続き学習していきます。