Add tests for auth system, refactors for testing

This commit is contained in:
Max Goodhart
2020-08-26 11:09:01 -07:00
parent 6e2db3e1c8
commit 21bfacd84b
6 changed files with 533 additions and 63 deletions

57
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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 }
}

392
src/node/server.test.js Normal file
View File

@@ -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')
})
})
})