diff --git a/package-lock.json b/package-lock.json index c8d742e..46cd3d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3703,6 +3703,12 @@ "safe-buffer": "~5.1.1" } }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, "cookies": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", @@ -5388,6 +5394,12 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==", + "dev": true + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -8739,6 +8751,12 @@ } } }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, "mime-db": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", @@ -11268,6 +11286,45 @@ "debug": "^4.1.0" } }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "supertest": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz", + "integrity": "sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ==", + "dev": true, + "requires": { + "methods": "^1.1.2", + "superagent": "^3.8.3" + } + }, "supports-color": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", diff --git a/package.json b/package.json index 0ccb8f2..8d1c616 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "jest": "^26.0.1", "prettier": "2.0.5", "style-loader": "^1.2.1", + "supertest": "^4.0.2", "webpack": "^4.43.0", "webpack-cli": "^3.3.11" }, diff --git a/src/node/auth.js b/src/node/auth.js index b6e8ff7..f9b01b3 100644 --- a/src/node/auth.js +++ b/src/node/auth.js @@ -20,8 +20,9 @@ async function hashToken62(token, salt) { } // Wrapper for state data to facilitate role-scoped data access. -export class StateWrapper { +export class StateWrapper extends EventEmitter { constructor(value) { + super() this._value = value } @@ -55,6 +56,7 @@ export class StateWrapper { update(value) { this._value = { ...this._value, ...value } + this.emit('state', this) } // Unprivileged getter @@ -64,10 +66,11 @@ export class StateWrapper { } export class Auth extends EventEmitter { - constructor({ adminUsername, adminPassword, persistData }) { + constructor({ adminUsername, adminPassword, persistData, logEnabled }) { super() this.adminUsername = adminUsername this.adminPassword = adminPassword + this.logEnabled = logEnabled || false this.salt = persistData?.salt || rand62(16) this.tokensById = new Map() this.tokensByHash = new Map() @@ -139,8 +142,12 @@ export class Auth extends EventEmitter { this.tokensById.set(id, tokenData) this.tokensByHash.set(tokenHash, tokenData) this.emitState() - console.log(`Created ${kind} token:`, { id, role, name }) - return secret + + if (this.logEnabled) { + console.log(`Created ${kind} token:`, { id, role, name }) + } + + return { id, secret } } deleteToken(tokenId) { diff --git a/src/node/index.js b/src/node/index.js index 028a02a..1e9ad66 100644 --- a/src/node/index.js +++ b/src/node/index.js @@ -1,4 +1,5 @@ import fs from 'fs' +import path from 'path' import yargs from 'yargs' import TOML from '@iarna/toml' import * as Y from 'yjs' @@ -220,6 +221,7 @@ async function main() { adminUsername: argv.control.username, adminPassword: argv.control.password, persistData: persistData.auth, + logEnabled: true, }) let browseWindow = null @@ -265,8 +267,6 @@ async function main() { streamWindow.setViews(viewContentMap) }) - const getInitialState = () => clientState - let broadcast = () => {} const onMessage = async (msg, respond) => { if (msg.type === 'set-listening-view') { streamWindow.setListeningView(msg.viewIdx) @@ -305,7 +305,7 @@ async function main() { } else if (msg.type === 'set-stream-censored' && streamdelayClient) { streamdelayClient.setCensored(msg.isCensored) } else if (msg.type === 'create-invite') { - const secret = await auth.createToken({ + const { secret } = await auth.createToken({ kind: 'invite', role: msg.role, name: msg.name, @@ -318,10 +318,6 @@ async function main() { function updateState(newState) { clientState.update(newState) - broadcast({ - type: 'state', - state: clientState, - }) streamWindow.send('state', clientState.info) if (twitchBot) { twitchBot.onState(clientState.info) @@ -329,18 +325,21 @@ async function main() { } if (argv.control.address) { - ;({ broadcast } = await initWebServer({ + const webDistPath = path.join(app.getAppPath(), 'web') + await initWebServer({ certDir: argv.cert.dir, certProduction: argv.cert.production, email: argv.cert.email, url: argv.control.address, hostname: argv.control.hostname, port: argv.control.port, + logEnabled: true, + webDistPath, auth, - getInitialState, + clientState, onMessage, stateDoc, - })) + }) if (argv.control.open) { shell.openExternal(argv.control.address) } diff --git a/src/node/server.js b/src/node/server.js index d9ea6e2..05cb178 100644 --- a/src/node/server.js +++ b/src/node/server.js @@ -1,6 +1,4 @@ -import { app } from 'electron' import { promisify } from 'util' -import path from 'path' import url from 'url' import http from 'http' import https from 'https' @@ -17,15 +15,21 @@ import { create as createJSONDiffPatch } from 'jsondiffpatch' import { roleCan } from '../roles' -const SESSION_COOKIE_NAME = 's' - -const webDistPath = path.join(app.getAppPath(), 'web') +export const SESSION_COOKIE_NAME = 's' const stateDiff = createJSONDiffPatch({ objectHash: (obj, idx) => obj._id || `$$index:${idx}`, }) -function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { +function initApp({ + auth, + baseURL, + webDistPath, + clientState, + logEnabled, + onMessage, + stateDoc, +}) { const expectedOrigin = new URL(baseURL).origin const sockets = new Set() @@ -44,12 +48,12 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { if (!tokenInfo || tokenInfo.kind !== 'invite') { return ctx.throw(403) } - const sessionToken = await auth.createToken({ + const { secret } = await auth.createToken({ kind: 'session', name: tokenInfo.name, role: tokenInfo.role, }) - ctx.cookies.set(SESSION_COOKIE_NAME, sessionToken, { + ctx.cookies.set(SESSION_COOKIE_NAME, secret, { maxAge: 1 * 365 * 24 * 60 * 60 * 1000, overwrite: true, }) @@ -71,10 +75,6 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { await next() return } - ctx.cookies.set(SESSION_COOKIE_NAME, '', { - maxAge: 0, - overwrite: true, - }) } await basicAuthMiddleware(ctx, async () => { ctx.state.identity = auth.admin() @@ -102,11 +102,12 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { const { identity } = ctx.state const ws = await ctx.ws() - sockets.add({ + const client = { ws, lastState: null, identity, - }) + } + sockets.add(client) ws.binaryType = 'arraybuffer' @@ -120,43 +121,55 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { }) ws.on('message', (rawData) => { + let msg + const respond = (responseData) => { + if (ws.readyState !== WebSocket.OPEN) { + return + } + ws.send( + JSON.stringify({ + ...responseData, + response: true, + id: msg && msg.id, + }), + ) + } if (rawData instanceof ArrayBuffer) { if (!roleCan(identity.role, 'mutate-state-doc')) { - console.warn( - `Unauthorized attempt to edit state doc by "${identity.name}"`, - ) + if (logEnabled) { + console.warn( + `Unauthorized attempt to edit state doc by "${identity.name}"`, + ) + } + respond({ + error: 'unauthorized', + }) return } Y.applyUpdate(stateDoc, new Uint8Array(rawData)) return } - let msg try { msg = JSON.parse(rawData) } catch (err) { - console.warn('received unexpected ws data:', rawData) + if (logEnabled) { + console.warn('received unexpected ws data:', rawData) + } return } try { if (!roleCan(identity.role, msg.type)) { - console.warn( - `Unauthorized attempt to "${msg.type}" by "${identity.name}"`, - ) - return - } - const respond = (responseData) => { - if (ws.readyState !== WebSocket.OPEN) { - return + if (logEnabled) { + console.warn( + `Unauthorized attempt to "${msg.type}" by "${identity.name}"`, + ) } - ws.send( - JSON.stringify({ - ...responseData, - response: true, - id: msg.id, - }), - ) + respond({ + error: 'unauthorized', + }) + return } onMessage(msg, respond) } catch (err) { @@ -164,33 +177,30 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { } }) - const state = getInitialState().view(identity.role) + const state = clientState.view(identity.role) ws.send(JSON.stringify({ type: 'state', state })) ws.send(Y.encodeStateAsUpdate(stateDoc)) + client.lastState = state return } ctx.status = 404 }), ) - const broadcast = (origMsg) => { - if (origMsg.type !== 'state') { - console.warn(`Unexpected ws broadcast type: ${origMsg.type}`) - return - } + clientState.on('state', (state) => { for (const client of sockets) { if (client.ws.readyState !== WebSocket.OPEN) { continue } - const state = origMsg.state.view(client.identity.role) - const delta = stateDiff.diff(client.lastState, state) - client.lastState = state + const stateView = state.view(client.identity.role) + const delta = stateDiff.diff(client.lastState, stateView) + client.lastState = stateView if (!delta) { continue } client.ws.send(JSON.stringify({ type: 'state-delta', delta })) } - } + }) stateDoc.on('update', (update) => { for (const client of sockets) { @@ -213,7 +223,7 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) { } }) - return { app, broadcast } + return { app } } export default async function initWebServer({ @@ -223,8 +233,10 @@ export default async function initWebServer({ url: baseURL, hostname: overrideHostname, port: overridePort, + webDistPath, auth, - getInitialState, + logEnabled, + clientState, onMessage, stateDoc, }) { @@ -236,10 +248,12 @@ export default async function initWebServer({ port = overridePort } - const { app, broadcast } = initApp({ + const { app } = initApp({ auth, baseURL, - getInitialState, + webDistPath, + clientState, + logEnabled, onMessage, stateDoc, }) @@ -261,5 +275,5 @@ export default async function initWebServer({ const listen = promisify(server.listen).bind(server) await listen(port, overrideHostname || hostname) - return { broadcast } + return { server } } diff --git a/src/node/server.test.js b/src/node/server.test.js new file mode 100644 index 0000000..6ee7847 --- /dev/null +++ b/src/node/server.test.js @@ -0,0 +1,392 @@ +// Mock koa middleware that require built statics +jest.mock('koa-static', () => () => (ctx, next) => next()) +jest.mock('koa-views', () => () => (ctx, next) => { + ctx.render = async () => { + ctx.body = 'mock' + } + return next() +}) + +import { on, once } from 'events' +import supertest from 'supertest' +import * as Y from 'yjs' +import WebSocket from 'ws' +import { patch as patchJSON } from 'jsondiffpatch' +import { Auth, StateWrapper } from './auth' +import initWebServer, { SESSION_COOKIE_NAME } from './server' +import base from 'base-x' + +describe('streamwall server', () => { + const adminUsername = 'admin' + const adminPassword = 'password' + const hostname = 'localhost' + const port = 8081 + const baseURL = `http://${hostname}:${port}` + + let auth + let clientState + let server + let request + let stateDoc + let onMessage + let onMessageCalled + let sockets + beforeEach(async () => { + sockets = [] + auth = new Auth({ + adminUsername, + adminPassword, + }) + clientState = new StateWrapper({ + config: { + width: 1920, + height: 1080, + gridCount: 6, + }, + auth: auth.getState(), + streams: [], + customStreams: [], + views: [], + streamdelay: null, + }) + stateDoc = new Y.Doc() + onMessageCalled = new Promise((resolve) => { + onMessage = jest.fn(resolve) + }) + ;({ server } = await initWebServer({ + url: baseURL, + hostname, + port, + auth, + clientState, + onMessage, + stateDoc, + })) + request = supertest(server) + auth.on('state', (authState) => { + clientState.update({ auth: authState }) + }) + }) + + afterEach(() => { + server.close() + for (const ws of sockets) { + ws.close() + } + }) + + function socket(options) { + const ws = new WebSocket(`ws://${hostname}:${port}/ws`, [], { + ...options, + origin: baseURL, + }) + sockets.push(ws) + + const msgs = on(ws, 'message') + + async function recvMsg() { + const { + value: [data], + } = await msgs.next() + if (typeof data === 'string') { + return JSON.parse(data) + } + return data + } + + function sendMsg(msg) { + ws.send(JSON.stringify(msg)) + } + + return { ws, recvMsg, sendMsg } + } + + function socketFromSecret(secret) { + return socket({ + headers: { Cookie: `${SESSION_COOKIE_NAME}=${secret}` }, + }) + } + + describe('basic auth', () => { + it('rejects missing credentials', async () => { + await request.get('/').expect(401) + }) + + it('rejects empty credentials', async () => { + await request.get('/').auth('', '').expect(401) + }) + + it('rejects incorrect credentials', async () => { + await request.get('/').auth('wrong', 'creds').expect(401) + }) + + it('accepts correct credentials', async () => { + await request.get('/').auth(adminUsername, adminPassword).expect(200) + }) + }) + + describe('invite urls', () => { + it('rejects missing token', async () => { + await request.get('/invite/').expect(401) + }) + + it('rejects invalid token', async () => { + await request.get('/invite/badtoken').expect(403) + }) + + it('rejects token of incorrect type', async () => { + const { secret } = await auth.createToken({ + kind: 'session', + role: 'operator', + name: 'test', + }) + await request.get(`/invite/${secret}`).expect(403) + }) + + it('accepts valid token and creates session cookie', async () => { + const { secret } = await auth.createToken({ + kind: 'invite', + role: 'operator', + name: 'test', + }) + expect(auth.getState().invites.length).toBe(1) + await request.get(`/invite/${secret}`).expect(302) + expect(auth.getState().invites.length).toBe(0) + }) + }) + + describe('token access', () => { + it('ignores empty tokens', async () => { + await request + .get('/') + .set('Cookie', `${SESSION_COOKIE_NAME}=`) + .expect(401) + }) + + it('ignores invite tokens', async () => { + const { secret } = await auth.createToken({ + kind: 'invite', + role: 'operator', + name: 'test', + }) + await request + .get('/') + .set('Cookie', `${SESSION_COOKIE_NAME}=${secret}`) + .expect(401) + }) + + it('accepts valid tokens', async () => { + const { secret } = await auth.createToken({ + kind: 'session', + role: 'operator', + name: 'test', + }) + await request + .get('/') + .set('Cookie', `${SESSION_COOKIE_NAME}=${secret}`) + .expect(200) + }) + + it('disconnects websocket on token deletion', async () => { + const { id: tokenId, secret } = await auth.createToken({ + kind: 'session', + role: 'operator', + name: 'test', + }) + const { recvMsg, ws } = await socketFromSecret(secret) + await recvMsg() + await recvMsg() + expect(ws.readyState === WebSocket.OPEN) + auth.deleteToken(tokenId) + await once(ws, 'close') + }) + }) + + describe('admin role', () => { + it('can view tokens', async () => { + await auth.createToken({ + kind: 'invite', + role: 'operator', + name: 'test', + }) + expect(auth.getState().invites.length).toBe(1) + + const { recvMsg } = await socket({ + auth: `${adminUsername}:${adminPassword}`, + }) + const firstMsg = await recvMsg() + expect(firstMsg.type).toBe('state') + expect(firstMsg.state).toHaveProperty('auth') + expect(firstMsg.state.auth.invites).toHaveLength(1) + }) + + it('receives token state updates', async () => { + const { recvMsg } = await socket({ + auth: `${adminUsername}:${adminPassword}`, + }) + const { state } = await recvMsg() + expect(state.auth.invites).toHaveLength(0) + await recvMsg() + + await auth.createToken({ + kind: 'invite', + role: 'operator', + name: 'test', + }) + const stateDelta = await recvMsg() + expect(stateDelta.type).toBe('state-delta') + expect(stateDelta.delta).toHaveProperty('auth') + const updatedState = patchJSON(state, stateDelta.delta) + expect(updatedState).toHaveProperty('auth') + expect(updatedState.auth.invites).toHaveLength(1) + }) + + it('can create an invite', async () => { + const { recvMsg, sendMsg } = await socket({ + auth: `${adminUsername}:${adminPassword}`, + }) + await recvMsg() + await recvMsg() + expect(auth.getState().invites.length).toBe(0) + sendMsg({ type: 'create-invite', role: 'operator', name: 'test' }) + await onMessageCalled + expect( + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'create-invite', + role: 'operator', + name: 'test', + }), + expect.any(Function), + ), + ) + }) + }) + + describe('operator role', () => { + let secret + beforeEach(async () => [ + ({ secret } = await auth.createToken({ + kind: 'session', + role: 'operator', + name: 'test', + })), + ]) + + it('cannot view tokens', async () => { + const { recvMsg } = await socketFromSecret(secret) + const firstMsg = await recvMsg() + expect(firstMsg.type).toBe('state') + expect(firstMsg.state).not.toHaveProperty('auth') + }) + + it('cannot create invites', async () => { + const { recvMsg, sendMsg } = await socketFromSecret(secret) + await recvMsg() + await recvMsg() + sendMsg({ type: 'create-invite', role: 'operator', name: 'test' }) + const resp = await recvMsg() + expect(resp.response).toBe(true) + expect(resp.error).toBe('unauthorized') + }) + + it('does not receive token state updates', async () => { + // FIXME: a bit difficult to test the lack of a state update sent; currently, this test triggers a second state update and assumes that if it receives it, the state update for the "auth" property was never sent. + const { recvMsg } = await socketFromSecret(secret) + await recvMsg() + await recvMsg() + + clientState.update({ streams: [{ _id: 'tes' }] }) + const testUpdate = await recvMsg() + expect(testUpdate.type).toBe('state-delta') + expect(testUpdate.delta).toHaveProperty('streams') + expect(testUpdate.delta).not.toHaveProperty('auth') + + await auth.createToken({ + kind: 'invite', + role: 'operator', + name: 'test', + }) + + clientState.update({ streams: [{ _id: 'tes2' }] }) + const testUpdate2 = await recvMsg() + expect(testUpdate2.type).toBe('state-delta') + expect(testUpdate2.delta).toHaveProperty('streams') + expect(testUpdate2.delta).not.toHaveProperty('auth') + }) + + it('can change listening view', async () => { + const { recvMsg, sendMsg } = await socketFromSecret(secret) + await recvMsg() + await recvMsg() + sendMsg({ type: 'set-listening-view', viewIdx: 7 }) + await onMessageCalled + expect( + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'set-listening-view', + viewIdx: 7, + }), + expect.any(Function), + ), + ) + }) + + it('can mutate state doc', async () => { + const { ws, recvMsg } = await socketFromSecret(secret) + await recvMsg() + + const doc = new Y.Doc() + const yUpdate = await recvMsg() + Y.applyUpdate(doc, new Uint8Array(yUpdate), 'server') + const updateEvent = on(doc, 'update') + doc.getMap('views').set(0, new Y.Map()) + const { + value: [updateToSend], + } = await updateEvent.next() + ws.send(updateToSend) + + const yUpdate2 = await recvMsg() + expect(yUpdate2).toBeInstanceOf(Buffer) + }) + }) + + describe('monitor role', () => { + let secret + beforeEach(async () => [ + ({ secret } = await auth.createToken({ + kind: 'session', + role: 'monitor', + name: 'test', + })), + ]) + + it('cannot view tokens', async () => { + const { recvMsg } = await socketFromSecret(secret) + const firstMsg = await recvMsg() + expect(firstMsg.type).toBe('state') + expect(firstMsg.state).not.toHaveProperty('auth') + }) + + it('cannot change listening view', async () => { + const { recvMsg, sendMsg } = await socketFromSecret(secret) + await recvMsg() + await recvMsg() + sendMsg({ type: 'set-listening-view', viewIdx: 7 }) + const resp = await recvMsg() + expect(resp.response).toBe(true) + expect(resp.error).toBe('unauthorized') + }) + + it('cannot mutate state doc', async () => { + const { ws, recvMsg } = await socketFromSecret(secret) + await recvMsg() + await recvMsg() + + ws.send(new ArrayBuffer()) + const resp = await recvMsg() + expect(resp.response).toBe(true) + expect(resp.error).toBe('unauthorized') + }) + }) +})