SIN@SAPPOROWORKSの覚書

C#を中心に、夜な夜な試行錯誤したコードの記録です。

FacebookのOAuthを実装する

1 Facebookテンプレートの認証UIはちょっと

VS2012 SP2 以降で提供されているMVC5用のFacebookテンプレートは、超簡単にFacebookアプリが作成できる優れものです。同テンプレートでは、Actionに[FacebookAuthorize]属性を指定するだけで、必要な認証処理を全てバックグランドでやってくれるため、プログラマは受けとったFacebookContextを使うだけです。

public class HomeController : Controller{
   [FacebookAuthorize("email", "user_photos")]
   public async Task<ActionResult> Index(FacebookContext context){
       var user = await ontext.Client.GetCurrentUserAsync<MyAppUser>();
       return View(user);
   }

しかし、このアプリ認証のUIにちょっと問題があります。

(1)問題点1 認証ダイアログが2回表示される

1つの目の問題は、認証ダイアログが2回表示されて、ユーザに違和感を与えるというものです。

テンプレートに付属されている上記のコードでも、認証ダイアログが次のように2回に表示されます。
001
1回目に表示されるダイアログ(左側)は、全てのFacebookユーザにデフォルトで与えられている許可である「公開プロファイル」と「友達リスト」に関する承認です。そして2回目に表示されるダイアログ(右側)は[FacebookAuthorize]属性で指定した、「"email", "user_photos"」「メールアドレス、写真」に関する追加承認です。

許可を求める権限は、oauth/dialogのscopeパラメータで指定するだけなのですが、なぜ、2回に分けているのでしょう。
以下は、Fiddler確認したoauth/dialogへの2回のリダイレクト先です。

GET https://www.facebook.com/dialog/oauth?redirect_uri={url}&client_id={appId} HTTP/1.1
GET https://www.facebook.com/dialog/oauth?redirect_uri={url}&client_id={appId}&scope=email%2Cuser_photos HTTP/1.1

当初scopeの指定のない認証を要求し、改めてscopeを指定した認証を行っているのが分かります。
デフォルトの権限を確認し、新たに、追加の権限を確認しているます。
気持ちはよく分かるのですが、この分け方が果たしてユーザに伝わっているのか・・・・

(2)問題点2 キャンセルできない

2回に分けて表示される認証ダイアログですが、1回目のダイアログで「OK」を選択すると、2回目に「キャンセル」を選択しても、延々と、2つ目のダイアログが表示されます。そう「OK」を選択するまで永遠と許可を求めてくるのです。逃れるためにはブラウザをKILLするしか無いのです。
1つ目のダイアログでOKしても、途中で気が変わることは十分にありますので、この無現ループは、比較的よく発生するのではないでしょうか。
問題1は、まだ少し許せるところもありますが、この問題2は、ちょっと許されないような気がしています。

2 OAuthの実装

という事で、OAuth部分を実装してみることにします。

(1)FbOAuthクラスの作成

最初に必要な設定値を保持したFbOAuthクラスを作成します。必要な値は下記の4つですが、1,2は「Facebookテンプレート」でWeb.configに記載されているので、それをそのまま使用することにしました。
1 アプリケーションID
2 アプリのシークレットキー
3 キャンパスページ
4 キャンパスページのURL

002



public class FbOAuth{

