AccountManagerでアカウントを管理する

はじめに

AccountManagerにおいて、アカウントの管理やトークンの取得を直接行うクラスのことをAuthenticatorと呼びます。 それにあわせて、この記事ではAuthenticatorを実装したアプリのことをAuthenticatorアプリと呼ぶことにします。

Authenticatorアプリの実装

Authenticatorアプリでは、以下の実装が必要です。

  1. アカウント種別の宣言 - AccountManagerに追加するアカウント種別の宣言
  2. 認証画面 - ユーザに提供するログイン画面のActivity
  3. Authenticator - AccountManagerへ提供する機能の実装
  4. 認証サービス - AccountManagerAuthenticatorを繋ぐためのService

以下、それぞれの実装について詳しく説明します。

アカウント種別の宣言

アカウントの管理機能を提供するためには、Authenticatorアプリであることをシステムに宣言する必要があります。 Authenticatorアプリの宣言はAndroidManifest.xmlにおいて、特定の種類の<intent-filter>を持ったサービスが存在するかどうかでチェックされます。 また、アカウントを編集するための権限として、AUTHENTICATE_ACCOUNTSが必要です。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.nein37.authenticatorsample">

    <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name">

        <service
            android:name=".AuthenticatoinService"
            android:exported="false">
            <intent-filter>
                <action android:name="android.accounts.AccountAuthenticator" />
            </intent-filter>
            <meta-data
                android:name="android.accounts.AccountAuthenticator"
                android:resource="@xml/authenticator" />
        </service>

        <activity
            android:name=".LoginActivity"
            android:exported="true"
            android:label="@string/app_name" />
    </application>
</manifest>

<Service>タグの<intent-filter><meta-data>の記述はAuthenticatorアプリを作る場合の決まり文句です。 前者でAuthenticator対応のServiceがあることを宣言し、後者で設定可能なアカウントの種類を宣言しています。

<meta-data>から参照されるXMLファイルでは、アカウント種別の宣言となるaccount-authenticator情報を記述します。 このうち、android:accountTypeは必ず他と重複しないようなユニークな値にする必要があります。 android:accountTypeAccountManager#getAccountsByType()などで指定するaccountTypeと同じものです。 android:iconandroid:smallIconはどちらも設定アプリのアカウント管理画面で表示されるアイコンですが、このとき指定する画像サイズに関する情報は見つけられませんでした…。 android:labelは設定アプリでの表示に利用されます。直接記述すると表示されないので、必ず文字列リソースに定義してください。

 <account-authenticator
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="com.example.test"
    android:icon="@drawable/ic_launcher"
    android:smallIcon="@drawable/ic_launcher"
    android:label="@string/account_label" />

認証画面の実装

認証画面はユーザIDやパスワードなど、必要な情報を入力し、サーバと通信して認証できる実装になっている必要があります。 認証処理に成功後、AccountManager#addAccountExplicitly()によってAccountManagerへアカウントの登録を行います。 このとき、accountTypeが必ずauthenticator.xmlで定義したものと同じになるようにしてください。 実装例では、サーバへの通信処理や例外処理などは省略しています。

public class LoginActivity extends ActionBarActivity  {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        final EditText nameEdit = (EditText) findViewById(R.id.name);
        final EditText passwordEdit = (EditText) findViewById(R.id.password);
        Button button = (Button) findViewById(R.id.login);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                String name = nameEdit.getText().toString();
                String password = passwordEdit.getText().toString();
                login(name, password);
            }
        });

    }

    // ログイン処理    
    public void login(final String name, final String password) {
        // TODO:このメソッドは非同期の通信処理でログインを試みます。
        // ログインに成功した場合、loginSuccess()を呼び出します。
        loginSuccess(name, password);
    }

    // ログイン処理のコールバック
    public void loginSuccess(final String name, final String password) {

        Account account = new Account(name, "com.example.test");
        AccountManager am = AccountManager.get(this);
        // アカウント情報を保存
        // TODO:本来はパスワードを暗号化する必要があります
        am.addAccountExplicitly(account, password, null);

        // 認証画面終了
        setResult(RESULT_OK);
        finish();
    }
}

