250: ゼロから作る認証機能
Other formats:
ほとんどすべてのRailsアプリケーションは、なんらかの認証機能を必要とします。パスワード認証機能を提供するライブラリとしてもっともよく使われるのが、Authlogic、Devise、 Restful Authentication、Clearanceの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テンプレートを作りましょう。ここには、email、password、 password_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モデルには属性としてpasswordと password_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_hashとpassword_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_saltとhash_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_hashとpassword_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され、SessionControllerのcreateアクションを呼び出します。フォームには、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メッセージが表示されます。
ログアウト
ここまでのところはうまく行きました。次にログアウト処理が必要です。まず、新しい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は、SessionsControllerのdestroyアクションに対応付けられています。このアクションは、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[動画を見る、原文を読む]を参照してください。


