RESTful なコントローラーにOpenID認証をくっつける

OpenID自体は、1.POST(consumer) => 2.Redirect-GET(consumer -> provider) => 3.POST(provider) => 4.Redirect-GET(provider -> consumer) となるので、Consumer側は、全然RESTfulになれない罠。とりあえず実装は完了したけど、どうだろう。1 と 4 のURIとその挙動を考えてみる。理想と実装の現実の狭間(笑。


1.POST(consumer) => 2.Redirect-GET(consumer -> provider) => 3.POST(provider) => 4.Redirect-GET(provider -> consumer)

1. は /accounts/current にした。なぜならローカルアカウント(メールアドレスとパスワードが登録されているユーザー) は /accounts/current に自分の認証情報をPOSTして、ログイン済みのユーザーを作成する、という動作にしたので。本当は /accounts/{login_name}/current のほうが正しいと思うけど。

ということで、/accounts/current に user[open_id_uri] をPOSTすると consumer が ID Providerに問い合わせるようになっている。

    begin
       req = consumer.begin(uri)
       return redirect_to(req.redirect_url(open_id_realm, open_id_return_to))
    rescue OpenID::OpenIDError => e
       flash[:error] = _("OpenID Error #{e.message}")
       valid = false
    end

問題はこのときの、open_id_return_to のURLで、とりあえず、/accounts/open_id にしてみた。

  def open_id_return_to
    open_id_accounts_url()  # map.resources :accounts, :collection => {:open_id}
  end

ちなみにconsumerは OpenID::Consumer。

  def consumer
    if @consumer.nil?
      dir = File.join(RAILS_ROOT, "tmp", "webjourney", "idstore")
      store = OpenID::Store::Filesystem.new(dir)
      @consumer = OpenID::Consumer.new(session, store)
    end
    @consumer
  end

1.POST(consumer) => 2.Redirect-GET(consumer -> provider) => 3.POST(provider) => 4.Redirect-GET(provider -> consumer)

ということで、/accounts/open_id のアクションを考えてみる。まず、GET

  # Resource for open id users
  # GET POST PUT DELETE /accounts/open_id
  #   GET    : the end point redirected from open id provider
  #   POST   : Create a new open id user
  #   PUT    : Not Supported.
  #   Delete : Not Supported
  def open_id
    case request.method

いきなりだが、PUT/DELETEはスルー。URIは単なるRedirectされるためのエンドポイントとして実装されればいいはず。で、そのGET

    when :get
      uri = params["openid.identity"]
      raise WebJourney::ForbbidenError.new unless uri
      res = consumer.complete(params.reject{|k,v|request.path_parameters[k]}, open_id_return_to)

OpenIDプロトコル上、ID Providerからリダイレクトされるときは openid.identity={認証に使用したOpenID} というのが含まれるらしい。というわけで、これがない場合の動作で迷う。

  • リダイレクトじゃないから、consumer.begin が行われていない。つまりconsumerとしてのcurrentが作られていないんだから、NotFoundじゃないのか?
  • いや、とりあえず「そんなアクセスだめよ」ってことでForbiddenか。

ここでは後者を採用。で、先に認証が失敗したときをみる。

   case res.status
   when OpenID::Consumer::FAILURE
     session[:wj_current_user_open_id_idp_authenticated] = false
     flash[:error] = "OpenID Authentication failed."
     redirect_to login_system_accounts_url(:_layout => "page")
   end

りだーいれくと。/accounts/login に戻されることで、ログイン処理に失敗したことにする(4XXを返してもいいんだけど、とりあえずはブラウザ相手ってことで)。次に成功したとき。

      when OpenID::Consumer::SUCCESS
        # see vendor/plugin/webjourney/lib/webjourney/security.rb
        session[:wj_current_user_open_id_idp_authenticated] = uri
        @user = do_open_id_login(uri)
        if @user
          go_to_current_user_start_page
        else
          # try to create user
          @user = WjOpenIdUser.new(:open_id_uri => uri)
          @user.login_name = WjOpenIdUser.suggest_login_name(uri)
          flash[:info] = _("You need to create your profile associated with your OpenID at first time.")
          # Render create User page
        end

OpenIDユーザーが登録されていれば*1、通常のログイン処理を行って、ユーザーのホームページにとばす。登録されていなければ、新しくユーザー登録を促すことになる。つまり /accounts/open_id というURIは、GETされたときは、

  • 登録済みユーザーにとっては認証処理用エンドポイント
  • 未登録ユーザーにとってはOpenIDプロファイル登録作成ページ

ということになってしまう。 そして最大の問題は、

session[:wj_current_user_open_id_idp_authenticated] = uri

つまり、OpenID認証されたことを記憶しておく必要があることだ。これは、POSTのための処理で、
POSTされたときのコードは次の通り。

    when :post
      uri = params[:user][:open_id_uri]
      if uri == session[:wj_current_user_open_id_idp_authenticated]
        @user = WjOpenIdUser.new(:open_id_uri => uri)
        @user.login_name http://d.hatena.ne.jp/images/admin/markup_url.gif= params[:user][:login_name]
        @user.disable     = false
        @user.wj_role_ids = WjRole.defaults.map(&:id)
        if @user.save
          do_open_id_login(@user.open_id_uri)
          # Goto User start page
          go_to_current_user_start_page
        end
      else
        flash[:error] = _("You have not authorized by the OpenId provider yet.")
        redirect_to login_system_accounts_url()
      end
    end

つまり、POST時にクライアントから渡される登録用のOpenIDURIと、サーバー側の認証済みOpenIDURIをマッチさせる必要がある。こうしないと、無制限にOpenIDユーザー作られてしまうし。IDPからRedirectされたときのパラメーターを全部HTML hiddenに埋め込んで、再度POSTするようにしても良さそうな気がするんだけれど。IDP側の設定で「一度だけ認証する」にしてしまうと、もう一度completeを発行することができないのでだめ。

とりとめがなくなってしまったが、OpenIDを使うときに、ステートレスなアクションを作るのは難しいってことで。

とりあえず、ソースは[http://project.webjourney.org/webjourney/trac/browser/trunk/components/system/accounts_controller.rb
=この辺]。

*1:なぜなら、OpenIDは認証はするけど認可はしてくれないので、認可用のプロファイルをサイトでもたなければならない