homeASCIIcasts

250: ゼロから作る認証機能 

(view original Railscast)

Other translations: En Es

Other formats:

Written by Naomi Fujimoto

ほとんどすべてのRailsアプリケーションは、なんらかの認証機能を必要とします。パスワード認証機能を提供するライブラリとしてもっともよく使われるのが、AuthlogicDeviseRestful AuthenticationClearanceの4つですが、どれを使うのがいいのでしょうか?AuthlogicとResful Authenticationはここ数ヶ月更新されていないので、残るのはDeviseかClearanceです。どちらもRailsのengineという仕組みによって、いくつかのコントローラとビューをアプリケーションに付加し認証機能を提供するものです。認証のような重要な機能を扱う場合、engineという仕組みは最適とは言えません。というのも、controllerやviewで提供される多くの機能を結局は上書きすることになりやすいからです。このような場合、engineという仕組みの利点は活かされず、アプリケーション全体を複雑なものにしてしまいます。

もちろんengineを使うのがふさわしい場合もあり、どんなときも使うべきでないというわけではありませんが、ここでは別の方法を検討したほうがいいかもしれません。認証機能の実装のためにはengineよりもgeneratorの方が向いているかも知れません。すべてのコードがアプリケーション内にあり、カスタマイズしやすいからです。例えば、Ryan BatesのNifty Authenticationには、アプリケーションにパスワード認証機能を 追加するためのベースになる簡単なコードを生成するジェネレータが含まれています。しかしこの記事はNifty Authenticationを紹介するのが目的ではありません。ここではパスワード認証をゼロから作る方法を紹介することにしましょう。それによって、実際にengineやgeneratorを使うときにも、その中身がどのような仕組みで動いているかについて理解が深まることでしょう。

はじめに

認証のしくみをゼロから作るにあたり、まずはauthという名前でRails 3のアプリケーションを新規作成しましょう。

$ rails new auth

続いて、新しく作成したauthディレクトリに cdし、ユーザ登録(sign-up)プロセスを作ります。ユーザを作成するためのコントローラが必要なので、UsersControllerを作成し、 その中にnew アクションを作ります。

$ rails g controller users new

このコントローラと一緒に、ユーザのメールアドレスとパスワードを保存するためのUserモデルも作成します。言うまでもないですが、パスワードは決して素のテキストで保存するべきではありません。その代わりにハッシュ値とソルトを保存します。

$ rails g model user email:string password_hash:string password_salt:string

モデルを作成したら、データベースをmigrateしてusersテーブルを作成します。

$ rake db:migrate

次にUsersController内のnew アクションとcreateアクションにコードを記述します。

/app/controllers/users_controller.rb

class UsersController < ApplicationController
  def new
    @user = User.new
  end
  
  def create
    @user = User.new(params[:user])
    if @user.save
      redirect_to root_url, :notice => "Signed up!"
    else
      render "new"
    end
  end
end

これはごく標準的なコントローラのコードです。newアクションで、新しいUserを生成します。createアクションでは、入力フォームから渡された引数に基づいてUserを生成します。新しく生成されたUserが有効なら、スタートページにリダイレクトします。(スタートページはまだ作成されていません。)Userが正しく生成されなければnewアクションのテンプレートを再表示します。

ここで、newテンプレートを作りましょう。ここには、emailpasswordpassword_confirmationの各フィールドとエラーメッセージを表示するためのコードが含まれます。

/app/views/users/new.html.erb

<h1>Sign Up</h1>

<%= form_for @user 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 %>
  <p>
    <%= f.label :email %><br />
    <%= f.text_field :email %>
  </p>
  <p>
    <%= f.label :password %><br />
    <%= f.password_field :password %>
  </p>
  <p>
    <%= f.label :password_confirmation %>
    <%= f.password_field :password_confirmation %>
  </p>
  <p class="button"><%= f.submit %></p>
<% end %>

先ほど作成したUserモデルには属性としてpasswordpassword_confirmationを持っていません。その代わりにUserモデル内にこれらを処理するアクセサメソッドを作ります。

