mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-27 15:32:48 -05:00
Add invite links with role based access control
This commit is contained in:
144
src/node/auth.js
Normal file
144
src/node/auth.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import EventEmitter from 'events'
|
||||
import { randomBytes, scrypt as scryptCb } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { validRoles } from '../roles'
|
||||
|
||||
const scrypt = promisify(scryptCb)
|
||||
|
||||
const base62 = require('base-x')(
|
||||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
)
|
||||
|
||||
function rand62(len) {
|
||||
return base62.encode(randomBytes(len))
|
||||
}
|
||||
|
||||
async function hashToken62(token, salt) {
|
||||
const hashBuffer = await scrypt(token, salt, 24)
|
||||
return base62.encode(hashBuffer)
|
||||
}
|
||||
|
||||
// Wrapper for state data to facilitate role-scoped data access.
|
||||
export class StateWrapper {
|
||||
constructor(value) {
|
||||
this._value = value
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return '<state data>'
|
||||
}
|
||||
|
||||
view(role) {
|
||||
const {
|
||||
config,
|
||||
auth,
|
||||
streams,
|
||||
customStreams,
|
||||
views,
|
||||
streamdelay,
|
||||
} = this._value
|
||||
|
||||
const state = {
|
||||
config,
|
||||
streams,
|
||||
customStreams,
|
||||
views,
|
||||
streamdelay,
|
||||
}
|
||||
if (role === 'admin') {
|
||||
state.auth = auth
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
update(value) {
|
||||
this._value = { ...this._value, ...value }
|
||||
}
|
||||
|
||||
// Unprivileged getter
|
||||
get info() {
|
||||
return this.view()
|
||||
}
|
||||
}
|
||||
|
||||
export class Auth extends EventEmitter {
|
||||
constructor({ adminUsername, adminPassword }) {
|
||||
super()
|
||||
this.adminUsername = adminUsername
|
||||
this.adminPassword = adminPassword
|
||||
this.salt = rand62(16)
|
||||
this.tokensById = new Map()
|
||||
this.tokensByHash = new Map()
|
||||
}
|
||||
|
||||
getState() {
|
||||
const toTokenInfo = ({ id, name, role }) => ({ id, name, role })
|
||||
return {
|
||||
invites: [...this.tokensById.values()]
|
||||
.filter((t) => t.kind === 'invite')
|
||||
.map(toTokenInfo),
|
||||
sessions: [...this.tokensById.values()]
|
||||
.filter((t) => t.kind === 'session')
|
||||
.map(toTokenInfo),
|
||||
}
|
||||
}
|
||||
|
||||
emitState() {
|
||||
this.emit('state', this.getState())
|
||||
}
|
||||
|
||||
admin() {
|
||||
return { id: 'admin', kind: 'admin', name: 'admin', role: 'admin' }
|
||||
}
|
||||
|
||||
async validateToken(secret) {
|
||||
const tokenHash = await hashToken62(secret, this.salt)
|
||||
const tokenData = this.tokensByHash.get(tokenHash)
|
||||
if (!tokenData) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: tokenData.id,
|
||||
kind: tokenData.kind,
|
||||
role: tokenData.role,
|
||||
name: tokenData.name,
|
||||
}
|
||||
}
|
||||
|
||||
async createToken({ kind, role, name }) {
|
||||
if (!validRoles.has(role)) {
|
||||
throw new Error(`invalid role: ${role}`)
|
||||
}
|
||||
let id = rand62(8)
|
||||
// Regenerate in case of an id collision
|
||||
while (this.tokensById.has(id)) {
|
||||
id = rand62(8)
|
||||
}
|
||||
const secret = rand62(24)
|
||||
const tokenHash = await hashToken62(secret, this.salt)
|
||||
const tokenData = {
|
||||
id,
|
||||
tokenHash,
|
||||
kind,
|
||||
role,
|
||||
name,
|
||||
}
|
||||
this.tokensById.set(id, tokenData)
|
||||
this.tokensByHash.set(tokenHash, tokenData)
|
||||
this.emitState()
|
||||
console.log(`Created ${kind} token:`, { id, role, name })
|
||||
return secret
|
||||
}
|
||||
|
||||
deleteToken(tokenId) {
|
||||
const tokenData = this.tokensById.get(tokenId)
|
||||
if (!tokenData) {
|
||||
return
|
||||
}
|
||||
this.tokensById.delete(tokenData.id)
|
||||
this.tokensByHash.delete(tokenData.tokenHash)
|
||||
this.emitState()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import fs from 'fs'
|
||||
import yargs from 'yargs'
|
||||
import TOML from '@iarna/toml'
|
||||
import * as Y from 'yjs'
|
||||
import { create as createJSONDiffPatch } from 'jsondiffpatch'
|
||||
import { Repeater } from '@repeaterjs/repeater'
|
||||
import { app, shell, session, BrowserWindow } from 'electron'
|
||||
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
markDataSource,
|
||||
combineDataSources,
|
||||
} from './data'
|
||||
import { Auth, StateWrapper } from './auth'
|
||||
import StreamWindow from './StreamWindow'
|
||||
import TwitchBot from './TwitchBot'
|
||||
import StreamdelayClient from './StreamdelayClient'
|
||||
@@ -213,21 +213,27 @@ async function main() {
|
||||
})
|
||||
streamWindow.init()
|
||||
|
||||
const auth = new Auth({
|
||||
adminUsername: argv.control.username,
|
||||
adminPassword: argv.control.password,
|
||||
})
|
||||
|
||||
let browseWindow = null
|
||||
let twitchBot = null
|
||||
let streamdelayClient = null
|
||||
|
||||
let clientState = {
|
||||
let clientState = new StateWrapper({
|
||||
config: {
|
||||
width: argv.window.width,
|
||||
height: argv.window.height,
|
||||
gridCount: argv.grid.count,
|
||||
},
|
||||
auth: auth.getState(),
|
||||
streams: [],
|
||||
customStreams: [],
|
||||
views: [],
|
||||
streamdelay: null,
|
||||
}
|
||||
})
|
||||
|
||||
const stateDoc = new Y.Doc()
|
||||
const viewsState = stateDoc.getMap('views')
|
||||
@@ -241,7 +247,7 @@ async function main() {
|
||||
viewsState.observeDeep(() => {
|
||||
const viewContentMap = new Map()
|
||||
for (const [key, viewData] of viewsState) {
|
||||
const stream = clientState.streams.find(
|
||||
const stream = clientState.info.streams.find(
|
||||
(s) => s._id === viewData.get('streamId'),
|
||||
)
|
||||
if (!stream) {
|
||||
@@ -257,7 +263,7 @@ async function main() {
|
||||
|
||||
const getInitialState = () => clientState
|
||||
let broadcast = () => {}
|
||||
const onMessage = (msg) => {
|
||||
const onMessage = async (msg, respond) => {
|
||||
if (msg.type === 'set-listening-view') {
|
||||
streamWindow.setListeningView(msg.viewIdx)
|
||||
} else if (msg.type === 'set-view-blurred') {
|
||||
@@ -294,26 +300,27 @@ 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({
|
||||
kind: 'invite',
|
||||
role: msg.role,
|
||||
name: msg.name,
|
||||
})
|
||||
respond({ name: msg.name, secret })
|
||||
} else if (msg.type === 'delete-token') {
|
||||
auth.deleteToken(msg.tokenId)
|
||||
}
|
||||
}
|
||||
|
||||
const stateDiff = createJSONDiffPatch({
|
||||
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
|
||||
})
|
||||
function updateState(newState) {
|
||||
const lastClientState = clientState
|
||||
clientState = { ...clientState, ...newState }
|
||||
const delta = stateDiff.diff(lastClientState, clientState)
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
clientState.update(newState)
|
||||
broadcast({
|
||||
type: 'state-delta',
|
||||
delta,
|
||||
type: 'state',
|
||||
state: clientState,
|
||||
})
|
||||
streamWindow.send('state', clientState)
|
||||
streamWindow.send('state', clientState.info)
|
||||
if (twitchBot) {
|
||||
twitchBot.onState(clientState)
|
||||
twitchBot.onState(clientState.info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,8 +332,7 @@ async function main() {
|
||||
url: argv.control.address,
|
||||
hostname: argv.control.hostname,
|
||||
port: argv.control.port,
|
||||
username: argv.control.username,
|
||||
password: argv.control.password,
|
||||
auth,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
@@ -360,6 +366,10 @@ async function main() {
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
auth.on('state', (authState) => {
|
||||
updateState({ auth: authState })
|
||||
})
|
||||
|
||||
const dataSources = [
|
||||
...argv.data['json-url'].map((url) =>
|
||||
markDataSource(pollDataURL(url, argv.data.interval), 'json-url'),
|
||||
|
||||
@@ -6,23 +6,26 @@ import http from 'http'
|
||||
import https from 'https'
|
||||
import simpleCert from 'node-simple-cert'
|
||||
import Koa from 'koa'
|
||||
import auth from 'koa-basic-auth'
|
||||
import basicAuth from 'koa-basic-auth'
|
||||
import route from 'koa-route'
|
||||
import serveStatic from 'koa-static'
|
||||
import views from 'koa-views'
|
||||
import websocket from 'koa-easy-ws'
|
||||
import WebSocket from 'ws'
|
||||
import * as Y from 'yjs'
|
||||
import { create as createJSONDiffPatch } from 'jsondiffpatch'
|
||||
|
||||
import { roleCan } from '../roles'
|
||||
|
||||
const SESSION_COOKIE_NAME = 's'
|
||||
|
||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||
|
||||
function initApp({
|
||||
username,
|
||||
password,
|
||||
baseURL,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
}) {
|
||||
const stateDiff = createJSONDiffPatch({
|
||||
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
|
||||
})
|
||||
|
||||
function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
const expectedOrigin = new URL(baseURL).origin
|
||||
const sockets = new Set()
|
||||
|
||||
@@ -31,15 +34,59 @@ function initApp({
|
||||
// silence koa printing errors when websockets close early
|
||||
app.silent = true
|
||||
|
||||
app.use(auth({ name: username, pass: password }))
|
||||
app.use(views(webDistPath, { extension: 'ejs' }))
|
||||
app.use(serveStatic(webDistPath))
|
||||
app.use(websocket())
|
||||
|
||||
app.use(
|
||||
route.get('/invite/:token', async (ctx, token) => {
|
||||
const tokenInfo = await auth.validateToken(token)
|
||||
if (!tokenInfo || tokenInfo.kind !== 'invite') {
|
||||
return ctx.throw(403)
|
||||
}
|
||||
const sessionToken = await auth.createToken({
|
||||
kind: 'session',
|
||||
name: tokenInfo.name,
|
||||
role: tokenInfo.role,
|
||||
})
|
||||
ctx.cookies.set(SESSION_COOKIE_NAME, sessionToken, {
|
||||
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
|
||||
overwrite: true,
|
||||
})
|
||||
await auth.deleteToken(tokenInfo.id)
|
||||
ctx.redirect('/')
|
||||
}),
|
||||
)
|
||||
|
||||
const basicAuthMiddleware = basicAuth({
|
||||
name: auth.adminUsername,
|
||||
pass: auth.adminPassword,
|
||||
})
|
||||
app.use(async (ctx, next) => {
|
||||
const sessionCookie = ctx.cookies.get(SESSION_COOKIE_NAME)
|
||||
if (sessionCookie) {
|
||||
const tokenInfo = await auth.validateToken(sessionCookie)
|
||||
if (tokenInfo && tokenInfo.kind === 'session') {
|
||||
ctx.state.identity = tokenInfo
|
||||
await next()
|
||||
return
|
||||
}
|
||||
ctx.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
maxAge: 0,
|
||||
overwrite: true,
|
||||
})
|
||||
}
|
||||
await basicAuthMiddleware(ctx, async () => {
|
||||
ctx.state.identity = auth.admin()
|
||||
await next()
|
||||
})
|
||||
})
|
||||
|
||||
app.use(
|
||||
route.get('/', async (ctx) => {
|
||||
await ctx.render('control', {
|
||||
wsEndpoint: url.resolve(baseURL, 'ws').replace(/^http/, 'ws'),
|
||||
role: ctx.state.identity.role,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -52,8 +99,14 @@ function initApp({
|
||||
return
|
||||
}
|
||||
|
||||
const { identity } = ctx.state
|
||||
|
||||
const ws = await ctx.ws()
|
||||
sockets.add(ws)
|
||||
sockets.add({
|
||||
ws,
|
||||
lastState: null,
|
||||
identity,
|
||||
})
|
||||
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
@@ -68,26 +121,50 @@ function initApp({
|
||||
|
||||
ws.on('message', (rawData) => {
|
||||
if (rawData instanceof ArrayBuffer) {
|
||||
if (!roleCan(identity.role, 'mutate-state-doc')) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to edit state doc by "${identity.name}"`,
|
||||
)
|
||||
return
|
||||
}
|
||||
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
|
||||
return
|
||||
}
|
||||
|
||||
let data
|
||||
let msg
|
||||
try {
|
||||
data = JSON.parse(rawData)
|
||||
msg = JSON.parse(rawData)
|
||||
} catch (err) {
|
||||
console.warn('received unexpected ws data:', rawData)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
onMessage(data)
|
||||
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
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
...responseData,
|
||||
response: true,
|
||||
id: msg.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onMessage(msg, respond)
|
||||
} catch (err) {
|
||||
console.error('failed to handle ws message:', data, err)
|
||||
}
|
||||
})
|
||||
|
||||
const state = getInitialState()
|
||||
const state = getInitialState().view(identity.role)
|
||||
ws.send(JSON.stringify({ type: 'state', state }))
|
||||
ws.send(Y.encodeStateAsUpdate(stateDoc))
|
||||
return
|
||||
@@ -96,15 +173,43 @@ function initApp({
|
||||
}),
|
||||
)
|
||||
|
||||
const broadcast = (data) => {
|
||||
for (const ws of sockets) {
|
||||
ws.send(JSON.stringify(data))
|
||||
const broadcast = (origMsg) => {
|
||||
if (origMsg.type !== 'state') {
|
||||
console.warn(`Unexpected ws broadcast type: ${origMsg.type}`)
|
||||
return
|
||||
}
|
||||
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
|
||||
if (!delta) {
|
||||
continue
|
||||
}
|
||||
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
|
||||
}
|
||||
}
|
||||
|
||||
stateDoc.on('update', (update) => {
|
||||
for (const ws of sockets) {
|
||||
ws.send(update)
|
||||
for (const client of sockets) {
|
||||
if (client.ws.readyState !== WebSocket.OPEN) {
|
||||
continue
|
||||
}
|
||||
client.ws.send(update)
|
||||
}
|
||||
})
|
||||
|
||||
auth.on('state', (state) => {
|
||||
const tokenIds = new Set(state.sessions.map((t) => t.id))
|
||||
for (const client of sockets) {
|
||||
if (client.identity.role === 'admin') {
|
||||
continue
|
||||
}
|
||||
if (!tokenIds.has(client.identity.id)) {
|
||||
client.ws.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -118,8 +223,7 @@ export default async function initWebServer({
|
||||
url: baseURL,
|
||||
hostname: overrideHostname,
|
||||
port: overridePort,
|
||||
username,
|
||||
password,
|
||||
auth,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
@@ -133,8 +237,7 @@ export default async function initWebServer({
|
||||
}
|
||||
|
||||
const { app, broadcast } = initApp({
|
||||
username,
|
||||
password,
|
||||
auth,
|
||||
baseURL,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
|
||||
Reference in New Issue
Block a user