2009年11月6日金曜日

認証あれこれ

GAEの認証

 GAEのアプリケーションを開発するに当たって、まず最初にどのような認証機能が必要かをあらかじめ整理しておくべきです。提供されている認証機能を把握することが先決で、独自の認証機能を開発するというのは最後の手段にすべきです。認証情報をGAEに任せることでユーザーのパスワードがアプリケーションから漏洩するというリスクを背負う必要がなくなります。ただしGAEの認証情報(UserAccount API)には、アカウントID、ログイン状態、GAE管理者か否かの3つの情報がアプリケーションから把握できるに過ぎません。もしアプリケーションで何らかの付属情報を持たせたい場合にはデータストアを使う必要があるでしょう。

 GAEではデプロイするアプリケーションIDの発行の際に認証方式を選択する必要があります。後からこれを修正することができません。選択する認証方式とは、一般のGoogleアカウントを使うのか、特定のAppsドメインを使うのかの二択になります。大きな意味では一般のGoogleアカウントも、@google.com という最大級のAppsドメインの1つと考えても良いと思います。要するにGAEアプリケーションで扱う認証機構の範囲は1つのドメインに属するユーザー全体ということになります。

 さて、ここから更に登場人物が増えます。Apps向けのサードパーティアプリケーションのインフラとしてGAEを使う場合です。Appsドメインに属するユーザーにはApps管理者か否かというユーザーロールがあります。GAEの管理者とApps管理者は同じではありません。Apps管理者か否かは後述するGoogle Apps Apiが関係します。さらに話をややこしくすると登場人物はまだいます。開発者である貴方です。一見、GAEのアプリケーションの管理者と開発者は同じ権限として混同しやすいのですが、Appsドメイン用アプリケーション開発という立ち位置が登場することにより、開発者とGAEアプリケーションの管理者という立場の違いをもたらします。

 例えば特定のAppsに契約していてAppsアプリケーションにない新たな社内で利用するアプリケーションやシステムを構築したいお客さんがいたとします。インハウスの開発者では足りない場合やGAEアプリケーションの開発が得意なところでないとヤバイという優れた嗅覚をお持ちであれば、例えば弊社などに外注することになるでしょう(^^;。GAEの開発環境はよくできていて、GAEアプリケーションの利用者ドメインに属さない開発者であってもDeveloperとしてinviteされていれば、ローカルサーバーでの開発作業もテストもデプロイ作業も可能です。唯一できないのはGAEでアプリケーションに直接ログインしてのテストとなります(同一ドメインであればこの限りではありません)。うまく本番でのテストと分業できるチーミングもやれないことはないといった感じです。但し現実的にはローカルサーバーとGAEは異なる環境です。QuotaもLimitも無いし完全なシミュレーションにはなりません。ローカルサーバーでは一瞬で終わった作業もGAEではタイムアウトするなんてことは日常茶飯事ですし、現実的には別のドメインのステージング環境のアプリケーションを立てる方が良いでしょう。

 以上の内容をまとめると登場人物と役割は以下のようになります。権限は、番号が小さいほど強いと考えて良いでしょう。

 番号 属性          登録方法         属するドメイン     アプリケーション内の利用可能領域
  ----  ---------------------   ------------------  ----------------   ---------------------------------
 1. 開発者         開発者としてInvite  特定不要           属するドメインがアプリと一緒であれば2.と等価。
                                    そうでなければ4.と等価。
 2. 管理者         開発者としてInvite  アプリのドメイン   auth-constraint/role-name/admin と *の領域。
 3. ユーザー(メンバー)  特になし。     アプリのドメイン   auth-constraint/role-name/* の領域。
 4. ユーザー(非メンバー) 特になし。          特定不要           auth-constraintを記述していない領域。

 この表に対してGoogleアカウント以外の、Appsメンバーがどう関係するか見ていきましょう。AppsユーザーはそのAppsドメイン用として、GAE登録時に設定したアプリケーションにのみログインできます。ドメインが一致する限り3.に位置します。混同しやすいのがApps管理者です。Apps管理者とGAE開発者・管理者は同じではありません。もしApps管理者をGAE開発者・管理者にしたい場合はやはりGAEコンソールからApps管理者のユーザーを開発者として登録する必要があります。

 アプリケーションからはweb.xml(デプロイメント・ディスクリプタ)にsecurity-constraint要素を記述して、アプリケーション内のURLパターン毎に、3種類のアクセス方法を選択できます。

  番号 role-name  動作          利用できるユーザー
 ----  ---------- ---------------------- -------------------------------
 1. なし    なし          非メンバー、メンバー、管理者
 2. *      ログイン処理が入る   メンバー、管理者
 3. admin    ログイン処理が入る   管理者

 URLパターンに / や /* を指定すると、security-constraintに記述の無いその他のURLパターン全てが、 / や /* に指定したauth-constraintの設定と同じになってしまうことに、注意が必要です。また、role-name要素の中身を空にしても非メンバーもアクセス可能な領域になりません。role-nameが空の場合は管理者も含めて誰も許可されない領域という意味になります。非メンバーのアクセスを許容するためにはauth-constraintを記述しないようにします。もし / や /* を、* や admin に指定しないのであれば、非メンバー領域の定義をsecurity-constraintを使って指定する必要もなくなります。

Gooogleアプリケーションの認証

 次にAppsアプリケーションと連携するGoogleアプリケーションをGAEで提供することを考えます。これには Google Data API(以下GData)などの利用が挙げられますが、そちらも認証が必要になります。二重に認証するというのは手続きの多い不恰好なアプリケーションになってしまいますので、GDataを前面に使用するのであれば、GDataの認証をメインにしてGAEのsecurity-constraintは管理者用のシステムメンテナンス画面ぐらいに利用するに留めるほうが良いでしょう。

 ただし、GAEの認証だけでGDataの認証をさせなくても良い方法がApps APIに用意されています。2-legged OAuthという仕組みです。これは、AppsユーザーがAppsアプリケーションにアクセスする手順を、Apps管理者が代行(またはシミュレート)するためのものです。

 2-legged OAuthの使い方は、まずApps管理者だけが知り得ているシークレットコードをGDataのリクエストに付加します。さらにAppsユーザーのIDを使って、要求者パラメータを渡します。こうすることで、あるAppsユーザーがログインしないとアクセスできない機能の結果を第3者であるApps管理者が得られるようになります。シークレットコードはApps管理者であればログインできるAppsコンソールの中で、[高度なツール]-[OAuth アクセスを管理]で参照が可能な、「OAuth コンシューマ シークレット」という文字列データになります。同画面の「Two-legged OAuth を有効にする」にチェックが無いと利用できないので注意してください。

 しかし厄介なことに、GData API for Javaのライブラリでは要求するコマンドによっては要求者IDのパラメータを渡すクチが無い場合があります。GDataのソースコード内でフィードを要求するリクエストオブジェクトにOAuth関連のパラメータを追記するように改造しないとなりません。

com.google.gdata.client.Service
/* patch start */
public Map<string, Map<String, String>> fixedParameters = new HashMap<string, Map<String, String>>();
/* patch end */

public GDataRequest createRequest(GDataRequest.RequestType type,
   URL requestUrl, ContentType inputType) throws IOException,
   ServiceException {

 /* patch start */
 Pattern startPattern = Pattern.compile("^(http:|https:)");
 String urlStr = requestUrl.toString();
 String urlStrRemovedProtocol = startPattern.matcher(urlStr).replaceAll("");
 for (String scope: fixedParameters.keySet()) {
   String scopeRemovedProtocol = startPattern.matcher(scope).replaceAll("");
   if (urlStrRemovedProtocol.startsWith(scopeRemovedProtocol)) {
     Map<String, String> extraParameters = fixedParameters.get(scope);
     if (extraParameters != null && extraParameters.size() > 0) {
       int paramStart = urlStr.indexOf('?');
       if (paramStart == -1) {
         paramStart = urlStr.length();
         urlStr = urlStr + "?";
       }
       for (String name: extraParameters.keySet()) {
         int indexOf = urlStr.indexOf(name+"=", paramStart);
         if (indexOf == -1) {
           String value = URLEncoder.encode(extraParameters.get(name), "UTF-8");
           if (!urlStr.endsWith("?")) {
             urlStr += "&";
           }
           urlStr += name+"="+value;
         }
       }
       requestUrl = new URL(urlStr);
     }
   }
 }
 /* patch end */

 GDataRequest request =
     requestFactory.getRequest(type, requestUrl, inputType);
 setTimeouts(request);
 return request;
}
com.google.gdata.client.GoogleService
public void setOAuthCredentials(OAuthParameters parameters,
   OAuthSigner signer) throws OAuthException {
 GoogleAuthTokenFactory googleAuthTokenFactory = getGoogleAuthTokenFactory();
 googleAuthTokenFactory.setOAuthCredentials(parameters, signer);
 requestFactory.setAuthToken(authTokenFactory.getAuthToken());

 /* patch start */
 if (parameters instanceof GoogleOAuthParameters) {
   GoogleOAuthParameters params = (GoogleOAuthParameters)parameters;
   fixedParameters.put(params.getScope(), params.getExtraParameters());
 }
 /* patch end */
}

 もう1つの認証としては普通にIDパスワードを受け取ってGData APIをコールする、User Credentialという方法がありますが、Googleの標準サービスではないサードパーティアプリケーションにあたるものを開発するわけですので冒頭にも挙げたようにユーザーだけでなくアプリケーションにとってもリスクだと思います。やっぱりお勧めできません。

 2-legged OAuthを使わない方法で、User Credentialも使わずに済むのがAuthSubです。これはAppsユーザーだけでなくGoogleアカウントのユーザーも利用ができますのでGoogleのサービスとのマッシュアップでは最も使われている方式だと思います。ユーザーの認証というよりも、ユーザーがGAEアプリケーションをユーザーアカウントに紐つけるような意味合いになります。AuthSub認証で受け取ったトークン情報をGoogleアプリケーションに渡すことで、Googleアプリケーションでは、ユーザーを認識し適切にGData APIの結果を返します。ただしアプリケーションからはユーザーIDさえ把握できなくなります。しかしトリッキーですがユーザーIDを取得する方法もあります。GData で Calendar APIを使い、カレンダー一覧の先頭のエントリのカレンダーIDを取得することです。先頭はかならず本人のデフォルトカレンダーになるので、カレンダーIDをパースして取得できます。またはデフォルトカレンダーのAuthorsプロパティの先頭のPersonのemailでも良いでしょう。詳しくはカレンダーAPIを参照してください。http://code.google.com/intl/ja/apis/calendar/data/2.0/developers_guide.html

 さてそろそろまとめたいと思います。先に示したようにGAEの認証はある特定のドメインという狭い範囲に絞られてしまいます。しかし、GDataとうまく連携することで1つのGAEアプリケーションでマルチドメイン向けのサービスを構築することも不可能ではなくなります。けっこう大きなシステムも作れそうですし、特定ドメイン間で協調しあえて情報共有もできるものも作れそうです。特に弊社GluegentとSIOS間での仕事の連携は多いのでそんなアプリケーションも出して行きたいところです。最後にGAE管理者ではなくApps管理者向けのURLスコープを設けるために、Apps管理者かどうかを判定する実際に使ったテクニックを紹介して、この長文を終わろうかと思います。これも組み込めば、アプリケーション全体の管理機能と、個々のドメイン毎の管理機能を分けて、Appsドメインの管理者に委譲するなんてことも出来そうです。

 Apps管理者かどうかの判断は、Apps APIをうまく使うことで可能になります。幾つかAPIがありますが、Provisioning APIでは管理者でなくても参照可能なリソースがありますし、Provisioning APIの設定が無効化されている場合は、ドメインの一般ユーザーでも管理者でも同じエラーになります。Reporting APIを実行してみて判断するしか今のところ無いようです。サンプルライブラリが Google Codeで公開されていますので、うまく流用すると良いでしょう。http://code.google.com/p/google-apps-reporting-api-client/downloads/list

 Apps管理者かどうかの判断をするにはユーザーのIDとパスワードを入力してもらうしかありません。サンプルライブラリを使って以下のように使うと良いでしょう。

  private static void reportRun(String adminEmail, String adminPassword,
      String domain, String reportName,
      OutputStream out) throws IOException, ReportException {
  
    ReportRunner runner = new ReportRunner();
    runner.setAdminEmail(adminEmail);
    runner.setAdminPassword(adminPassword);
    runner.setDomain(domain);
    runner.setPage(1);
    runner.setHasUserRequestedSinglePage(true);
    runner.runReport(reportName, (String)null, out);
  }
  
  private boolean isAppsAdmin(OAuth oauth) {
    // Apps Report API を使って確認。
    try {
      ByteArrayOutputStream out = new ByteArrayOutputStream();
      String reportName = "summary";
      reportRun(oauth.getAdminUser(), oauth.getAdminPassword(),
          oauth.getConsumerKey(), reportName, out);
      String result = new String(out.toByteArray(), "UTF-8");
      LOG.info(reportName + "\n" + result);
    } catch (IOException e) {
      LOG.log(Level.INFO, e.toString());
      return false;
    } catch (ReportException e) {
      LOG.log(Level.INFO, e.getReason());
      return false;
    }
    return true;
  }

0 件のコメント:

コメントを投稿