Shogo's Blog

Nov 24, 2012 - 4 minute read - Android Twitter

OAuthの認証にWebViewを使うのはやめよう

AndroidからTwitterへアクセスするためのライブラリとして,Twitter4Jが有名です. これを使ってみようと,「Android Twitter4J」と検索すると 認証にWebViewを使った例がたくさん出てきます.

・・・いや,ちょっとまて. それはちょっとまずいだろう.

そういうわけでもうちょっと賢い方法を探してみました.

何がまずいのさ

「Android Twitter4J」と検索すると,上位にこんなページが出てきます.

上のサイトでは次の様は方法をとっています.

  • アプリ内にWebViewを貼り付け
  • WebViewでTwitterの認証画面を表示
  • onPageStarted や onPageFinished をオーバーライドして callback URL へのアクセスを検出
  • URL に入っている認証コードで認証

アプリ内でWebViewを使うとURLが表示されません. つまり ** 本当にツイッターにアクセスしているかわからない ** のです. もし,表示されるのが偽の認証画面だったら,アプリから簡単にパスワードがわかってしまいます.

じゃあ,URL を表示させればいいかというとそういうわけでもありません. 画面上のURL表示なんて簡単に偽装できてしまいます. どんな工夫をしても ** アプリがパスワードの要求をしていることには変わりありません ** . アプリはパスワードを簡単に取得できます.

アプリのユーザはTwitterに限らずSNSへのログイン時にブラウザを開かないアプリは信用しないようにしましょう. どこかでパスワードの抜かれている可能性があります. (ただし,公式アプリは除く.公式アプリが信用できないならそもそもサービスを利用できないもんね.)

じゃあどうするのさ

じゃあ,開発者はどうするのかって話ですが,もう少し詳しく検索してみましょう. 他の方法を使っているページもでてきます.

PIN コードを利用

一つ目の方法はPC版クライアントでよく使われる方法. 認証後にPINコードと呼ばれる数字が表示されるので,それをアプリに入力します. twiccaなんかでも使われてますね. Twitter へのアプリケーション登録のときにコールバックURLを入力しないとこの認証方式になります.

認証画面に,ブラウザを開くボタン,PINコードの入力ボックス,ログインボタンを用意しておきます.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <Button
        android:id="@+id/button_start_login"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Twitterへアクセス" />

   <EditText
      android:id="@+id/edit_pin_code"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:ems="10"
      android:inputType="number" />

   <Button
      android:id="@+id/button_login"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_below="@+id/edit_pin_code"
      android:text="ログイン" />

</LinearLayout>

あとはボタンが押されたときにブラウザを呼ぶだけです. 認証したらPINコードを入力してもらいます.

package net.sorablue.shogo82148.yuire;

import twitter4j.AsyncTwitter;
import twitter4j.AsyncTwitterFactory;
import twitter4j.TwitterAdapter;
import twitter4j.TwitterException;
import twitter4j.TwitterListener;
import twitter4j.TwitterMethod;
import twitter4j.auth.AccessToken;
import twitter4j.auth.RequestToken;
import android.net.Uri;
import android.os.Bundle;
import android.app.Activity;
import android.content.Intent;
import android.view.View.OnClickListener;
import android.widget.EditText;

public class OAuthActivity extends Activity implements OnClickListener {
    public final static String EXTRA_CONSUMER_KEY = "consumer_key";
    public final static String EXTRA_CONSUMER_SECRET = "consumer_secret";
    public final static String EXTRA_ACCESS_TOKEN = "access_token";
    public final static String EXTRA_ACCESS_TOKEN_SECRET = "access_token_secret";

    private RequestToken mRequestToken;
    final AsyncTwitterFactory factory = new AsyncTwitterFactory();
    final AsyncTwitter twitter = factory.getInstance();
    
