2008年01月09日

Python で OpenID のサンプルサーバーを動かす。(その2)

はてなブックマークに登録

前回は、OpenIDの仕様と、python-openidのサンプルサーバを立ち上げるまで を説明しました。今回はサンプルのコードを見ながら実際のコーディングの雰 囲気に慣れたいと思います。

仕組みなどに興味なく OpenIDの認証だけ使いたい場合は、Webフレームワーク の Pylons で AuthKit を使うと、非常に簡単にアプリケーションに OpenID の認証を実装させることができます。そのほか、Plone や TurboGears, Django なども対応しているようです。Webフレームワークを使っていて、とり あえず使いたい方は参照してみてください。
Python Package Indexで検索
django-openid

RP(コンシューマ)用ライブラリの概要

モジュール openid.consumer.consumer は Relying Parties (別名 Consumers) をサポートします。
RPでは次のことを行います。

  1. ユーザーがコンシューマサイトでIDを入力してログインします
  2. discover:コンシューマサイトはYadis プロトコルを使ってプロバイダを見つ ます。
  3. コンシューマサイトはブラウザをプロバイダにリダイレクトします。
  4. プロバイダサイトは認証要求に対する応答をつけてブラウザをコンシューマサ イトに戻します。

SESSIONS, STORES, AND STATELESS MODE
consumer は2つのタイプの状態を保持します。

  1. ユーザーの現在の認証の試行状態:
    見つけられたエンドポイントのURLの リスト,到達できないエンドポイント,すでに試行したエンドポイント。この状 態はConsumer.begin()から Consumer.complete()までの間維持される必要が あります。
  2. サーバーとの関係の状態:
    サーバとの共有秘密(associations)や一時的な 署名されたメッセージがあります。この情報はあるセッションから次のセッショ ンまで持続し、異常なユーザーエージェントに接続しないようにしなければな りません。
Consumerのコンストラクタの引数は、辞書型のsessionと openid.store.interface.OpenIDStoreのインスタンスのstoreです。storeには プロバイダのサーバと共有する秘密を保持するので、保存先には注意する必要 があります。適切な保存先が無い場合はstoreをNoneにしてステートレスモー ドにし、保存しないようにします。
ステートレスモードでは、動作が遅 くなりプロバイダの負荷が増えますが、リプレイ攻撃に対しては安全になりま す。
storeの保持先としては、通常のファイルやSQLデータベースを利用 することができます。

IMMEDIATE MODE

上記のフローでは、プロバイダーはユーザーに対してコンシューマサイトに戻 る前に、IDを確認するページを表示する必要があります。すぐに応答を得たい 場合には、コンシューマサイトはimmediate modeでライブラリを利用すること ができます。

実際のコーディング手順

  1. 最初に、Consumerのインスタンスを作成し、IDをつけてbegin()を実行すると、 AuthRequestオブジェクトを返します。
  2. 引数にOpenIDサーバがIDの確認を試みた後に戻るURL(return_to)とユーザー が認証するときにウェブサイトを確認するURLまたはURLのパターン(realm)を 指定して、AuthRequestオブジェクトのredirectURL()を実行してと、ブラウザ にOPへのリダイレクトを送信します。

ここまでが最初の半分のプロセスです。

  1. 残りのプロセスは、プロバイダがユーザーのブラウザをredirectURL()で指定 したreturn_toのURLにリダイレクトして戻した後に行われます。
    そのリ クエストにはプロバイダによってURLにリクエストを終了するために必要な情 報がリクエストのクエリに付け加えられます。
  2. 同じsessionとstoreのConsumerインスタンスを選択し、complete()を呼び出し ます。

BaseHTTPServer版のコンシューマサーバ(RP)のコードを見る

それでは、サンプルのコードを見てみます。

Consumerのインスタンスを作成。

このサンプルでは、store は filestore または、memstore が使われてい ますが、Django版では、PostgreSQLStore,MySQLStore,SQLiteStore が使える ようになっています。
(以下、examples/consumer.py から抜粋)

from openid.consumer import consumer
...

class OpenIDRequestHandler(BaseHTTPRequestHandler):
...
    def getConsumer(self):
        # セッションとストアを渡してConsumerのインスタンスを作成します。
        # store は コンシューマに必要な情報を保持します。
        # store は
        # openid.store.memstore.MemoryStore() または
        # openid.store.filestore.FileOpenIDStore(data_path)
        #
        # self.getSession() は sessionを戻します。
        # sessionは 辞書型変数でsession['id']にクッキーがセットされている。
        # session['id'] = sid
        return consumer.Consumer(self.getSession(), self.server.store)

    def doVerify(self):
        ...
        oidconsumer = self.getConsumer()
