Rails勉強会 に行ってきた。しばらく抑うつ症状やらパニック症状やらの状態が悪かったので、久しぶりの参加だ。でも、最初から最後までどうも頭が働いてなかった。処理が追いつかなくて、切り返しが鈍かったり反応を返せなかったりした人にはごめんなさい。医師には「もう暫くゆっくりしなさい」 と言われてしまったけど、なるほどまだ本調子じゃないね。
うむ。気がつけばこの勉強会も回数を重ねて、もうすぐ2周年。 いつもの通りの形式 である。
前半セッション
3つのセッションに分かれた。
- 初心者セッション(acts_as_authenticated)
- Rails悩み事相談室
- Ruby Hoedown 2007 の動画を見る
私は初心者セッションに出た。オーナーは諸橋さん。とはいえ、私も我が物顔で口を挟ませてもらった。
acts_as_authenticatedとは
acts_as_authenticatedとは、Railsアプリケーションにログイン認証機能を提供するプラグインである。ユーザー登録、ログイン認証、ログアウトなどの機能は広く様々なアプリケーションで使われるであろうが、毎度毎度これを書くのは結構面倒である。acts_as_authenticatedはこの部分を簡単にしかも枯れていて安全な形で実装してくれる。
実際、ログイン機能というのは定型パターンであるのに手で書こうとすると結構面倒なものだ。ViewからDBまでの各レイヤーにわたってセキュリティに関する配慮は勿論求められる。かつ、ロジックの記述はDRYでありたい。変更の整合性がとれなくてこれほど困る部分もないからだ。と考えて真面目にやっていくと意外と手間が掛かる。acts_as_authenticatedはここの部分を簡単に解決してくれる。
以前はこの目的のプラグインというとLogin Engineが主流であったが、Rails 1.2以降ではEngineシステムが動かなくなった。Login Engineもメンテナンスは停止している。そういうことで、今ではacts_as_authenticatedが認証用プラグインの筆頭である。
インストールと構成
インストール方法は至って普通のRailsプラグインである。いつものごとく、
$ ruby script/plugin install http://svn.techno-weenie.net/projects/plugins/acts_as_authenticated
これで、 vendor/plugins/acts_as_authenticated にプラグインが格納される。このプラグインは script/generate に対して新しい2つのジェネレータを提供する。
- authenticated: アカウント登録/ログイン機能を生成
- authenticated_mailer: アカウント登録後のメールによるアクティベーションの機能を生成
今回は、authenticated_mailer部分については割愛した。
acts_as_authenticatedの特徴の1つは、Railsのランタイムに外から作用するのではなく、generatorを提供するに留まるということである。生成されるのは幾らか癖はあるにせよ普通のRailsのMVCであるから、ユーザーはRailsアプリケーション開発に関する知識を活用してそれを自由にカスタマイズできる。
使い方
プラグインが提供しているauthenticatedジェネレータを利用する。
$ ruby script/generate authenticated MODEL CONTROLLER
ここで、 MODEL というのはモデルクラスの名前、 CONTROLLER というのはコントローラの名前である。例えば次のようになる。
$ ruby script/generate authenticated User Account exists app/models/ exists app/controllers/ exists app/helpers/ create app/views/account exists test/functional/ exists test/unit/ create app/models/user.rb create app/controllers/account_controller.rb create lib/authenticated_system.rb create lib/authenticated_test_helper.rb create test/functional/account_controller_test.rb create app/helpers/account_helper.rb create test/unit/user_test.rb create test/fixtures/users.yml create app/views/account/index.rhtml create app/views/account/login.rhtml create app/views/account/signup.rhtml create db/migrate create db/migrate/001_create_users.rb
ユーザー情報を表すモデルクラス User
と User
を操作するための AccountController
、及び付随するview、テストケースが生成された。
モデルクラスに対応するマイグレーション( db/migrate/001_create_users
)が生成されているのでこれを見てみよう。
class CreateUsers < ActiveRecord::Migration def self.up create_table "users", :force => true do |t| t.column :login, :string t.column :email, :string t.column :crypted_password, :string, :limit => 40 t.column :salt, :string, :limit => 40 t.column :created_at, :datetime t.column :updated_at, :datetime t.column :remember_token, :string t.column :remember_token_expires_at, :datetime end end def self.down drop_table "users" end end
生成した時点ではまだマイグレーションは実行されていない。今のうちに、好きなようにこのマイグレーションを書き換えることもできる。例えば、ユーザー情報として上記の他にニックネームが必要であるならそのカラム定義を加えればよい。余分なカラムがある分にはacts_as_authenticatedの動作に差し障ることはない。
私が以前acts_as_authenticatedを使った際、ログイン名はメールアドレスで兼用していた(この方針についてセキュリティ上の是非はあるだろうが、今は勘弁して欲しい)。だから、 login
カラムの定義は削ってしまった。それでも後でちょっと手を加えるだけで問題なく動作させることができた。
既存のユーザー管理テーブルがあるなら、やはりマイグレーションやモデルクラスをいじくって何とかしてやればそのテーブルをacts_as_authenticatedと共存できる。
さて、ここではそのまま変更せずにこのmigrationを実行しよう。
$ rake db:migrate == CreateUsers: migrating ===================================================== -- create_table("users", {:force=>true}) -> 0.0833s == CreateUsers: migrated (0.0835s) ============================================
これで、 users
テープルが作成された。これでもう認証機能を利用できる。その様子を撮ったのが冒頭のスクリーンキャプチャである。素っ気はないが、機能は十分である。
解剖
acts_as_authenticatedが生成したAccountControllerの中身を見てみよう。
まず目立つのは、冒頭のこの1行。
include AuthenticatedSystem
acts_as_authenticatedはAuthenticatedSystemモジュールを通じてコントローラーに認証機能を提供する。AuthenticatedSystemモジュールは lib/authenticated_system.rb
に生成されている。認証の詳しい方法を変更したりするには、このモジュールをいじくれば良い。
アカウント登録
さて、ではユーザーアクションに沿って順に見ていこう。アカウント登録するには、http://localhost:3000/account/signupにアクセスする。
def signup @user = User.new(params[:user]) return unless request.post? @user.save! self.current_user = @user redirect_back_or_default(:controller => '/account', :action => 'index') flash[:notice] = "Thanks for signing up!" rescue ActiveRecord::RecordInvalid render :action => 'signup' end
最初の2行はRailsでモデルオブジェクトを編集するときの頻出パターンだね。と、まあ初心者セッションだから、その辺もフォロー。今、何もパラメータは渡していないので params[:user]
は nil
だけど、とにかくそれで無理矢理 User
オブジェクトを作ってしまう。で、 GET
でアクセスしているから、この場合は2行目でreturnする。
return後にレンダリングされるテンプレート( app/views/account/signup.rhtml
)を見てみるとこうなっている。
<%= error_messages_for :user %> <% form_for :user do |f| -%> <p><label for="login">Login</label><br/> <%= f.text_field :login %></p> <p><label for="email">Email</label><br/> <%= f.text_field :email %></p> <p><label for="password">Password</label><br/> <%= f.password_field :password %></p> <p><label for="password_confirmation">Confirm Password</label><br/> <%= f.password_field :password_confirmation %></p> <p><%= submit_tag 'Sign up' %></p> <% end -%>
さっき、空の User
オブジェクトを作って @user
に設定しておいたお陰で、 form_for
ヘルパーを使って楽に記述できる。
さて、このテンプレートで生成されるフォームは元と同じ account/signup
に対してPOSTするように書かれている。ポストするとどうなるか。再び、 signup
アクションの定義に戻る。
def signup @user = User.new(params[:user]) return unless request.post? @user.save! self.current_user = @user redirect_back_or_default(:controller => '/account', :action => 'index') flash[:notice] = "Thanks for signing up!" rescue ActiveRecord::RecordInvalid render :action => 'signup' end
今度は、フォームの値が渡ってきて params
に入っている。このとき、次のような値を送信したとしよう。
Railsの魔法によりフォーム入力は解析されて次のようなHashによる構造に自動的に変換される。
{ "user" => { "password_confirmation"=>"test", "login"=>"hoge", "password"=>"test", "email"=>"test@localhost" }, "commit"=>"Sign up", "action"=>"signup", "controller"=>"account" }
で、 params[:user]
を使って、今度こそ User
オブジェクトを作るのだ。2行目においても、今度はPOSTメソッドでアクセスしているからここではreturnしない。
で、 @user.save!
する。ジェネレータが生成した段階で User
クラスには細かくvalidationが設定されている。そのため、正しい登録情報であれば保存され、何かが間違っていれば例外が発生する。今は正常系を見ていくとしよう。
current_user
という属性はAuthenticatedSystemモジュールの属性で、後で見るようにログイン管理の中核を為す。acts_as_authenticatedの基本的な仕組みとしては、この属性にユーザー情報オブジェクトが入っていれば"ログイン状態"、そうでなければ"未ログイン状態"ということになっている。つまり、ここではユーザー登録と同時にログイン状態に移行してしまうわけだ。
そして、「元のページ」または /account
にリダイレクトする。以上。リダイレクト先はアプリケーションに合わせて自由に修正すればよい。
ここで質問が出た。
入力が不正な場合はどうなるのか
validationに失敗して、例外が発生する。最後の
rescue
節がこれを捕まえて、再びapp/views/account/signup.rhtml
をレンダリングする。error_messages_for
ヘルパーやなんかの働きでこんな感じに表示される。
ユーザー情報の保存
さて、上ではユーザー情報の保存されるところを save!
で保存されるとだけ流してしまったが、その中身を詳しく見てみよう。 app/models/user.rb
を見る。
冒頭にはvalidationが沢山。これが登録時の入力エラーなんかを弾いてくれる。
validates_presence_of :login, :email validates_presence_of :password, :if => :password_required? validates_presence_of :password_confirmation, :if => :password_required? validates_length_of :password, :within => 4..40, :if => :password_required? validates_confirmation_of :password, :if => :password_required? validates_length_of :login, :within => 3..40 validates_length_of :email, :within => 3..100 validates_uniqueness_of :login, :email, :case_sensitive => false
ここで、「ん? password
」と思った人は偉い。さっきmigrationを見たとき、 password
なんていうカラムはなかった筈だ。だから、そのままなら User
オブジェクトにも password
なんていう属性は定義されない。
crypted_password
カラムならあった。実はacts_as_authenticatedではパスワードのSHA1ハッシュだけを保存するのだ。生パスワードは保存しない。ま、これはセキュリティ上の定石ってやつだけど、このあたりの解説をちゃんとセッション予定に組み込んでた諸橋さんは偉い。Rails初心者と言ってもJava経験者だったりすると知ってるかも知れないけど、経歴は様々だから、確かにセッションオーナーは配慮したほうがいいよね。
さて、じゃあ crypted_password
に対して生パスワードと思われる password
属性はどこにあるんだろう。見回すと、 User
クラスの冒頭で明示的に定義している。
# Virtual attribute for the unencrypted password attr_accessor :password
そうなのだ。実はRailsのvalidationはActiveRecordで自動生成された属性以外にも使える。その属性がありさえすればよい。
さてと、validationのすぐ下にこんな記述がある。
before_save :encrypt_password
これが登録時の鍵だ。この記述により User
オブジェクトを保存する前には必ず encrypt_password
メソッドが呼ばれる。その中身を見てみよう。
def encrypt_password return if password.blank? self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record? self.crypted_password = encrypt(password) end
これが生パスワードである password
属性から crypted_password
属性の値を作り出す。仕組みはこう。
password
が空なら何もしない。ユーザー毎に一意っぽい
salt
を作る。salt
っていうのは、事前計算攻撃に対する対策。ハッシュ値と共にDBに保存される。で、
encrypt
メソッドでcrypted_password
を作る。
encrypt
は、というと
# Encrypts the password with the user salt def encrypt(password) self.class.encrypt(password, salt) end
作っておいた salt
を使ってクラスメソッドの User::encrypt
を呼ぶ。更にその中身。
# Encrypts some data with the salt. def self.encrypt(password, salt) Digest::SHA1.hexdigest("--#{salt}--#{password}--") end
受け取った salt
と password
を使ってSHA1ハッシュを作ってるだけ。
分散してるので面倒だけど、これは最初に言ったロジックをDRYに保つため。要は、オブジェクトの保存前に生パスワードからsalt付きのハッシュを計算するというだけだ。以上が、acts_as_authenticatedのアカウント登録の流れであった。
ログイン
では、登録後にログインするところを見てみよう。ログインには、 /account/login
にアクセスすればよい。 login
アクションの中身を見てみよう。
def login return unless request.post? self.current_user = User.authenticate(params[:login], params[:password]) if logged_in? if params[:remember_me] == "1" self.current_user.remember_me cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at } end redirect_back_or_default(:controller => '/account', :action => 'index') flash[:notice] = "Logged in successfully" end end
remember_me
がどうとか書いてあるのはセッション終了後もログイン状態を保つ機能、よく「ログインを保つ」とか書いてある機能を提供するためのものだ。今回は深くは立ち入らなかった。その部分を割愛すればこのメソッドはシンプルだ。
def login return unless request.post? self.current_user = User.authenticate(params[:login], params[:password]) if logged_in? redirect_back_or_default(:controller => '/account', :action => 'index') flash[:notice] = "Logged in successfully" end end
POSTメソッドでなければそのまま戻るのは signup
のときと同じ。これでログインフォームがrenderされる。
で、そのフォームから再び、今度はPOSTで戻ってきたとき。 User::authenticate
というクラスメソッドが認証処理の実体らしい。
多少書き換えると、こうなっている。
# Authenticates a user by their login name and unencrypted password. Returns the user or nil. def self.authenticate(login, password) u = find_by_login(login) # need to get the salt u && (u.crypted_password == u.encrypt(password) ? u : nil end
やってることは簡単だ。
ログイン名に基づいて
User
オブジェクトを取得。ActiveRecordのdynamic finderを使ってる。該当するレコードがあれば
User
オブジェクトが、そうでなければnil
が返る。次の条件を満たすとき、
u
を返す。そうでなければnil
を返す。u
がUser
オブジェクトであってnil
ではないことu
の保存されているハッシュと、今入力された生パスワードを同じ手順でハッシュ化したものとが、一致する
ふーん。それじゃあ、また AccountController
の login
アクションに戻ろう。
def login return unless request.post? self.current_user = User.authenticate(params[:login], params[:password]) if logged_in? redirect_back_or_default(:controller => '/account', :action => 'index') flash[:notice] = "Logged in successfully" end end
authenticate
メソッドは、ログイン名とパスワードが一致すればそのユーザーを表す User
オブジェクトを、さもなくば nil
を返すのであった。それで、それを current_user
属性に設定。
前に書いたように、内部的には「 current_user
に User
オブジェクトが設定されている」== 「ログイン状態である」ということなのであった。これでログイン終わり。
諸橋さんは、このアクションでセッションを切り替えないとセッション固定攻撃されるんでないかと言ってた。私も同感。ここはパッチを送る必要がありそうだ。
ログイン要求/判別
AccountControllerはこれで良いとして、他のControllerでログインを要求するにはどうしたら良いか。
class HogeController include AutheticatedSystem before_filter :login_required .... end
これだけである。これで、 HogeController
配下のアクションでは全てログインが必要となる。未ログイン状態でアクセスすると /account/login
にリダイレクトされ、そこでログインすると元のアクションに戻ってくる。
初心者セッションなのでフィルタを知らない人もいた。Railsにおけるフィルタっていうのは、まぁ、JavaのServlet APIのフィルタと同じようなもんで、リクエストをアクションメソッドが受け付ける前、あるいは後に処理を挟み込むことができる。入出力を加工したり、特定の条件下では本来のアクションメソッドへ処理を移さずに他へとばしたりもできる。
対象となるアクションを絞り込むこともできる。この例では、特定のアクションにだけログインを要求したり、特定のアクションだけログインしなくても良いようにしたり。それにはこんな構文をつかう。
before_filter :login_required, :only => [:foo, :bar]
とか
before_filter :login_required, :except => [:foo, :bar]
とか。
ふーん。で、じゃあ、 login_required
フィルタの中身を見てみよう。ここでログイン状態判別の方法を見ることができるはずだ。実装は lib/authenticated_system.rb
にある。
def login_required username, passwd = get_auth_data self.current_user ||= User.authenticate(username, passwd) || :false if username && passwd logged_in? && authorized? ? true : access_denied end
User::authenticate
はさっき見たよね。細かいことはさておき、とにかくやっぱり self.current_user
が肝なのだ。じゃあ、 current_user
属性の実装を見てみよう。
# Accesses the current user from the session. def current_user @current_user ||= (session[:user] && User.find_by_id(session[:user])) || :false end # Store the given user in the session. def current_user=(new_user) session[:user] = (new_user.nil? || new_user.is_a?(Symbol)) ? nil : new_user.id @current_user = new_user end
getterを見てみると、ちょっとコードが技巧的だ。
- インスタンス変数
@current_user
が設定済みならそれが活きる そうでなければ
session[:user]
を見て、セッション変数が設定されているようならそれをUser
オブジェクトのidと見て取得を試みる。- 取得成功すれば、それを
@current_user
に保存しておく。 - そうでなければ、
:false
を@current_user
に設定しておく。
- 取得成功すれば、それを
いずれにせよ、ここまでで設定されている
@current_user
の値を返す。
ここで、 :false
なんていう変なシンボルを使ってる意味がわかんないよねー、と私と諸橋さんはセッションでぶちぶち言ってたけど、今気がついた。あちこちで @current_user ||=
というコードが実行されることになるから、「ログイン状態未知」と「非ログイン状態」を区別する必要がある。ここで @current_user
に nil
とか false
を入れておくと本来は「非ログイン状態」であることは既知の筈なのに、毎回ログインを試みて失敗することになり無駄なクエリが走るのだ。それで、「無効値であることが見た目に分かりやすく、かつ、Rubyにとっては真として評価される」値が必要なんだ。
setterのほうは、これは前に書いたように User
オブジェクトまたは nil
を設定されるものと想定している。 is_a? Symbol
してるところを見ると :false
も受け付けるみたいね。まず最初は、
- 有効値(
User
オブジェクト)を与えられたなら、そのidをセッション変数に設定 - 無効値を与えられたなら、セッション変数に
nil
を設定
で、 @current_user
にも値を保存しておく。と、
これでget/setできる @current_user
をどこで使ってるかというと、主に AuthenticatedSystem#logged_in?
で使ってる。
# Returns true or false if the user is logged in. # Preloads @current_user with the user model if they're logged in. def logged_in? current_user != :false end
おー、シンプル。うん、さっき見た current_user
の定義で、必ず User
オブジェクトから :false
が返るようになってるから、 :false
と比較するだけでログインしているか判別できるのだ。
で、この logged_in?
をあちこちで使ってる訳ね。これが、ログイン判別の仕組みであった。
後半
- 初心者向け(Scaffoldの先)
- Componentの改善案を探る
- 某Ruby書籍翻訳査読会
最初のやつはyuum3さんがやってくださった。ハンズ・オン形式で、scaffoldは分かったけど、その先何も分からないという人のためのセッション。
次のは、 render_component
が遅くてかなわないので改善案を考えようというセッション。
私は、自分で持ち込んだ某書籍査読会のオーナーになった。内容はないしょ。とりあえず、みんなで楽しんだとだけ言っておこう。
懇親会
jig.jpの人とか来てた。携帯上の開発は別世界なので聞いてて面白い。クラスローダの無いJava!
与太話
懇親会で、諸橋さん瀧内さんとも話した。
大体このあたりで話すといつもRailsアプリケーションのパフォーマンスをどうしてるかとかそういう話になる。瀧内さんが「cascaded eagar loadingを5段7段と重ねるとメモリーを喰ってそれが足を引っ張る」「でもABD的にやるとどうしてもそんな感じのクエリが必要」という話をしてて、諸橋さんがキャッシュの話をしてて。
で、私がまた電波を飛ばし始めた。「DBサーバーとアプリケーションサーバーの間にもう1つサーバーを置けばいいよね」と。まぁ、例によって思いつきベースの与太話なんだけど。
アプリケーションロジックをDBサーバーに任せる発想というのは私は好きで、だから私はストアドプロシージャ大好き。だけれど、それでDBサーバーに負荷が増えるのは嬉しくない。だいたいが、Railsアプリケーションの並列化ではアプリケーションは殆どshared nothingなんだけど、その分がDBに集中するんだよね。なら、APPとDBの間にもう一層おいて、そこでできるだけキャッシュして、そこで必要なトリガを引いて、そこにビジネスロジックを盛り込んで、APPサーバーはそのサーバーを参照すればいいじゃない。dRubyで。
特に、Railsが得意とするような「今からCREATE TABLEします」っていう開発ではDBを参照している既存のアプリケーションというのは無くてこれから全てを作るのだから、このやり方で無理がない。
と、発想は出てくるけど、考えれば考えるほど「それ、なんて言うEJB?」なんだ。
でもね、私は今実際にそういう形で動かしてる。キャッシュや、データ変更に対するObserverを保持するサーバープロセスがmongrelの裏に3つぐらい動いてる。今のところはキャッシュが無効化される頻度が極めて少ないので手で裏方サーバーを再起動してキャッシュクリアしてるけど、これを発展させて、より洗練していけばそこにたどり着く。
結局、EJBは悪くないんだ。ロジックが単独のWebアプリケーションに収まらないとき、あるいは遅延させてバックグラウンドで動かすとき。そういうときにロジックを裏方サーバーで動かしておくのは悪くない。
全部が全部リモート扱いじゃパフォーマンスに響くからEJBはLocalインターフェースを作った。でも、結局インターフェースを定義する手間は変わってない。EJB3になってPOJOになって、Homeインターフェースも作らなくて済むようになって、でもやっぱり分散を視野に入れた定義は必要で。
それは、初期段階ではover killなんだ。自社でB2Cサービスを提供して勝ち残っていこうとするような我々、インターネット業界の住人にとってはover killだ。我々に必要なのはいち早くアプリケーションが動きはじめて、エンドユーザーに見てもらえること。そのためにはRailsは良い道具だ。分散がどうこうとか、そういうのは必要がないし、そのためのわずかなコストも惜しい。
そして、サービスが成長していったとき、そのときになって自然な形でロジックを裏方に委譲できる仕組みが欲しい。dRubyにはその潜在能力がある。私はRubyは好きで、個人的にはずっと使っていくだろう。でも、仕事でRubyを使っているのはRailsとdRubyがあるからだ。dRubyの力で、モデル層の後ろ半分をリモートプロセスに自然に引きはがせるような、そういう仕組みが欲しい。作れるはずだ、いつか作ってやる。
とか、そんなことを話した。