    // 非同期版 Twitter4J のリスナ
    private final TwitterListener listener = new TwitterAdapter() {
            @Override
            public void gotOAuthRequestToken(RequestToken token) {
                mRequestToken = token;
            }

            @Override
            public void gotOAuthAccessToken(AccessToken token) {
                // Access Token 取得成功
                // 呼び出し元に Access Token を返す
                final Intent intent = new Intent();
                intent.putExtra(EXTRA_ACCESS_TOKEN, token.getToken());
                intent.putExtra(EXTRA_ACCESS_TOKEN_SECRET, token.getTokenSecret());
                setResult(Activity.RESULT_OK, intent);
                finish();
            }
        };

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oauth);

        // Request Token をリクエスト
        final Intent intent = getIntent();
        final String consumer_key = intent.getStringExtra(EXTRA_CONSUMER_KEY);
        final String consumer_secret = intent.getStringExtra(EXTRA_CONSUMER_SECRET);
        twitter.addListener(listener);
        twitter.setOAuthConsumer(consumer_key, consumer_secret);
        twitter.getOAuthRequestTokenAsync();
        
        // EventListener をセット
        final View start_login = findViewById(R.id.button_start_login);
        start_login.setOnClickListener(this);
        final View login = findViewById(R.id.button_login);
        login.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch(v.getId()) {
        case R.id.button_start_login:
            {
                // 認証画面をブラウザで開く
                final Intent intent = new Intent(Intent.ACTION_VIEW,
                                                 Uri.parse(mRequestToken.getAuthorizationURL()));
                startActivity(intent);
            }
            break;
        case R.id.button_login:
            {
                // PINコードを取得
                final String pin = editPin.getText().toString();
                
                // Access Token をリクエスト
                twitter.getOAuthAccessTokenAsync(mRequestToken, pin);
            }
            break;
        }
    }
}

Token の取得にはインターネットアクセスが必要なので, Twitter4J に含まれている非同期版のライブラリを使っています.

Consumer Key と Consumer Secret はアクティビティの呼び出し時にインテントに設定します.

final int REQUEST_ACCESS_TOKEN = 0;
final Intent intent = new Intent(this, OAuthActivity.class);
intent.setExtraString(OAuthActivity.EXTRA_CONSUMER_KEY, "Your Cosumer Key");
intent.setExtraString(OAuthActivity.EXTRA_CONSUMER_SECRET, "Your Consumer Secret");
startActivityForResult(intent, REQUEST_ACCESS_TOKEN);

認証が完了すると onAcivityResult が呼び出されるので, Access Token を保存するなり,つぶやくのに使うだけです.

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    if(requestCode == REQUEST_ACCESS_TOKEN && resultCode == Activity.RESULT_OK) {
        final String token = data.getStringExtra(OAuthActivity.EXTRA_ACCESS_TOKEN);
        final String token_secret = data.getStringExtra(OAuthActivity.EXTRA_ACCESS_TOKEN_SECRET);
        twitter.setOAuthAccessToken(new AccessToken(token, token_secret));
    }
}

Intent Filterを利用

二つ目の方法は Intent Fileter を使って callback URL へアクセスしたときに,ブラウザにインテントを発行してもらう方法です. ユーザがPINコードを覚える必要がないので楽ちんです.

** (2013-03-09追記) ** この記事を公開したらギルティ言われてしまいました. (TwitterのOAuthの問題まとめTwitterのOAuthの問題の補足とか) 「Consumer Key が漏れる可能性を否定できないクライアントアプリでは,Callback URL をつかべきではない」とのご指摘です. ごもっとなご意見です. この方法は** 非推奨 **です. PINコードを使った認証を使いましょう.

その分開発は面倒ですが. ポイントは以下の点です.

  • Twitter へのアプリケーション登録時に Callback URL にテキトーなURLを入れておく
  • 独自スキーマを定義して,受け取れるようにしておく
  • getOAuthRequestToken 呼び出し時に,Callback URL を明示的に渡す
  • アクティビティの多重起動を防止しておく

Intent Filter に http:// で始まるURLでも設定してしまうと, アプリケーションの選択画面が開いてしまったり, ブラウザによってはリダイレクト時にインテントを飛ばしてくれなかったりします. そのため, myapplication:// のような独自スキーマを使う必要があるのですが, Twitterへアプリケーション登録時に設定する Callback URL は http:// で始まっていないと受け付けてくれません. かと言って空にしておくとうまく動かないので callback URL にはテキトーな URL を入れておいて, getOAuthRequestToken 呼び出し時に Callback URL を指定します(なぜかこっちは独自スキーマが使える).

具体的なプログラムは以下のような感じ.

package net.sorablue.shogo82148.yuire;

import twitter4j.AsyncTwitter;
import twitter4j.AsyncTwitterFactory;
import twitter4j.TwitterAdapter;
import twitter4j.TwitterException;
import twitter4j.TwitterListener;
import twitter4j.TwitterMethod;
import twitter4j.auth.AccessToken;
import twitter4j.auth.RequestToken;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.app.Activity;
import android.content.Intent;

public class MainActivity extends Activity {
    public final static String CALLBACK = "myappsheme://callback/";
    public final static String CONSUMER_KEY = "Your consumer_key";
    public final static String CONSUMER_SECRET = "Your consumer secret";

    private RequestToken mRequestToken;
    final AsyncTwitterFactory factory = new AsyncTwitterFactory();
    final AsyncTwitter twitter = factory.getInstance();