セッションキーは次のコードで与えられています。
    SESSION_COOKIE_NAME = 'pyoidconsexsid'

    def setSessionCookie(self):
        sid = self.getSession()['id']
        session_cookie = '%s=%s;' % (self.SESSION_COOKIE_NAME, sid)
        self.send_header('Set-Cookie', session_cookie)
    def getSession(self):
        """Return the existing session or a new session"""
        if self.session is not None:
            return self.session

        # Get value of cookie header that was sent
        cookie_str = self.headers.get('Cookie')
        if cookie_str:
            cookie_obj = SimpleCookie(cookie_str)
            sid_morsel = cookie_obj.get(self.SESSION_COOKIE_NAME, None)
            if sid_morsel is not None:
                sid = sid_morsel.value
            else:
                sid = None
        else:
            sid = None

        # If a session id was not set, create a new one
        if sid is None:
            sid = randomString(16, '0123456789abcdef')
            session = None
        else:
            session = self.server.sessions.get(sid)

        # If no session exists for this session ID, create one
        if session is None:
            session = self.server.sessions[sid] = {}

        session['id'] = sid
        self.session = session
        return session

ディスカバーの実行

IDを渡して、begin()を実行します。begin()はdiscoverを実行し、失敗すると consumer.DiscoveryFailure例外を発生します。serviceが無い場合はNoneを返 します。

        try:
            request = oidconsumer.begin(openid_url)
        except consumer.DiscoveryFailure, exc:
            # 例外処理
           ...
        else:
            if request is None:
                ...
            else:
                # discover 成功

拡張の設定

requestが正常にリターンされた後、拡張が選択された場合のコードがあります。

openid.extensions.sreg:(Simple registration request)
2.0からサポートされました。
http://openid.net/specs/openid-simple-registration-extension-1_1-01.html sregはRPとOP両方で使われます。

  1. RPではrequestオブジェクトをつくり、SRegRequestを加えます。

    auth_request.addExtension(SRegRequest(required=['email']))

  2. OPではRPからのrequestからSRegRequest.fromOpenIDRequestを使ってsregを取 り出します。その後,SRegResponseオブジェクトを作成しresponseにユーザー のデータをつけたsregのレスポンスを加えます。

    sreg_req = SRegRequest.fromOpenIDRequest(checkid_request)
    # [ get the user's approval and data, informing the user that
    # the fields in sreg_response were requested ]
    sreg_resp = SRegResponse.extractResponse(sreg_req, user_data)
    sreg_resp.toMessage(openid_response.fields)

  3. RPは SRegResponse.fromSuccessResponseを使ってresponseのsregを取り出し ます。

    sreg_resp = SRegResponse.fromSuccessResponse(success_response)

openid.extensions.pape:
An implementation of the OpenID Provider Authentication Policy Extension 1.0
まだドラフトの規格である2.1でサポートされます。OpenID Provider Authentication Policy Extension - Draft 2
次の認証ポ リシーが指定できまます。

  • AUTH_PHISHING_RESISTANT: http://schemas.openid.net/pape/policies/2007/06/phishing-resistant'
    潜在的にRPの支配下にある相手にエンドユーザーが共有キーを渡さない認証方 式(潜在的に悪意があるRPによって、ユーザーエージェントがリダイレクトさ れて、その結果それをエンドユーザの実際のOPに送らないように制御されるこ とに注意してください。)
  • AUTH_MULTI_FACTOR: 'http://schemas.openid.net/pape/policies/2007/06/multi-factor'
    エ ンドユーザーがひとつの認証要素以上を渡すことによってOPに対して認証する 認証方式。例としては、パスワードとソフトウェアトークンや電子証明書を使っ た認証がある。
  • AUTH_MULTI_FACTOR_PHYSICAL: 'http://schemas.openid.net/pape/policies/2007/06/multi-factor-physical'
    上記のAUTH_MULTI_FACTORを含み、ハードウェアトークンを使用するような場 合。

オプションを選択したときのコード:

                # sregが指定されている場合
                if use_sreg:
                    self.requestRegistrationData(request)

                # papeが指定されている場合
                if use_pape:
                    self.requestPAPEDetails(request)
