RailsでOpenIDを使うときにハマったところ

前々から興味のあったOpenIDRails上で試そうとしたところ, ちょいちょい動かなかったりしたのでそのメモメモ.

とりあえず導入. OpenIDを使うためのRubyGemsRailsプラグインを入れる.

$ sudo gem install ruby-openid
$ script/plugin install open_id_authentication

そしてプラグインが使うテーブルを追加する.

$ rake open_id_authentication:db:create # これでマイグレーションファイルが作られるので,
$ rake db:migrate                       # 改めてmigrate

そして適当にコントローラーとビューを書く. ちなみに技評さんの記事を参考にしました.

<!-- apps/views/sessions/new.html.erb -->
<% form_for :session, :url => {:action => 'create'} do |f| -%>
  <%= f.label :openid_url, 'OpenID URL' %>
  <%= f.text_field :openid_url %>
  <%= f.submit 'Login' %>
<% end -%>
# apps/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    openid_url = params[:session][:openid_url]
    authenticate_with_open_id openid_url do |result, identity_url, sreg|
      if result.unsuccessful?
        # ログインに失敗
        return
      end

      if User.find_by_identity_url identity_url
        # ログインに成功し, すでにアカウントがある
      else
        # ログインには成功したけど, アカウントを所有していない
      end
    end
  end

  def destroy
    # 省略
  end
end

あとルーティングも設定.

# config/routes.rb
ActionController::Routing::Routes.draw do |map|
  map.resource :session, :only => [:new, :create, :destroy]
end

"undefined method 'relative_url_root' ~"の問題

プラグインのOpenIdAuthentication#requested_urlで使われているActionController::Request#relative_url_rootがRailsのバージョンによっては存在しない(たぶんActionController::Baseに移された)ので上記の例外が投げられる.

なのでrelative_url_rootの呼び出しの箇所をActionController::Base.relative_url_rootに置き換えればいいかと思ったけど, なぜかnilを返すので, いっそ取り除いてみる.

def requested_url
  "#{request.protocol + request.host_with_port + request.path}"
end

ひとまずおk, かな?

認証後に"Only post and delete requests are allowed."とか言われる問題

とりあえずOpenIdAuthenticationモジュールのコードの一部を載せる.

# vendor/plugins/open_id_authentication/lib/open_id_authentication.rb
  protected

    # 略

    def authenticate_with_open_id(identity_url = params[:openid_url], options = {}, &block) #:doc:
      if params[:open_id_complete].nil?
        begin_open_id_authentication(normalize_url(identity_url), options, &block)
      else
        complete_open_id_authentication(&block)
      end
    end

  private
    def begin_open_id_authentication(identity_url, options = {})
      return_to = options.delete(:return_to)
      open_id_request = open_id_consumer.begin(identity_url)
      add_simple_registration_fields(open_id_request, options)
      redirect_to(open_id_redirect_url(open_id_request, return_to))
    rescue OpenID::OpenIDError, Timeout::Error => e
      logger.error("[OPENID] #{e}")
      yield Result[:missing], identity_url, nil
    end

    def complete_open_id_authentication
      params_with_path = params.reject { |key, value| request.path_parameters[key] }
      params_with_path.delete(:format)
      open_id_response = timeout_protection_from_identity_server { open_id_consumer.complete(params_with_path, requested_url) }
      identity_url     = normalize_url(open_id_response.endpoint.claimed_id) if open_id_response.endpoint.claimed_id

      case open_id_response.status
      when OpenID::Consumer::SUCCESS
        yield Result[:successful], identity_url, OpenID::SReg::Response.from_success_response(open_id_response)
      when OpenID::Consumer::CANCEL
        yield Result[:canceled], identity_url, nil
      when OpenID::Consumer::FAILURE
        yield Result[:failed], identity_url, nil
      when OpenID::Consumer::SETUP_NEEDED
        yield Result[:setup_needed], open_id_response.setup_url, nil
      end
    end

createアクションで使っているauthenticate_with_open_idはパラメーターにopen_id_completeがあるかどうかで:

  • フォームからOpenID URLが送信された場合(begin_open_id_authenticationの実行)
  • OPによって認証され, 戻ってきた場合(complete_open_id_authenticationの実行)

を振り分けている. なのでcreateアクションは上記の2つの場合で実行されることになる.

ただし1回目はフォームからのPOSTメソッドでのアクセスだが, 2回目はOPからのリダイレクトによるGETメソッドでのアクセスなので, resourcesを使っている場合, アウトー*1. リダイレクトされるURL(return_to)はこの場合/session?open_id_complete=1になるので, showアクションがあれば問題ないけど, 同じコード書くのは……

んでちょっといろいろ見ていたら(というより他サイトで試していたら)iKnow!改めsmart.fmが良さげな感じに対処してた. smart.fmのreturn_toは以下の通り.

https://smart.fm/openid?_method=post&open_id_complete=1

なるほどー! 以下のようにOpenIdAuthentication#open_id_redirect_urlを書き換えれば_method=postを追加できるのでやってみる.

def open_id_redirect_url(open_id_request, return_to = nil)
  open_id_request.return_to_args['_method'] = 'post'
  open_id_request.return_to_args['open_id_complete'] = '1'
  open_id_request.redirect_url realm, return_to || requested_url
end

でもダメー. なんでだろ?

ひとまずボクなりに解決はしてみた. authenticate_with_open_idを用いてひとつのアクションで対応するのではなく, ふたつのアクションで対応してみた.

# apps/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    # authenticate_with_open_idで行われるnormalize_urlが行われなくなるので,
    # この時点で使っておく
    openid_url = normalize_url params[:session][:openid_url]
    begin_open_id_authentication openid_url do |result, identity_url, sreg|
      # ログインに失敗
      # (begin_open_id_authenticationメソッドに与えたブロックは認証に失敗したとき
      #  にしか呼ばれないので, ブロック実行=認証失敗)
    end
  end

  def complete
    complete_open_id_authentication do |result, identity_url, sreg|
      if User.find_by_identity_url identity_url
        # ログインに成功し, すでにアカウントがある
      else
        # ログインには成功したけど, アカウントを所有していない
      end
    end
  rescue => ex
    render :text => ex.message
  end

  def destroy
    # 略
  end
end
# config/routes.rb
ActionController::Routing::Routes.draw do |map|
  map.resource :session, :only => [:new, :create, :destroy], :member => {:complete => :get}
end
# config/initializers/fix_open_id_authentication.rb
require 'open_id_authentication'

module OpenIdAuthentication
  private
    def open_id_redirect_url(open_id_request, return_to = nil)
      # return_toをコントローラ名/completeにするので, open_id_complete=1で判断する必要なし
      # よって取り除いた(たぶんその判断にしか使っていないと思う)
      open_id_request.redirect_url realm, return_to || url_for(:action => 'complete')
    end

    def requested_url
      "#{request.protocol + request.host_with_port + request.path}"
    end
end

コレで一応OK. 用途の違うブロックをひとつのメソッド(authenticate_with_open_id)に与えないのでこっちの方がいいかも?

*1:たとえ通しても, createアクションに書かれたコードではparams[:session]がnilとなってnil[]を行ってしまうからどのみちマズい