次に、routesファイルを少し修正します。controllerジェネレータは、次のようなrouteを生成しました。

get "users/new"

このrouteを/sign_upに変えて、users#newにアクセスするように指示し、名前を"sign_up"にします。また、root routeをユーザ登録フォームに対応づけます。最後にrecources :usersを追加して、createアクションが機能するようにします。

/config/routes.rb

Auth::Application.routes.draw do
  get "sign_up" => "users#new", :as => "sign_up"
  root :to => "users#new"
  resources :users
end

ここでサーバを起動して、登録フォームのページにアクセスすると、エラーが表示されます。

フォームにアクセスするとエラーが表示される

このエラーが表示されたのは、ユーザ情報を入力するフォームにはpasswordフィールドがあるにもかかわらずデータベースには対応するフィールドがないためUserモデルにもpassword属性がないからです。そこでモデルでpassword属性を生成し、それと同時にpassword_confirmationフィールドを処理する属性も作ります。 パスワード確認には、validates_confirmation_ofを使えば2つのフィールドへの入力が一致しているかも同時にチェックしてくれます。ちょうどいいタイミングなので、フォーム内の他の入力確認、メールアドレスとパスワードの存在チェック、メールアドレスの重複チェックも追加しましょう。

/app/models/user.rb

class User < ActiveRecord::Base
  attr_accessor :password
  validates_confirmation_of :password
  validates_presence_of :password, :on => :create
  validates_presence_of :email
  validates_uniqueness_of :email
end

Userモデルを作成したときに、暗号化したパスワードを保存するためにpassword_hashpassword_saltを作りました。フォームが登録されるときにpasswordフィールドの値を暗号化し、結果のハッシュ値とソルトをこれらのフィールドに保存します。パスワードの暗号化には、bcryptが便利です。ここではbcrypt-ruby gemを使うことにします。まずGemfileにgemへの参照情報を記述して、bundleコマンドを実行し、gemがインストールされたことを確認します。

/Gemfile

source 'http://rubygems.org'

gem 'rails', '3.0.3'

gem 'sqlite3-ruby', :require => 'sqlite3'
gem 'bcrypt-ruby', :require => 'bcrypt'

次にUserモデルを編集し、パスワードが保存される前に暗号化するように修正します。これを実現するために、before_saveコールバックを使用し、この後作成するencrypt_passwordメソッドを呼び出します。このメソッドはパスワードが存在するかをチェックし、もし存在すれば、BCrypt::Engineの2つのメソッドgenerate_salthash_secretを使って、ソルトとハッシュ値を生成します。

/app/models/user.rb

class User < ActiveRecord::Base
  attr_accessor :password
  before_save :encrypt_password
  
  validates_confirmation_of :password
  validates_presence_of :password, :on => :create
  validates_presence_of :email
  validates_uniqueness_of :email
  
  def encrypt_password
    if password.present?      self.password_salt = BCrypt::Engine.generate_salt
      self.password_hash = BCrypt::Engine.hash_secret(password, password_salt)
    end
  end
end

これで、ユーザ情報が登録されると、password_hashpassword_saltがデータベースに保存されるようになりました。ユーザ登録フォームにアクセスして情報を正しく入力すると、homeページにリダイレクトされます。そこでデータベース内のusersテーブルを見てみると、新しいユーザの情報が、暗号化されたパスワードのハッシュ値とソルトと共に登録されているのがわかります。

$ rails dbconsole
SQLite version 3.6.12
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> .mode column
sqlite> .header on
sqlite> SELECT * FROM users;
id          email                  password_hash                                                 password_salt                  created_at                  updated_at                
----------  ---------------------  ------------------------------------------------------------  -----------------------------  --------------------------  --------------------------
1           eifion@asciicasts.com  $2a$10$Jh./oyCeThSChUCY8Of6F.fiHP8m4gMkZNjUR3vsDgvupUPgumNs.  $2a$10$Jh./oyCeThSChUCY8Of6F.  2011-01-26 21:51:56.399518  2011-01-26 21:51:56.399518