        static readonly string AppId = ConfigurationManager.AppSettings["Facebook:AppId"];
        static readonly string AppSecret = ConfigurationManager.AppSettings["Facebook:AppSecret"];
        const string CanvasPage = "https://apps.facebook.com/test_abcde/";
        const string CanvasUrl = "https://localhost:44302/";

}

(2)oauth/dialogへのリダイレクト

認証の最初のアクションは https://www.facebook.com/dialog/oauth へのリダイレクトです。このリダイレクト先(パラメータをセットしたもの)を返すメソッド Dialog() をFbOAuthクラスに実装することにします。

public class FbOAuth{
    public static string Dialog(string scope, string callback){
        var fb = new FacebookClient();
        var loginUrl = fb.GetLoginUrl(new{
            client_id = AppId,
            client_secret = AppSecret,
            redirect_uri = CanvasUrl + callback, //callback URL
            response_type = "code",
            scope = scope});
            //Canvasの場合、iframe内からなので、この方法でないとリダイレクトできない
            return string.Format("<html><script>window.top.location.href ='{0}'; </script></html>",loginUrl.AbsoluteUri);
    }

ここで使用している FacebookClientのGetLoginUrl()メソッドは、/oauth/dialogへのurl(パラメータつき)を生成するメソッドです。
redirect_uriには、このリダイレクト先で認証作業が完了した際に、このアプリに戻ってくる時のURLを指定しています。

なお、このリダイレクトは return Redirect(new_url)という風にしてもブラウザは真っ白になってしまいます。
これは、Facebookアプリ(Canvasアプリ)がiframeの中で動作しているため、iframeの外でリダイレクトさせる必要があるからです。
ここでは、window.top.location.hrefにリダイレクト先を指定したScriptを含んだHTMLを生成し、return Context(html)と処理することで対処しています。

下記は、このDialogメソッドを呼び出しているAction側のコードになります。


public class HomeController : Controller{
   public async Task<ActionResult> Index(){
      //......
      return Content(FbOAuth.Dialog("email,user_photos","/Home/CallBack"));
      //...... 
   }

(3)コールバック

リダイレクト先で認証処理を終了すると、「認可コード」を添えて、指定したコールバックに戻されます。


public class HomeController : Controller{
  public ActionResult Callback(string code){
      var accessToken = FbOAuth.GetAccessToken(code, Request.Url.AbsoluteUri);
      if (accessToken == null){
          return View("Error");//キャンセルされた
      }
      Session["AccessToken"] = accessToken;
      return Redirect(FbOAuth.Canvas());
  }

受け取った「認証コード」を使用して「アクセストークン」を取得するメソッド GetAccessToken() をFbOAuthクラスに実装します。
具体的には、https://graph.facebook.com/oauth/access_tokenへアクセスしてトークンを取得しています。


public class FbOAuth{
    public static string GetAccessToken(string code, string requestUrl){
       if (code == null){
          return null;//認証をキャンセルされた場合、code=nullになっている
       }
       //アクセストークンの取得
       try{
          var fb = new FacebookClient();

          //2014.02.28 修正 dynamic result = fb.Post("oauth/access_token",new{
          dynamic result = fb.Get("oauth/access_token",new{
                        client_id = AppId,
                        client_secret = AppSecret,
                        redirect_uri = requestUrl, //これがoauth/dialogへのリクエストと一致していないとトークンは取得できない
                        code = code
                    });
                return result.access_token;
            } catch (Exception){
                ; //coedがタイムアウトしている場合などは、失敗する
            }
            return null;
        }

※2014.02.28追記
こちらのリクエストはPostではなくGetでした。修正させて頂きました

最後に、Coallbackメソッドで、元のURLにリダイレクトさせるためのURL取得用メソッド Canvas()ですが、これは、単純に、「キャンパスページ」を返しているだけです。

public class FbOAuth{
   public static string Canvas(){
         return CanvasPage;
     }


3 FbOAuthクラスの使用

最後に、今回作成したFbOAuthクラスを使用して書き換えたIndexアクションを記載します。
仕様としては、FbOAothクラスを使用してアクセストークン取得に成功した場合、Session["AccessToken"]に保持するようにしています。

なお、AccessTokenを使用してFasebookClientを生成する場合、ClientProviderを使用しないと、内部のSerializerなどが初期化されないので注意(※1)が必要です。


public class HomeController : Controller{
     //[FacebookAuthorize("email", "user_photos")]
     public async Task<ActionResult> Index(){
        var accessToken = (string)Session["AccessToken"];
        if (accessToken == null){
            return Content(FbOAuth.Dialog("email,user_photos","/Home/CallBack"));
        }
        //Privider経由で作成しないとSerializer等が初期化されない
        var client = GlobalFacebookConfiguration.Configuration.ClientProvider.CreateClient();
        client.AccessToken = accessToken;
        var user = await client.GetCurrentUserAsync<MyAppUser>();
        return View(user);
     }

上記で、見た目は当初のサンプルとまったく同じですが、認証ダイアログの表示は1回となり、ちゃんとキャンセルもできるようになりました。めでたしめでたし。

※1「しばやん雑記 2013-07-14 ASP.NET MVC で Facebook アプリを作る時には気をつけろ