    private final TwitterListener listener = new TwitterAdapter() {
            @Override
            public void gotOAuthRequestToken(RequestToken token) {
                // ブラウザを開く
                mRequestToken = token;
                final Intent intent = new Intent(Intent.ACTION_VIEW,
                                                 Uri.parse(mRequestToken.getAuthorizationURL()));
                startActivity(intent);
            }

            @Override
            public void gotOAuthAccessToken(AccessToken token) {
                // 永続化とかする
            }
        };

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oauth);

        twitter.addListener(listener);
        twitter.setOAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET);

        // 認証開始
        // Request Token を取得する
        twitter.getOAuthRequestTokenAsync(CALLBACK); // ここで CALLBACK URL を渡す!
    }

    @Override
    public void onNewIntent(Intent intent) {
        // callback してきた
        final Uri uri = intent.getData();
        if(uri == null) return ;
        final String verifier = uri.getQueryParameter("oauth_verifier");
        twitter.getOAuthAccessTokenAsync(mRequestToken, verifier);
    }
}

多重起動防止と独自スキーマの定義はマニフェストに記述します.

<activity
    android:name=".MainActivity"
    android:label="@string/title_activity_main"
    android:launchMode="singleTask" > <!-- 多重起動防止 -->

    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    
    <!-- 独自スキーマの定義 -->
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="myappsheme" />
    </intent-filter>
</activity>

launchModesingleTask を指定すると, 多重起動のときに新しいアクティビティを起動する代わりに,すでに起動していたアクティビティの onNewIntent が実行されます.

PINコードを同じインターフェース(startActivityForResult で認証用アクティビティを呼び出すと,戻り値に Access Token が入っている)にしたかったけど, 認証用アクティビティが singleTask だと,ブラウザを開いた時に呼び出し元のonActivityResultが呼び出されてしまう. 誰かいい案ありません?

まとめ

WebViewはアプリからブラウザの機能を扱うのに非常に便利ですが, あくまでもアプリの管理下にあるもので,ブラウザとは少し性質が違うものということに注意. WebViewの脆弱性に関する資料を見つけてビクビクしています. 認証画面にかぎらず WebView を使うときはセキュリティに注意しましょう.

今回調べたことを使って, じょりぼっとにお湯入れたとつぶやくだけのアプリ「お湯入れた」を 作りました. 3分間計りたいときにどうぞ.

おへんじ

たくさんシェアしてもらったのでお返事書いておきます.

WebViewでURLフックを入れるよりは,PINコードのほうがコードもわかりやすく簡単かと. ただPINコードのコピペが面倒なのはそのとおりなので,Android アプリの場合採用は難しいかもしれません.

Intent Filterはマニフェストに手を入れる必要がある分面倒.ブラウザにタブが残るのも厄介です. ここは利便性とセキュリティとのトレードオフと割り切るしか無いでしょう. Web上での本人確認の方法がパスワードくらいしかない以上,パスワード流出の危険性はかなり重大な欠陥だと言えます. 手間なのは最初の一回だけですし,Intent Filter を使うのが賢い方法だと思います.

はい.おっしゃるとおりです. わざわざ反論してまで WebView を使う理由が思い浮かびません. 代替手法を考えるべきだと思います.

このポストで取り上げているのは「認証画面を偽装してパスワードを盗む」ことが可能という,まさしくフィッシングの話題です. 開発者は,自前で「** ブラウザっぽいもの ** 」を実装するのではなく,「 ** 本物のブラウザ ** 」を使いましょう,という紹介でした.

問題なのは「パスワードを聞かれる or 聞かれない」ではなく,「どのアプリがパスワードを聞いているか」です. ログインしていなければ当然Webブラウザでもパスワードは聞かれます. 認証を求めているアプリとは別の,** 信用のできるアプリ ** がパスワードを聞いてくるということが重要なのです. Webブラウザを信用出来ないというのであれば,そもそもWebサービスを使うべきではありません.

パケットキャプチャだけでは見抜けないと思います. 認証画面のDOM要素を直接見れば,通信にまったく介入しなくてもパスワードなんて簡単に抜けます. 実際には WebView からDOM要素を見ることはできないようですが, DOM 操作が可能な WebView を自前実装することだって技術的には可能です. そのため,パスワードを盗みとっているかどうかを外からみた動作だけで判断することは非常に難しく, 内部構造を解析する必要があると思います.

パケットキャプチャやらリバースエンジニアリングを駆使すれば理論的はすべての不正は防げるとは思いますが, それをすべてのアプリでやるのは非常に面倒ですし,一般ユーザが実践するのは困難です. こういうことをやらなくてもある程度安全にサービスを使えるようにするのが OAuth の役割. 積極的に利用していくべきでしょう.