|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 package org.mozilla.gecko.fxa.authenticator; |
|
6 |
|
7 import java.io.UnsupportedEncodingException; |
|
8 import java.net.URISyntaxException; |
|
9 import java.security.GeneralSecurityException; |
|
10 import java.util.ArrayList; |
|
11 import java.util.Arrays; |
|
12 import java.util.Collections; |
|
13 import java.util.EnumSet; |
|
14 import java.util.List; |
|
15 |
|
16 import org.mozilla.gecko.background.common.GlobalConstants; |
|
17 import org.mozilla.gecko.background.common.log.Logger; |
|
18 import org.mozilla.gecko.background.fxa.FxAccountUtils; |
|
19 import org.mozilla.gecko.db.BrowserContract; |
|
20 import org.mozilla.gecko.fxa.FirefoxAccounts; |
|
21 import org.mozilla.gecko.fxa.FxAccountConstants; |
|
22 import org.mozilla.gecko.fxa.login.State; |
|
23 import org.mozilla.gecko.fxa.login.State.StateLabel; |
|
24 import org.mozilla.gecko.fxa.login.StateFactory; |
|
25 import org.mozilla.gecko.sync.ExtendedJSONObject; |
|
26 import org.mozilla.gecko.sync.Utils; |
|
27 |
|
28 import android.accounts.Account; |
|
29 import android.accounts.AccountManager; |
|
30 import android.content.ContentResolver; |
|
31 import android.content.Context; |
|
32 import android.content.Intent; |
|
33 import android.content.SharedPreferences; |
|
34 import android.os.Bundle; |
|
35 |
|
36 /** |
|
37 * A Firefox Account that stores its details and state as user data attached to |
|
38 * an Android Account instance. |
|
39 * <p> |
|
40 * Account user data is accessible only to the Android App(s) that own the |
|
41 * Account type. Account user data is not removed when the App's private data is |
|
42 * cleared. |
|
43 */ |
|
44 public class AndroidFxAccount { |
|
45 protected static final String LOG_TAG = AndroidFxAccount.class.getSimpleName(); |
|
46 |
|
47 public static final int CURRENT_PREFS_VERSION = 1; |
|
48 |
|
49 // When updating the account, do not forget to update AccountPickler. |
|
50 public static final int CURRENT_ACCOUNT_VERSION = 3; |
|
51 public static final String ACCOUNT_KEY_ACCOUNT_VERSION = "version"; |
|
52 public static final String ACCOUNT_KEY_PROFILE = "profile"; |
|
53 public static final String ACCOUNT_KEY_IDP_SERVER = "idpServerURI"; |
|
54 |
|
55 // The audience should always be a prefix of the token server URI. |
|
56 public static final String ACCOUNT_KEY_AUDIENCE = "audience"; // Sync-specific. |
|
57 public static final String ACCOUNT_KEY_TOKEN_SERVER = "tokenServerURI"; // Sync-specific. |
|
58 public static final String ACCOUNT_KEY_DESCRIPTOR = "descriptor"; |
|
59 |
|
60 public static final int CURRENT_BUNDLE_VERSION = 2; |
|
61 public static final String BUNDLE_KEY_BUNDLE_VERSION = "version"; |
|
62 public static final String BUNDLE_KEY_STATE_LABEL = "stateLabel"; |
|
63 public static final String BUNDLE_KEY_STATE = "state"; |
|
64 |
|
65 protected static final List<String> ANDROID_AUTHORITIES = Collections.unmodifiableList(Arrays.asList( |
|
66 new String[] { BrowserContract.AUTHORITY })); |
|
67 |
|
68 protected final Context context; |
|
69 protected final AccountManager accountManager; |
|
70 protected final Account account; |
|
71 |
|
72 /** |
|
73 * Create an Android Firefox Account instance backed by an Android Account |
|
74 * instance. |
|
75 * <p> |
|
76 * We expect a long-lived application context to avoid life-cycle issues that |
|
77 * might arise if the internally cached AccountManager instance surfaces UI. |
|
78 * <p> |
|
79 * We take care to not install any listeners or observers that might outlive |
|
80 * the AccountManager; and Android ensures the AccountManager doesn't outlive |
|
81 * the associated context. |
|
82 * |
|
83 * @param applicationContext |
|
84 * to use as long-lived ambient Android context. |
|
85 * @param account |
|
86 * Android account to use for storage. |
|
87 */ |
|
88 public AndroidFxAccount(Context applicationContext, Account account) { |
|
89 this.context = applicationContext; |
|
90 this.account = account; |
|
91 this.accountManager = AccountManager.get(this.context); |
|
92 } |
|
93 |
|
94 /** |
|
95 * Persist the Firefox account to disk as a JSON object. Note that this is a wrapper around |
|
96 * {@link AccountPickler#pickle}, and is identical to calling it directly. |
|
97 * <p> |
|
98 * Note that pickling is different from bundling, which involves operations on a |
|
99 * {@link android.os.Bundle Bundle} object of miscellaenous data associated with the account. |
|
100 * See {@link #persistBundle} and {@link #unbundle} for more. |
|
101 */ |
|
102 public void pickle(final String filename) { |
|
103 AccountPickler.pickle(this, filename); |
|
104 } |
|
105 |
|
106 public Account getAndroidAccount() { |
|
107 return this.account; |
|
108 } |
|
109 |
|
110 protected int getAccountVersion() { |
|
111 String v = accountManager.getUserData(account, ACCOUNT_KEY_ACCOUNT_VERSION); |
|
112 if (v == null) { |
|
113 return 0; // Implicit. |
|
114 } |
|
115 |
|
116 try { |
|
117 return Integer.parseInt(v, 10); |
|
118 } catch (NumberFormatException ex) { |
|
119 return 0; |
|
120 } |
|
121 } |
|
122 |
|
123 /** |
|
124 * Saves the given data as the internal bundle associated with this account. |
|
125 * @param bundle to write to account. |
|
126 */ |
|
127 protected void persistBundle(ExtendedJSONObject bundle) { |
|
128 accountManager.setUserData(account, ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString()); |
|
129 } |
|
130 |
|
131 /** |
|
132 * Retrieve the internal bundle associated with this account. |
|
133 * @return bundle associated with account. |
|
134 */ |
|
135 protected ExtendedJSONObject unbundle() { |
|
136 final int version = getAccountVersion(); |
|
137 if (version < CURRENT_ACCOUNT_VERSION) { |
|
138 // Needs upgrade. For now, do nothing. We'd like to just put your account |
|
139 // into the Separated state here and have you update your credentials. |
|
140 return null; |
|
141 } |
|
142 |
|
143 if (version > CURRENT_ACCOUNT_VERSION) { |
|
144 // Oh dear. |
|
145 return null; |
|
146 } |
|
147 |
|
148 String bundle = accountManager.getUserData(account, ACCOUNT_KEY_DESCRIPTOR); |
|
149 if (bundle == null) { |
|
150 return null; |
|
151 } |
|
152 return unbundleAccountV2(bundle); |
|
153 } |
|
154 |
|
155 protected String getBundleData(String key) { |
|
156 ExtendedJSONObject o = unbundle(); |
|
157 if (o == null) { |
|
158 return null; |
|
159 } |
|
160 return o.getString(key); |
|
161 } |
|
162 |
|
163 protected boolean getBundleDataBoolean(String key, boolean def) { |
|
164 ExtendedJSONObject o = unbundle(); |
|
165 if (o == null) { |
|
166 return def; |
|
167 } |
|
168 Boolean b = o.getBoolean(key); |
|
169 if (b == null) { |
|
170 return def; |
|
171 } |
|
172 return b.booleanValue(); |
|
173 } |
|
174 |
|
175 protected byte[] getBundleDataBytes(String key) { |
|
176 ExtendedJSONObject o = unbundle(); |
|
177 if (o == null) { |
|
178 return null; |
|
179 } |
|
180 return o.getByteArrayHex(key); |
|
181 } |
|
182 |
|
183 protected void updateBundleDataBytes(String key, byte[] value) { |
|
184 updateBundleValue(key, value == null ? null : Utils.byte2Hex(value)); |
|
185 } |
|
186 |
|
187 protected void updateBundleValue(String key, boolean value) { |
|
188 ExtendedJSONObject descriptor = unbundle(); |
|
189 if (descriptor == null) { |
|
190 return; |
|
191 } |
|
192 descriptor.put(key, value); |
|
193 persistBundle(descriptor); |
|
194 } |
|
195 |
|
196 protected void updateBundleValue(String key, String value) { |
|
197 ExtendedJSONObject descriptor = unbundle(); |
|
198 if (descriptor == null) { |
|
199 return; |
|
200 } |
|
201 descriptor.put(key, value); |
|
202 persistBundle(descriptor); |
|
203 } |
|
204 |
|
205 private ExtendedJSONObject unbundleAccountV1(String bundle) { |
|
206 ExtendedJSONObject o; |
|
207 try { |
|
208 o = new ExtendedJSONObject(bundle); |
|
209 } catch (Exception e) { |
|
210 return null; |
|
211 } |
|
212 if (CURRENT_BUNDLE_VERSION == o.getIntegerSafely(BUNDLE_KEY_BUNDLE_VERSION)) { |
|
213 return o; |
|
214 } |
|
215 return null; |
|
216 } |
|
217 |
|
218 private ExtendedJSONObject unbundleAccountV2(String bundle) { |
|
219 return unbundleAccountV1(bundle); |
|
220 } |
|
221 |
|
222 /** |
|
223 * Note that if the user clears data, an account will be left pointing to a |
|
224 * deleted profile. Such is life. |
|
225 */ |
|
226 public String getProfile() { |
|
227 return accountManager.getUserData(account, ACCOUNT_KEY_PROFILE); |
|
228 } |
|
229 |
|
230 public String getAccountServerURI() { |
|
231 return accountManager.getUserData(account, ACCOUNT_KEY_IDP_SERVER); |
|
232 } |
|
233 |
|
234 public String getAudience() { |
|
235 return accountManager.getUserData(account, ACCOUNT_KEY_AUDIENCE); |
|
236 } |
|
237 |
|
238 public String getTokenServerURI() { |
|
239 return accountManager.getUserData(account, ACCOUNT_KEY_TOKEN_SERVER); |
|
240 } |
|
241 |
|
242 /** |
|
243 * This needs to return a string because of the tortured prefs access in GlobalSession. |
|
244 */ |
|
245 public String getSyncPrefsPath() throws GeneralSecurityException, UnsupportedEncodingException { |
|
246 String profile = getProfile(); |
|
247 String username = account.name; |
|
248 |
|
249 if (profile == null) { |
|
250 throw new IllegalStateException("Missing profile. Cannot fetch prefs."); |
|
251 } |
|
252 |
|
253 if (username == null) { |
|
254 throw new IllegalStateException("Missing username. Cannot fetch prefs."); |
|
255 } |
|
256 |
|
257 final String tokenServerURI = getTokenServerURI(); |
|
258 if (tokenServerURI == null) { |
|
259 throw new IllegalStateException("No token server URI. Cannot fetch prefs."); |
|
260 } |
|
261 |
|
262 final String fxaServerURI = getAccountServerURI(); |
|
263 if (fxaServerURI == null) { |
|
264 throw new IllegalStateException("No account server URI. Cannot fetch prefs."); |
|
265 } |
|
266 |
|
267 final String product = GlobalConstants.BROWSER_INTENT_PACKAGE + ".fxa"; |
|
268 final long version = CURRENT_PREFS_VERSION; |
|
269 |
|
270 // This is unique for each syncing 'view' of the account. |
|
271 final String serverURLThing = fxaServerURI + "!" + tokenServerURI; |
|
272 return Utils.getPrefsPath(product, username, serverURLThing, profile, version); |
|
273 } |
|
274 |
|
275 public SharedPreferences getSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException { |
|
276 return context.getSharedPreferences(getSyncPrefsPath(), Utils.SHARED_PREFERENCES_MODE); |
|
277 } |
|
278 |
|
279 /** |
|
280 * Extract a JSON dictionary of the string values associated to this account. |
|
281 * <p> |
|
282 * <b>For debugging use only!</b> The contents of this JSON object completely |
|
283 * determine the user's Firefox Account status and yield access to whatever |
|
284 * user data the device has access to. |
|
285 * |
|
286 * @return JSON-object of Strings. |
|
287 */ |
|
288 public ExtendedJSONObject toJSONObject() { |
|
289 ExtendedJSONObject o = unbundle(); |
|
290 o.put("email", account.name); |
|
291 try { |
|
292 o.put("emailUTF8", Utils.byte2Hex(account.name.getBytes("UTF-8"))); |
|
293 } catch (UnsupportedEncodingException e) { |
|
294 // Ignore. |
|
295 } |
|
296 return o; |
|
297 } |
|
298 |
|
299 public static AndroidFxAccount addAndroidAccount( |
|
300 Context context, |
|
301 String email, |
|
302 String profile, |
|
303 String idpServerURI, |
|
304 String tokenServerURI, |
|
305 State state) |
|
306 throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException { |
|
307 return addAndroidAccount(context, email, profile, idpServerURI, tokenServerURI, state, |
|
308 CURRENT_ACCOUNT_VERSION, true, false, null); |
|
309 } |
|
310 |
|
311 public static AndroidFxAccount addAndroidAccount( |
|
312 Context context, |
|
313 String email, |
|
314 String profile, |
|
315 String idpServerURI, |
|
316 String tokenServerURI, |
|
317 State state, |
|
318 final int accountVersion, |
|
319 final boolean syncEnabled, |
|
320 final boolean fromPickle, |
|
321 ExtendedJSONObject bundle) |
|
322 throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException { |
|
323 if (email == null) { |
|
324 throw new IllegalArgumentException("email must not be null"); |
|
325 } |
|
326 if (idpServerURI == null) { |
|
327 throw new IllegalArgumentException("idpServerURI must not be null"); |
|
328 } |
|
329 if (tokenServerURI == null) { |
|
330 throw new IllegalArgumentException("tokenServerURI must not be null"); |
|
331 } |
|
332 if (state == null) { |
|
333 throw new IllegalArgumentException("state must not be null"); |
|
334 } |
|
335 |
|
336 // TODO: Add migration code. |
|
337 if (accountVersion != CURRENT_ACCOUNT_VERSION) { |
|
338 throw new IllegalStateException("Could not create account of version " + accountVersion + |
|
339 ". Current version is " + CURRENT_ACCOUNT_VERSION + "."); |
|
340 } |
|
341 |
|
342 // Android has internal restrictions that require all values in this |
|
343 // bundle to be strings. *sigh* |
|
344 Bundle userdata = new Bundle(); |
|
345 userdata.putString(ACCOUNT_KEY_ACCOUNT_VERSION, "" + CURRENT_ACCOUNT_VERSION); |
|
346 userdata.putString(ACCOUNT_KEY_IDP_SERVER, idpServerURI); |
|
347 userdata.putString(ACCOUNT_KEY_TOKEN_SERVER, tokenServerURI); |
|
348 userdata.putString(ACCOUNT_KEY_AUDIENCE, FxAccountUtils.getAudienceForURL(tokenServerURI)); |
|
349 userdata.putString(ACCOUNT_KEY_PROFILE, profile); |
|
350 |
|
351 if (bundle == null) { |
|
352 bundle = new ExtendedJSONObject(); |
|
353 // TODO: How to upgrade? |
|
354 bundle.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION); |
|
355 } |
|
356 bundle.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name()); |
|
357 bundle.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString()); |
|
358 |
|
359 userdata.putString(ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString()); |
|
360 |
|
361 Account account = new Account(email, FxAccountConstants.ACCOUNT_TYPE); |
|
362 AccountManager accountManager = AccountManager.get(context); |
|
363 // We don't set an Android password, because we don't want to persist the |
|
364 // password (or anything else as powerful as the password). Instead, we |
|
365 // internally manage a sessionToken with a remotely owned lifecycle. |
|
366 boolean added = accountManager.addAccountExplicitly(account, null, userdata); |
|
367 if (!added) { |
|
368 return null; |
|
369 } |
|
370 |
|
371 AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); |
|
372 |
|
373 if (!fromPickle) { |
|
374 fxAccount.clearSyncPrefs(); |
|
375 } |
|
376 |
|
377 if (syncEnabled) { |
|
378 fxAccount.enableSyncing(); |
|
379 } else { |
|
380 fxAccount.disableSyncing(); |
|
381 } |
|
382 |
|
383 return fxAccount; |
|
384 } |
|
385 |
|
386 public void clearSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException { |
|
387 getSyncPrefs().edit().clear().commit(); |
|
388 } |
|
389 |
|
390 public static Iterable<String> getAndroidAuthorities() { |
|
391 return ANDROID_AUTHORITIES; |
|
392 } |
|
393 |
|
394 /** |
|
395 * Return true if the underlying Android account is currently set to sync automatically. |
|
396 * <p> |
|
397 * This is, confusingly, not the same thing as "being syncable": that refers |
|
398 * to whether this account can be synced, ever; this refers to whether Android |
|
399 * will try to sync the account at appropriate times. |
|
400 * |
|
401 * @return true if the account is set to sync automatically. |
|
402 */ |
|
403 public boolean isSyncing() { |
|
404 boolean isSyncEnabled = true; |
|
405 for (String authority : getAndroidAuthorities()) { |
|
406 isSyncEnabled &= ContentResolver.getSyncAutomatically(account, authority); |
|
407 } |
|
408 return isSyncEnabled; |
|
409 } |
|
410 |
|
411 public void enableSyncing() { |
|
412 Logger.info(LOG_TAG, "Enabling sync for account named like " + getObfuscatedEmail()); |
|
413 for (String authority : getAndroidAuthorities()) { |
|
414 ContentResolver.setSyncAutomatically(account, authority, true); |
|
415 ContentResolver.setIsSyncable(account, authority, 1); |
|
416 } |
|
417 } |
|
418 |
|
419 public void disableSyncing() { |
|
420 Logger.info(LOG_TAG, "Disabling sync for account named like " + getObfuscatedEmail()); |
|
421 for (String authority : getAndroidAuthorities()) { |
|
422 ContentResolver.setSyncAutomatically(account, authority, false); |
|
423 } |
|
424 } |
|
425 |
|
426 /** |
|
427 * Is a sync currently in progress? |
|
428 * |
|
429 * @return true if Android is currently syncing the underlying Android Account. |
|
430 */ |
|
431 public boolean isCurrentlySyncing() { |
|
432 boolean active = false; |
|
433 for (String authority : AndroidFxAccount.getAndroidAuthorities()) { |
|
434 active |= ContentResolver.isSyncActive(account, authority); |
|
435 } |
|
436 return active; |
|
437 } |
|
438 |
|
439 /** |
|
440 * Request a sync. See {@link FirefoxAccounts#requestSync(Account, EnumSet, String[], String[])}. |
|
441 */ |
|
442 public void requestSync() { |
|
443 requestSync(FirefoxAccounts.SOON, null, null); |
|
444 } |
|
445 |
|
446 /** |
|
447 * Request a sync. See {@link FirefoxAccounts#requestSync(Account, EnumSet, String[], String[])}. |
|
448 * |
|
449 * @param syncHints to pass to sync. |
|
450 */ |
|
451 public void requestSync(EnumSet<FirefoxAccounts.SyncHint> syncHints) { |
|
452 requestSync(syncHints, null, null); |
|
453 } |
|
454 |
|
455 /** |
|
456 * Request a sync. See {@link FirefoxAccounts#requestSync(Account, EnumSet, String[], String[])}. |
|
457 * |
|
458 * @param syncHints to pass to sync. |
|
459 * @param stagesToSync stage names to sync. |
|
460 * @param stagesToSkip stage names to skip. |
|
461 */ |
|
462 public void requestSync(EnumSet<FirefoxAccounts.SyncHint> syncHints, String[] stagesToSync, String[] stagesToSkip) { |
|
463 FirefoxAccounts.requestSync(getAndroidAccount(), syncHints, stagesToSync, stagesToSkip); |
|
464 } |
|
465 |
|
466 public synchronized void setState(State state) { |
|
467 if (state == null) { |
|
468 throw new IllegalArgumentException("state must not be null"); |
|
469 } |
|
470 Logger.info(LOG_TAG, "Moving account named like " + getObfuscatedEmail() + |
|
471 " to state " + state.getStateLabel().toString()); |
|
472 updateBundleValue(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name()); |
|
473 updateBundleValue(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString()); |
|
474 } |
|
475 |
|
476 public synchronized State getState() { |
|
477 String stateLabelString = getBundleData(BUNDLE_KEY_STATE_LABEL); |
|
478 String stateString = getBundleData(BUNDLE_KEY_STATE); |
|
479 if (stateLabelString == null) { |
|
480 throw new IllegalStateException("stateLabelString must not be null"); |
|
481 } |
|
482 if (stateString == null) { |
|
483 throw new IllegalStateException("stateString must not be null"); |
|
484 } |
|
485 |
|
486 try { |
|
487 StateLabel stateLabel = StateLabel.valueOf(stateLabelString); |
|
488 return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString)); |
|
489 } catch (Exception e) { |
|
490 throw new IllegalStateException("could not get state", e); |
|
491 } |
|
492 } |
|
493 |
|
494 /** |
|
495 * <b>For debugging only!</b> |
|
496 */ |
|
497 public void dump() { |
|
498 if (!FxAccountConstants.LOG_PERSONAL_INFORMATION) { |
|
499 return; |
|
500 } |
|
501 ExtendedJSONObject o = toJSONObject(); |
|
502 ArrayList<String> list = new ArrayList<String>(o.keySet()); |
|
503 Collections.sort(list); |
|
504 for (String key : list) { |
|
505 FxAccountConstants.pii(LOG_TAG, key + ": " + o.get(key)); |
|
506 } |
|
507 } |
|
508 |
|
509 /** |
|
510 * Return the Firefox Account's local email address. |
|
511 * <p> |
|
512 * It is important to note that this is the local email address, and not |
|
513 * necessarily the normalized remote email address that the server expects. |
|
514 * |
|
515 * @return local email address. |
|
516 */ |
|
517 public String getEmail() { |
|
518 return account.name; |
|
519 } |
|
520 |
|
521 /** |
|
522 * Return the Firefox Account's local email address, obfuscated. |
|
523 * <p> |
|
524 * Use this when logging. |
|
525 * |
|
526 * @return local email address, obfuscated. |
|
527 */ |
|
528 public String getObfuscatedEmail() { |
|
529 return Utils.obfuscateEmail(account.name); |
|
530 } |
|
531 |
|
532 /** |
|
533 * Create an intent announcing that a Firefox account will be deleted. |
|
534 * |
|
535 * @param context |
|
536 * Android context. |
|
537 * @param account |
|
538 * Android account being removed. |
|
539 * @return <code>Intent</code> to broadcast. |
|
540 */ |
|
541 public static Intent makeDeletedAccountIntent(final Context context, final Account account) { |
|
542 final Intent intent = new Intent(FxAccountConstants.ACCOUNT_DELETED_ACTION); |
|
543 |
|
544 intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY, |
|
545 Long.valueOf(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION)); |
|
546 intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name); |
|
547 return intent; |
|
548 } |
|
549 } |