Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wait for extension unlock before processing eth_requestAccounts #8149

Merged
merged 12 commits into from
Mar 23, 2020
4 changes: 3 additions & 1 deletion app/scripts/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ function setupController (initState, initLangCode) {
controller.encryptionPublicKeyManager.on('updateBadge', updateBadge)
controller.typedMessageManager.on('updateBadge', updateBadge)
controller.permissionsController.permissions.subscribe(updateBadge)
controller.appStateController.on('updateBadge', updateBadge)

/**
* Updates the Web Extension's "badge" number, on the little fox in the toolbar.
Expand All @@ -435,8 +436,9 @@ function setupController (initState, initLangCode) {
const unapprovedEncryptionPublicKeyMsgCount = controller.encryptionPublicKeyManager.unapprovedEncryptionPublicKeyMsgCount
const unapprovedTypedMessagesCount = controller.typedMessageManager.unapprovedTypedMessagesCount
const pendingPermissionRequests = Object.keys(controller.permissionsController.permissions.state.permissionsRequests).length
const waitingForUnlockCount = controller.appStateController.waitingForUnlock.length
const count = unapprovedTxCount + unapprovedMsgCount + unapprovedPersonalMsgCount + unapprovedDecryptMsgCount + unapprovedEncryptionPublicKeyMsgCount +
unapprovedTypedMessagesCount + pendingPermissionRequests
unapprovedTypedMessagesCount + pendingPermissionRequests + waitingForUnlockCount
if (count) {
label = String(count)
}
Expand Down
47 changes: 45 additions & 2 deletions app/scripts/controllers/app-state.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,71 @@
import ObservableStore from 'obs-store'
import EventEmitter from 'events'

class AppStateController {
class AppStateController extends EventEmitter {
/**
* @constructor
* @param opts
*/
constructor (opts = {}) {
const { initState, onInactiveTimeout, preferencesStore } = opts
const {
addUnlockListener,
isUnlocked,
initState,
onInactiveTimeout,
preferencesStore,
} = opts
const { preferences } = preferencesStore.getState()

super()

this.onInactiveTimeout = onInactiveTimeout || (() => {})
this.store = new ObservableStore(Object.assign({
timeoutMinutes: 0,
mkrMigrationReminderTimestamp: null,
}, initState))
this.timer = null

this.isUnlocked = isUnlocked
this.waitingForUnlock = []
addUnlockListener(this.handleUnlock.bind(this))

preferencesStore.subscribe((state) => {
this._setInactiveTimeout(state.preferences.autoLockTimeLimit)
})

this._setInactiveTimeout(preferences.autoLockTimeLimit)
}

/**
* Get a Promise that resolves when the extension is unlocked.
* This Promise will never reject.
*
* @returns {Promise<void>} A promise that resolves when the extension is
* unlocked, or immediately if the extension is already unlocked.
*/
getUnlockPromise () {
return new Promise((resolve) => {
if (this.isUnlocked()) {
resolve()
} else {
this.waitingForUnlock.push({ resolve })
this.emit('updateBadge')
}
})
}

/**
* Drains the waitingForUnlock queue, resolving all the related Promises.
*/
handleUnlock () {
if (this.waitingForUnlock.length > 0) {
while (this.waitingForUnlock.length > 0) {
this.waitingForUnlock.shift().resolve()
}
this.emit('updateBadge')
}
}

setMkrMigrationReminderTimestamp (timestamp) {
this.store.updateState({
mkrMigrationReminderTimestamp: timestamp,
Expand Down
25 changes: 22 additions & 3 deletions app/scripts/controllers/permissions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ export class PermissionsController {

constructor (
{
platform, notifyDomain, notifyAllDomains,
getKeyringAccounts, getRestrictedMethods,
getKeyringAccounts,
getRestrictedMethods,
getUnlockPromise,
notifyDomain,
notifyAllDomains,
platform,
} = {},
restoredPermissions = {},
restoredState = {}) {
Expand All @@ -36,10 +40,12 @@ export class PermissionsController {
[HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {},
})

this.getKeyringAccounts = getKeyringAccounts
this.getUnlockPromise = getUnlockPromise
this._notifyDomain = notifyDomain
this.notifyAllDomains = notifyAllDomains
this.getKeyringAccounts = getKeyringAccounts
this._platform = platform

this._restrictedMethods = getRestrictedMethods(this)
this.permissionsLog = new PermissionsLogController({
restrictedMethods: Object.keys(this._restrictedMethods),
Expand Down Expand Up @@ -73,6 +79,8 @@ export class PermissionsController {
store: this.store,
storeKey: METADATA_STORE_KEY,
getAccounts: this.getAccounts.bind(this, origin),
getUnlockPromise: this.getUnlockPromise,
hasPermission: this.hasPermission.bind(this, origin),
requestAccountsPermission: this._requestPermissions.bind(
this, origin, { eth_accounts: {} }
),
Expand Down Expand Up @@ -111,6 +119,17 @@ export class PermissionsController {
})
}

/**
* Returns whether the given origin has the given permission.
*
* @param {string} origin - The origin to check.
* @param {string} permission - The permission to check for.
* @returns {boolean} Whether the origin has the permission.
*/
hasPermission (origin, permission) {
return Boolean(this.permissions.getPermission(origin, permission))
}

/**
* Submits a permissions request to rpc-cap. Internal, background use only.
*
Expand Down
23 changes: 22 additions & 1 deletion app/scripts/controllers/permissions/methodMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ import { ethErrors } from 'eth-json-rpc-errors'
* Create middleware for handling certain methods and preprocessing permissions requests.
*/
export default function createMethodMiddleware ({
store, storeKey, getAccounts, requestAccountsPermission,
getAccounts,
getUnlockPromise,
hasPermission,
requestAccountsPermission,
store,
storeKey,
}) {

let isProcessingRequestAccounts = false

return createAsyncMiddleware(async (req, res, next) => {

switch (req.method) {
Expand All @@ -21,6 +29,19 @@ export default function createMethodMiddleware ({

case 'eth_requestAccounts':

if (isProcessingRequestAccounts) {
res.error = ethErrors.rpc.resourceUnavailable(
'Already processing eth_requestAccounts. Please wait.'
Gudahtt marked this conversation as resolved.
Show resolved Hide resolved
)
return
}

if (hasPermission('eth_accounts')) {
isProcessingRequestAccounts = true
await getUnlockPromise()
isProcessingRequestAccounts = false
}

// first, just try to get accounts
let accounts = await getAccounts()
if (accounts.length > 0) {
Expand Down
39 changes: 23 additions & 16 deletions app/scripts/metamask-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ export default class MetamaskController extends EventEmitter {
})

this.appStateController = new AppStateController({
preferencesStore: this.preferencesController.store,
onInactiveTimeout: () => this.setLocked(),
addUnlockListener: this.on.bind(this, 'unlock'),
isUnlocked: this.isUnlocked.bind(this),
initState: initState.AppStateController,
onInactiveTimeout: () => this.setLocked(),
preferencesStore: this.preferencesController.store,
})

this.currencyRateController = new CurrencyRateController(undefined, initState.CurrencyController)
Expand Down Expand Up @@ -206,13 +208,15 @@ export default class MetamaskController extends EventEmitter {
encryptor: opts.encryptor || undefined,
})
this.keyringController.memStore.subscribe((s) => this._onKeyringControllerUpdate(s))
this.keyringController.on('unlock', () => this.emit('unlock'))
rekmarks marked this conversation as resolved.
Show resolved Hide resolved

this.permissionsController = new PermissionsController({
getKeyringAccounts: this.keyringController.getAccounts.bind(this.keyringController),
platform: opts.platform,
getRestrictedMethods,
getUnlockPromise: this.appStateController.getUnlockPromise.bind(this.appStateController),
notifyDomain: this.notifyConnections.bind(this),
notifyAllDomains: this.notifyAllConnections.bind(this),
getRestrictedMethods,
platform: opts.platform,
}, initState.PermissionsController, initState.PermissionsMetadata)

this.detectTokensController = new DetectTokensController({
Expand Down Expand Up @@ -352,9 +356,7 @@ export default class MetamaskController extends EventEmitter {
if (origin === 'metamask') {
const selectedAddress = this.preferencesController.getSelectedAddress()
return selectedAddress ? [selectedAddress] : []
} else if (
this.keyringController.memStore.getState().isUnlocked
) {
} else if (this.isUnlocked()) {
rekmarks marked this conversation as resolved.
Show resolved Hide resolved
return await this.permissionsController.getAccounts(origin)
}
return [] // changing this is a breaking change
Expand Down Expand Up @@ -613,11 +615,11 @@ export default class MetamaskController extends EventEmitter {
this.preferencesController.setAddresses(accounts)
this.selectFirstIdentity()
}
releaseLock()
return vault
} catch (err) {
releaseLock()
throw err
} finally {
releaseLock()
}
}

Expand Down Expand Up @@ -657,11 +659,11 @@ export default class MetamaskController extends EventEmitter {
// set new identities
this.preferencesController.setAddresses(accounts)
this.selectFirstIdentity()
releaseLock()
return vault
} catch (err) {
releaseLock()
throw err
} finally {
releaseLock()
}
}

Expand Down Expand Up @@ -1730,9 +1732,8 @@ export default class MetamaskController extends EventEmitter {
*/
notifyConnections (origin, payload) {

const { isUnlocked } = this.getState()
const connections = this.connections[origin]
if (!isUnlocked || !connections) {
if (!this.isUnlocked() || !connections) {
return
}

Expand All @@ -1750,8 +1751,7 @@ export default class MetamaskController extends EventEmitter {
*/
notifyAllConnections (payload) {

const { isUnlocked } = this.getState()
if (!isUnlocked) {
if (!this.isUnlocked()) {
return
}

Expand Down Expand Up @@ -1793,6 +1793,13 @@ export default class MetamaskController extends EventEmitter {
this.emit('update', this.getState())
}

/**
* @returns {boolean} Whether the extension is unlocked.
*/
isUnlocked () {
return this.keyringController.memStore.getState().isUnlocked
}

//=============================================================================
// MISCELLANEOUS
//=============================================================================
Expand Down Expand Up @@ -2083,7 +2090,7 @@ export default class MetamaskController extends EventEmitter {
*/
set isClientOpen (open) {
this._isClientOpen = open
this.isClientOpenAndUnlocked = this.getState().isUnlocked && open
this.isClientOpenAndUnlocked = this.isUnlocked() && open
this.detectTokensController.isOpen = open
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"eth-json-rpc-filters": "^4.1.1",
"eth-json-rpc-infura": "^4.0.2",
"eth-json-rpc-middleware": "^4.4.1",
"eth-keyring-controller": "^5.5.0",
"eth-keyring-controller": "^5.6.0",
"eth-method-registry": "^1.2.0",
"eth-phishing-detect": "^1.1.4",
"eth-query": "^2.1.2",
Expand Down
13 changes: 12 additions & 1 deletion test/unit/app/controllers/permissions/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const getRestrictedMethods = (permController) => {
}
}

const getUnlockPromise = () => Promise.resolve()

/**
* Gets default mock constructor options for a permissions controller.
*
Expand All @@ -67,9 +69,10 @@ export function getPermControllerOpts () {
return {
platform,
getKeyringAccounts,
getUnlockPromise,
getRestrictedMethods,
notifyDomain: noop,
notifyAllDomains: noop,
getRestrictedMethods,
}
}

Expand Down Expand Up @@ -398,6 +401,14 @@ export const getters = deepFreeze({
}
},
},

eth_requestAccounts: {
requestAlreadyPending: () => {
return {
message: 'Already processing eth_requestAccounts. Please wait.',
}
},
},
},

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,49 @@ describe('permissions controller', function () {
})
})

describe('hasPermission', function () {

it('returns correct values', async function () {

const permController = initPermController()
grantPermissions(
permController, ORIGINS.a,
PERMS.finalizedRequests.eth_accounts(ACCOUNT_ARRAYS.a)
)
grantPermissions(
permController, ORIGINS.b,
PERMS.finalizedRequests.test_method()
)

assert.ok(
permController.hasPermission(ORIGINS.a, 'eth_accounts'),
'should return true for granted permission'
)
assert.ok(
permController.hasPermission(ORIGINS.b, 'test_method'),
'should return true for granted permission'
)

assert.ok(
!permController.hasPermission(ORIGINS.a, 'test_method'),
'should return false for non-granted permission'
)
assert.ok(
!permController.hasPermission(ORIGINS.b, 'eth_accounts'),
'should return true for non-granted permission'
)

assert.ok(
!permController.hasPermission('foo', 'eth_accounts'),
'should return false for unknown origin'
)
assert.ok(
!permController.hasPermission(ORIGINS.b, 'foo'),
'should return false for unknown permission'
)
})
})

describe('clearPermissions', function () {

it('notifies all appropriate domains and removes permissions', async function () {
Expand Down
Loading