#274 Remember Me & Reset Password
- Download:
- source codeProject Files in Zip (109 KB)
- mp4Full Size H.264 Video (17.5 MB)
- m4vSmaller H.264 Video (11.9 MB)
- webmFull Size VP8 Video (14.3 MB)
- ogvFull Size Theora Video (27.6 MB)
Railsアプリケーション用の優れた認証ツールはいくつもありますが、自分で作成するという選択もあります。エピソード250 [動画を見る, 読む]ではそれを実践し、その後のエピソード270 [動画を見る, 読む]ではRails 3.1になってhas_secure_passwordによって自動的にパスワードハッシュを生成できるようになって、作業がさらに簡単になったことを紹介しました。
これらのエピソードの中で作成した認証のしくみは基本的なものだったので、今回のエピソードではそれをさらに改良して新しい機能を追加していきます。最初に「ログイン状態を記憶」のチェックボックスをログインページに追加して、ユーザが自動的にログインすることを選択できるようにして、その次に「パスワードをリセット」のリンクを追加して、パスワードを忘れたユーザが再設定できるようにします。エピソード270で作成したアプリケーションを拡張する形でこれらの機能を実装していきます。このアプリケーションではRails 3.1を使用していますが、Rails 3.0でも同じように動作します。
「ログイン状態を記憶」チェックボックスを追加する
ユーザがこのアプリケーションにログインすると、id
がセッションに保存されます。これはSessionsController
のcreate
アクションで行われます。
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
引数をとり、後ほど必要になったときに複数のトークンを持てるようにします。
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
アクションも修正し、ユーザがログアウトしたときにはクッキーを削除します。
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
これでユーザがログインすると、永続的にログインした状態になります。ユーザがこれを望まない場合もあるので、ログインフォームにチェックボックスを追加してユーザが自分で選択できるようにします。フォームへの変更はわかりやすいでしょう。チェックボックスを追加して、何のためのものかのラベルを付けます。
<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
を修正して、ユーザがチェックボックをチェックした場合のみ 永続的クッキーを設定するように変更します。チェックしなかった場合は、ログイン情報はセッションクッキーに保存されます。
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ではなく認証トークンを読むようにします。
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
ではこれを試してみましょう。アプリケーションにログインすると「ログイン状態を記憶」のチェックボックが表示されています。チェックボックをチェックしてログインし、一度ブラウザを閉じてから再度開くと、自動的にログインします。「ログイン状態を記憶」の機能が期待通りに動作しています。
「パスワード忘れ」機能を追加する
ユーザがパスワードを忘れた場合にリセットできるようにする方法を見ていきます。まずはログインフォームに、相応しい名前のリンクを作成します。
<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
の呼び出しに変更します。
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メールアドレスを入力してパスワードのリセットをリクエストするためのフォームを作成します。フォームは以下のようになります。
<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
メソッド内で行われます。
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メールを送信します。
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
メーラでユーザをインスタンス変数に割り当て、テンプレートからアクセスして受信者とタイトルを設定できるようにします。
class UserMailer < ActionMailer::Base default from: "from@example.com" def password_reset(user) @user = user mail :to => user.email, :subject => "Password Reset" end end
テンプレートに、操作の指示とパスワードをリセットするリンクを追加します。
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
に追加します。
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エラーが投げられます。
def edit @user = User.find_by_password_reset_token!(params[:id]) end
関連するビューに、ユーザがパスワードをリセットできるフォームを作成します。
<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時間以内のものであれば、ユーザの更新を行います。これが成功したらトップページにリダイレクトしてメッセージを表示します。成功しなかった場合はフォームの内容にエラーがあるはずなので再度フォームを表示します。
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などのツールには最初からこの機能が備わっていますが、特に多くのカスタマイズが必要になるような場合にはゼロから作る方がいいでしょう。