274: 「ログイン状態を記憶」と「パスワードをリセット」
Other formats:
Railsアプリケーション用の優れた認証ツールはいくつもありますが、自分で作成するという選択もあります。エピソード250 [動画を見る, 読む]ではそれを実践し、その後のエピソード270 [動画を見る, 読む]ではRails 3.1になってhas_secure_passwordによって自動的にパスワードハッシュを生成できるようになって、作業がさらに簡単になったことを紹介しました。
これらのエピソードの中で作成した認証のしくみは基本的なものだったので、今回のエピソードではそれをさらに改良して新しい機能を追加していきます。最初に「ログイン状態を記憶」のチェックボックスをログインページに追加して、ユーザが自動的にログインすることを選択できるようにして、その次に「パスワードをリセット」のリンクを追加して、パスワードを忘れたユーザが再設定できるようにします。エピソード270で作成したアプリケーションを拡張する形でこれらの機能を実装していきます。このアプリケーションではRails 3.1を使用していますが、Rails 3.0でも同じように動作します。
「ログイン状態を記憶」チェックボックスを追加する
ユーザがこのアプリケーションにログインすると、idがセッションに保存されます。これはSessionsControllerのcreateアクションで行われます。
/app/controllers/sessions_controller.rb
def create
user = User.find_by_email(params[:email])
if user && user.authenticate(params[:password])
session[:user_id] = user.id
redirect_to root_url, :notice => "Logged in!"
else
flash.now.alert = "Invalid email or password"
render "new"
end
end
ログインしたユーザがブラウザを閉じると、セッションクッキーが削除されて、次にアプリケーションを開いたときに再度ログインを求められます。このセッションクッキーを永続的クッキーに置き換え、各ユーザのidを保持し続けられるようにします。
この方法でまず明らかに問題なのは、idが連続した整数として保存されることです。idが永続的クッキーに保存される場合、悪意を持ったユーザが簡単に値を変えて他のユーザのデータを見ることができてしまいます。これを防ぐためには、代わりに各ユーザ毎に類推できない一意的なトークンを生成してその値をクッキーに保存するようにします。
各ユーザは個別のトークンを持ち、それをデータベースに保存するので、usersテーブルにauth_tokenフィールドを追加するmigrationを作成し、データベースのマイグレーションを行います。
$ rails g migration add_auth_token_to_users auth_token:string
新規ユーザが作成されたときにこの一意的なトークンを生成する方法が必要なので、Userモデルにgenerate_tokenというメソッドを書きます。このメソッドはcolumn引数をとり、後ほど必要になったときに複数のトークンを持てるようにします。
/app/models/user.rb
class User < ActiveRecord::Base
attr_accessible :email, :password, :password_confirmation
has_secure_password
validates_presence_of :password, :on => :create
before_create { generate_token(:auth_token) }
def generate_token(column)
begin
self[column] = SecureRandom.urlsafe_base64
end while User.exists?(column => self[column])
end
end
トークンを作成するために、ActiveSupportのSecureRandomクラスを使ってランダムな文字列を生成させます。生成したトークンと同じものを持つユーザが存在しないかをチェックして、存在しないことが確認できるまで別のランダムなトークンを繰り返し生成します。before_createフィルターのメソッドを呼び出すことで、新規ユーザが初めて保存されるときにトークンが生成されます。データベースにすでにusersテーブルが存在する場合、それに対してトークンを生成する必要があります。これにはrakeタスクを作成して対応可能ですが、ここでは行いません。
SessionsControllerのcreateアクションを修正して、ユーザがログインするときにトークンをクッキーに保存するようにします。destroyアクションも修正し、ユーザがログアウトしたときにはクッキーを削除します。
/app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by_email(params[:email])
if user && user.authenticate(params[:password])
cookies.permanent[:auth_token] = user.auth_token
redirect_to root_url, :notice => "Logged in!"
else
flash.now.alert = "Invalid email or password"
render "new"
end
end
def destroy
cookies.delete(:auth_token)
redirect_to root_url, :notice => "Logged out!"
end
end
これでユーザがログインすると、永続的にログインした状態になります。ユーザがこれを望まない場合もあるので、ログインフォームにチェックボックスを追加してユーザが自分で選択できるようにします。フォームへの変更はわかりやすいでしょう。チェックボックスを追加して、何のためのものかのラベルを付けます。
/app/views/sessions/new.html.erb
<h1>Log in</h1>
<%= form_tag sessions_path do %>
<div class="field">
<%= label_tag :email %>
<%= text_field_tag :email, params[:email] %>
</div>
<div class="field">
<%= label_tag :password %>
<%= password_field_tag :password %>
</div>
<div class="field">
<%= label_tag :remember_me %>
<%= check_box_tag :remember_me, 1, params[:remember_me] %>
</div>
<div class="actions"><%= submit_tag "Log in" %></div>
<% end %>
ここでSessionsControllerを修正して、ユーザがチェックボックをチェックした場合のみ 永続的クッキーを設定するように変更します。チェックしなかった場合は、ログイン情報はセッションクッキーに保存されます。
/app/controllers/sessions_controller.rb
def create
user = User.find_by_email(params[:email])
if user && user.authenticate(params[:password])
if params[:remember_me]
cookies.permanent[:auth_token] = user.auth_token
else
cookies[:auth_token] = user.auth_token
end
redirect_to root_url, :notice => "Logged in!"
else
flash.now.alert = "Invalid email or password"
render "new"
end
end
もうひとつ変更が必要な部分があります。ApplicationControllerを変更して、セッション情報内のユーザidではなく認証トークンを読むようにします。
/app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery
private
def current_user
@current_user ||= User.find_by_auth_token( ↵
cookies[:auth_token]) if cookies[:auth_token]
end
helper_method :current_user
end
ではこれを試してみましょう。アプリケーションにログインすると「ログイン状態を記憶」のチェックボックが表示されています。チェックボックをチェックしてログインし、一度ブラウザを閉じてから再度開くと、自動的にログインします。「ログイン状態を記憶」の機能が期待通りに動作しています。
「パスワード忘れ」機能を追加する
ユーザがパスワードを忘れた場合にリセットできるようにする方法を見ていきます。まずはログインフォームに、相応しい名前のリンクを作成します。
/app/views/sessions/new.html.erb
<h1>Log in</h1>
<%= form_tag sessions_path do %>
<div class="field">
<%= label_tag :email %>
<%= text_field_tag :email, params[:email] %>
</div>
<div class="field">
<%= label_tag :password %>
<%= password_field_tag :password %>
</div>
<p><%= link_to "forgotten password?", ↵
new_password_reset_path %></p>
<div class="field">
<%= label_tag :remember_me %>
<%= check_box_tag :remember_me, 1, params[:remember_me] %>
</div>
<div class="actions"><%= submit_tag "Log in" %></div>
<% end %>
リンクの先はnew_password_reset_pathとなっていますが、これはまだ作成されていないリソースの一部です。今からPasswordResetsコントローラと、その中に新しいアクションを作成します。
$ rails g controller password_resets new
このコントローラをリソースとして扱いたいので、ルートファイルを編集し、作成されたルートをresourcesの呼び出しに変更します。
/config/routes.rb
Auth::Application.routes.draw do get "logout" => "sessions#destroy", :as => "logout" get "login" => "sessions#new", :as => "login" get "signup" => "users#new", :as => "signup" root :to => "home#index" resources :users resources :sessions resources :password_resets end
これはモデルに基づく正規のリソースではないですが、今回の用途にはこれで足ります。
newアクションのビューに、ユーザがEメールアドレスを入力してパスワードのリセットをリクエストするためのフォームを作成します。フォームは以下のようになります。
/app/views/password_resets/new.html.erb
<h1>Reset Password</h1>
<%= form_tag password_resets_path, :method => :post do %>
<div class="field">
<%= label_tag :email %>
<%= text_field_tag :email, params[:email] %>
</div>
<div class="actions"><%= submit_tag "Reset Password" %></div>
<% end %>
モデルに基づいたリソースではないので、ここではform_tagを使用します。フォームはPasswordResetsコントローラのcreateアクションにPOSTするので、次にそのアクションを書きます。そこで、与えられたEメールアドレスのユーザを探し、パスワードをリセットする手順を送信します。これは、Userモデル内に新たに作られたsend_password_resetメソッド内で行われます。
/app/controllers/password_resets.rb
def create
user = User.find_by_email(params[:email])
user.send_password_reset if user
redirect_to root_url, :notice => "Email sent with ↵
password reset instructions."
end
ユーザが見つかったかどうかが通知されます。これによってセキュリティが多少向上し、悪意を持ったユーザに対してデータベース中のあるユーザが存在するかどうかをわからなくします。
ではsend_password_resetメソッドを書きます。このメソッドでは、パスワードリセットのリクエストのためのトークンが含まれたEメールを送信します。トークンは一定の期間(例えば2〜3時間)が過ぎたら失効させて、リセットがリクエストされた後の短時間のみリンクが有効となるようにします。このデータを保持するためにusersテーブルにいくつか追加のフィールドが必要になるので、そのためのマイグレーションを書いて実行します。
$ rails g migration add_password_reset_to_users password_reset_token:string password_reset_sent_at:datetime
send_password_reset内に前に書いたgenerate_tokenメソッドで、パスワードリセット用のトークンを生成します。トークンを失効させる時間がわかるようにpassword_reset_sent_atフィールドを設定してUserを保存します。Userへの変更を保存後、それをUserMailerに渡してリセット用のEメールを送信します。
/app/models/user.rb
def send_password_reset generate_token(:password_reset_token) self.password_reset_sent_at = Time.zone.now save! UserMailer.password_reset(self).deliver end
UserMailerをまだ作成していなかったので、ここで作成します。
$ rails g mailer user_mailer password_reset
メーラでユーザをインスタンス変数に割り当て、テンプレートからアクセスして受信者とタイトルを設定できるようにします。
/app/mailers/user_mailer.rb
class UserMailer < ActionMailer::Base
default from: "from@example.com"
def password_reset(user)
@user = user
mail :to => user.email, :subject => "Password Reset"
end
end
テンプレートに、操作の指示とパスワードをリセットするリンクを追加します。
/app/views/user_mailer/password_reset_text.erb
To reset your password click the URL below. <%= edit_password_reset_url(@user.password_reset_token) %> If you did not request your password to be reset please ignore this email and your password will stay as it is.
Eメール中のリンクが、ユーザをPasswordResetsControllerのeditアクションに導きます。技術的にはこれは理想的なRESTfulなアプローチではないですが、今回の目的には十分でしょう。mailer内でURLを機能させるために、環境設定を変更して次の行をdevelopment.rbに追加します。
/config/environments/development.rb
Auth::Application.configure do
# Other config items omitted.
config.action_mailer.default_url_options = { :host => ↵
"localhost:3000" }
end
同じような行をproduction.rbに追加し、実際のドメイン名を指定します。
では試してみましょう。パスワードをリセットするページにアクセスしてEメールアドレスを入力すると、リセットの方法が書かれたEメールが送信された旨が表示されます。
development logを確認すると、Eメールの詳細を見ることができます。
Sent mail to eifion@asciicasts.com (65ms) Date: Thu, 14 Jul 2011 20:18:48 +0100 From: from@example.com To: eifion@asciicasts.com Message-ID: <4e1f4118af661_31a81639e544652a@noonoo.home.mail> Subject: Password Reset Mime-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit To reset your password click the URL below. http://localhost:3000/password_resets/DeStUAsv2QTX_SR3ub_N0g/edit If you did not request your password to be reset please ignore this email and your password will stay as it is. Redirected to http://localhost:3000/ Completed 302 Found in 1889ms
EメールにはパスワードをリセットするためのURLへのリンクが含まれています。このURLにはidパラメータとしてリセット用のトークンが含まれています。
次にeditアクションを書きます。ここではリセット用トークンを使ってユーザを取得します。!マーク付のメソッドを使用しているので、ユーザが見つからなかった場合404エラーが投げられます。
/app/controllers/password_resets_controller.rb
def edit @user = User.find_by_password_reset_token!(params[:id]) end
関連するビューに、ユーザがパスワードをリセットできるフォームを作成します。
/app/views/password_resets/edit.html.erb
<h1>Reset Password</h1>
<%= form_for @user, :url => password_reset_path(params[:id]) ↵
do |f| %>
<% if @user.errors.any?%>
<div class="error_messages">
<h2>Form is invalid</h2>
<ul>
<% for message in @user.errors.full_messages %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password %>
</div>
<div class="field">
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>
</div>
<div class="actions"><%= f.submit "Update Password" %></div>
<% end %>
このフォームでは、リソースを修正しているのでform_forを使用します。このため明示的に:urlパラメータを設定して、フォームの内容がUsersControllerにPOSTされないようにします。その代わりにPasswordResetsControllerのupdateアクションに送られ、リセット用のトークンがidとして渡されます。このフォームには、エラーメッセージを表示するためのセクションと新しいパスワードの入力および確認入力用のフィールドが含まれています。
次にupdateアクションを書きます。これはまずパスワードリセット用のトークンが2時間以内に発行されたものかどうかをチェックして、もしそうでなければユーザを再度リセット用のフォームにリダイレクトします。トークンが2時間以内のものであれば、ユーザの更新を行います。これが成功したらトップページにリダイレクトしてメッセージを表示します。成功しなかった場合はフォームの内容にエラーがあるはずなので再度フォームを表示します。
/app/controllers/password_resets_controller.rb
def update
@user = User.find_by_password_reset_token!(params[:id])
if @user.password_reset_sent_at < 2.hours.ago
redirect_to new_password_reset_path, :alert => "Password ↵
reset has expired."
elsif @user.update_attributes(params[:user])
redirect_to root_url, :notice => "Password has been reset."
else
render :edit
end
end
これを試すために、リセット用のEメールからブラウザにURLを貼り付けてみます。
入力したパスワードが一致しないとエラーメッセージが表示されます。正しく入力された場合は、パスワードのリセットが成功します。
このパスワードリセットのアイデアは、他の機能を追加するときにも利用できます。例えば、新規アカウントの登録確認などです。これはパスワードのリセットと非常に似ています。リンクがクリックされた時にパスワードをリセットする代わりに、データベースにフラグを立てて、アカウント登録が確認されたことを記録できます。
ログインを記憶する方法とパスワードを記録する方法についての今回のエピソードは以上で終わりです。Deviseなどのツールには最初からこの機能が備わっていますが、特に多くのカスタマイズが必要になるような場合にはゼロから作る方がいいでしょう。



