diff options
| author | Erik Larkö <erik@mullvad.net> | 2017-10-16 12:45:19 +0200 |
|---|---|---|
| committer | Erik Larkö <erik@mullvad.net> | 2017-10-16 12:45:19 +0200 |
| commit | 21ef1e1be95518530ef69572fa14f9b09e33475b (patch) | |
| tree | 71a59da8aedaa09c15d5c9e34e4cdc55c72ba8ed | |
| parent | fa39927850f5147545afe42e2560c0ce094092f6 (diff) | |
| parent | 5c70a74bd6fd94d6c1a71b327d02012e85198aeb (diff) | |
| download | mullvadvpn-21ef1e1be95518530ef69572fa14f9b09e33475b.tar.xz mullvadvpn-21ef1e1be95518530ef69572fa14f9b09e33475b.zip | |
Merge branch 'websocket-auth'
| -rw-r--r-- | app/app.js | 2 | ||||
| -rw-r--r-- | app/lib/backend.js | 295 | ||||
| -rw-r--r-- | app/lib/ipc-facade.js | 11 | ||||
| -rw-r--r-- | app/lib/jsonrpc-ws-ipc.js | 9 | ||||
| -rw-r--r-- | app/main.js | 19 | ||||
| -rw-r--r-- | test/auth.spec.js | 56 | ||||
| -rw-r--r-- | test/connect.spec.js | 30 | ||||
| -rw-r--r-- | test/helpers/IpcChain.js | 19 | ||||
| -rw-r--r-- | test/helpers/ipc-helpers.js | 26 | ||||
| -rw-r--r-- | test/mocks/ipc.js | 12 |
10 files changed, 349 insertions, 130 deletions
diff --git a/app/app.js b/app/app.js index 4886e42527..c0682343c3 100644 --- a/app/app.js +++ b/app/app.js @@ -24,7 +24,7 @@ const store = configureStore(initialState, memoryHistory); ////////////////////////////////////////////////////////////////////////// const backend = new Backend(store); ipcRenderer.on('backend-info', (_event, args) => { - backend.setLocation(args.addr); + backend.setCredentials(args.credentials); backend.sync(); backend.autologin() .catch( e => { diff --git a/app/lib/backend.js b/app/lib/backend.js index e159ca51df..b7f3f3e489 100644 --- a/app/lib/backend.js +++ b/app/lib/backend.js @@ -1,6 +1,7 @@ // @flow -import log from 'electron-log'; +//import log from 'electron-log'; +const log = console; import EventEmitter from 'events'; import { servers } from '../config'; import { IpcFacade, RealIpc } from './ipc-facade'; @@ -9,9 +10,8 @@ import connectionActions from '../redux/connection/actions'; import type { ReduxStore } from '../redux/store'; import { push } from 'react-router-redux'; -import type { BackendState } from './ipc-facade'; +import type { BackendState, RelayEndpoint } from './ipc-facade'; import type { ConnectionState } from '../redux/connection/reducers'; -import type { RelayEndpoint } from './ipc-facade'; export type EventType = 'connect' | 'connecting' | 'disconnect' | 'login' | 'logging' | 'logout' | 'updatedIp' | 'updatedLocation' | 'updatedReachability'; export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT' | 'NO_ACCOUNT'; @@ -66,31 +66,66 @@ export class BackendError extends Error { } + +export type IpcCredentials = { + connectionString: string, + sharedSecret: string, +}; +export function parseIpcCredentials(data: string): ?IpcCredentials { + const [connectionString, sharedSecret] = data.split('\n', 2); + if(connectionString && sharedSecret) { + return { + connectionString, + sharedSecret, + }; + } else { + return null; + } +} + + /** * Backend implementation */ export class Backend { _ipc: IpcFacade; + _credentials: ?IpcCredentials; + _authenticationPromise: ?Promise<void>; _store: ReduxStore; _eventEmitter = new EventEmitter(); - constructor(store: ReduxStore, ipc: ?IpcFacade) { + constructor(store: ReduxStore, credentials?: IpcCredentials, ipc: ?IpcFacade) { this._store = store; + this._credentials = credentials; + + if(ipc) { this._ipc = ipc; + + // force to re-authenticate when connection closed + this._ipc.setCloseConnectionHandler(() => { + this._authenticationPromise = null; + }); + this._registerIpcListeners(); this._startReachability(); } } - setLocation(loc: string) { - log.info('Got connection info to backend', loc); + setCredentials(credentials: IpcCredentials) { + log.info('Got connection info to backend', credentials.connectionString); + this._credentials = credentials; if (this._ipc) { - this._ipc.setConnectionString(loc); + this._credentials = credentials; } else { - this._ipc = new RealIpc(loc); + this._ipc = new RealIpc(credentials.connectionString); + + // force to re-authenticate when connection closed + this._ipc.setCloseConnectionHandler(() => { + this._authenticationPromise = null; + }); } this._registerIpcListeners(); } @@ -98,27 +133,33 @@ export class Backend { sync() { log.info('Syncing with the backend...'); - this._ipc.getIp() - .then( ip => { - log.info('Got ip', ip); - this._store.dispatch(connectionActions.newPublicIp(ip)); - }) - .catch(e => { - log.info('Failed syncing with the backend,', e.message); + this._ensureAuthenticated() + .then( () => { + this._ipc.getIp() + .then( ip => { + log.info('Got ip', ip); + this._store.dispatch(connectionActions.newPublicIp(ip)); + }) + .catch(e => { + log.info('Failed syncing with the backend,', e.message); + }); }); - this._ipc.getLocation() - .then( location => { - log.info('Got location', location); - const newLocation = { - location: location.latlong, - country: location.country, - city: location.city - }; - this._store.dispatch(connectionActions.newLocation(newLocation)); - }) - .catch(e => { - log.info('Failed getting new location,', e.message); + this._ensureAuthenticated() + .then( () => { + this._ipc.getLocation() + .then( location => { + log.info('Got location', location); + const newLocation = { + location: location.latlong, + country: location.country, + city: location.city + }; + this._store.dispatch(connectionActions.newLocation(newLocation)); + }) + .catch(e => { + log.info('Failed getting new location,', e.message); + }); }); } @@ -131,30 +172,33 @@ export class Backend { this._store.dispatch(accountActions.startLogin(accountToken)); - return this._ipc.getAccountData(accountToken) - .then( response => { - log.info('Account exists', response); + return this._ensureAuthenticated() + .then( () => { + return this._ipc.getAccountData(accountToken) + .then( response => { + log.info('Account exists', response); - return this._ipc.setAccount(accountToken) - .then( () => response ); + return this._ipc.setAccount(accountToken) + .then( () => response ); - }).then( accountData => { - log.info('Log in complete'); + }).then( accountData => { + log.info('Log in complete'); - this._store.dispatch(accountActions.loginSuccessful(accountData.expiry)); + this._store.dispatch(accountActions.loginSuccessful(accountData.expiry)); - // Redirect the user after some time to allow for - // the 'Login Successful' screen to be visible - setTimeout(() => { - this._store.dispatch(push('/connect')); - this.connect(); - }, 1000); - }).catch(e => { - log.error('Failed to log in,', e.message); + // Redirect the user after some time to allow for + // the 'Login Successful' screen to be visible + setTimeout(() => { + this._store.dispatch(push('/connect')); + this.connect(); + }, 1000); + }).catch(e => { + log.error('Failed to log in,', e.message); - // TODO: This is not true. If there is a communication link failure the promise will be rejected too - const err = new BackendError('INVALID_ACCOUNT'); - this._store.dispatch(accountActions.loginFailed(err)); + // TODO: This is not true. If there is a communication link failure the promise will be rejected too + const err = new BackendError('INVALID_ACCOUNT'); + this._store.dispatch(accountActions.loginFailed(err)); + }); }); } @@ -163,49 +207,55 @@ export class Backend { this._store.dispatch(accountActions.startLogin()); - return this._ipc.getAccount() - .then( accountToken => { - if (!accountToken) { - throw new BackendError('NO_ACCOUNT'); - } - log.debug('The backend had an account number stored:', accountToken); - this._store.dispatch(accountActions.startLogin(accountToken)); + return this._ensureAuthenticated() + .then( () => { + return this._ipc.getAccount() + .then( accountToken => { + if (!accountToken) { + throw new BackendError('NO_ACCOUNT'); + } + log.debug('The backend had an account number stored:', accountToken); + this._store.dispatch(accountActions.startLogin(accountToken)); - return this._ipc.getAccountData(accountToken); - }) - .then( accountData => { - log.info('The stored account number still exists', accountData); + return this._ipc.getAccountData(accountToken); + }) + .then( accountData => { + log.info('The stored account number still exists', accountData); - this._store.dispatch(accountActions.loginSuccessful(accountData.expiry)); + this._store.dispatch(accountActions.loginSuccessful(accountData.expiry)); - this._store.dispatch(push('/connect')); - this.connect(); - }) - .catch( e => { - log.warn('Unable to autologin,', e.message); + this._store.dispatch(push('/connect')); + this.connect(); + }) + .catch( e => { + log.warn('Unable to autologin,', e.message); - this._store.dispatch(accountActions.autoLoginFailed()); - this._store.dispatch(push('/')); + this._store.dispatch(accountActions.autoLoginFailed()); + this._store.dispatch(push('/')); - throw e; + throw e; + }); }); } logout() { // @TODO: What does it mean for a logout to be successful or failed? - this._ipc.setAccount(null) - .then(() => { + return this._ensureAuthenticated() + .then( () => { + return this._ipc.setAccount(null) + .then(() => { - this._store.dispatch(accountActions.loggedOut()); + this._store.dispatch(accountActions.loggedOut()); - // disconnect user during logout - return this.disconnect() - .then( () => { - this._store.dispatch(push('/')); + // disconnect user during logout + return this.disconnect() + .then( () => { + this._store.dispatch(push('/')); + }); + }) + .catch(e => { + log.info('Failed to logout,', e.message); }); - }) - .catch(e => { - log.info('Failed to logout,', e.message); }); } @@ -215,28 +265,37 @@ export class Backend { if (relayEndpoint) { this._store.dispatch(connectionActions.connectingTo(relayEndpoint)); - return this._ipc.setCustomRelay(relayEndpoint) + return this._ensureAuthenticated() .then( () => { - return this._ipc.connect(); - }) - .catch(e => { - log.info('Failed connecting to', relayEndpoint.host, '-', e.message); - this._store.dispatch(connectionActions.disconnected()); + return this._ipc.setCustomRelay(relayEndpoint) + .then( () => { + return this._ipc.connect(); + }) + .catch(e => { + log.info('Failed connecting to', relayEndpoint.host, '-', e.message); + this._store.dispatch(connectionActions.disconnected()); + }); }); } else { - return this._ipc.connect() - .catch(e => { - log.info('Failed connecting to the relay set in the backend, ', e.message); - this._store.dispatch(connectionActions.disconnected()); + return this._ensureAuthenticated() + .then( () => { + return this._ipc.connect() + .catch(e => { + log.info('Failed connecting to the relay set in the backend, ', e.message); + this._store.dispatch(connectionActions.disconnected()); + }); }); } } disconnect(): Promise<void> { // @TODO: Failure modes - return this._ipc.disconnect() - .catch(e => { - log.info('Failed to disconnect,', e.message); + return this._ensureAuthenticated() + .then( () => { + return this._ipc.disconnect() + .catch(e => { + log.info('Failed to disconnect,', e.message); + }); }); } @@ -266,24 +325,27 @@ export class Backend { } _registerIpcListeners() { - this._ipc.registerStateListener(newState => { - log.info('Got new state from backend', newState); + return this._ensureAuthenticated() + .then( () => { + return this._ipc.registerStateListener(newState => { + log.info('Got new state from backend', newState); - const newStatus = this._securityStateToConnectionState(newState); - switch(newStatus) { - case 'connecting': - this._store.dispatch(connectionActions.connecting()); - break; - case 'connected': - this._store.dispatch(connectionActions.connected()); - break; - case 'disconnected': - this._store.dispatch(connectionActions.disconnected()); - break; - } + const newStatus = this._securityStateToConnectionState(newState); + switch(newStatus) { + case 'connecting': + this._store.dispatch(connectionActions.connecting()); + break; + case 'connected': + this._store.dispatch(connectionActions.connected()); + break; + case 'disconnected': + this._store.dispatch(connectionActions.disconnected()); + break; + } - this.sync(); - }); + this.sync(); + }); + }); } _securityStateToConnectionState(backendState: BackendState): ConnectionState { @@ -296,4 +358,27 @@ export class Backend { } throw new Error('Unsupported state/target state combination: ' + JSON.stringify(backendState)); } + + _ensureAuthenticated(): Promise<void> { + const credentials = this._credentials; + if(credentials) { + if(!this._authenticationPromise) { + this._authenticationPromise = this._authenticate(credentials.sharedSecret); + } + return this._authenticationPromise; + } else { + return Promise.reject(new Error('Missing authentication credentials.')); + } + } + + _authenticate(sharedSecret: string): Promise<void> { + return this._ipc.authenticate(sharedSecret) + .then(() => { + log.info('Authenticated with backend'); + }) + .catch((e) => { + log.error('Failed to authenticate with backend: ', e.message); + throw e; + }); + } } diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js index 29c369dd9b..4039d44ee9 100644 --- a/app/lib/ipc-facade.js +++ b/app/lib/ipc-facade.js @@ -44,6 +44,8 @@ export interface IpcFacade { getLocation(): Promise<Location>, getState(): Promise<BackendState>, registerStateListener((BackendState) => void): void, + setCloseConnectionHandler(() => void): void, + authenticate(sharedSecret: string): Promise<void>, } export class RealIpc implements IpcFacade { @@ -163,4 +165,13 @@ export class RealIpc implements IpcFacade { listener(parsedEvent); }); } + + authenticate(sharedSecret: string): Promise<void> { + return this._ipc.send('auth', sharedSecret) + .then(this._ignoreResponse); + } + + setCloseConnectionHandler(handler: () => void) { + console.log('appa', handler); + } } diff --git a/app/lib/jsonrpc-ws-ipc.js b/app/lib/jsonrpc-ws-ipc.js index 2609d1514c..deefce3d66 100644 --- a/app/lib/jsonrpc-ws-ipc.js +++ b/app/lib/jsonrpc-ws-ipc.js @@ -75,6 +75,7 @@ export default class Ipc { _websocket: WebSocket; _backoff: ReconnectionBackoff; _websocketFactory: (string) => WebSocket; + _closeConnectionHandler: ?() => void; constructor(connectionString: string, websocketFactory: ?(string)=>WebSocket) { this._connectionString = connectionString; @@ -91,6 +92,10 @@ export default class Ipc { this._connectionString = str; } + setCloseConnectionHandler(handler: ?() => void) { + this._closeConnectionHandler = handler; + } + on(event: string, listener: (mixed) => void): Promise<*> { log.info('Adding a listener to', event); @@ -246,6 +251,10 @@ export default class Ipc { }; this._websocket.onclose = () => { + if(this._closeConnectionHandler) { + this._closeConnectionHandler(); + } + const delay = this._backoff.getIncreasedBackoff(); log.warn('The websocket connetion closed, attempting to reconnect it in', delay, 'milliseconds'); setTimeout(() => this._reconnect(), delay); diff --git a/app/main.js b/app/main.js index 5d24239791..7dcb83f9c6 100644 --- a/app/main.js +++ b/app/main.js @@ -7,6 +7,7 @@ import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'; import TrayIconManager from './lib/tray-icon-manager'; import ElectronSudo from 'electron-sudo'; import { version } from '../package.json'; +import { parseIpcCredentials } from './lib/backend'; import type { TrayIconType } from './lib/tray-icon-manager'; @@ -88,6 +89,7 @@ const appDelegate = { browserWindowReady = true; appDelegate._pollForConnectionInfoFile(); }); + ipcMain.on('show-window', () => { appDelegate._showWindow(window, appDelegate._tray); }); @@ -101,6 +103,8 @@ const appDelegate = { // create tray icon on macOS if(isMacOS) { appDelegate._tray = appDelegate._createTray(window); + } else { + appDelegate._showWindow(window, null); } appDelegate._setAppMenu(); @@ -227,10 +231,13 @@ const appDelegate = { return log.error('Could not find backend connection info', err); } - log.info('Read IPC connection info', data); - window.webContents.send('backend-info', { - addr: data, - }); + const credentials = parseIpcCredentials(data); + if(credentials) { + log.info('Read IPC connection info', credentials.connectionString); + window.webContents.send('backend-info', { credentials }); + } else { + log.error('Could not parse IPC credentials.'); + } }); }, @@ -255,6 +262,7 @@ const appDelegate = { resizable: false, maximizable: false, fullscreenable: false, + show: false, webPreferences: { // prevents renderer process code from not running when window is hidden backgroundThrottling: false, @@ -268,8 +276,7 @@ const appDelegate = { options = Object.assign({}, options, { height: contentHeight + 12, // 12 is the size of transparent area around arrow frame: false, - transparent: true, - show: false + transparent: true }); } diff --git a/test/auth.spec.js b/test/auth.spec.js new file mode 100644 index 0000000000..c3fd78c83d --- /dev/null +++ b/test/auth.spec.js @@ -0,0 +1,56 @@ +// @flow + +import { expect } from 'chai'; +import { setupIpcAndStore, setupBackendAndStore, failFast, checkNextTick } from './helpers/ipc-helpers'; +import { IpcChain } from './helpers/IpcChain'; +import { Backend } from '../app/lib/backend'; + +describe('authentication', () => { + + it('authenticates before ipc call if unauthenticated', (done) => { + const { store, mockIpc } = setupIpcAndStore(); + const credentials = { + sharedSecret: 'foo', + connectionString: '', + }; + + + const chain = new IpcChain(mockIpc); + chain.require('authenticate') + .withInputValidation( secret => { + expect(secret).to.equal(credentials.sharedSecret); + }) + .done(); + + chain.require('connect') + .done(); + + chain.onSuccessOrFailure(done); + + + const backend = new Backend(store, credentials, mockIpc); + backend.connect(); + }); + + it('reauthenticates on reconnect', (done) => { + const { mockIpc, backend } = setupBackendAndStore(); + + let authCount = 0; + mockIpc.authenticate = () => { + authCount++; + return Promise.resolve(); + }; + + + mockIpc.killWebSocket(); + failFast(() => { + expect(authCount).to.equal(0); + }, done); + + + backend.connect(); + checkNextTick(() => { + expect(authCount).to.equal(1); + }, done); + }); +}); diff --git a/test/connect.spec.js b/test/connect.spec.js index bb6ac41526..07e3091c6e 100644 --- a/test/connect.spec.js +++ b/test/connect.spec.js @@ -61,29 +61,35 @@ describe('connect', () => { }); }); - it('should correctly deduce \'connected\' from backend states', () => { + it('should correctly deduce \'connected\' from backend states', (done) => { const { store, mockIpc } = setupBackendAndStore(); - expect(store.getState().connection.status).not.to.equal('connected'); - mockIpc.sendNewState({ state: 'secured', target_state: 'secured' }); - expect(store.getState().connection.status).to.equal('connected'); + checkNextTick( () => { + expect(store.getState().connection.status).not.to.equal('connected'); + mockIpc.sendNewState({ state: 'secured', target_state: 'secured' }); + expect(store.getState().connection.status).to.equal('connected'); + }, done); }); - it('should correctly deduce \'connecting\' from backend states', () => { + it('should correctly deduce \'connecting\' from backend states', (done) => { const { store, mockIpc } = setupBackendAndStore(); - expect(store.getState().connection.status).not.to.equal('connecting'); - mockIpc.sendNewState({ state: 'unsecured', target_state: 'secured' }); - expect(store.getState().connection.status).to.equal('connecting'); + checkNextTick( () => { + expect(store.getState().connection.status).not.to.equal('connecting'); + mockIpc.sendNewState({ state: 'unsecured', target_state: 'secured' }); + expect(store.getState().connection.status).to.equal('connecting'); + }, done); }); - it('should correctly deduce \'disconnected\' from backend states', () => { + it('should correctly deduce \'disconnected\' from backend states', (done) => { const { store, mockIpc } = setupBackendAndStore(); store.dispatch(connectionActions.connected()); - expect(store.getState().connection.status).not.to.equal('disconnected'); - mockIpc.sendNewState({ state: 'unsecured', target_state: 'unsecured' }); - expect(store.getState().connection.status).to.equal('disconnected'); + checkNextTick( () => { + expect(store.getState().connection.status).not.to.equal('disconnected'); + mockIpc.sendNewState({ state: 'unsecured', target_state: 'unsecured' }); + expect(store.getState().connection.status).to.equal('disconnected'); + }, done); }); }); diff --git a/test/helpers/IpcChain.js b/test/helpers/IpcChain.js index a7916e1e9f..a62626a6a6 100644 --- a/test/helpers/IpcChain.js +++ b/test/helpers/IpcChain.js @@ -8,11 +8,13 @@ export class IpcChain { _recordedCalls: Array<string>; _mockIpc: {}; _done: (*) => void; + _aborted: boolean; constructor(mockIpc: {}) { this._expectedCalls = []; this._recordedCalls = []; this._mockIpc = mockIpc; + this._aborted = false; } require(ipcCall: string): StepBuilder { @@ -28,10 +30,21 @@ export class IpcChain { } _stepPromiseCallback(step, resolve, args) { + if (this._aborted) { + return; + } + this._registerCall(step.ipcCall); if (step.inputValidation) { - failFast(() => step.inputValidation(...args), this._done); + const failedInputValidation = failFast(() => { + step.inputValidation(...args); + }, this._done); + + if (failedInputValidation) { + this._abort(); + return; + } } if (this._isLastCall()) { @@ -41,6 +54,10 @@ export class IpcChain { resolve(step.returnValue); } + _abort() { + this._aborted = true; + } + _isLastCall(): boolean { return this._recordedCalls.length === this._expectedCalls.length; } diff --git a/test/helpers/ipc-helpers.js b/test/helpers/ipc-helpers.js index 5e4b2941d6..e2a578973b 100644 --- a/test/helpers/ipc-helpers.js +++ b/test/helpers/ipc-helpers.js @@ -9,14 +9,24 @@ import { mockState, mockStore } from '../mocks/redux'; type DoneCallback = (?mixed) => void; type Check = () => void; -export function setupBackendAndStore() { - +export function setupIpcAndStore() { const memoryHistory = createMemoryHistory(); const store = configureStore(null, memoryHistory); const mockIpc = newMockIpc(); - const backend = new Backend(store, mockIpc); + return { store, mockIpc }; +} + +export function setupBackendAndStore() { + + const { store, mockIpc } = setupIpcAndStore(); + + const credentials = { + sharedSecret: '', + connectionString: '', + }; + const backend = new Backend(store, credentials, mockIpc); return { store, mockIpc, backend }; } @@ -24,7 +34,11 @@ export function setupBackendAndStore() { export function setupBackendAndMockStore() { const store = mockStore(mockState()); const mockIpc = newMockIpc(); - const backend = new Backend(store, mockIpc); + const credentials = { + sharedSecret: '', + connectionString: '', + }; + const backend = new Backend(store, credentials, mockIpc); return { store, mockIpc, backend }; } @@ -54,11 +68,13 @@ export function checkNextTick(fn: Check, done: DoneCallback) { // In async tests where we want to test a chain of IPC messages // we can only invoke `done` for the last message. This function // is for the intermediate messages. -export function failFast(fn: Check, done: DoneCallback) { +export function failFast(fn: Check, done: DoneCallback): boolean { try { fn(); + return false; } catch(e) { done(e); + return true; } } export function failFastNextTick(fn: Check, done: DoneCallback) { diff --git a/test/mocks/ipc.js b/test/mocks/ipc.js index 472811ef4c..5b337ebbc9 100644 --- a/test/mocks/ipc.js +++ b/test/mocks/ipc.js @@ -3,14 +3,17 @@ import type { IpcFacade, BackendState } from '../../app/lib/ipc-facade'; interface MockIpc { sendNewState: (BackendState) => void; + killWebSocket: () => void; -getAccountData: *; -connect: *; -getAccount: *; + -authenticate: *; } export function newMockIpc() { const stateListeners = []; + const connectionCloseListeners = []; const mockIpc: IpcFacade & MockIpc = { @@ -60,6 +63,15 @@ export function newMockIpc() { l(state); } }, + authenticate: (_secret: string) => Promise.resolve(), + setCloseConnectionHandler: (listener: () => void) => { + connectionCloseListeners.push(listener); + }, + killWebSocket: () => { + for(const l of connectionCloseListeners) { + l(); + } + } }; return mockIpc; |
