Bug 1740372 [wpt PR 31567] - Move change-duration-during-transtion.html to WPT, a...
[gecko.git] / services / fxaccounts / FxAccountsStorage.jsm
blob099d5a25b49a7938df8e0c92678ed344c8bbaa34
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 "use strict";
6 var EXPORTED_SYMBOLS = [
7   "FxAccountsStorageManagerCanStoreField",
8   "FxAccountsStorageManager",
9 ];
11 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
12 const {
13   DATA_FORMAT_VERSION,
14   DEFAULT_STORAGE_FILENAME,
15   FXA_PWDMGR_HOST,
16   FXA_PWDMGR_PLAINTEXT_FIELDS,
17   FXA_PWDMGR_REALM,
18   FXA_PWDMGR_SECURE_FIELDS,
19   log,
20 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
21 const { CommonUtils } = ChromeUtils.import(
22   "resource://services-common/utils.js"
25 // A helper function so code can check what fields are able to be stored by
26 // the storage manager without having a reference to a manager instance.
27 function FxAccountsStorageManagerCanStoreField(fieldName) {
28   return (
29     FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) ||
30     FXA_PWDMGR_SECURE_FIELDS.has(fieldName)
31   );
34 // The storage manager object.
35 var FxAccountsStorageManager = function(options = {}) {
36   this.options = {
37     filename: options.filename || DEFAULT_STORAGE_FILENAME,
38     baseDir: options.baseDir || Services.dirsvc.get("ProfD", Ci.nsIFile).path,
39   };
40   this.plainStorage = new JSONStorage(this.options);
41   // Tests may want to pretend secure storage isn't available.
42   let useSecure = "useSecure" in options ? options.useSecure : true;
43   if (useSecure) {
44     this.secureStorage = new LoginManagerStorage();
45   } else {
46     this.secureStorage = null;
47   }
48   this._clearCachedData();
49   // See .initialize() below - this protects against it not being called.
50   this._promiseInitialized = Promise.reject("initialize not called");
51   // A promise to avoid storage races - see _queueStorageOperation
52   this._promiseStorageComplete = Promise.resolve();
55 FxAccountsStorageManager.prototype = {
56   _initialized: false,
57   _needToReadSecure: true,
59   // An initialization routine that *looks* synchronous to the callers, but
60   // is actually async as everything else waits for it to complete.
61   initialize(accountData) {
62     if (this._initialized) {
63       throw new Error("already initialized");
64     }
65     this._initialized = true;
66     // If we just throw away our pre-rejected promise it is reported as an
67     // unhandled exception when it is GCd - so add an empty .catch handler here
68     // to prevent this.
69     this._promiseInitialized.catch(() => {});
70     this._promiseInitialized = this._initialize(accountData);
71   },
73   async _initialize(accountData) {
74     log.trace("initializing new storage manager");
75     try {
76       if (accountData) {
77         // If accountData is passed we don't need to read any storage.
78         this._needToReadSecure = false;
79         // split it into the 2 parts, write it and we are done.
80         for (let [name, val] of Object.entries(accountData)) {
81           if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
82             this.cachedPlain[name] = val;
83           } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
84             this.cachedSecure[name] = val;
85           } else {
86             // Unknown fields are silently discarded, because there is no way
87             // for them to be read back later.
88             log.error(
89               "Unknown FxA field name in user data, it will be ignored",
90               name
91             );
92           }
93         }
94         // write it out and we are done.
95         await this._write();
96         return;
97       }
98       // So we were initialized without account data - that means we need to
99       // read the state from storage. We try and read plain storage first and
100       // only attempt to read secure storage if the plain storage had a user.
101       this._needToReadSecure = await this._readPlainStorage();
102       if (this._needToReadSecure && this.secureStorage) {
103         await this._doReadAndUpdateSecure();
104       }
105     } finally {
106       log.trace("initializing of new storage manager done");
107     }
108   },
110   finalize() {
111     // We can't throw this instance away while it is still writing or we may
112     // end up racing with the newly created one.
113     log.trace("StorageManager finalizing");
114     return this._promiseInitialized
115       .then(() => {
116         return this._promiseStorageComplete;
117       })
118       .then(() => {
119         this._promiseStorageComplete = null;
120         this._promiseInitialized = null;
121         this._clearCachedData();
122         log.trace("StorageManager finalized");
123       });
124   },
126   // We want to make sure we don't end up doing multiple storage requests
127   // concurrently - which has a small window for reads if the master-password
128   // is locked at initialization time and becomes unlocked later, and always
129   // has an opportunity for updates.
130   // We also want to make sure we finished writing when finalizing, so we
131   // can't accidentally end up with the previous user's write finishing after
132   // a signOut attempts to clear it.
133   // So all such operations "queue" themselves via this.
134   _queueStorageOperation(func) {
135     // |result| is the promise we return - it has no .catch handler, so callers
136     // of the storage operation still see failure as a normal rejection.
137     let result = this._promiseStorageComplete.then(func);
138     // But the promise we assign to _promiseStorageComplete *does* have a catch
139     // handler so that rejections in one storage operation does not prevent
140     // future operations from starting (ie, _promiseStorageComplete must never
141     // be in a rejected state)
142     this._promiseStorageComplete = result.catch(err => {
143       log.error("${func} failed: ${err}", { func, err });
144     });
145     return result;
146   },
148   // Get the account data by combining the plain and secure storage.
149   // If fieldNames is specified, it may be a string or an array of strings,
150   // and only those fields are returned. If not specified the entire account
151   // data is returned except for "in memory" fields. Note that not specifying
152   // field names will soon be deprecated/removed - we want all callers to
153   // specify the fields they care about.
154   async getAccountData(fieldNames = null) {
155     await this._promiseInitialized;
156     // We know we are initialized - this means our .cachedPlain is accurate
157     // and doesn't need to be read (it was read if necessary by initialize).
158     // So if there's no uid, there's no user signed in.
159     if (!("uid" in this.cachedPlain)) {
160       return null;
161     }
162     let result = {};
163     if (fieldNames === null) {
164       // The "old" deprecated way of fetching a logged in user.
165       for (let [name, value] of Object.entries(this.cachedPlain)) {
166         result[name] = value;
167       }
168       // But the secure data may not have been read, so try that now.
169       await this._maybeReadAndUpdateSecure();
170       // .cachedSecure now has as much as it possibly can (which is possibly
171       // nothing if (a) secure storage remains locked and (b) we've never updated
172       // a field to be stored in secure storage.)
173       for (let [name, value] of Object.entries(this.cachedSecure)) {
174         result[name] = value;
175       }
176       return result;
177     }
178     // The new explicit way of getting attributes.
179     if (!Array.isArray(fieldNames)) {
180       fieldNames = [fieldNames];
181     }
182     let checkedSecure = false;
183     for (let fieldName of fieldNames) {
184       if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) {
185         if (this.cachedPlain[fieldName] !== undefined) {
186           result[fieldName] = this.cachedPlain[fieldName];
187         }
188       } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) {
189         // We may not have read secure storage yet.
190         if (!checkedSecure) {
191           await this._maybeReadAndUpdateSecure();
192           checkedSecure = true;
193         }
194         if (this.cachedSecure[fieldName] !== undefined) {
195           result[fieldName] = this.cachedSecure[fieldName];
196         }
197       } else {
198         throw new Error("unexpected field '" + fieldName + "'");
199       }
200     }
201     return result;
202   },
204   // Update just the specified fields. This DOES NOT allow you to change to
205   // a different user, nor to set the user as signed-out.
206   async updateAccountData(newFields) {
207     await this._promiseInitialized;
208     if (!("uid" in this.cachedPlain)) {
209       // If this storage instance shows no logged in user, then you can't
210       // update fields.
211       throw new Error("No user is logged in");
212     }
213     if (!newFields || "uid" in newFields) {
214       throw new Error("Can't change uid");
215     }
216     log.debug("_updateAccountData with items", Object.keys(newFields));
217     // work out what bucket.
218     for (let [name, value] of Object.entries(newFields)) {
219       if (value == null) {
220         delete this.cachedPlain[name];
221         // no need to do the "delete on null" thing for this.cachedSecure -
222         // we need to keep it until we have managed to read so we can nuke
223         // it on write.
224         this.cachedSecure[name] = null;
225       } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
226         this.cachedPlain[name] = value;
227       } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
228         this.cachedSecure[name] = value;
229       } else {
230         // Throwing seems reasonable here as some client code has explicitly
231         // specified the field name, so it's either confused or needs to update
232         // how this field is to be treated.
233         throw new Error("unexpected field '" + name + "'");
234       }
235     }
236     // If we haven't yet read the secure data, do so now, else we may write
237     // out partial data.
238     await this._maybeReadAndUpdateSecure();
239     // Now save it - but don't wait on the _write promise - it's queued up as
240     // a storage operation, so .finalize() will wait for completion, but no need
241     // for us to.
242     this._write();
243   },
245   _clearCachedData() {
246     this.cachedPlain = {};
247     // If we don't have secure storage available we have cachedPlain and
248     // cachedSecure be the same object.
249     this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {};
250   },
252   /* Reads the plain storage and caches the read values in this.cachedPlain.
253      Only ever called once and unlike the "secure" storage, is expected to never
254      fail (ie, plain storage is considered always available, whereas secure
255      storage may be unavailable if it is locked).
257      Returns a promise that resolves with true if valid account data was found,
258      false otherwise.
260      Note: _readPlainStorage is only called during initialize, so isn't
261      protected via _queueStorageOperation() nor _promiseInitialized.
262   */
263   async _readPlainStorage() {
264     let got;
265     try {
266       got = await this.plainStorage.get();
267     } catch (err) {
268       // File hasn't been created yet.  That will be done
269       // when write is called.
270       if (!err.name == "NotFoundError") {
271         log.error("Failed to read plain storage", err);
272       }
273       // either way, we return null.
274       got = null;
275     }
276     if (
277       !got ||
278       !got.accountData ||
279       !got.accountData.uid ||
280       got.version != DATA_FORMAT_VERSION
281     ) {
282       return false;
283     }
284     // We need to update our .cachedPlain, but can't just assign to it as
285     // it may need to be the exact same object as .cachedSecure
286     // As a sanity check, .cachedPlain must be empty (as we are called by init)
287     // XXX - this would be a good use-case for a RuntimeAssert or similar, as
288     // being added in bug 1080457.
289     if (Object.keys(this.cachedPlain).length != 0) {
290       throw new Error("should be impossible to have cached data already.");
291     }
292     for (let [name, value] of Object.entries(got.accountData)) {
293       this.cachedPlain[name] = value;
294     }
295     return true;
296   },
298   /* If we haven't managed to read the secure storage, try now, so
299      we can merge our cached data with the data that's already been set.
300   */
301   _maybeReadAndUpdateSecure() {
302     if (this.secureStorage == null || !this._needToReadSecure) {
303       return null;
304     }
305     return this._queueStorageOperation(() => {
306       if (this._needToReadSecure) {
307         // we might have read it by now!
308         return this._doReadAndUpdateSecure();
309       }
310       return null;
311     });
312   },
314   /* Unconditionally read the secure storage and merge our cached data (ie, data
315      which has already been set while the secure storage was locked) with
316      the read data
317   */
318   async _doReadAndUpdateSecure() {
319     let { uid, email } = this.cachedPlain;
320     try {
321       log.debug(
322         "reading secure storage with existing",
323         Object.keys(this.cachedSecure)
324       );
325       // If we already have anything in .cachedSecure it means something has
326       // updated cachedSecure before we've read it. That means that after we do
327       // manage to read we must write back the merged data.
328       let needWrite = Object.keys(this.cachedSecure).length != 0;
329       let readSecure = await this.secureStorage.get(uid, email);
330       // and update our cached data with it - anything already in .cachedSecure
331       // wins (including the fact it may be null or undefined, the latter
332       // which means it will be removed from storage.
333       if (readSecure && readSecure.version != DATA_FORMAT_VERSION) {
334         log.warn("got secure data but the data format version doesn't match");
335         readSecure = null;
336       }
337       if (readSecure && readSecure.accountData) {
338         log.debug(
339           "secure read fetched items",
340           Object.keys(readSecure.accountData)
341         );
342         for (let [name, value] of Object.entries(readSecure.accountData)) {
343           if (!(name in this.cachedSecure)) {
344             this.cachedSecure[name] = value;
345           }
346         }
347         if (needWrite) {
348           log.debug("successfully read secure data; writing updated data back");
349           await this._doWriteSecure();
350         }
351       }
352       this._needToReadSecure = false;
353     } catch (ex) {
354       if (ex instanceof this.secureStorage.STORAGE_LOCKED) {
355         log.debug("setAccountData: secure storage is locked trying to read");
356       } else {
357         log.error("failed to read secure storage", ex);
358         throw ex;
359       }
360     }
361   },
363   _write() {
364     // We don't want multiple writes happening concurrently, and we also need to
365     // know when an "old" storage manager is done (this.finalize() waits for this)
366     return this._queueStorageOperation(() => this.__write());
367   },
369   async __write() {
370     // Write everything back - later we could track what's actually dirty,
371     // but for now we write it all.
372     log.debug("writing plain storage", Object.keys(this.cachedPlain));
373     let toWritePlain = {
374       version: DATA_FORMAT_VERSION,
375       accountData: this.cachedPlain,
376     };
377     await this.plainStorage.set(toWritePlain);
379     // If we have no secure storage manager we are done.
380     if (this.secureStorage == null) {
381       return;
382     }
383     // and only attempt to write to secure storage if we've managed to read it,
384     // otherwise we might clobber data that's already there.
385     if (!this._needToReadSecure) {
386       await this._doWriteSecure();
387     }
388   },
390   /* Do the actual write of secure data. Caller is expected to check if we actually
391      need to write and to ensure we are in a queued storage operation.
392   */
393   async _doWriteSecure() {
394     // We need to remove null items here.
395     for (let [name, value] of Object.entries(this.cachedSecure)) {
396       if (value == null) {
397         delete this.cachedSecure[name];
398       }
399     }
400     log.debug("writing secure storage", Object.keys(this.cachedSecure));
401     let toWriteSecure = {
402       version: DATA_FORMAT_VERSION,
403       accountData: this.cachedSecure,
404     };
405     try {
406       await this.secureStorage.set(this.cachedPlain.uid, toWriteSecure);
407     } catch (ex) {
408       if (!(ex instanceof this.secureStorage.STORAGE_LOCKED)) {
409         throw ex;
410       }
411       // This shouldn't be possible as once it is unlocked it can't be
412       // re-locked, and we can only be here if we've previously managed to
413       // read.
414       log.error("setAccountData: secure storage is locked trying to write");
415     }
416   },
418   // Delete the data for an account - ie, called on "sign out".
419   deleteAccountData() {
420     return this._queueStorageOperation(() => this._deleteAccountData());
421   },
423   async _deleteAccountData() {
424     log.debug("removing account data");
425     await this._promiseInitialized;
426     await this.plainStorage.set(null);
427     if (this.secureStorage) {
428       await this.secureStorage.set(null);
429     }
430     this._clearCachedData();
431     log.debug("account data reset");
432   },
436  * JSONStorage constructor that creates instances that may set/get
437  * to a specified file, in a directory that will be created if it
438  * doesn't exist.
440  * @param options {
441  *                  filename: of the file to write to
442  *                  baseDir: directory where the file resides
443  *                }
444  * @return instance
445  */
446 function JSONStorage(options) {
447   this.baseDir = options.baseDir;
448   this.path = PathUtils.join(options.baseDir, options.filename);
451 JSONStorage.prototype = {
452   set(contents) {
453     log.trace(
454       "starting write of json user data",
455       contents ? Object.keys(contents.accountData) : "null"
456     );
457     let start = Date.now();
458     return IOUtils.makeDirectory(this.baseDir, { ignoreExisting: true })
459       .then(CommonUtils.writeJSON.bind(null, contents, this.path))
460       .then(result => {
461         log.trace(
462           "finished write of json user data - took",
463           Date.now() - start
464         );
465         return result;
466       });
467   },
469   get() {
470     log.trace("starting fetch of json user data");
471     let start = Date.now();
472     return CommonUtils.readJSON(this.path).then(result => {
473       log.trace("finished fetch of json user data - took", Date.now() - start);
474       return result;
475     });
476   },
479 function StorageLockedError() {}
481  * LoginManagerStorage constructor that creates instances that set/get
482  * data stored securely in the nsILoginManager.
484  * @return instance
485  */
487 function LoginManagerStorage() {}
489 LoginManagerStorage.prototype = {
490   STORAGE_LOCKED: StorageLockedError,
491   // The fields in the credentials JSON object that are stored in plain-text
492   // in the profile directory.  All other fields are stored in the login manager,
493   // and thus are only available when the master-password is unlocked.
495   // a hook point for testing.
496   get _isLoggedIn() {
497     return Services.logins.isLoggedIn;
498   },
500   // Clear any data from the login manager.  Returns true if the login manager
501   // was unlocked (even if no existing logins existed) or false if it was
502   // locked (meaning we don't even know if it existed or not.)
503   async _clearLoginMgrData() {
504     try {
505       // Services.logins might be third-party and broken...
506       await Services.logins.initializationPromise;
507       if (!this._isLoggedIn) {
508         return false;
509       }
510       let logins = Services.logins.findLogins(
511         FXA_PWDMGR_HOST,
512         null,
513         FXA_PWDMGR_REALM
514       );
515       for (let login of logins) {
516         Services.logins.removeLogin(login);
517       }
518       return true;
519     } catch (ex) {
520       log.error("Failed to clear login data: ${}", ex);
521       return false;
522     }
523   },
525   async set(uid, contents) {
526     if (!contents) {
527       // Nuke it from the login manager.
528       let cleared = await this._clearLoginMgrData();
529       if (!cleared) {
530         // just log a message - we verify that the uid matches when
531         // we reload it, so having a stale entry doesn't really hurt.
532         log.info("not removing credentials from login manager - not logged in");
533       }
534       log.trace("storage set finished clearing account data");
535       return;
536     }
538     // We are saving actual data.
539     log.trace("starting write of user data to the login manager");
540     try {
541       // Services.logins might be third-party and broken...
542       // and the stuff into the login manager.
543       await Services.logins.initializationPromise;
544       // If MP is locked we silently fail - the user may need to re-auth
545       // next startup.
546       if (!this._isLoggedIn) {
547         log.info("not saving credentials to login manager - not logged in");
548         throw new this.STORAGE_LOCKED();
549       }
550       // write the data to the login manager.
551       let loginInfo = new Components.Constructor(
552         "@mozilla.org/login-manager/loginInfo;1",
553         Ci.nsILoginInfo,
554         "init"
555       );
556       let login = new loginInfo(
557         FXA_PWDMGR_HOST,
558         null, // aFormActionOrigin,
559         FXA_PWDMGR_REALM, // aHttpRealm,
560         uid, // aUsername
561         JSON.stringify(contents), // aPassword
562         "", // aUsernameField
563         ""
564       ); // aPasswordField
566       let existingLogins = Services.logins.findLogins(
567         FXA_PWDMGR_HOST,
568         null,
569         FXA_PWDMGR_REALM
570       );
571       if (existingLogins.length) {
572         Services.logins.modifyLogin(existingLogins[0], login);
573       } else {
574         Services.logins.addLogin(login);
575       }
576       log.trace("finished write of user data to the login manager");
577     } catch (ex) {
578       if (ex instanceof this.STORAGE_LOCKED) {
579         throw ex;
580       }
581       // just log and consume the error here - it may be a 3rd party login
582       // manager replacement that's simply broken.
583       log.error("Failed to save data to the login manager", ex);
584     }
585   },
587   async get(uid, email) {
588     log.trace("starting fetch of user data from the login manager");
590     try {
591       // Services.logins might be third-party and broken...
592       // read the data from the login manager and merge it for return.
593       await Services.logins.initializationPromise;
595       if (!this._isLoggedIn) {
596         log.info(
597           "returning partial account data as the login manager is locked."
598         );
599         throw new this.STORAGE_LOCKED();
600       }
602       let logins = Services.logins.findLogins(
603         FXA_PWDMGR_HOST,
604         null,
605         FXA_PWDMGR_REALM
606       );
607       if (logins.length == 0) {
608         // This could happen if the MP was locked when we wrote the data.
609         log.info("Can't find any credentials in the login manager");
610         return null;
611       }
612       let login = logins[0];
613       // Support either the uid or the email as the username - as of bug 1183951
614       // we store the uid, but we support having either for b/w compat.
615       if (login.username == uid || login.username == email) {
616         return JSON.parse(login.password);
617       }
618       log.info("username in the login manager doesn't match - ignoring it");
619       await this._clearLoginMgrData();
620     } catch (ex) {
621       if (ex instanceof this.STORAGE_LOCKED) {
622         throw ex;
623       }
624       // just log and consume the error here - it may be a 3rd party login
625       // manager replacement that's simply broken.
626       log.error("Failed to get data from the login manager", ex);
627     }
628     return null;
629   },