ログイン

これで半分まで来ました。ユーザ登録(sign up)ができるようになりましたが、まだログイン(sign in)ができません。それではこれから、ログインフォームを処理するsessionsコントローラを新しく作成しましょう。

$ rails g controller sessions new

newビューのファイル内に、ログイン用のフォームを作成します。

/app/views/sessions/new.html.erb

<h1>Log in</h1>

<%= form_tag sessions_path do %>
  <p>
    <%= label_tag :email %><br />
    <%= text_field_tag :email, params[:email] %>
  </p>
  <p>
    <%= label_tag :password %><br />
    <%= password_field_tag :password %>
  </p>
  <p class="button"><%= submit_tag %></p>
<% end %>

ここではform_forではなくform_tagを使います。form_forを使った場合、フォーム名に対応するモデルがあるということを示すことになるからです。今回、Sessionモデルはないので、form_forは使えません。フォームからのデータはsessions_pathにPOSTされ、SessionControllercreateアクションを呼び出します。フォームには、email addressとpasswordという2つのフィールドがあります。

ここで、ルーティングにも変更を加えます。ジェネレータが生成した"sessions/new"を、"log_in"ルートに置き換えます。また、ユーザ登録フォームが正しく動くよう、resources :sessionsも追加しておきます。

/config/routes.rb

Auth::Application.routes.draw do
  get "log_in" => "sessions#new", :as => "log_in"

  get "sign_up" => "users#new", :as => "sign_up"
  root :to => "users#new"
  resources :users
  resources :sessions
end

SessionsController内にcreateアクションを作成し、ユーザがログインしたときの処理を記述します。そこでは、Userモデルのnew class methodをコールすることでユーザ認証をおこないます。ユーザが正しく認証されたら、このメソッドがUserのレコードを返します。その場合、ユーザIDをセッション変数に格納して、スタートページにリダイレクトし、ユーザにはflashメッセージを表示して正しくログインしたことを知らせます。認証が成功しなかった場合は、別のflashメッセージを表示してフォームを再表示します。ポイントは、他のページへリダイレクトするのではなく、flash.nowを使ってページを表示しています。

/app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def new
  end
  
  def create
    user = User.authenticate(params[:email], params[:password])
    if user
      session[:user_id] = user.id
      redirect_to root_url, :notice => "Logged in!"
    else
      flash.now.alert = "Invalid email or password"
      render "new"
    end
  end
end

ここで、User.authenticateメソッドを作ります。このメソッドは入力されたメールアドレスをもとにユーザを検索します。ユーザが検索されたら、ユーザ登録のときと同様にフォームから入力されたパスワードをそのユーザのpassword_saltを用いて暗号化します。パスワードから生成されたハッシュ値が、保存されたハッシュ値と一致すればパスワードが正しいということでユーザが返されるか、そうでなければnilが返されます。Rubyでは、elseステートメントがなくてもnilが返されるので、実際のところelseステートメントは不要ですが、ここでは分かりやすさのためあえて付け加えてあります。

/app/models/user.rb

def self.authenticate(email, password)
    user = find_by_email(email)
    if user && user.password_hash == BCrypt::Engine.hash_secret ↵
      (password, user.password_salt)
      user
    else
      nil
    end
  end
end

これをテストする前に、アプリケーションのレイアウトファイルを修正し、flashメッセージが表示されるようにします。

/app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
<head>
  <title>Auth</title>
  <%= stylesheet_link_tag :all %>
  <%= javascript_include_tag :defaults %>
  <%= csrf_meta_tag %>
</head>
<body>
  <% flash.each do |name, msg| %>
    <%= content_tag :div, msg, :id => "flash#{name}" %>
  <% end %>

<%= yield %>

</body>
</html>

正しくないユーザやパスワードでログインしようとすると、ログインフォームが再表示され、さらにそれに加えて、ログインが正しく行われなかったことを知らせるflashメッセージが表示されます。

ログイン情報が正しくないとエラーメッセージが表示される

