services/sync/modules/browserid_identity.js

branch
TOR_BUG_9701
changeset 15
b8a032363ba2
equal deleted inserted replaced
-1:000000000000 0:f654b0469a31
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 file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 "use strict";
6
7 this.EXPORTED_SYMBOLS = ["BrowserIDManager"];
8
9 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
10
11 Cu.import("resource://gre/modules/Log.jsm");
12 Cu.import("resource://services-common/async.js");
13 Cu.import("resource://services-common/utils.js");
14 Cu.import("resource://services-common/tokenserverclient.js");
15 Cu.import("resource://services-crypto/utils.js");
16 Cu.import("resource://services-sync/identity.js");
17 Cu.import("resource://services-sync/util.js");
18 Cu.import("resource://services-common/tokenserverclient.js");
19 Cu.import("resource://gre/modules/Services.jsm");
20 Cu.import("resource://services-sync/constants.js");
21 Cu.import("resource://gre/modules/Promise.jsm");
22 Cu.import("resource://services-sync/stages/cluster.js");
23 Cu.import("resource://gre/modules/FxAccounts.jsm");
24
25 // Lazy imports to prevent unnecessary load on startup.
26 XPCOMUtils.defineLazyModuleGetter(this, "Weave",
27 "resource://services-sync/main.js");
28
29 XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle",
30 "resource://services-sync/keys.js");
31
32 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
33 "resource://gre/modules/FxAccounts.jsm");
34
35 XPCOMUtils.defineLazyGetter(this, 'log', function() {
36 let log = Log.repository.getLogger("Sync.BrowserIDManager");
37 log.level = Log.Level[Svc.Prefs.get("log.logger.identity")] || Log.Level.Error;
38 return log;
39 });
40
41 // FxAccountsCommon.js doesn't use a "namespace", so create one here.
42 let fxAccountsCommon = {};
43 Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
44
45 const OBSERVER_TOPICS = [
46 fxAccountsCommon.ONLOGIN_NOTIFICATION,
47 fxAccountsCommon.ONLOGOUT_NOTIFICATION,
48 ];
49
50 const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync.ui.showCustomizationDialog";
51
52 function deriveKeyBundle(kB) {
53 let out = CryptoUtils.hkdf(kB, undefined,
54 "identity.mozilla.com/picl/v1/oldsync", 2*32);
55 let bundle = new BulkKeyBundle();
56 // [encryptionKey, hmacKey]
57 bundle.keyPair = [out.slice(0, 32), out.slice(32, 64)];
58 return bundle;
59 }
60
61 /*
62 General authentication error for abstracting authentication
63 errors from multiple sources (e.g., from FxAccounts, TokenServer).
64 details is additional details about the error - it might be a string, or
65 some other error object (which should do the right thing when toString() is
66 called on it)
67 */
68 function AuthenticationError(details) {
69 this.details = details;
70 }
71
72 AuthenticationError.prototype = {
73 toString: function() {
74 return "AuthenticationError(" + this.details + ")";
75 }
76 }
77
78 this.BrowserIDManager = function BrowserIDManager() {
79 // NOTE: _fxaService and _tokenServerClient are replaced with mocks by
80 // the test suite.
81 this._fxaService = fxAccounts;
82 this._tokenServerClient = new TokenServerClient();
83 this._tokenServerClient.observerPrefix = "weave:service";
84 // will be a promise that resolves when we are ready to authenticate
85 this.whenReadyToAuthenticate = null;
86 this._log = log;
87 };
88
89 this.BrowserIDManager.prototype = {
90 __proto__: IdentityManager.prototype,
91
92 _fxaService: null,
93 _tokenServerClient: null,
94 // https://docs.services.mozilla.com/token/apis.html
95 _token: null,
96 _signedInUser: null, // the signedinuser we got from FxAccounts.
97
98 // null if no error, otherwise a LOGIN_FAILED_* value that indicates why
99 // we failed to authenticate (but note it might not be an actual
100 // authentication problem, just a transient network error or similar)
101 _authFailureReason: null,
102
103 // it takes some time to fetch a sync key bundle, so until this flag is set,
104 // we don't consider the lack of a keybundle as a failure state.
105 _shouldHaveSyncKeyBundle: false,
106
107 get readyToAuthenticate() {
108 // We are finished initializing when we *should* have a sync key bundle,
109 // although we might not actually have one due to auth failures etc.
110 return this._shouldHaveSyncKeyBundle;
111 },
112
113 get needsCustomization() {
114 try {
115 return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION);
116 } catch (e) {
117 return false;
118 }
119 },
120
121 initialize: function() {
122 for (let topic of OBSERVER_TOPICS) {
123 Services.obs.addObserver(this, topic, false);
124 }
125 return this.initializeWithCurrentIdentity();
126 },
127
128 /**
129 * Ensure the user is logged in. Returns a promise that resolves when
130 * the user is logged in, or is rejected if the login attempt has failed.
131 */
132 ensureLoggedIn: function() {
133 if (!this._shouldHaveSyncKeyBundle) {
134 // We are already in the process of logging in.
135 return this.whenReadyToAuthenticate.promise;
136 }
137
138 // If we are already happy then there is nothing more to do.
139 if (this._syncKeyBundle) {
140 return Promise.resolve();
141 }
142
143 // Similarly, if we have a previous failure that implies an explicit
144 // re-entering of credentials by the user is necessary we don't take any
145 // further action - an observer will fire when the user does that.
146 if (Weave.Status.login == LOGIN_FAILED_LOGIN_REJECTED) {
147 return Promise.reject();
148 }
149
150 // So - we've a previous auth problem and aren't currently attempting to
151 // log in - so fire that off.
152 this.initializeWithCurrentIdentity();
153 return this.whenReadyToAuthenticate.promise;
154 },
155
156 finalize: function() {
157 // After this is called, we can expect Service.identity != this.
158 for (let topic of OBSERVER_TOPICS) {
159 Services.obs.removeObserver(this, topic);
160 }
161 this.resetCredentials();
162 this._signedInUser = null;
163 return Promise.resolve();
164 },
165
166 offerSyncOptions: function () {
167 // If the user chose to "Customize sync options" when signing
168 // up with Firefox Accounts, ask them to choose what to sync.
169 const url = "chrome://browser/content/sync/customize.xul";
170 const features = "centerscreen,chrome,modal,dialog,resizable=no";
171 let win = Services.wm.getMostRecentWindow("navigator:browser");
172
173 let data = {accepted: false};
174 win.openDialog(url, "_blank", features, data);
175
176 return data;
177 },
178
179 initializeWithCurrentIdentity: function(isInitialSync=false) {
180 // While this function returns a promise that resolves once we've started
181 // the auth process, that process is complete when
182 // this.whenReadyToAuthenticate.promise resolves.
183 this._log.trace("initializeWithCurrentIdentity");
184
185 // Reset the world before we do anything async.
186 this.whenReadyToAuthenticate = Promise.defer();
187 this.whenReadyToAuthenticate.promise.then(null, (err) => {
188 this._log.error("Could not authenticate: " + err);
189 });
190
191 this._shouldHaveSyncKeyBundle = false;
192 this._authFailureReason = null;
193
194 return this._fxaService.getSignedInUser().then(accountData => {
195 if (!accountData) {
196 this._log.info("initializeWithCurrentIdentity has no user logged in");
197 this.account = null;
198 // and we are as ready as we can ever be for auth.
199 this._shouldHaveSyncKeyBundle = true;
200 this.whenReadyToAuthenticate.reject("no user is logged in");
201 return;
202 }
203
204 this.account = accountData.email;
205 this._updateSignedInUser(accountData);
206 // The user must be verified before we can do anything at all; we kick
207 // this and the rest of initialization off in the background (ie, we
208 // don't return the promise)
209 this._log.info("Waiting for user to be verified.");
210 this._fxaService.whenVerified(accountData).then(accountData => {
211 this._updateSignedInUser(accountData);
212 this._log.info("Starting fetch for key bundle.");
213 if (this.needsCustomization) {
214 let data = this.offerSyncOptions();
215 if (data.accepted) {
216 Services.prefs.clearUserPref(PREF_SYNC_SHOW_CUSTOMIZATION);
217
218 // Mark any non-selected engines as declined.
219 Weave.Service.engineManager.declineDisabled();
220 } else {
221 // Log out if the user canceled the dialog.
222 return this._fxaService.signOut();
223 }
224 }
225 }).then(() => {
226 return this._fetchTokenForUser();
227 }).then(token => {
228 this._token = token;
229 this._shouldHaveSyncKeyBundle = true; // and we should actually have one...
230 this.whenReadyToAuthenticate.resolve();
231 this._log.info("Background fetch for key bundle done");
232 Weave.Status.login = LOGIN_SUCCEEDED;
233 if (isInitialSync) {
234 this._log.info("Doing initial sync actions");
235 Svc.Prefs.set("firstSync", "resetClient");
236 Services.obs.notifyObservers(null, "weave:service:setup-complete", null);
237 Weave.Utils.nextTick(Weave.Service.sync, Weave.Service);
238 }
239 }).then(null, err => {
240 this._shouldHaveSyncKeyBundle = true; // but we probably don't have one...
241 this.whenReadyToAuthenticate.reject(err);
242 // report what failed...
243 this._log.error("Background fetch for key bundle failed: " + err);
244 });
245 // and we are done - the fetch continues on in the background...
246 }).then(null, err => {
247 this._log.error("Processing logged in account: " + err);
248 });
249 },
250
251 _updateSignedInUser: function(userData) {
252 // This object should only ever be used for a single user. It is an
253 // error to update the data if the user changes (but updates are still
254 // necessary, as each call may add more attributes to the user).
255 // We start with no user, so an initial update is always ok.
256 if (this._signedInUser && this._signedInUser.email != userData.email) {
257 throw new Error("Attempting to update to a different user.")
258 }
259 this._signedInUser = userData;
260 },
261
262 logout: function() {
263 // This will be called when sync fails (or when the account is being
264 // unlinked etc). It may have failed because we got a 401 from a sync
265 // server, so we nuke the token. Next time sync runs and wants an
266 // authentication header, we will notice the lack of the token and fetch a
267 // new one.
268 this._token = null;
269 },
270
271 observe: function (subject, topic, data) {
272 this._log.debug("observed " + topic);
273 switch (topic) {
274 case fxAccountsCommon.ONLOGIN_NOTIFICATION:
275 // This should only happen if we've been initialized without a current
276 // user - otherwise we'd have seen the LOGOUT notification and been
277 // thrown away.
278 // The exception is when we've initialized with a user that needs to
279 // reauth with the server - in that case we will also get here, but
280 // should have the same identity.
281 // initializeWithCurrentIdentity will throw and log if these contraints
282 // aren't met, so just go ahead and do the init.
283 this.initializeWithCurrentIdentity(true);
284 break;
285
286 case fxAccountsCommon.ONLOGOUT_NOTIFICATION:
287 Weave.Service.startOver();
288 // startOver will cause this instance to be thrown away, so there's
289 // nothing else to do.
290 break;
291 }
292 },
293
294 /**
295 * Compute the sha256 of the message bytes. Return bytes.
296 */
297 _sha256: function(message) {
298 let hasher = Cc["@mozilla.org/security/hash;1"]
299 .createInstance(Ci.nsICryptoHash);
300 hasher.init(hasher.SHA256);
301 return CryptoUtils.digestBytes(message, hasher);
302 },
303
304 /**
305 * Compute the X-Client-State header given the byte string kB.
306 *
307 * Return string: hex(first16Bytes(sha256(kBbytes)))
308 */
309 _computeXClientState: function(kBbytes) {
310 return CommonUtils.bytesAsHex(this._sha256(kBbytes).slice(0, 16), false);
311 },
312
313 /**
314 * Provide override point for testing token expiration.
315 */
316 _now: function() {
317 return this._fxaService.now()
318 },
319
320 get _localtimeOffsetMsec() {
321 return this._fxaService.localtimeOffsetMsec;
322 },
323
324 usernameFromAccount: function(val) {
325 // we don't differentiate between "username" and "account"
326 return val;
327 },
328
329 /**
330 * Obtains the HTTP Basic auth password.
331 *
332 * Returns a string if set or null if it is not set.
333 */
334 get basicPassword() {
335 this._log.error("basicPassword getter should be not used in BrowserIDManager");
336 return null;
337 },
338
339 /**
340 * Set the HTTP basic password to use.
341 *
342 * Changes will not persist unless persistSyncCredentials() is called.
343 */
344 set basicPassword(value) {
345 throw "basicPassword setter should be not used in BrowserIDManager";
346 },
347
348 /**
349 * Obtain the Sync Key.
350 *
351 * This returns a 26 character "friendly" Base32 encoded string on success or
352 * null if no Sync Key could be found.
353 *
354 * If the Sync Key hasn't been set in this session, this will look in the
355 * password manager for the sync key.
356 */
357 get syncKey() {
358 if (this.syncKeyBundle) {
359 // TODO: This is probably fine because the code shouldn't be
360 // using the sync key directly (it should use the sync key
361 // bundle), but I don't like it. We should probably refactor
362 // code that is inspecting this to not do validation on this
363 // field directly and instead call a isSyncKeyValid() function
364 // that we can override.
365 return "99999999999999999999999999";
366 }
367 else {
368 return null;
369 }
370 },
371
372 set syncKey(value) {
373 throw "syncKey setter should be not used in BrowserIDManager";
374 },
375
376 get syncKeyBundle() {
377 return this._syncKeyBundle;
378 },
379
380 /**
381 * Resets/Drops all credentials we hold for the current user.
382 */
383 resetCredentials: function() {
384 this.resetSyncKey();
385 this._token = null;
386 },
387
388 /**
389 * Resets/Drops the sync key we hold for the current user.
390 */
391 resetSyncKey: function() {
392 this._syncKey = null;
393 this._syncKeyBundle = null;
394 this._syncKeyUpdated = true;
395 this._shouldHaveSyncKeyBundle = false;
396 },
397
398 /**
399 * The current state of the auth credentials.
400 *
401 * This essentially validates that enough credentials are available to use
402 * Sync.
403 */
404 get currentAuthState() {
405 if (this._authFailureReason) {
406 this._log.info("currentAuthState returning " + this._authFailureReason +
407 " due to previous failure");
408 return this._authFailureReason;
409 }
410 // TODO: need to revisit this. Currently this isn't ready to go until
411 // both the username and syncKeyBundle are both configured and having no
412 // username seems to make things fail fast so that's good.
413 if (!this.username) {
414 return LOGIN_FAILED_NO_USERNAME;
415 }
416
417 // No need to check this.syncKey as our getter for that attribute
418 // uses this.syncKeyBundle
419 // If bundle creation started, but failed.
420 if (this._shouldHaveSyncKeyBundle && !this.syncKeyBundle) {
421 return LOGIN_FAILED_NO_PASSPHRASE;
422 }
423
424 return STATUS_OK;
425 },
426
427 /**
428 * Do we have a non-null, not yet expired token for the user currently
429 * signed in?
430 */
431 hasValidToken: function() {
432 if (!this._token) {
433 return false;
434 }
435 if (this._token.expiration < this._now()) {
436 return false;
437 }
438 return true;
439 },
440
441 // Refresh the sync token for our user.
442 _fetchTokenForUser: function() {
443 let tokenServerURI = Svc.Prefs.get("tokenServerURI");
444 let log = this._log;
445 let client = this._tokenServerClient;
446 let fxa = this._fxaService;
447 let userData = this._signedInUser;
448
449 log.info("Fetching assertion and token from: " + tokenServerURI);
450
451 let maybeFetchKeys = () => {
452 // This is called at login time and every time we need a new token - in
453 // the latter case we already have kA and kB, so optimise that case.
454 if (userData.kA && userData.kB) {
455 return;
456 }
457 return this._fxaService.getKeys().then(
458 newUserData => {
459 userData = newUserData;
460 this._updateSignedInUser(userData); // throws if the user changed.
461 }
462 );
463 }
464
465 let getToken = (tokenServerURI, assertion) => {
466 log.debug("Getting a token");
467 let deferred = Promise.defer();
468 let cb = function (err, token) {
469 if (err) {
470 return deferred.reject(err);
471 }
472 log.debug("Successfully got a sync token");
473 return deferred.resolve(token);
474 };
475
476 let kBbytes = CommonUtils.hexToBytes(userData.kB);
477 let headers = {"X-Client-State": this._computeXClientState(kBbytes)};
478 client.getTokenFromBrowserIDAssertion(tokenServerURI, assertion, cb, headers);
479 return deferred.promise;
480 }
481
482 let getAssertion = () => {
483 log.debug("Getting an assertion");
484 let audience = Services.io.newURI(tokenServerURI, null, null).prePath;
485 return fxa.getAssertion(audience);
486 };
487
488 // wait until the account email is verified and we know that
489 // getAssertion() will return a real assertion (not null).
490 return fxa.whenVerified(this._signedInUser)
491 .then(() => maybeFetchKeys())
492 .then(() => getAssertion())
493 .then(assertion => getToken(tokenServerURI, assertion))
494 .then(token => {
495 // TODO: Make it be only 80% of the duration, so refresh the token
496 // before it actually expires. This is to avoid sync storage errors
497 // otherwise, we get a nasty notification bar briefly. Bug 966568.
498 token.expiration = this._now() + (token.duration * 1000) * 0.80;
499 if (!this._syncKeyBundle) {
500 // We are given kA/kB as hex.
501 this._syncKeyBundle = deriveKeyBundle(Utils.hexToBytes(userData.kB));
502 }
503 return token;
504 })
505 .then(null, err => {
506 // TODO: unify these errors - we need to handle errors thrown by
507 // both tokenserverclient and hawkclient.
508 // A tokenserver error thrown based on a bad response.
509 if (err.response && err.response.status === 401) {
510 err = new AuthenticationError(err);
511 // A hawkclient error.
512 } else if (err.code === 401) {
513 err = new AuthenticationError(err);
514 }
515
516 // TODO: write tests to make sure that different auth error cases are handled here
517 // properly: auth error getting assertion, auth error getting token (invalid generation
518 // and client-state error)
519 if (err instanceof AuthenticationError) {
520 this._log.error("Authentication error in _fetchTokenForUser: " + err);
521 // set it to the "fatal" LOGIN_FAILED_LOGIN_REJECTED reason.
522 this._authFailureReason = LOGIN_FAILED_LOGIN_REJECTED;
523 } else {
524 this._log.error("Non-authentication error in _fetchTokenForUser: " + err.message);
525 // for now assume it is just a transient network related problem.
526 this._authFailureReason = LOGIN_FAILED_NETWORK_ERROR;
527 }
528 // Drop the sync key bundle, but still expect to have one.
529 // This will arrange for us to be in the right 'currentAuthState'
530 // such that UI will show the right error.
531 this._shouldHaveSyncKeyBundle = true;
532 Weave.Status.login = this._authFailureReason;
533 Services.obs.notifyObservers(null, "weave:service:login:error", null);
534 throw err;
535 });
536 },
537
538 // Returns a promise that is resolved when we have a valid token for the
539 // current user stored in this._token. When resolved, this._token is valid.
540 _ensureValidToken: function() {
541 if (this.hasValidToken()) {
542 this._log.debug("_ensureValidToken already has one");
543 return Promise.resolve();
544 }
545 return this._fetchTokenForUser().then(
546 token => {
547 this._token = token;
548 }
549 );
550 },
551
552 getResourceAuthenticator: function () {
553 return this._getAuthenticationHeader.bind(this);
554 },
555
556 /**
557 * Obtain a function to be used for adding auth to RESTRequest instances.
558 */
559 getRESTRequestAuthenticator: function() {
560 return this._addAuthenticationHeader.bind(this);
561 },
562
563 /**
564 * @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri
565 * of a RESTRequest or AsyncResponse object.
566 */
567 _getAuthenticationHeader: function(httpObject, method) {
568 let cb = Async.makeSpinningCallback();
569 this._ensureValidToken().then(cb, cb);
570 try {
571 cb.wait();
572 } catch (ex) {
573 this._log.error("Failed to fetch a token for authentication: " + ex);
574 return null;
575 }
576 if (!this._token) {
577 return null;
578 }
579 let credentials = {algorithm: "sha256",
580 id: this._token.id,
581 key: this._token.key,
582 };
583 method = method || httpObject.method;
584
585 // Get the local clock offset from the Firefox Accounts server. This should
586 // be close to the offset from the storage server.
587 let options = {
588 now: this._now(),
589 localtimeOffsetMsec: this._localtimeOffsetMsec,
590 credentials: credentials,
591 };
592
593 let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, options);
594 return {headers: {authorization: headerValue.field}};
595 },
596
597 _addAuthenticationHeader: function(request, method) {
598 let header = this._getAuthenticationHeader(request, method);
599 if (!header) {
600 return null;
601 }
602 request.setHeader("authorization", header.headers.authorization);
603 return request;
604 },
605
606 createClusterManager: function(service) {
607 return new BrowserIDClusterManager(service);
608 }
609
610 };
611
612 /* An implementation of the ClusterManager for this identity
613 */
614
615 function BrowserIDClusterManager(service) {
616 ClusterManager.call(this, service);
617 }
618
619 BrowserIDClusterManager.prototype = {
620 __proto__: ClusterManager.prototype,
621
622 _findCluster: function() {
623 let endPointFromIdentityToken = function() {
624 let endpoint = this.identity._token.endpoint;
625 // For Sync 1.5 storage endpoints, we use the base endpoint verbatim.
626 // However, it should end in "/" because we will extend it with
627 // well known path components. So we add a "/" if it's missing.
628 if (!endpoint.endsWith("/")) {
629 endpoint += "/";
630 }
631 log.debug("_findCluster returning " + endpoint);
632 return endpoint;
633 }.bind(this);
634
635 // Spinningly ensure we are ready to authenticate and have a valid token.
636 let promiseClusterURL = function() {
637 return this.identity.whenReadyToAuthenticate.promise.then(
638 () => {
639 // We need to handle node reassignment here. If we are being asked
640 // for a clusterURL while the service already has a clusterURL, then
641 // it's likely a 401 was received using the existing token - in which
642 // case we just discard the existing token and fetch a new one.
643 if (this.service.clusterURL) {
644 log.debug("_findCluster found existing clusterURL, so discarding the current token");
645 this.identity._token = null;
646 }
647 return this.identity._ensureValidToken();
648 }
649 ).then(endPointFromIdentityToken
650 );
651 }.bind(this);
652
653 let cb = Async.makeSpinningCallback();
654 promiseClusterURL().then(function (clusterURL) {
655 cb(null, clusterURL);
656 }).then(
657 null, err => {
658 // service.js's verifyLogin() method will attempt to fetch a cluster
659 // URL when it sees a 401. If it gets null, it treats it as a "real"
660 // auth error and sets Status.login to LOGIN_FAILED_LOGIN_REJECTED, which
661 // in turn causes a notification bar to appear informing the user they
662 // need to re-authenticate.
663 // On the other hand, if fetching the cluster URL fails with an exception,
664 // verifyLogin() assumes it is a transient error, and thus doesn't show
665 // the notification bar under the assumption the issue will resolve
666 // itself.
667 // Thus:
668 // * On a real 401, we must return null.
669 // * On any other problem we must let an exception bubble up.
670 if (err instanceof AuthenticationError) {
671 // callback with no error and a null result - cb.wait() returns null.
672 cb(null, null);
673 } else {
674 // callback with an error - cb.wait() completes by raising an exception.
675 cb(err);
676 }
677 });
678 return cb.wait();
679 },
680
681 getUserBaseURL: function() {
682 // Legacy Sync and FxA Sync construct the userBaseURL differently. Legacy
683 // Sync appends path components onto an empty path, and in FxA Sync the
684 // token server constructs this for us in an opaque manner. Since the
685 // cluster manager already sets the clusterURL on Service and also has
686 // access to the current identity, we added this functionality here.
687 return this.service.clusterURL;
688 }
689 }

mercurial