Authenticatorの実装

AuthenticatorAccountManagerからの要求に対して応答を行うAuthenticatorアプリで最も重要な機能です。 必要なメソッドなどはAbstractAccountAuthenticatorという抽象クラスで宣言されているので、このクラスを継承して実装します。 最低限実装が必要なメソッドaddAccount()getAuthToken()の2つです。 addAccount()AccountManager#getAccount()が呼び出されたときに実行されるメソッドで、認証画面を起動するためのIntentを生成して返します。 getAuthToken()も同じようにAccountManager#getAuthToken()が呼び出されたときに実行され、メソッドの中でサーバとの通信処理を行ってトークンを取得後、返却します。 どちらのメソッドBundleに特定のキーで返却する必要が有るため、AccountManagerが持つ定数について調べておく必要があります。

public class MyAuthenticator extends AbstractAccountAuthenticator {

    public static final String ACCOUNT_TYPE = "com.example.test";

    final Context mContext;

    public MyAuthenticator(Context context) {
        super(context);
        mContext = context;
    }

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType,
            String authTokenType, String[] requiredFeatures, Bundle options)
            throws NetworkErrorException {

        // アカウントの追加を行う画面を呼び出すIntentを生成
        final Intent intent = new Intent(mContext, LoginActivity.class);
        // アカウント追加後、戻り先の画面を設定
        intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);

        // Intentを返却
        final Bundle bundle = new Bundle();
        bundle.putParcelable(AccountManager.KEY_INTENT, intent);
        return bundle;
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account,
            String authTokenType, Bundle options) throws NetworkErrorException {

        AccountManager manager = AccountManager.get(mContext);
        String name = account.name;
        // TODO:本来はパスワードを復号化する必要があります
        String password = manager.getPassword(account);

        // TODO:本来はここで通信を行い、ユーザ名とパスワードからトークンの取得を行う
        String authToken = "AUTH_TOKEN";
        // トークンをキャッシュ
        manager.setAuthToken(account,authTokenType,authToken);

        // トークンを返却する
        Bundle result = new Bundle();
        result.putString(AccountManager.KEY_ACCOUNT_NAME, name);
        result.putString(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
        result.putString(AccountManager.KEY_AUTHTOKEN, authToken);
        return result;
    }

    @Override
    public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
        return null;
    }

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account,
            Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public String getAuthTokenLabel(String authTokenType) {
        return null;
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account,
            String authTokenType, Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account,
            String[] features) throws NetworkErrorException {
        return null;
    }
}

認証サービスの実装

認証サービスはAccountManagerAuthenticatorとの橋渡しを行います。 …といっても難しいことはなく、Service#onBind()AbstractAccountAuthenticator#getIBinder()を結果を返すだけです。 あとはAccountManagerIBinderを経由してAuthenticatorメソッドを呼び出し、認証画面の呼び出しやトークンの返却を行ってくれます。

public class AuthenticatoinService extends Service {

    private MyAuthenticator mAuthenticator;

    @Override
    public void onCreate() {
        super.onCreate();
        mAuthenticator =new MyAuthenticator(this);
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mAuthenticator.getIBinder();
    }
}

おまけ

以上でAuthenticatorアプリに必要な最低限の実装は終わりです。 しかし、Authenticatorアプリが扱う内容は非常にデリケートなため、セキュリティに細心の注意を払う必要があります。 以下ではAuthenticatorアプリを実装する上での注意点や補足などをまとめます。

Service、Activityの公開範囲を設定する

認証サービスを非公開サービス、認証画面を公開アクティビティとして設定する必要があります。 設定方法については上記AndroidManifest.xmlexported設定を参照してください。

パスワードを暗号化する

AccountManagerはアカウント情報をDBに保存しています。 このDBは通常、アプリからアクセスすることができませんが、まったく暗号化されていません。 root化された端末などではAccountManagerのDBを簡単に見られてしまうため、パスワードを平文で保存してはいけません。 暗号化する場合はAccountManager#addAccountExplicitly()する際に暗号化し、AccountManager#getPassword()した際に復号化します。