正しいログイン情報を入力すると、homeページにリダイレクトされ、正しくログインできたことを知らせるflashメッセージが表示されます。

ログイン情報が正しければ、homeページにリダイレクトされる

ログアウト

ここまでのところはうまく行きました。次にログアウト処理が必要です。まず、新しいrouteである"log_out"を追加しましょう。

/config/routes.rb

Auth::Application.routes.draw do
  get "log_in" => "sessions#new", :as => "log_in"
  get "log_out" => "sessions#destroy", :as => "log_out"

  get "sign_up" => "users#new", :as => "sign_up"
  root :to => "users#new"
  resources :users
  resources :sessions
end

このrouteは、SessionsControllerdestroyアクションに対応付けられています。このアクションは、user_idセッション変数を削除することでユーザをログアウトし、スタートページにリダイレクトします。

/app/controllers/sessions_controller.rb

def destroy
  session[:user_id] = nil
  redirect_to root_url, :notice => "Logged out!"
end

正しく動作するかどうか、/log_outにアクセスして確認します。スタートページにリダイレクトされて、"Logged out(ログアウトしました)!"とflashメッセージが表示されます。

ログアウトするとスタートページにリダイレクトされる

リンクを追加する

ユーザがログインやログアウトのためにブラウザのアドレスバーにURLをタイプするよりも、ページへのリンクを準備する方がずっと便利でしょう。レイアウトファイル内のflashメッセージを表示するコードの直前に以下のコードを置きます。

/app/views/layouts/application.html.erb

<div id="user_nav">
  <% if current_user %>
    Logged in as <%= current_user.email %>
    <%= link_to "Log out", log_out_path %>
  <% else %>
    <%= link_to "Sign up", sign_up_path %> or 
    <%= link_to "Log in", log_in_path %>
  <% end %>
</div>

current_userメソッドがまだないので、ここで作ることにします。ApplicationController内に記述します。

/app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery
  helper_method :current_user
  
  private
  def current_user
    @current_user ||= User.find(session[:user_id]) if ↵
      session[:user_id]
  end
end

current_userメソッドは、セッション変数から現在のユーザのidを得て、それをインスタンス変数にキャッシュします。それをヘルパーメソッドとして作成し、アプリケーションのビューのコードの中でも利用できるようにします。

ページをリロードすると、sign up(ユーザ登録)とlog in(ログイン)のリンクが表示されています。ログインすると、ログイン情報と、ログアウト用のリンクが表示されます。

これでログインとログアウト用のリンクができた

これで認証システムが一通りできあがりました。今回のエピソードでは多くのコードを扱いましたが、そのほとんどがコントローラ内とビュー内に記述するものでした。認証のロジックはすべてUserモデル内のself.authenticateメソッドとencrypt_passwordメソッドで発生します。このコードはとてもシンプルです。

あなたも、engineを使わずに認証システムをゼロから作るのであれば、パスワード認証はそれほど複雑ではなく、コントローラやビューを、あなたのアプリケーションに合うように好きなようにカスタマイズすることが可能です。ここで説明したものは、最低限のしくみです。実システムでは、Userモデル内にパスワードの長さやメールアドレスのフォーマットのチェック機能を追加するのもいいでしょう。

Userモデルに追加するべき大事なものは、一括設定できる属性を制限するコードです。これは、Userモデル内にattr_accessorを追加することで実現します。

/app/models/user.rb

class User < ActiveRecord::Base
  attr_accessible :email, :password, :password_confirmation
  
  #rest of code omitted
end

これによって、Userモデル内のpassword_hashなどのフィールドを入力フォーム以外から更新できないようにします。

今回のエピソードはこれで終わりです。gemとして入手できる認証のしくみはそのまま利用できて便利ですが、通常のパスワード認証がどのようなしくみで動作しているのかを理解することはとても大切です。もしパスワード認証に加えて、サードパーティによる外部認証が必要な場合は、Simple OmniAuthについて説明しているエピソード241[動画を見る原文を読む]を参照してください。