8章:ログイン機能(sessionメソッドによる実装)
- 認証:その人が誰か
- 認可:誰が誰にどのような権限を与えるか
※このログイン機能では、認可モデル (Authorization Model) を活用し、認証に基づく認可の概念を実装する
-
HTTPはステートレス(前の通信内容を引き継がない)な通信。このHTTP通信とは別のセッション (Session) と呼ばれる半永続的な接続を利用し、アプリケーション側のセッション情報をクライアント側のブラウザにcookies情報として持たせる。
このセッション情報を持ったcookieにより、例えばログイン中のユーザーが所有する情報を、アプリケーション側はデータベースから取り出すことができる。 -
実装には、users_controllerとは別にsessons_controllerを作成するが、別途モデルを作る必要はない。(Userモデルを操作するため)
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) do |f| %>
〜省略〜
<% end %>
sessionモデルは存在しないので、form_forの引数にリソースと、それに対応するpathを指定してあげる必要がある。
※form_withを使った場合も、考え方は一緒。
- session[:user_id]に、ユーザーのidを格納し、cookieにセッション情報を持たせ、ログイン状態を作る。セッション情報は暗号化されているため、攻撃者がたとえこの情報をcookiesから盗み出すことができたとしても、それを使って本物のユーザーとしてログインすることはできない。
※ただし、cookieメソッドの場合はその限りではない。
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
# session[:user_id] = user.idをカスタムヘルパーで定義してもの
log_in user
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
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.present?の書き方でも良い
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
# 念のため、ここで下記インスタンス変数をnilにしている
@current_user = nil
end
end
User.find(session[:user_id])を使うと、例外を発生させてしまう可能性がある。
ユーザーがログインしていない場合もあり得るため、nilでも良い。そのため、find_byを使っている。
※nilの可能性がある場合は、基本、find_byでidを引っ張ってくる。
- current_userメソッド内で、session[:user_id]のbooleanを呼び出している理由
セッションにユーザーIDが存在しない場合、自動的にnilを返します。current_userメソッドが1リクエスト内の処理で何度も呼び出されてしまうと、サーバ負荷に繋がる。
9章:ログイン機能(cookiesメソッド/remember digestによる実装)
トークンとは:クライアントアプリケーション上でユーザーの代わりにアカウント登録/ログインができるもの。
または、人でなくコンピューターが生成した、パスワードの平文と同じような秘匿されるべき情報のこと。
【cookiesメソッドによる永続的cookiesの作成で注意すべき4点】
- 管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
→SSLの適用によりネットワーク全体を暗号化 - データベースから記憶トークンを取り出す。
→DBへ保存する際、記憶トークンのハッシュ値を保存する。 - クロスサイトスクリプティング (XSS) を使う。
→Railsによって自動的に対策されている。(一般的に送られてきた文字列をそのままHTMLで表示するのではなく、加工を施してからHTMLで表示するという対策が取られている) - ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。
【永続的セッションを生成する時の要件】
- 記憶トークンにはランダムな文字列を生成して用いる。
- ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
- トークンはハッシュ値に変換してからデータベースに保存する。
- ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
- 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する
$ rails generate migration add_remember_digest_to_users remember_digest:string
記憶ダイジェストはユーザーが直接読み出すことはないため、indexは付与しない
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: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す。Userモデルのクラスメソッドとして作成する
def self.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
# ランダムなトークンを返す。Userモデルのクラスメソッドとして作成する
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を返す
# has_secure_passwordで提供されていたauthenticateメソッドと同じようなものを、独自に定義している。
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end
- まず、ランダムに生成したrememberトークンを発行。
- このremenmberトークンをハッシュ化し、remember_digestカラムを更新する。
- アクセス時、渡されたトークンがダイジェストと一致したらtrueを返す
※remember_token属性はDBに存在しないため、user.remember_tokenメソッドを使ってトークンにアクセスできるようにし、かつ、トークンをデータベースに保存せずに実装する必要がある。
※update_attribureメソッドはバーションを素通りさせる必要がある。
今回はユーザーのパスワードやパスワード確認にアクセスできないので、バリデーションを素通りさせなければいけない。
※渡されたトークンがユーザーの記憶ダイジェストと一致することを確認します
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
# クッキーメソッドを使い、ユーザーのセッションを永続的にする。
def remember(user)
user.remember
cookies.permanent.signed[: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
session.delete(:user_id)
@current_user = nil
end
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
- 署名付きcookieを使い、cookieをブラウザに保存する前に安全に暗号化するために、signedメソッドを適用している。
永続cookiesを生成するには、
1.remember_digestカラムににハッシュ化したremember_tokenを格納。
2.cookieに、暗号化したユーザーIDと、remember_tokenを格納。sessionメソッドでは必要がなかった暗号化を、ここではsignedメソッドで実行している。
session[:user_id]が存在すれば一時セッションからユーザーを取り出す。
それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする処理を実装する。
curent_userメソッドのアップデートとログアウト周りのメソッドの実装
# 記憶トークンcookieに対応するユーザーを返す
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: 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 forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
# 現在のユーザーをログアウトする
def log_out
forget(current_user)
session.delete(:user_id)
@current_user = nil
end
- if (user_id = session[:user_id])の意味は、user_idに、ユーザーIDのセッションが存在していれば、、、という意味。
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in user
# 三項演算子で条件分岐処理
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_url
end
end
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) 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>