パスワードを保存しない

サービスへのアクセスによりトークンの期限を延長できるようなサービスの場合、パスワードを保存しないという選択肢があります。 その場合、Authenticator#getAuthToken()では新たにトークンを取得することが不可能なため、認証画面での認証成功事にAccountManager#setAuthToken()を呼び出してトークンをキャッシュする必要があります。 また、Authenticator#getAuthToken()が呼び出されたときはトークンの再取得が不可能なため、こちらも認証画面を再度表示させるなどの工夫が必要です。

トークンがキャッシュされる働きを理解する

認証トークンはAccountManager#setAuthToken()されたときにキャッシュされ、AccountManager#invalidateAuthToken()されたときに無効となります。 キャッシュが有効な間、AccountManager#getAuthToken()Authenticator#getAuthToken()を呼び出しません。 以下のようなAuthenticator#getAuthToken()の実装は不要です。

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account,
            String authTokenType, Bundle options) throws NetworkErrorException {

        AccountManager manager = AccountManager.get(mContext);
        // キャッシュからトークンを取得
        String authToken = manager.peekAuthToken(account,authTokenType);
        if(TextUtils.isEmpty(authToken)){
            // キャッシュが無効なのでトークンを取得
            authToken = "AUTH_TOKEN";
            manager.setAuthToken(account,authTokenType,authToken);
        }
        // ...

authTokenTypeによるトークン取得制限

アプリから認証トークンを取得する際、AccountManager#getAuthToken()authTokenTypeを指定する必要があります。 このauthTokenTypeについてサンプルではチェックしていませんが、本来はAuthenticator#getAuthToken()で有効な値かどうかを判定し、それに応じたトークンの取得を行うことになっています。 AccountManagerからは設定可能なauthTokenTypeの一覧を知ることができないため、トークンを取得したいアプリは最初からauthTokenTypeを知っている必要があります。 この制限のため、原理的にはauthTokenTypeを非公開とすることでトークンの取得制限をかけることができますが、実際にはauthTokenTypeも平文でDBに保存されるためroot化された端末では簡単に解析されてしまいます。 従って、非公開authTokenTypeによってセキュリティが確保されたと考えるのは危険です。

Authenticatorアプリの競合

※Authenticatorの実装と直接関係ありません。 まったく同一のaccountTypeを持つ複数Authenticatorアプリが端末にインストールされると、先にインストールされたAuthenticatorアプリだけが有効になります。 悪意のあるAuthenticatorアプリが先にインストールされた端末ではユーザIDやパスワードが漏洩する恐れがあるため、AccountManagerを利用するアプリは接続先のAuthenticatorが本物かどうか気をつける必要があります。 …多分AccountManager#getAuthenticatorTypes()AuthenticatorDescriptionを取得してパッケージ探して証明書を検証するんじゃないかと思うんですが、具体的にどう実装すればチェックできるのか把握できていません。

証明書の異なるアプリからアクセスする際の注意

Authenticatorアプリと異なる証明書を持ったアプリでAccountManager#getAuthToken()した場合、Android OS 4.0.x端末ではクラッシュします。 具体的には、システムがGrantCredentialsPermissionActivityという画面を呼びだそうとして、その途中でNullPointerExceptionが発生するようです。 この問題はAOSPのissuesにも登録されていて、4.1.xでは修正されているようですが、回避方法がわかりません。 どなたか回避方法をご存知の方は教えてください…。 https://code.google.com/p/android/issues/detail?id=23421

参考資料

この記事を書くにあたって以下の資料を参考にしました。 JSSEC 『Android アプリのセキュア設計・セキュアコーディングガイド』2014年7月1日版 http://www.jssec.org/report/securecoding.html

タオソフトウェア株式会社 Android Security 安全なアプリケーションを作成するために http://www.amazon.co.jp/dp/4844331345