diff options
Diffstat (limited to 'services/fxaccounts/FxAccountsManager.jsm')
-rw-r--r-- | services/fxaccounts/FxAccountsManager.jsm | 654 |
1 files changed, 654 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsManager.jsm b/services/fxaccounts/FxAccountsManager.jsm new file mode 100644 index 000000000..680310ff5 --- /dev/null +++ b/services/fxaccounts/FxAccountsManager.jsm @@ -0,0 +1,654 @@ +/* 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/FxAccountsCommon.js"); + +XPCOMUtils.defineLazyServiceGetter(this, "permissionManager", + "@mozilla.org/permissionmanager;1", + "nsIPermissionManager"); + +this.FxAccountsManager = { + + init: function() { + Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false); + Services.obs.addObserver(this, ON_FXA_UPDATE_NOTIFICATION, false); + }, + + observe: function(aSubject, aTopic, aData) { + // Both topics indicate our cache is invalid + this._activeSession = null; + + if (aData == ONVERIFIED_NOTIFICATION) { + log.debug("FxAccountsManager: cache cleared, broadcasting: " + aData); + Services.obs.notifyObservers(null, aData, null); + } + }, + + // 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, + + // Are we refreshing our authentication? If so, allow attempts to sign in + // while we are already signed in. + _refreshing: false, + + // We only expose the email and the verified status so far. + get _user() { + if (!this._activeSession || !this._activeSession.email) { + return null; + } + + return { + email: this._activeSession.email, + verified: this._activeSession.verified, + profile: this._activeSession.profile, + } + }, + + _error: function(aError, aDetails) { + log.error(aError); + let reason = { + error: aError + }; + if (aDetails) { + reason.details = aDetails; + } + return Promise.reject(reason); + }, + + _getError: function(aServerResponse) { + if (!aServerResponse || !aServerResponse.error || !aServerResponse.error.errno) { + return; + } + let error = SERVER_ERRNO_TO_ERROR[aServerResponse.error.errno]; + return error; + }, + + _serverError: function(aServerResponse) { + let error = this._getError({ error: aServerResponse }); + return this._error(error ? error : ERROR_SERVER_ERROR, aServerResponse); + }, + + // As with _fxAccounts, we don't really need this method, but this way we + // allow tests to mock FxAccountsClient. By default, we want to return the + // client used by the fxAccounts object because deep down they should have + // access to the same hawk request object which will enable them to share + // local clock skeq data. + _getFxAccountsClient: function() { + return this._fxAccounts.getAccountsClient(); + }, + + _signInSignUp: function(aMethod, aEmail, aPassword, aFetchKeys) { + if (Services.io.offline) { + return this._error(ERROR_OFFLINE); + } + + if (!aEmail) { + return this._error(ERROR_INVALID_EMAIL); + } + + if (!aPassword) { + return this._error(ERROR_INVALID_PASSWORD); + } + + // Check that there is no signed in account first. + if ((!this._refreshing) && this._activeSession) { + return this._error(ERROR_ALREADY_SIGNED_IN_USER, { + user: this._user + }); + } + + let client = this._getFxAccountsClient(); + return this._fxAccounts.getSignedInUser().then( + user => { + if ((!this._refreshing) && user) { + return this._error(ERROR_ALREADY_SIGNED_IN_USER, { + user: this._user + }); + } + return client[aMethod](aEmail, aPassword, aFetchKeys); + } + ).then( + user => { + let error = this._getError(user); + if (!user || !user.uid || !user.sessionToken || error) { + return this._error(error ? error : ERROR_INTERNAL_INVALID_USER, { + user: user + }); + } + + // If the user object includes an email field, it may differ in + // capitalization from what we sent down. This is the server's + // canonical capitalization and should be used instead. + user.email = user.email || aEmail; + + // If we're using server-side sign to refreshAuthentication + // we don't need to update local state; also because of two + // interacting glitches we need to bypass an event emission. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1031580 + if (this._refreshing) { + return Promise.resolve({user: this._user}); + } + + return this._fxAccounts.setSignedInUser(user).then( + () => { + this._activeSession = user; + log.debug("User signed in: " + JSON.stringify(this._user) + + " - Account created " + (aMethod == "signUp")); + + // There is no way to obtain the key fetch token afterwards + // without login out the user and asking her to log in again. + // Also, key fetch tokens are designed to be short-lived, so + // we need to fetch kB as soon as we have the key fetch token. + if (aFetchKeys) { + this._fxAccounts.getKeys(); + } + + return this._fxAccounts.getSignedInUserProfile().catch(error => { + // Not fetching the profile is sad but the FxA logs will already + // have noise. + return null; + }); + } + ).then(profile => { + if (profile) { + this._activeSession.profile = profile; + } + + return Promise.resolve({ + accountCreated: aMethod === "signUp", + user: this._user + }); + }); + }, + reason => { return this._serverError(reason); } + ); + }, + + /** + * Determine whether the incoming error means that the current account + * has new server-side state via deletion or password change, and if so, + * spawn the appropriate UI (sign in or refresh); otherwise re-reject. + * + * As of May 2014, the only HTTP call triggered by this._getAssertion() + * is to /certificate/sign via: + * FxAccounts.getAssertion() + * FxAccountsInternal.getCertificateSigned() + * FxAccountsClient.signCertificate() + * See the latter method for possible (error code, errno) pairs. + */ + _handleGetAssertionError: function(reason, aAudience, aPrincipal) { + log.debug("FxAccountsManager._handleGetAssertionError()"); + let errno = (reason ? reason.errno : NaN) || NaN; + // If the previously valid email/password pair is no longer valid ... + if (errno == ERRNO_INVALID_AUTH_TOKEN) { + return this._fxAccounts.accountStatus().then( + (exists) => { + // ... if the email still maps to an account, the password + // must have changed, so ask the user to enter the new one ... + if (exists) { + return this.getAccount().then( + (user) => { + return this._refreshAuthentication(aAudience, user.email, + aPrincipal, + true /* logoutOnFailure */); + } + ); + } + // ... otherwise, the account was deleted, so ask for Sign In/Up + return this._localSignOut().then( + () => { + return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience, + aPrincipal); + }, + (reason) => { + // reject primary problem, not signout failure + log.error("Signing out in response to server error threw: " + + reason); + return this._error(reason); + } + ); + } + ); + } + return Promise.reject(reason.message ? { error: reason.message } : reason); + }, + + _getAssertion: function(aAudience, aPrincipal) { + return this._fxAccounts.getAssertion(aAudience).then( + (result) => { + if (aPrincipal) { + this._addPermission(aPrincipal); + } + return result; + }, + (reason) => { + return this._handleGetAssertionError(reason, aAudience, aPrincipal); + } + ); + }, + + /** + * "Refresh authentication" means: + * Interactively demonstrate knowledge of the FxA password + * for the currently logged-in account. + * There are two very different scenarios: + * 1) The password has changed on the server. Failure should log + * the current account OUT. + * 2) The person typing can't prove knowledge of the password used + * to log in. Failure should do nothing. + */ + _refreshAuthentication: function(aAudience, aEmail, aPrincipal, + logoutOnFailure=false) { + this._refreshing = true; + return this._uiRequest(UI_REQUEST_REFRESH_AUTH, + aAudience, aPrincipal, aEmail).then( + (assertion) => { + this._refreshing = false; + return assertion; + }, + (reason) => { + this._refreshing = false; + if (logoutOnFailure) { + return this._signOut().then( + () => { + return this._error(reason); + } + ); + } + return this._error(reason); + } + ); + }, + + _localSignOut: function() { + return this._fxAccounts.signOut(true); + }, + + _signOut: function() { + if (!this._activeSession) { + return Promise.resolve(); + } + + // We clear the local session cache as soon as we get the onlogout + // notification triggered within FxAccounts.signOut, so we save the + // session token value to be able to remove the remote server session + // in case that we have network connection. + let sessionToken = this._activeSession.sessionToken; + + return this._localSignOut().then( + () => { + // At this point the local session should already be removed. + + // 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) { + return Promise.resolve(); + } + // Otherwise, we try to remove the remote session. + let client = this._getFxAccountsClient(); + return client.signOut(sessionToken).then( + result => { + let error = this._getError(result); + if (error) { + return this._error(error, result); + } + log.debug("Signed out"); + return Promise.resolve(); + }, + reason => { + return this._serverError(reason); + } + ); + } + ); + }, + + _uiRequest: function(aRequest, aAudience, aPrincipal, aParams) { + if (Services.io.offline) { + return this._error(ERROR_OFFLINE); + } + let ui = Cc["@mozilla.org/fxaccounts/fxaccounts-ui-glue;1"] + .createInstance(Ci.nsIFxAccountsUIGlue); + if (!ui[aRequest]) { + return this._error(ERROR_UI_REQUEST); + } + + if (!aParams || !Array.isArray(aParams)) { + aParams = [aParams]; + } + + return ui[aRequest].apply(this, aParams).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, aPrincipal); + } + + return this._error(ERROR_UNVERIFIED_ACCOUNT, { + user: result + }); + }, + error => { + return this._error(ERROR_UI_ERROR, error); + } + ); + }, + + _addPermission: function(aPrincipal) { + // This will fail from tests cause we are running them in the child + // process until we have chrome tests in b2g. Bug 797164. + try { + permissionManager.addFromPrincipal(aPrincipal, FXACCOUNTS_PERMISSION, + Ci.nsIPermissionManager.ALLOW_ACTION); + } catch (e) { + log.warn("Could not add permission " + e); + } + }, + + // -- API -- + + signIn: function(aEmail, aPassword, aFetchKeys) { + return this._signInSignUp("signIn", aEmail, aPassword, aFetchKeys); + }, + + signUp: function(aEmail, aPassword, aFetchKeys) { + return this._signInSignUp("signUp", aEmail, aPassword, aFetchKeys); + }, + + 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(); + }, + + resendVerificationEmail: function() { + return this._fxAccounts.resendVerificationEmail().then( + (result) => { + return result; + }, + (error) => { + return this._error(ERROR_SERVER_ERROR, error); + } + ); + }, + + 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 kick off verification before returning what we have. + if (!this._activeSession.verified) { + this.verificationStatus(this._activeSession); + } + log.debug("Account " + JSON.stringify(this._user)); + 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) { + log.debug("No signed in account"); + return Promise.resolve(null); + } + + this._activeSession = user; + // If we get a stored information of a not yet verified account, + // we kick off verification before returning what we have. + if (!user.verified) { + this.verificationStatus(user); + // Trying to get the profile for unverified users will fail, so we + // don't even try in that case. + log.debug("Account ", this._user); + return Promise.resolve(this._user); + } + + return this._fxAccounts.getSignedInUserProfile().then(profile => { + if (profile) { + this._activeSession.profile = profile; + } + log.debug("Account ", this._user); + return Promise.resolve(this._user); + }).catch(error => { + // FxAccounts logs already inform about the error. + log.debug("Account ", this._user); + return Promise.resolve(this._user); + }); + } + ); + }, + + queryAccount: function(aEmail) { + log.debug("queryAccount " + aEmail); + if (Services.io.offline) { + return this._error(ERROR_OFFLINE); + } + + let deferred = Promise.defer(); + + if (!aEmail) { + return this._error(ERROR_INVALID_EMAIL); + } + + let client = this._getFxAccountsClient(); + return client.accountExists(aEmail).then( + result => { + log.debug("Account " + result ? "" : "does not" + " exists"); + let error = this._getError(result); + if (error) { + return this._error(error, result); + } + + return Promise.resolve({ + registered: result + }); + }, + reason => { this._serverError(reason); } + ); + }, + + verificationStatus: function() { + log.debug("verificationStatus"); + if (!this._activeSession || !this._activeSession.sessionToken) { + this._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) { + log.debug("Account already verified"); + return; + } + + if (Services.io.offline) { + log.warn("Offline; skipping verification."); + return; + } + + let client = this._getFxAccountsClient(); + client.recoveryEmailStatus(this._activeSession.sessionToken).then( + data => { + let error = this._getError(data); + if (error) { + this._error(error, data); + } + // If the verification status has changed, update state. + if (this._activeSession.verified != data.verified) { + this._activeSession.verified = data.verified; + this._fxAccounts.setSignedInUser(this._activeSession); + this._fxAccounts.getSignedInUserProfile().then(profile => { + if (profile) { + this._activeSession.profile = profile; + } + }).catch(error => { + // FxAccounts logs already inform about the error. + }); + } + log.debug(JSON.stringify(this._user)); + }, + reason => { this._serverError(reason); } + ); + }, + + /* + * Try to get an assertion for the given audience. Here we implement + * the heart of the response to navigator.mozId.request() on device. + * (We can also be called via the IAC API, but it's request() that + * makes this method complex.) The state machine looks like this, + * ignoring simple errors: + * If no one is signed in, and we aren't suppressing the UI: + * trigger the sign in flow. + * else if we were asked to refresh and the grace period is up: + * trigger the refresh flow. + * else: + * request user permission to share an assertion if we don't have it + * already and ask the core code for an assertion, which might itself + * trigger either the sign in or refresh flows (if our account + * changed on the server). + * + * aOptions can include: + * refreshAuthentication - (bool) Force re-auth. + * silent - (bool) Prevent any UI interaction. + * I.e., try to get an automatic assertion. + */ + getAssertion: function(aAudience, aPrincipal, aOptions) { + if (!aAudience) { + return this._error(ERROR_INVALID_AUDIENCE); + } + + let principal = aPrincipal; + log.debug("FxAccountsManager.getAssertion() aPrincipal: ", + principal.origin, principal.appId, + principal.isInIsolatedMozBrowserElement); + + return this.getAccount().then( + user => { + if (user) { + // Three have-user cases to consider. First: are we unverified? + if (!user.verified) { + return this._error(ERROR_UNVERIFIED_ACCOUNT, { + user: user + }); + } + // Second case: do we need to refresh? + if (aOptions && + (typeof(aOptions.refreshAuthentication) != "undefined")) { + let gracePeriod = aOptions.refreshAuthentication; + if (typeof(gracePeriod) !== "number" || isNaN(gracePeriod)) { + return this._error(ERROR_INVALID_REFRESH_AUTH_VALUE); + } + // Forcing refreshAuth to silent is a contradiction in terms, + // though it might succeed silently if we didn't reject here. + if (aOptions.silent) { + return this._error(ERROR_NO_SILENT_REFRESH_AUTH); + } + let secondsSinceAuth = (Date.now() / 1000) - + this._activeSession.authAt; + if (secondsSinceAuth > gracePeriod) { + return this._refreshAuthentication(aAudience, user.email, + principal, + false /* logoutOnFailure */); + } + } + // Third case: we are all set *locally*. Probably we just return + // the assertion, but the attempt might lead to the server saying + // we are deleted or have a new password, which will trigger a flow. + // Also we need to check if we have permission to get the assertion, + // otherwise we need to show the forceAuth UI to let the user know + // that the RP with no fxa permissions is trying to obtain an + // assertion. Once the user authenticates herself in the forceAuth UI + // the permission will be remembered by default. + let permission = permissionManager.testPermissionFromPrincipal( + principal, + FXACCOUNTS_PERMISSION + ); + if (permission == Ci.nsIPermissionManager.PROMPT_ACTION && + !this._refreshing) { + return this._refreshAuthentication(aAudience, user.email, + principal, + false /* logoutOnFailure */); + } else if (permission == Ci.nsIPermissionManager.DENY_ACTION && + !this._refreshing) { + return this._error(ERROR_PERMISSION_DENIED); + } else if (this._refreshing) { + // If we are blocked asking for a password we should not continue + // the getAssertion process. + return Promise.resolve(null); + } + return this._getAssertion(aAudience, principal); + } + log.debug("No signed in user"); + if (aOptions && aOptions.silent) { + return Promise.resolve(null); + } + return this._uiRequest(UI_REQUEST_SIGN_IN_FLOW, aAudience, principal); + } + ); + }, + + getKeys: function() { + let syncEnabled = false; + try { + syncEnabled = Services.prefs.getBoolPref("services.sync.enabled"); + } catch(e) { + dump("Sync is disabled, so you won't get the keys. " + e + "\n"); + } + + if (!syncEnabled) { + return Promise.reject(ERROR_SYNC_DISABLED); + } + + return this.getAccount().then( + user => { + if (!user) { + log.debug("No signed in user"); + return Promise.resolve(null); + } + + if (!user.verified) { + return this._error(ERROR_UNVERIFIED_ACCOUNT, { + user: user + }); + } + + return this._fxAccounts.getKeys(); + } + ); + } +}; + +FxAccountsManager.init(); |