はじめに
AccountManager
において、アカウントの管理やトークンの取得を直接行うクラスのことをAuthenticator
と呼びます。
それにあわせて、この記事ではAuthenticator
を実装したアプリのことをAuthenticator
アプリと呼ぶことにします。
Authenticatorアプリの実装
Authenticator
アプリでは、以下の実装が必要です。
- アカウント種別の宣言 -
AccountManager
に追加するアカウント種別の宣言 - 認証画面 - ユーザに提供するログイン画面の
Activity
- Authenticator -
AccountManager
へ提供する機能の実装 - 認証サービス -
AccountManager
とAuthenticator
を繋ぐための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:accountType
はAccountManager#getAccountsByType()
などで指定するaccountType
と同じものです。
android:icon
、android: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の実装
Authenticator
はAccountManager
からの要求に対して応答を行うAuthenticator
アプリで最も重要な機能です。
必要なメソッドなどはAbstractAccountAuthenticator
という抽象クラスで宣言されているので、このクラスを継承して実装します。
最低限実装が必要なメソッドはaddAccount()
とgetAuthToken()
の2つです。
addAccount()
はAccountManager#addAccount()
が呼び出されたときに実行されるメソッドで、認証画面を起動するための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;
}
}
認証サービスの実装
認証サービスはAccountManager
とAuthenticator
との橋渡しを行います。
…といっても難しいことはなく、Service#onBind()
でAbstractAccountAuthenticator#getIBinder()
を結果を返すだけです。
あとはAccountManager
がIBinder
を経由して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.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()
の実装は不要です。
@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