From 038666babf1df3d7e2ecc5aa4a7c485b68f43e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Jim=C3=A9nez?= Date: Fri, 13 Dec 2013 12:25:26 +0100 Subject: [PATCH] Bug 949526 - FxAccountsManager and B2G implementation. Part 1: FxAccountsManager. r=markh --- services/fxaccounts/FxAccounts.jsm | 9 +- services/fxaccounts/FxAccountsConsts.js | 85 +++++ services/fxaccounts/FxAccountsManager.jsm | 382 ++++++++++++++++++++++ services/fxaccounts/moz.build | 8 +- 4 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 services/fxaccounts/FxAccountsConsts.js create mode 100644 services/fxaccounts/FxAccountsManager.jsm diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm index aa900cc49ac..c7efa8429b9 100644 --- a/services/fxaccounts/FxAccounts.jsm +++ b/services/fxaccounts/FxAccounts.jsm @@ -16,18 +16,11 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/FxAccountsClient.jsm"); +Cu.import("resource://gre/modules/FxAccountsConsts.js"); XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", "resource://gre/modules/identity/jwcrypto.jsm"); -const DATA_FORMAT_VERSION = 1; -const DEFAULT_STORAGE_FILENAME = "signedInUser.json"; -const ASSERTION_LIFETIME = 1000 * 60 * 5; // 5 minutes -const KEY_LIFETIME = 1000 * 3600 * 12; // 12 hours -const CERT_LIFETIME = 1000 * 3600 * 6; // 6 hours -const POLL_SESSION = 1000 * 60 * 5; // 5 minutes -const POLL_STEP = 1000 * 3; // 3 seconds - // loglevel preference should be one of: "FATAL", "ERROR", "WARN", "INFO", // "CONFIG", "DEBUG", "TRACE" or "ALL". We will be logging error messages by // default. diff --git a/services/fxaccounts/FxAccountsConsts.js b/services/fxaccounts/FxAccountsConsts.js new file mode 100644 index 00000000000..51e8427b038 --- /dev/null +++ b/services/fxaccounts/FxAccountsConsts.js @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.DATA_FORMAT_VERSION = 1; +this.DEFAULT_STORAGE_FILENAME = "signedInUser.json"; + +// Token life times. +this.ASSERTION_LIFETIME = 1000 * 60 * 5; // 5 minutes +this.CERT_LIFETIME = 1000 * 3600 * 6; // 6 hours +this.KEY_LIFETIME = 1000 * 3600 * 12; // 12 hours + +// Polling timings. +this.POLL_SESSION = 1000 * 60 * 5; // 5 minutes +this.POLL_STEP = 1000 * 3; // 3 seconds + +// Server errno. +// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format +this.ERRNO_ACCOUNT_ALREADY_EXISTS = 101; +this.ERRNO_ACCOUNT_DOES_NOT_EXISTS = 102; +this.ERRNO_INCORRECT_PASSWORD = 103; +this.ERRNO_UNVERIFIED_ACCOUNT = 104; +this.ERRNO_INVALID_VERIFICATION_CODE = 105; +this.ERRNO_NOT_VALID_JSON_BODY = 106; +this.ERRNO_INVALID_BODY_PARAMETERS = 107; +this.ERRNO_MISSING_BODY_PARAMETERS = 108; +this.ERRNO_INVALID_REQUEST_SIGNATURE = 109; +this.ERRNO_INVALID_AUTH_TOKEN = 110; +this.ERRNO_INVALID_AUTH_TIMESTAMP = 111; +this.ERRNO_MISSING_CONTENT_LENGTH = 112; +this.ERRNO_REQUEST_BODY_TOO_LARGE = 113; +this.ERRNO_TOO_MANY_CLIENT_REQUESTS = 114; +this.ERRNO_INVALID_AUTH_NONCE = 115; +this.ERRNO_SERVICE_TEMP_UNAVAILABLE = 201; +this.ERRNO_UNKNOWN_ERROR = 999; + +// Errors. +this.ERROR_ACCOUNT_ALREADY_EXISTS = "ACCOUNT_ALREADY_EXISTS"; +this.ERROR_ACCOUNT_DOES_NOT_EXISTS = "ACCOUNT_DOES_NOT_EXISTS"; +this.ERROR_ALREADY_SIGNED_IN_USER = "ALREADY_SIGNED_IN_USER"; +this.ERROR_INVALID_ACCOUNTID = "INVALID_ACCOUNTID"; +this.ERROR_INVALID_AUDIENCE = "INVALID_AUDIENCE"; +this.ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN"; +this.ERROR_INVALID_AUTH_TIMESTAMP = "INVALID_AUTH_TIMESTAMP"; +this.ERROR_INVALID_AUTH_NONCE = "INVALID_AUTH_NONCE"; +this.ERROR_INVALID_BODY_PARAMETERS = "INVALID_BODY_PARAMETERS"; +this.ERROR_INVALID_PASSWORD = "INVALID_PASSWORD"; +this.ERROR_INVALID_VERIFICATION_CODE = "INVALID_VERIFICATION_CODE"; +this.ERROR_INVALID_REQUEST_SIGNATURE = "INVALID_REQUEST_SIGNATURE"; +this.ERROR_INTERNAL_INVALID_USER = "INTERNAL_ERROR_INVALID_USER"; +this.ERROR_MISSING_BODY_PARAMETERS = "MISSING_BODY_PARAMETERS"; +this.ERROR_MISSING_CONTENT_LENGTH = "MISSING_CONTENT_LENGTH"; +this.ERROR_NO_TOKEN_SESSION = "NO_TOKEN_SESSION"; +this.ERROR_NOT_VALID_JSON_BODY = "NOT_VALID_JSON_BODY"; +this.ERROR_OFFLINE = "OFFLINE"; +this.ERROR_REQUEST_BODY_TOO_LARGE = "REQUEST_BODY_TOO_LARGE"; +this.ERROR_SERVER_ERROR = "SERVER_ERROR"; +this.ERROR_TOO_MANY_CLIENT_REQUESTS = "TOO_MANY_CLIENT_REQUESTS"; +this.ERROR_SERVICE_TEMP_UNAVAILABLE = "SERVICE_TEMPORARY_UNAVAILABLE"; +this.ERROR_UI_ERROR = "UI_ERROR"; +this.ERROR_UNKNOWN = "UNKNOWN_ERROR"; +this.ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT"; + +// Error matching. +this.SERVER_ERRNO_TO_ERROR = {}; +SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_ALREADY_EXISTS] = ERROR_ACCOUNT_ALREADY_EXISTS; +SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXISTS] = ERROR_ACCOUNT_DOES_NOT_EXISTS; +SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_PASSWORD] = ERROR_INVALID_PASSWORD; +SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT] = ERROR_UNVERIFIED_ACCOUNT; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE] = ERROR_INVALID_VERIFICATION_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_NOT_VALID_JSON_BODY] = ERROR_NOT_VALID_JSON_BODY; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_PARAMETERS] = ERROR_INVALID_BODY_PARAMETERS; +SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_BODY_PARAMETERS] = ERROR_MISSING_BODY_PARAMETERS; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE] = ERROR_INVALID_REQUEST_SIGNATURE; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN] = ERROR_INVALID_AUTH_TOKEN; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TIMESTAMP] = ERROR_INVALID_AUTH_TIMESTAMP; +SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONTENT_LENGTH] = ERROR_MISSING_CONTENT_LENGTH; +SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE] = ERROR_REQUEST_BODY_TOO_LARGE; +SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_CLIENT_REQUESTS] = ERROR_TOO_MANY_CLIENT_REQUESTS; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_NONCE] = ERROR_INVALID_AUTH_NONCE; +SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMP_UNAVAILABLE] = ERROR_SERVICE_TEMP_UNAVAILABLE; +SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR] = ERROR_UNKNOWN; + +// Allow this file to be imported via Components.utils.import(). +this.EXPORTED_SYMBOLS = Object.keys(this); diff --git a/services/fxaccounts/FxAccountsManager.jsm b/services/fxaccounts/FxAccountsManager.jsm new file mode 100644 index 00000000000..ea45c30cbb8 --- /dev/null +++ b/services/fxaccounts/FxAccountsManager.jsm @@ -0,0 +1,382 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Temporary abstraction layer for common Fx Accounts operations. + * For now, we will be using this module only from B2G but in the end we might + * want this to be merged with FxAccounts.jsm and let other products also use + * it. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["FxAccountsManager"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/FxAccountsConsts.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsClient", + "resource://gre/modules/FxAccountsClient.jsm"); + +this.FxAccountsManager = { + + // We don't really need to save fxAccounts instance but this way we allow + // to mock FxAccounts from tests. + _fxAccounts: fxAccounts, + + // We keep the session details here so consumers don't need to deal with + // session tokens and are only required to handle the email. + _activeSession: null, + + // We only expose the email and the verified status so far. + get _user() { + if (!this._activeSession || !this._activeSession.email) { + return null; + } + + return { + accountId: this._activeSession.email, + verified: this._activeSession.verified + } + }, + + _getError: function(aServerResponse) { + if (!aServerResponse || !aServerResponse.error || !aServerResponse.error.errno) { + return; + } + return SERVER_ERRNO_TO_ERROR[aServerResponse.error.errno]; + }, + + _serverError: function(aServerResponse) { + let error = this._getError({ error: aServerResponse }); + return Promise.reject({ + error: error ? error : ERROR_SERVER_ERROR, + details: aServerResponse + }); + }, + + // As we do with _fxAccounts, we don't really need this factory, but this way + // we allow tests to mock FxAccountsClient. + _createFxAccountsClient: function() { + return new FxAccountsClient(); + }, + + _signInSignUp: function(aMethod, aAccountId, aPassword) { + if (Services.io.offline) { + return Promise.reject({ + error: ERROR_OFFLINE + }); + } + + if (!aAccountId) { + return Promise.reject({ + error: ERROR_INVALID_ACCOUNTID + }); + } + + if (!aPassword) { + return Promise.reject({ + error: ERROR_INVALID_PASSWORD + }); + } + + // Check that there is no signed in account first. + if (this._activeSession) { + return Promise.reject({ + error: ERROR_ALREADY_SIGNED_IN_USER, + details: { + user: this._user + } + }); + } + + let client = this._createFxAccountsClient(); + return this._fxAccounts.getSignedInUser().then( + user => { + if (user) { + return Promise.reject({ + error: ERROR_ALREADY_SIGNED_IN_USER, + details: { + user: user + } + }); + } + return client[aMethod](aAccountId, aPassword); + } + ).then( + user => { + let error = this._getError(user); + if (!user || !user.uid || !user.sessionToken || error) { + return Promise.reject({ + error: error ? error : ERROR_INTERNAL_INVALID_USER, + details: { + user: user + } + }); + } + + // Save the credentials of the signed in user. + user.email = aAccountId; + return this._fxAccounts.setSignedInUser(user, false).then( + () => { + this._activeSession = user; + return Promise.resolve({ + accountCreated: aMethod === "signUp", + user: this._user + }); + } + ); + }, + reason => { return this._serverError(reason); } + ); + }, + + _getAssertion: function(aAudience) { + return this._fxAccounts.getAssertion(aAudience); + }, + + _signOut: function() { + if (!this._activeSession) { + return Promise.resolve(); + } + + return this._fxAccounts.signOut(this._activeSession.sessionToken).then( + () => { + // If there is no connection, removing the local session should be + // enough. The client can create new sessions up to the limit (100?). + // Orphaned tokens on the server will eventually be garbage collected. + if (Services.io.offline) { + this._activeSession = null; + return Promise.resolve(); + } + // Otherwise, we try to remove the remote session. + let client = this._createFxAccountsClient(); + return client.signOut(this._activeSession.sessionToken).then( + result => { + // Even if there is a remote server error, we remove the local + // session. + this._activeSession = null; + let error = this._getError(result); + if (error) { + return Promise.reject({ + error: error, + details: result + }); + } + return Promise.resolve(); + }, + reason => { + // Even if there is a remote server error, we remove the local + // session. + this._activeSession = null; + return this._serverError(reason); + } + ); + } + ); + }, + + // -- API -- + + signIn: function(aAccountId, aPassword) { + return this._signInSignUp("signIn", aAccountId, aPassword); + }, + + signUp: function(aAccountId, aPassword) { + return this._signInSignUp("signUp", aAccountId, aPassword); + }, + + signOut: function() { + if (!this._activeSession) { + // If there is no cached active session, we try to get it from the + // account storage. + return this.getAccount().then( + result => { + if (!result) { + return Promise.resolve(); + } + return this._signOut(); + } + ); + } + return this._signOut(); + }, + + getAccount: function() { + // We check first if we have session details cached. + if (this._activeSession) { + // If our cache says that the account is not yet verified, we check that + // this information is correct, and update the cached data if not. + if (this._activeSession && !this._activeSession.verified && + !Services.io.offline) { + return this.verificationStatus(this._activeSession); + } + + return Promise.resolve(this._user); + } + + // If no cached information, we try to get it from the persistent storage. + return this._fxAccounts.getSignedInUser().then( + user => { + if (!user || !user.email) { + return Promise.resolve(null); + } + + this._activeSession = user; + // If we get a stored information of a not yet verified account, + // we check this information with the server, update the stored + // data if needed and finally return the account details. + if (!user.verified && !Services.io.offline) { + return this.verificationStatus(user); + } + + return Promise.resolve(this._user); + } + ); + }, + + queryAccount: function(aAccountId) { + if (Services.io.offline) { + return Promise.reject({ + error: ERROR_OFFLINE + }); + } + + let deferred = Promise.defer(); + + if (!aAccountId) { + return Promise.reject({ + error: ERROR_INVALID_ACCOUNTID + }); + } + + let client = this._createFxAccountsClient(); + return client.accountExists(aAccountId).then( + result => { + let error = this._getError(result); + if (error) { + return Promise.reject({ + error: error, + details: result + }); + } + + return Promise.resolve({ + registered: result + }); + }, + reason => { this._serverError(reason); } + ); + }, + + verificationStatus: function() { + if (!this._activeSession || !this._activeSession.sessionToken) { + return Promise.reject({ + error: ERROR_NO_TOKEN_SESSION + }); + } + + // There is no way to unverify an already verified account, so we just + // return the account details of a verified account + if (this._activeSession.verified) { + return Promise.resolve(this._user); + } + + if (Services.io.offline) { + return Promise.reject({ + error: ERROR_OFFLINE + }); + } + + let client = this._createFxAccountsClient(); + return client.recoveryEmailStatus(this._activeSession.sessionToken).then( + data => { + let error = this._getError(data); + if (error) { + return Promise.reject({ + error: error, + details: data + }); + } + + // If the verification status is different from the one that we have + // stored, we update it and return the session data. If not, we simply + // return the session data. + if (this._activeSession.verified != data.verified) { + this._activeSession.verified = data.verified; + return this._fxAccounts.setSignedInUser(this._activeSession).then( + () => { + return Promise.resolve(this._user); + } + ); + } + return Promise.resolve(this._user); + }, + reason => { return this._serverError(reason); } + ); + }, + + getAssertion: function(aAudience) { + if (!aAudience) { + return Promise.reject({ + error: ERROR_INVALID_AUDIENCE + }); + } + + if (Services.io.offline) { + return Promise.reject({ + error: ERROR_OFFLINE + }); + } + + return this.getAccount().then( + user => { + if (user) { + // We cannot get assertions for unverified accounts. + if (user.verified) { + return this._getAssertion(aAudience); + } + + return Promise.reject({ + error: ERROR_UNVERIFIED_ACCOUNT, + details: { + user: user + } + }); + } + + // If there is no currently signed in user, we trigger the signIn UI + // flow. + let ui = Cc["@mozilla.org/fxaccounts/fxaccounts-ui-glue;1"] + .createInstance(Ci.nsIFxAccountsUIGlue); + return ui.signInFlow().then( + result => { + // Even if we get a successful result from the UI, the account will + // most likely be unverified, so we cannot get an assertion. + if (result && result.verified) { + return this._getAssertion(aAudience); + } + return Promise.reject({ + error: ERROR_UNVERIFIED_ACCOUNT, + details: { + user: result + } + }); + }, + error => { + return Promise.reject({ + error: ERROR_UI_ERROR, + details: error + }); + } + ); + } + ); + } +}; diff --git a/services/fxaccounts/moz.build b/services/fxaccounts/moz.build index 0f9e7e3ff30..98741dfd292 100644 --- a/services/fxaccounts/moz.build +++ b/services/fxaccounts/moz.build @@ -5,7 +5,13 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. TEST_DIRS += ['tests'] + EXTRA_JS_MODULES += [ 'FxAccounts.jsm', - 'FxAccountsClient.jsm' + 'FxAccountsClient.jsm', + 'FxAccountsConsts.js' ] + +# For now, we will only be using the FxA manager in B2G. +if CONFIG['MOZ_B2G']: + EXTRA_JS_MODULES += ['FxAccountsManager.jsm']