sregとpapeのそれぞれ該当する関数のコード
sregを選択した場合は'nickname'、'fullname'、'email'を取得しようとしています。
from openid.extensions import pape, sreg
...

    def requestRegistrationData(self, request):
        #required にnickname、optionalに'fullname', 'email'を指定しています。
        sreg_request = sreg.SRegRequest(
            required=['nickname'], optional=['fullname', 'email'])
        request.addExtension(sreg_request)

    def requestPAPEDetails(self, request):
        pape_request = pape.Request([pape.AUTH_PHISHING_RESISTANT])
        request.addExtension(pape_request)

リダイレクト

request.shouldSendRedirect()によって、リダイレクトするかPOSTを送信する か判断します。request.redirectURLでリダイレクト先のURLを取得し、ブラウ ザをリダイレクトします。

                trust_root = self.server.base_url
                return_to = self.buildURL('process')
                if request.shouldSendRedirect():
                    # trust_root はrealmです。
                    redirect_url = request.redirectURL(
                        trust_root, return_to, immediate=immediate)
                    self.send_response(302)
                    self.send_header('Location', redirect_url)
                    self.writeUserHeader()
                    self.end_headers()
                else:
                    form_html = request.formMarkup(
                        trust_root, return_to,
                        form_tag_attrs={'id':'openid_message'},
                        immediate=immediate)

                    self.autoSubmit(form_html, 'openid_message')

ここまででリクエストの前半が終了です。

認証の後半 - return_to での処理

後半です。最初と同じようにConsumerのインスタンスを作成。

    def getConsumer(self):
        return consumer.Consumer(self.getSession(), self.server.store)

    def doProcess(self):
        """Handle the redirect from the OpenID server.
        """
        oidconsumer = self.getConsumer()
query(辞書型変数)とurl を渡してConsumerのcomplete()を実行。
        url = 'http://'+self.headers.get('Host')+self.path
        info = oidconsumer.complete(self.query, url)
complete()は、SUCCESS, CANCEL, FAILURE, または SETUP_NEEDED のステー タスを含むResponseのサブクラスを返します。以下、成功した場合、
        display_identifier = info.getDisplayIdentifier()
        if info.status == consumer.FAILURE and display_identifier:
            ...
        elif info.status == consumer.SUCCESS:
            ...
            fmt = "You have successfully verified %s as your identity."
            message = fmt % (cgi.escape(display_identifier),)
            sreg_resp = sreg.SRegResponse.fromSuccessResponse(info)
            pape_resp = pape.Response.fromSuccessResponse(info)
            if info.endpoint.canonicalID:
                # You should authorize i-name users by their canonicalID,
                # rather than their more human-friendly identifiers.  That
                # way their account with you is not compromised if their
                # i-name registration expires and is bought by someone else.
                message += ("  This is an i-name, and its persistent ID is %s"
                            % (cgi.escape(info.endpoint.canonicalID),))
以上で終了です。

整理すると

  1. アプリケーションで入力されたIDを取得する。
  2. セッションクッキーとstoreを作り、Consumerのインスタンスを作る
  3. begin()でディスカバーを実行する。
  4. OPにリダイレクトする。
  5. OPからreturn_toにレスポンスが帰って来る。
  6. レスポンスからConsumerのインスタンスを作り、complete()で結果を取得する
  7. アプリケーションに結果を渡す。

アプリケーション側では、ここからアプリケーションのユーザーのトップペー ジにアプリケーション用のセッションIDをつけてさらにリダイレクトしたりし ます。
アプリケーション内の任意のページから認証する場合は、RPのロ グインページにアプリケーションのページからリダイレクトするとき、クッキー やクエリ文字列に元ページの情報をセットし、それをRPがreturn_toのクエリ にセットしておくと、レスポンス時にその情報をクエリから取得することがで きます。それをもとに、レスポンスが帰ってきたときにアプリケーションの要 求元のページに対し、アプリケーション側から指定されたキーなどやその他の 情報を、クッキーやクエリにつけてアプリケーションの元ページにリダイレク トして戻すこともできます。

終わりに

OpenIDの仕様はセキュリティ関連の暗号化の話や、ユーザーとRP、OPの3者間 の通信などで理解しがたく、とっつきにくいのですが、調べてみると、すでに Webフレームワークなどで広く対応されているようです。皆さんも是非お試し ください。

klab_gijutsu2 at 20:09│Comments(0)TrackBack(0)

トラックバックURL

この記事にコメントする

名前:
URL:
  情報を記憶: 評価: 顔   
 
 
 
Blog内検索
Archives
このブログについて
DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
最新コメント