开发你自己的Android 授权管理器
轉自:http://www.devtf.cn/?p=1121
- 原文鏈接 :?Write your own Android Authenticator
- 原文作者 :?UDI COHEN
- 譯文出自 :?開發技術前線 www.devtf.cn。未經允許,不得轉載!
- 譯者 :?kevinhong
- 校對者:?desmond1121
- 狀態 : 完成
18個月之前,我在開發Any.DD同步系統時,打算使用安卓提供的AccountManager?API實現認證的相關功能并存儲用戶秘鑰。當我用它來訪問Google賬號時,一切都非常簡單,所以我想Any.DD就用這個API來做吧。確實,使用SyncAdapter進行同步操作進展很順利,看起來的確是一個完美的方案。但它的問題也隨之出現——沒有好的文檔,開發者社區也沒能提供太多可供參考的經驗,而我們也沒有太多時間來研究這個“無人之地“引發的各種問題。所以當時決定使用其他方案。
但是,今非昔比…
因為一個最近著手的項目,我最近又開始研究相關功能,我突然發現這方面知識的豐富程度有了巨大的提升。包括Android.com上的更好的文檔,外面的教程(?教程1?教程2)也逐漸豐富了起來。讓我們了解到聲名狼藉的AccountManager的神秘之處。坊間傳聞的用來創建個人賬戶的方式,我幾乎全都讀了。
但是,好像還是缺點什么。
我感覺整個流程并非盡知,有些部分并沒有足夠清楚。所以我決定用我的方法調查它——就像我平時想要了解一件事情所用的方法一樣——用“杰克鮑爾“的方式。我發表了這篇深入調查后的結論性文章,包含了所有這個服務所能提供的功能和一些我覺得重要的需要發掘的細節。后面,我還會貼出一篇關于“SyncAdapter“的文章,所以如果讀者感興趣,我建議讀者通過RSS或Twitter來訂閱(我的博客)。我還是比較了解這方面的諸多細節,不僅僅是教程提供的簡單功能。但如果我遺漏了什么,請在評論中指出。
為什么選擇 Account Manager?
為什么?
為什么不是寫一個簡單的登錄表單,實現一個提交按鈕,發送(post)所有信息到服務器,然后服務器返回一個鑒權令牌(auth token)?原因在于有很多(與用戶鑒權相關的)附加功能和小細節你未必能考慮周全。這些容易被開發者忽略的小細節可能導致用戶重新登錄,或者被“100000個用戶才會出現一次,無所謂“的聲音 忽略掉。用戶如果在另外一個客戶端修改密碼該如何處理?auth-token的過期判斷呢?是要運行一個沒有用戶交互UI的后臺服務嗎?
想要用戶登錄一次,相關APP就可以自動登錄的便利嗎?(就像Google的APP那樣)
讀這篇文章之前或許讓你感覺有些東西太復雜,但其實不是。對于絕大多數應用場景來說, Account Manager都簡化了登錄過程。而且我也給你提供了代碼樣例,還有什么理由不用呢?
好吧,讓我們來看看(使用?AccountManager)都有哪些好處:
好處:標準的用戶鑒權方式;為開發者簡化了登錄的流程;處理訪問拒絕的場景;可以為一個賬戶處理不同類型的訪問令牌(如:只讀、全權限);輕松的在不同程序間共享令牌;有如Sync Adapter這樣的后臺處理的良好支持;并且,在手機的Setting界面中有一個很酷的入口:
看,媽媽,設置屏幕上有我的”名紙”!
缺陷:需要學習它!但是,嗨,這不就是你讀此文的目的嗎?
要實現這些功能,需要下面幾步:
1. 創建Authenticator,這是所有操作的核心
2. 創建若干Activity,用戶可以在其中輸入驗證需要的信息
3. 創建Service,通過Service我們可以與Authenticator進行交互
首先,(來看)一些概念。
Authenti..啥?
授權令牌 (Authentication Token,auth-token)?– 是由服務器提供的一個臨時的訪問令牌。所有需要識別用戶的請求,在發送到服務器時都要帶著這個令牌。在這篇文章中,我們使用OAuth2,它也是現在最為流行的方法。
授權服務器?– 用來管理所有用戶的服務器。它將會為登錄到服務器的用戶生成授權令牌(auth-token),并且校驗所有的用戶請求(是否合法)。授權令牌有時間限制,過期后將時效。
AccountManager?– 管理設備上的所有賬戶,也是這項功能的核心。App從AccountManager獲得auth-token,它也將決定是否該打開登錄、創建用戶的Activity,或者從之前的請求中返回一個已經存儲好的auth-token。AccountManager了解不同場景下該調用何種操作。
AccountAuthenticator?– 是一個為具體賬戶類型提供鑒權處理過程的組件。AccountManager查找合適的AccountAuthenticator,與其通信,并根據賬戶類型執行所有動作。AccountAuthenticator知道哪個Activity用來讓用戶輸入登錄信息,也知道服務器上次返回的auth-token在哪里存儲。在一個賬戶類型下,多個不同的服務也會共用同一個AccountAuthenticator。比如Google的AccountAuthenticator為GMail提供認證服務,也為其他的Google程序,如:Google Calendar和Google Drive提供授權服務。
AccountAuthenticatorActivity?– “登錄/創建用戶“Activity的基類,當用戶需要認證的時候,authenticator調用會這個Activity。這個Activity負責用戶登錄或用戶創建過程,并將auth-token返回給
authenticator。
當你的App需要auth-token時,只需調用AccountManager#getAuthToken()。AccountManager將負責一切必須的步驟直到給你拿到auth-token。Google提供了一個流程圖展現了整個過程:
<img src="http://blog.udinic.com/assets/media/images/2013-04-24-write-your-own-android-authenticator/oauth_dance1.png" alt="Google-<code>AccountManager</code>-Process-Daigram” title=”Title” /></p><p>這圖看起來有點繁瑣,但其實它很簡單。我將通過用戶首次在設備上登陸的場景來進行解釋。</p><p>用戶首次登陸時</p><blockquote><ul><li>App向<code>AccountManager</code>請求auth-token。</li><li><code>AccountManager</code> 詢問與其關聯的 <code>AccountAuthenticator</code> 是否保存了有效的token</li><li>由于目前還沒有(用戶還沒有登陸嘛),他將調用 <code>AccountAuthenticatorActivity</code>來讓用戶登錄。</li><li>用戶正常登陸,服務器返回了auth-token。</li><li><code>AccountManager</code>存儲了auth-token以備將來使用。</li><li>App獲得了它想要的auth-token</li></ul></blockquote><p>皆大歡喜!</p><p>在用戶已經登陸的情況下,上述的第二步將直接返回auth-token。你可以在<a href=" http:="" developer.android.com="" training="" id-auth="" authenticate.html"="" style="box-sizing: border-box; border: 0px; vertical-align: middle; max-width: 100%; height: auto;">這里獲得更多如何使用OAuth2進行認證的文章。
現在,我們已經了解了基礎知識。現在來看看如何建立一個自有賬戶類型的authenticator。
建立我們自己的Authenticator
如前文所述, Account Authenticator 由AccountManager管理并滿足賬戶相關的所有任務:存儲auth-token;展現賬戶登錄屏幕;處理服務器的用戶登錄。
建立我們自己的Authenticator需要繼承AbstractAccountAuthenticator并實現一些方法。我們現在來關注其中兩個主要方法:
addAccount
當用戶打算登錄并在一個設備上新建賬戶時,會調用這個方法。
我們需要返回一個Bundle,其中包含一個會啟動我們自己的_AccountAuthenticatorActivity(稍后解釋)的Intent,這個方法在app通過調用AccountManager#addAccount()?(需要特殊權限)時被調用。或者在手機設置 中,用戶點擊“添加新用戶“時被調用,即:
例如:
| 123456789101112 | @Overridepublic Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {????final Intent intent = new Intent(mContext, AuthenticatorActivity.class);????intent.putExtra(AuthenticatorActivity.ARG_ACCOUNT_TYPE, accountType);????intent.putExtra(AuthenticatorActivity.ARG_AUTH_TYPE, authTokenType);????intent.putExtra(AuthenticatorActivity.ARG_IS_ADDING_NEW_ACCOUNT, true);????intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);????final Bundle bundle = new Bundle();????bundle.putParcelable(AccountManager.KEY_INTENT, intent);????return bundle;} |
getAuthToken
如上面的流程圖所示,getAuthToken可以獲取存儲在設備上的已經登陸成功用戶的auth-token。如果auth-token不存在,將會提示用戶登錄。在成功登陸之后,請求auth-token的app會“長等待“此token。為了避免此情況,我們應該通過AccountManager#peekAuthToken()來檢查AccountManager是否已經存在一個有效的auth-token。如果沒有,我們應該返回與addAccount()相同的結果。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | <br />@Override public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { ????// Extract the username and password from the Account Manager, and ask ????// the server for an appropriate AuthToken. ????final AccountManager am = AccountManager.get(mContext); ????String authToken = am.peekAuthToken(account, authTokenType); ????// Lets give another try to authenticate the user ????if (TextUtils.isEmpty(authToken)) { ????????final String password = am.getPassword(account); ????????if (password != null) { ????????????authToken = sServerAuthenticate.userSignIn(account.name, password, authTokenType); ????????} ????} ????// If we get an authToken - we return it ????if (!TextUtils.isEmpty(authToken)) { ????????final Bundle result = new Bundle(); ????????result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); ????????result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); ????????result.putString(AccountManager.KEY_AUTHTOKEN, authToken); ????????return result; ????} ????// If we get here, then we couldn't access the user's password - so we ????// need to re-prompt them for their credentials. We do that by creating ????// an intent to display our AuthenticatorActivity. ????final Intent intent = new Intent(mContext, AuthenticatorActivity.class); ????intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); ????intent.putExtra(AuthenticatorActivity.ARG_ACCOUNT_TYPE, account.type); ????intent.putExtra(AuthenticatorActivity.ARG_AUTH_TYPE, authTokenType); ????final Bundle bundle = new Bundle(); ????bundle.putParcelable(AccountManager.KEY_INTENT, intent); ????return bundle; } |
如果我們通過此方法獲得的auth-token已經無效了,比如過期了或者用戶從其他客戶端修改了密碼。我們應該調用AccountManager#invalidateAuthToken()使當前存儲在AccountManager的auth-token失效,并調用getAuthToken()再次請求auth-token。再次調用?getAuthToken()時會嘗試使用之前存儲的密碼進行登陸,如果失敗,用戶將必須再次輸入登陸信息。
所以,用戶要在哪輸入驗證信息?這就是AccountAuthenticatorActivity了。
創建Activity
AccountAuthenticatorActivity是整個過程中唯一直接與用戶交互的Activity。
Authenticator首先調用這個Activity,此Activity將展現一個用戶登錄表單,發送到服務器鑒權用戶,并將結果傳給authenticator。我們繼承AccountAuthenticatorActivity,不僅要實現常規Activity的功能,還要實現setAccountAuthenticatorResult()方法。此方法負責將鑒權過程的結果發送給Authenticator。此方法也為我們省掉了與Authenticator直接交互。
我在我的Activity中構建了一個簡單的用戶名/密碼表單。你可以使用Android官方網站上建議使用的登錄Activity模版,提交時,我進行了以下操作:
| 123456789101112131415161718192021 | public void submit() {????final String userName = ((TextView) findViewById(R.id.accountName)).getText().toString();????final String userPass = ((TextView) findViewById(R.id.accountPassword)).getText().toString();????new AsyncTask<Void, Void, Intent>() {????????@Override????????protected Intent doInBackground(Void... params) {????????????String authtoken = sServerAuthenticate.userSignIn(userName, userPass, mAuthTokenType);????????????final Intent res = new Intent();????????????res.putExtra(AccountManager.KEY_ACCOUNT_NAME, userName);????????????res.putExtra(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);????????????res.putExtra(AccountManager.KEY_AUTHTOKEN, authtoken);????????????res.putExtra(PARAM_USER_PASS, userPass);????????????return res;????????}????????@Override????????protected void onPostExecute(Intent intent) {????????????finishLogin(intent);????????}????}.execute();} |
sServerAuthenticate是與服務器進行認證的接口,我實現了了其中例如userSignIn(用戶登錄)和userSignUp(用戶注冊)的方法,這些方法會在登錄成功時獲得服務器返回的auth-token。
mAuthTokenType是我從服務器請求的令牌的類型。我可以讓服務器給我不同的令牌例如只讀或全訪問,或者在相同的賬戶下的不同服務。一個好的列子是Google‘賬號,它的令牌類型包括:“_Manage your calendars”(管理日歷), “Manage your _tasks”(管理任務), “View your calendars”(查看日歷)等等。在這個列子中,我不會為不同類型的令牌區分不同的操作。
完成后,調用 finishLogin():
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | private void finishLogin(Intent intent) { ????String accountName = intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME); ????String accountPassword = intent.getStringExtra(PARAM_USER_PASS); ????final Account account = new Account(accountName, intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE)); ????if (getIntent().getBooleanExtra(ARG_IS_ADDING_NEW_ACCOUNT, false)) { ????????String authtoken = intent.getStringExtra(AccountManager.KEY_AUTHTOKEN); ????????String authtokenType = mAuthTokenType; ????????// Creating the account on the device and setting the auth token we got ????????// (Not setting the auth token will cause another call to the server to authenticate the user) ????????mAccountManager.addAccountExplicitly(account, accountPassword, null); ????????mAccountManager.setAuthToken(account, authtokenType, authtoken); ????} else { ????????mAccountManager.setPassword(account, accountPassword); ????} ????setAccountAuthenticatorResult(intent.getExtras()); ????setResult(RESULT_OK, intent); ????finish(); } |
通過上面的方法我們獲得了一個全新的auth-token,具體細節如下:
在這個案例中,AccountManager已經存在了一條記錄,它是一個已經失效了的auth-token。新的auth-token會替代原有的,此時你不需要做任何操作。但如果用戶的密碼已經修改,你需要向AccountManager更新用戶密碼,就像上面代碼中實現的那樣。
添加了一個新的賬戶到設備 —— 這是有技巧的部分。當新賬戶創建時,auth-token并沒有立刻保存到AccountManager,你需要顯示的設置auth-token。這也是我在添加完賬戶之后,又明確的設置auth-token的原因。如果設置失敗了,AccountManager將會再到服務器執行獲取auth-token的過程,這時getAuthToken會被調用,并再次進行用戶鑒權。
注意:addAccountExplicitly() 的第三個參數,是用戶數據Bundle,它可以用在AccountManager保存其他認證信息的同時存儲一些自定義信息,比如你的服務的API Key。當然這些信息也可以通過setUserData()設置。
在Activity完成登陸之后,我們已經為AccountManager建立了我們的賬戶。最后調用setAccountAuthenticatorResult()將信息傳回給Authenticator。
現在一切流程準備就緒,那誰來啟動這個過程呢?(其他應用)如何來訪問它?我們需要讓我們的Authenticator對其他想使用它的應用可用,因此我們需要讓Authenticator在后臺運行(還可以調用登錄屏幕),使用Service是一個明顯的選擇。
創建Service
Service非常簡單。
我們要做的,是讓其他的進程與我們的服務綁定,并于Authenticator交互。幸運的是,Authenticator的父類AbstractAccountAuthenticator提供了getIBinder()?方法,該方法返回了一個IBInder實現。我們的服務需要在onBind()方法中調用它。這個基本的實現保證了其他進程請求Authenticator時進行適當的操作(原文為:“調用合適的方法“。譯者注)。如果讀者想了解其中的細節,可以看看Transport,一個AbstractAccountAuthenticator的內部類,并了解關于AIDL——進程間通信的一些知識。
現在我們的服務是這樣的:
| 12345678 | public class UdinicAuthenticatorService extends Service {????@Override????public IBinder onBind(Intent intent) {????????UdinicAuthenticator authenticator = new UdinicAuthenticator(this);????????return authenticator.getIBinder();????}} |
..and on the manifest we need to add our service with the
在manifest文件中,需要對Service聲明
| 1 2 3 4 5 6 7 8 | <service android:name=".authentication.UdinicAuthenticatorService"> ????<intent-filter> ????????<action android:name="android.accounts.AccountAuthenticator" /> ????</intent-filter> ????<meta-data android:name="android.accounts.AccountAuthenticator" ?????????????? android:resource="@xml/authenticator" /> </service> |
很簡單,是吧?
作為資源引用的authenticator.xml用來定義Authenticator用到的一些屬性。
| 1 2 3 4 5 6 7 | <account-authenticator xmlns:android="http://schemas.android.com/apk/res/android" ?????????????????????? android:accountType="com.udinic.auth_example" ?????????????????????? android:icon="@drawable/ic_udinic" ?????????????????????? android:smallIcon="@drawable/ic_udinic" ?????????????????????? android:label="@string/label" ?????????????????????? android:accountPreferences="@xml/prefs"/> |
讓我們來解釋一下:
-
accountType(賬戶類型)?是一個獨一無二的名字,用來識別我們的賬戶類別。當其他app要通過我們的應用進行鑒權操作時。它需要明確知道這個名字來使用AccountManager。
-
icon and smallIcon(圖標和小圖標)?是在設備的Setting畫面中,賬戶條目顯示的圖標。在賬戶確認畫面(稍后會解釋),它也會出現。
-
label(標簽)?是在設備Setting畫面中顯示的代表我們賬戶的的字符串。
-
accountPreferences(賬戶偏好)?是一個偏好XML的引用。它將在通過設備的Setting畫面中訪問賬戶偏好時展現,它允許用戶更好的控制莊戶。作為例子,你可以看看Google及Dropbox的賬戶偏好畫面,里面包含了一些可供調整的項。我的例子如下:
你需要了解的其他特性
在我調查的過程中,我發現了一些有意思的場景。為了讓你使用相關API時不致想破頭,我把它分享出來。
“為一個設備上不存在的賬戶請求auth-token,將會導致未定義的失敗。“
在我這,這個“未定義的失敗“雖熱調用了登錄畫面,但當我輸入認證信息后卻沒有任何反應,所以你得注意一下。
先進先服務 – 如果你復制了authenticator的相關代碼到你的兩個應用,二者具備相同的邏輯,但在每個應用中修改了自己的登錄畫面。在這種情況下,無論哪個應用需要請求auth-token,都會調用先安裝的app的authenticator。如果你卸載了第一個應用,第二個應用的authenticator才會起作用(因為它是唯一有個了)。克服這個問題的一個技巧,是將不同的登陸頁放到同一個authenticator中,并在調用addAccount()?時,把各自的設計需求通過addAccountOptions 的參數傳過去。
為了安全,小心共享 – 如果你打算獲取其他應用的authenticator生成的auth-token,并且該應用與你的應用具有不同的簽名秘鑰,用戶必須顯示的同意這個操作。用戶將會看到如下畫面:
“Full access to..”字符串是通過我們的Authenticator的_getAuthTokenLabel()方法獲得。你可以為每一個auth-token類型指定一個標簽,以便在類似的場景下對用戶更加友好。
1, 存儲密碼 –?AccountManager并沒有使用加密方法存儲密碼。所有的密碼都明文存儲。你不能對其他的Authenticator調用peekAuthToken()?方法(獲得其他Authenticator的auth-token)(會出現“caller uid X is different than the authenticator’s uid”錯誤),但root權限或adb命令卻可以得到。在示例代碼中,存儲明文密碼是為了讓已經過期的token自動登錄更方便。在大多數場合中,我選擇更加安全的方式,但有的時候,為用戶的錯誤犧牲便利性是不值得的——如果有人獲得了root權限并能在設備上執行adb命令,那與獲得用戶的“最高分“比起來,他可以做到更大的危害。
接下來?
現在,你已經對這個很棒的服務比較熟悉了。你可以在Google Play上下載我開發的樣例程序。它會在你的設備創建“Udinic account”類型的賬戶,驗證則會通過Parse.com賬戶來進行。樣例應用提供了下面幾項功能:
getAuthToken按鈕首先會查詢設備上是否有“Udinic”類型的賬戶。如果有,它通過調用AccountManager#getAuthToken()來返回token。如果有多個token,它將彈出一個對話框讓用戶選擇打算使用哪一個。
getAuthTokenByFeatures?調用了AccountManager提供的一個很棒很方便的同名方法,它會為你完成所有的工作。它也會查詢AccountManager中是否包含目標類型“Udinic”。它的行為遵循以下步驟:
- 沒有賬戶時: 調用_ addAccount()_讓用戶創建一個賬戶。此后,自動調用getAuthToken()獲取token。
- 存在賬戶時:獲取auth-token。
- 有兩個賬戶或更多時:創建一個賬戶選擇對話框,并返回用戶所選賬戶的token。
如果你打算讓token失效,你可以用invalidateAuthToken按鈕。注意:Udinic authenticator知道如何恢復失效token,就像之前示例代碼中所展示的那樣,通過getAuthToken()方法。那意味著,在讓token失效后,getAuthToken按鈕仍然可以返回token,但只在它向服務器請求成功之后。你可以在LogCat中通過查看網絡狀態來確認。刪除帳戶只能通過設備的Setting菜單。
你可以在Github上下載相關源代碼。里面包含了2個樣例應用,所以你可以嘗試下2個應用之間共享相同的Authenticator。比如:使用不同的簽名密鑰打包程序,一個應用請求由另一個應用創建的auth-token。你也可以嘗試創建一個authenticator的apklib(apk庫),并在不同的應用中重用它。如果你有意見或建議,別猶豫直接在文章后面指出或在Github上提出PR吧。
總結
以上是生活随笔為你收集整理的开发你自己的Android 授权管理器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 红宝书读书笔记 第八章
- 下一篇: 尝试用ubuntu 22.04 LTS系