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

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

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

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

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

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

```AndroidManifest.xml
<?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:accountType`は`AccountManager#getAccountsByType()`などで指定する`accountType`と同じものです。
`android:icon`、`android:smallIcon`はどちらも設定アプリのアカウント管理画面で表示されるアイコンですが、このとき指定する画像サイズに関する情報は見つけられませんでした…。
`android:label`は設定アプリでの表示に利用されます。直接記述すると表示されないので、必ず文字列リソースに定義してください。

```/res/xml/authenticator.xml
 <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`で定義したものと同じになるようにしてください。
実装例では、サーバへの通信処理や例外処理などは省略しています。

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

```MyAuthenticator.java
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;
    }
}
```

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

```AuthenticatoinService.java
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.xml`の`exported`設定を参照してください。

## パスワードを暗号化する
`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()`の実装は**不要です。**

```Authenticator.java
    @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