homeASCIIcasts

274: 「ログイン状態を記憶」と「パスワードをリセット」 

(view original Railscast)

Other translations: En Fr Es

Other formats:

Written by Naomi Fujimoto

Railsアプリケーション用の優れた認証ツールはいくつもありますが、自分で作成するという選択もあります。エピソード250 [動画を見る, 読む]ではそれを実践し、その後のエピソード270 [動画を見る, 読む]ではRails 3.1になってhas_secure_passwordによって自動的にパスワードハッシュを生成できるようになって、作業がさらに簡単になったことを紹介しました。

これらのエピソードの中で作成した認証のしくみは基本的なものだったので、今回のエピソードではそれをさらに改良して新しい機能を追加していきます。最初に「ログイン状態を記憶」のチェックボックスをログインページに追加して、ユーザが自動的にログインすることを選択できるようにして、その次に「パスワードをリセット」のリンクを追加して、パスワードを忘れたユーザが再設定できるようにします。エピソード270で作成したアプリケーションを拡張する形でこれらの機能を実装していきます。このアプリケーションではRails 3.1を使用していますが、Rails 3.0でも同じように動作します。

「ログイン状態を記憶」チェックボックスを追加する

ユーザがこのアプリケーションにログインすると、idがセッションに保存されます。これはSessionsControllercreateアクションで行われます。

/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

トークンを作成するために、ActiveSupportSecureRandomクラスを使ってランダムな文字列を生成させます。生成したトークンと同じものを持つユーザが存在しないかをチェックして、存在しないことが確認できるまで別のランダムなトークンを繰り返し生成します。before_createフィルターのメソッドを呼び出すことで、新規ユーザが初めて保存されるときにトークンが生成されます。データベースにすでにusersテーブルが存在する場合、それに対してトークンを生成する必要があります。これにはrakeタスクを作成して対応可能ですが、ここでは行いません。

SessionsControllercreateアクションを修正して、ユーザがログインするときにトークンをクッキーに保存するようにします。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メール中のリンクが、ユーザをPasswordResetsControllereditアクションに導きます。技術的にはこれは理想的な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メールが送信された旨が表示されます。

パスワードをリセットするための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されないようにします。その代わりにPasswordResetsControllerupdateアクションに送られ、リセット用のトークンが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などのツールには最初からこの機能が備わっていますが、特に多くのカスタマイズが必要になるような場合にはゼロから作る方がいいでしょう。