From 3a349205ed44bfdb16d8b19ee15b0e48c68daedc Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Mon, 19 Dec 2022 10:02:29 -0800 Subject: [PATCH] server: user/acl work roughed in --- server/package-lock.json | 18 ++--- server/package.json | 4 +- server/src/db-types.ts | 2 +- server/src/plugin/acl.ts | 104 +++++++++++++++++++++++++++ server/src/plugin/plugin-api.ts | 57 ++++++++++----- server/src/plugin/plugin-device.ts | 6 ++ server/src/plugin/plugin-host-api.ts | 7 +- server/src/plugin/plugin-host.ts | 21 +++--- server/src/plugin/plugin-http.ts | 1 + server/src/plugin/plugin-remote.ts | 55 +++++++++++--- server/src/rpc.ts | 15 +++- server/src/runtime.ts | 59 ++++++++++----- server/src/scrypted-server-main.ts | 22 +++--- server/src/services/plugin.ts | 14 +--- server/src/services/users.ts | 43 +++++++++++ server/src/usertoken.ts | 19 +++-- 16 files changed, 346 insertions(+), 101 deletions(-) create mode 100644 server/src/plugin/acl.ts create mode 100644 server/src/services/users.ts diff --git a/server/package-lock.json b/server/package-lock.json index df8c2fdb1..02aec7350 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,17 +1,17 @@ { "name": "@scrypted/server", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/server", - "version": "0.4.6", + "version": "0.4.7", "license": "ISC", "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", "@mapbox/node-pre-gyp": "^1.0.10", - "@scrypted/types": "^0.2.29", + "@scrypted/types": "^0.2.36", "adm-zip": "^0.5.9", "axios": "^0.21.4", "body-parser": "^1.19.0", @@ -245,9 +245,9 @@ } }, "node_modules/@scrypted/types": { - "version": "0.2.29", - "resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.29.tgz", - "integrity": "sha512-l6BCe+2jHPnLaKbUfNxjtgfBkzzIVDhUo8iTjAKyJUfdWZsHZ8IFkzP9GTwenUHmOapDwYDboRum9NCxnjt7AA==" + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.36.tgz", + "integrity": "sha512-eXOGIeYUyecupHXsezp/nYkvSgLw/u1X71Cakn93t6suK96JUZwdW6vjF2s0dcSpUh4GCS+gwcUgqU8jF3znRA==" }, "node_modules/@tootallnate/once": { "version": "1.1.2", @@ -3281,9 +3281,9 @@ } }, "@scrypted/types": { - "version": "0.2.29", - "resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.29.tgz", - "integrity": "sha512-l6BCe+2jHPnLaKbUfNxjtgfBkzzIVDhUo8iTjAKyJUfdWZsHZ8IFkzP9GTwenUHmOapDwYDboRum9NCxnjt7AA==" + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.36.tgz", + "integrity": "sha512-eXOGIeYUyecupHXsezp/nYkvSgLw/u1X71Cakn93t6suK96JUZwdW6vjF2s0dcSpUh4GCS+gwcUgqU8jF3znRA==" }, "@tootallnate/once": { "version": "1.1.2", diff --git a/server/package.json b/server/package.json index 1305a409b..1708e912d 100644 --- a/server/package.json +++ b/server/package.json @@ -1,11 +1,11 @@ { "name": "@scrypted/server", - "version": "0.4.6", + "version": "0.4.7", "description": "", "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", "@mapbox/node-pre-gyp": "^1.0.10", - "@scrypted/types": "^0.2.29", + "@scrypted/types": "^0.2.36", "adm-zip": "^0.5.9", "axios": "^0.21.4", "body-parser": "^1.19.0", diff --git a/server/src/db-types.ts b/server/src/db-types.ts index a86a0fb97..d79d83480 100644 --- a/server/src/db-types.ts +++ b/server/src/db-types.ts @@ -20,7 +20,7 @@ export class ScryptedUser extends ScryptedDocument { passwordHash: string; token: string; salt: string; - restricted: boolean; + aclId: string; } export class ScryptedAlert extends ScryptedDocument { diff --git a/server/src/plugin/acl.ts b/server/src/plugin/acl.ts new file mode 100644 index 000000000..9e2e2cd4c --- /dev/null +++ b/server/src/plugin/acl.ts @@ -0,0 +1,104 @@ +import { EventDetails, ScryptedInterface, ScryptedUserAccessControl } from "@scrypted/types"; + +/** + * Scrypted Access Controls allow selective reading of state, subscription to evemts, + * and invocation of methods. + * Everything else should be rejected. + */ +export class AccessControls { + constructor(public acl: ScryptedUserAccessControl) { + } + + deny(reason: string = 'User does not have permission') { + throw new Error(reason); + } + + shouldRejectDevice(id: string) { + if (this.acl.devicesAccessControls === null) + return false; + + if (!this.acl.devicesAccessControls) + return true; + + const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id); + return !dacls.length; + } + + shouldRejectProperty(id: string, property: string) { + if (this.acl.devicesAccessControls === null) + return false; + + if (!this.acl.devicesAccessControls) + return true; + + const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id); + + for (const dacl of dacls) { + if (!dacl.properties || dacl.properties.includes(property)) + return false; + } + + return true; + } + + shouldRejectEvent(id: string, eventDetails: EventDetails) { + if (this.acl.devicesAccessControls === null) + return false; + + if (!this.acl.devicesAccessControls) + return true; + + const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id); + + const { property } = eventDetails; + if (property) { + for (const dacl of dacls) { + if (!dacl.properties || dacl.properties.includes(property)) + return false; + } + } + + const { eventInterface } = eventDetails; + + for (const dacl of dacls) { + if (!dacl.interfaces || dacl.interfaces.includes(eventInterface)) + return false; + } + + return true; + } + + shouldRejectInterface(id: string, scryptedInterface: ScryptedInterface) { + if (this.acl.devicesAccessControls === null) + return false; + + if (!this.acl.devicesAccessControls) + return true; + + const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id); + + for (const dacl of dacls) { + if (!dacl.interfaces || dacl.interfaces.includes(scryptedInterface)) + return false; + } + + return true; + } + + shouldRejectMethod(id: string, method: string) { + if (this.acl.devicesAccessControls === null) + return false; + + if (!this.acl.devicesAccessControls) + return true; + + const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id); + + for (const dacl of dacls) { + if (!dacl.methods || dacl.methods.includes(method)) + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/server/src/plugin/plugin-api.ts b/server/src/plugin/plugin-api.ts index fa088d529..ab11a7aa5 100644 --- a/server/src/plugin/plugin-api.ts +++ b/server/src/plugin/plugin-api.ts @@ -1,4 +1,5 @@ -import type { ScryptedNativeId, ScryptedDevice, Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, ScryptedInterfaceProperty, MediaObject, SystemDeviceState, MediaManager, HttpRequest, ScryptedInterfaceDescriptor } from '@scrypted/types' +import type { Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, MediaManager, MediaObject, ScryptedDevice, ScryptedInterfaceDescriptor, ScryptedInterfaceProperty, ScryptedNativeId, SystemDeviceState } from '@scrypted/types'; +import { AccessControls } from './acl'; export interface PluginLogger { log(level: string, message: string): Promise; @@ -14,7 +15,7 @@ export interface PluginAPI { onDeviceEvent(nativeId: ScryptedNativeId, eventInterface: string, eventData?: any): Promise; onMixinEvent(id: string, nativeId: ScryptedNativeId, eventInterface: string, eventData?: any): Promise; onDeviceRemoved(nativeId: string): Promise; - setStorage(nativeId: string, storage: {[key: string]: any}): Promise; + setStorage(nativeId: string, storage: { [key: string]: any }): Promise; getDeviceById(id: string): Promise; setDeviceProperty(id: string, property: ScryptedInterfaceProperty, value: any): Promise; @@ -22,11 +23,9 @@ export interface PluginAPI { listen(EventListener: (id: string, eventDetails: EventDetails, eventData: any) => void): Promise; listenDevice(id: string, event: string | EventListenerOptions, callback: (eventDetails: EventDetails, eventData: any) => void): Promise; - deliverPush(endpoint: string, request: HttpRequest): Promise; - getLogger(nativeId: ScryptedNativeId): Promise; - getComponent(id: string): Promise; + getComponent(id: string): Promise; getMediaManager(): Promise; @@ -66,57 +65,82 @@ export class PluginAPIManagedListeners { } export class PluginAPIProxy extends PluginAPIManagedListeners implements PluginAPI { + acl: AccessControls; + constructor(public api: PluginAPI, public mediaManager?: MediaManager) { super(); } setScryptedInterfaceDescriptors(typesVersion: string, descriptors: { [scryptedInterface: string]: ScryptedInterfaceDescriptor }): Promise { + this.acl?.deny(); return this.api.setScryptedInterfaceDescriptors(typesVersion, descriptors); } setState(nativeId: ScryptedNativeId, key: string, value: any): Promise { + this.acl?.deny(); return this.api.setState(nativeId, key, value); } onDevicesChanged(deviceManifest: DeviceManifest): Promise { + this.acl?.deny(); return this.api.onDevicesChanged(deviceManifest); } onDeviceDiscovered(device: Device): Promise { + this.acl?.deny(); return this.api.onDeviceDiscovered(device); } onDeviceEvent(nativeId: ScryptedNativeId, eventInterface: any, eventData?: any): Promise { + this.acl?.deny(); return this.api.onDeviceEvent(nativeId, eventInterface, eventData); } onMixinEvent(id: string, nativeId: ScryptedNativeId, eventInterface: string, eventData?: any): Promise { + this.acl?.deny(); return this.api.onMixinEvent(id, nativeId, eventInterface, eventData); } onDeviceRemoved(nativeId: string): Promise { + this.acl?.deny(); return this.api.onDeviceRemoved(nativeId); } setStorage(nativeId: ScryptedNativeId, storage: { [key: string]: any; }): Promise { + this.acl?.deny(); return this.api.setStorage(nativeId, storage); } getDeviceById(id: string): Promise { + if (this.acl?.shouldRejectDevice(id)) + return; return this.api.getDeviceById(id); } setDeviceProperty(id: string, property: ScryptedInterfaceProperty, value: any): Promise { + this.acl?.deny(); return this.api.setDeviceProperty(id, property, value); } removeDevice(id: string): Promise { + this.acl?.deny(); return this.api.removeDevice(id); } async listen(callback: (id: string, eventDetails: EventDetails, eventData: any) => void): Promise { - return this.manageListener(await this.api.listen(callback)); + if (!this.acl) + return this.manageListener(await this.api.listen(callback)); + + return this.manageListener(await this.api.listen((id, details, data) => { + if (!this.acl.shouldRejectEvent(id, details)) + callback(id, details, data); + })); } async listenDevice(id: string, event: string | EventListenerOptions, callback: (eventDetails: EventDetails, eventData: any) => void): Promise { - return this.manageListener(await this.api.listenDevice(id, event, callback)); - } - deliverPush(endpoint: string, request: HttpRequest): Promise { - return this.api.deliverPush(endpoint, request); + if (!this.acl) + return this.manageListener(await this.api.listenDevice(id, event, callback)); + + return this.manageListener(await this.api.listenDevice(id, event, (details, data) => { + if (!this.acl.shouldRejectEvent(id, details)) + callback(details, data); + })); } getLogger(nativeId: ScryptedNativeId): Promise { + this.acl?.deny(); return this.api.getLogger(nativeId); } getComponent(id: string): Promise { + this.acl?.deny(); return this.api.getComponent(id); } async getMediaManager(): Promise { @@ -124,6 +148,7 @@ export class PluginAPIProxy extends PluginAPIManagedListeners implements PluginA } async requestRestart() { + this.acl?.deny(); return this.api.requestRestart(); } } @@ -142,11 +167,11 @@ export interface PluginRemoteLoadZipOptions { } export interface PluginRemote { - loadZip(packageJson: any, zipData: Buffer|string, options?: PluginRemoteLoadZipOptions): Promise; - setSystemState(state: {[id: string]: {[property: string]: SystemDeviceState}}): Promise; - setNativeId(nativeId: ScryptedNativeId, id: string, storage: {[key: string]: any}): Promise; - updateDeviceState(id: string, state: {[property: string]: SystemDeviceState}): Promise; - notify(id: string, eventTime: number, eventInterface: string, property: string|undefined, value: SystemDeviceState|any, changed?: boolean): Promise; + loadZip(packageJson: any, zipData: Buffer | string, options?: PluginRemoteLoadZipOptions): Promise; + setSystemState(state: { [id: string]: { [property: string]: SystemDeviceState } }): Promise; + setNativeId(nativeId: ScryptedNativeId, id: string, storage: { [key: string]: any }): Promise; + updateDeviceState(id: string, state: { [property: string]: SystemDeviceState }): Promise; + notify(id: string, eventTime: number, eventInterface: string, property: string | undefined, value: SystemDeviceState | any, changed?: boolean): Promise; ioEvent(id: string, event: string, message?: any): Promise; @@ -156,5 +181,5 @@ export interface PluginRemote { } export interface MediaObjectRemote extends MediaObject { - getData(): Promise; + getData(): Promise; } diff --git a/server/src/plugin/plugin-device.ts b/server/src/plugin/plugin-device.ts index 20200efd1..600dd4d91 100644 --- a/server/src/plugin/plugin-device.ts +++ b/server/src/plugin/plugin-device.ts @@ -7,6 +7,7 @@ import { PrimitiveProxyHandler, RpcPeer } from "../rpc"; import { ScryptedRuntime } from "../runtime"; import { sleep } from "../sleep"; import { getState } from "../state"; +import { AccessControls } from "./acl"; import { allInterfaceProperties, getInterfaceMethods, getPropertyInterfaces } from "./descriptor"; import { PluginError } from "./plugin-error"; @@ -396,6 +397,11 @@ export class PluginDeviceProxyHandler implements PrimitiveProxyHandler, Scr async apply(target: any, thisArg: any, argArray?: any): Promise { const method = target(); + const { activeRpcPeer } = RpcPeer; + const acl: AccessControls = activeRpcPeer?.tags?.acl; + if (acl?.shouldRejectMethod(this.id, method)) + acl.deny(); + this.ensureProxy(); const pluginDevice = this.scrypted.findPluginDeviceById(this.id); diff --git a/server/src/plugin/plugin-host-api.ts b/server/src/plugin/plugin-host-api.ts index 507f74042..520e3b259 100644 --- a/server/src/plugin/plugin-host-api.ts +++ b/server/src/plugin/plugin-host-api.ts @@ -1,4 +1,4 @@ -import { Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, HttpRequest, MediaManager, ScryptedDevice, ScryptedInterfaceDescriptor, ScryptedInterfaceProperty, ScryptedNativeId } from '@scrypted/types'; +import { Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, MediaManager, ScryptedDevice, ScryptedInterfaceDescriptor, ScryptedInterfaceProperty, ScryptedNativeId } from '@scrypted/types'; import debounce from 'lodash/debounce'; import { Plugin } from '../db-types'; import { Logger } from '../logger'; @@ -21,7 +21,6 @@ export class PluginHostAPI extends PluginAPIManagedListeners implements PluginAP 'onDeviceEvent', 'setStorage', 'setDeviceProperty', - 'deliverPush', 'requestRestart', "setState", ]; @@ -76,10 +75,6 @@ export class PluginHostAPI extends PluginAPIManagedListeners implements PluginAP return this.mediaManager; } - async deliverPush(endpoint: string, httpRequest: HttpRequest) { - return this.scrypted.deliverPush(endpoint, httpRequest); - } - async getLogger(nativeId: ScryptedNativeId): Promise { const device = this.scrypted.findPluginDevice(this.pluginId, nativeId); return this.scrypted.getDeviceLogger(device); diff --git a/server/src/plugin/plugin-host.ts b/server/src/plugin/plugin-host.ts index ef76f5020..8e92bae55 100644 --- a/server/src/plugin/plugin-host.ts +++ b/server/src/plugin/plugin-host.ts @@ -9,13 +9,14 @@ import path from 'path'; import rimraf from 'rimraf'; import { Duplex } from 'stream'; import WebSocket from 'ws'; -import { Plugin } from '../db-types'; +import { Plugin, ScryptedUser } from '../db-types'; import { IOServer, IOServerSocket } from '../io'; import { Logger } from '../logger'; import { RpcPeer } from '../rpc'; import { createDuplexRpcPeer, createRpcSerializer } from '../rpc-serializer'; import { ScryptedRuntime } from '../runtime'; import { sleep } from '../sleep'; +import { AccessControls } from './acl'; import { MediaManagerHostImpl } from './media'; import { PluginAPIProxy, PluginRemote, PluginRemoteLoadZipOptions } from './plugin-api'; import { ConsoleServer, createConsoleServer } from './plugin-console'; @@ -134,6 +135,12 @@ export class PluginHost { this.io.on('connection', async (socket) => { try { + const { + accessControls, + endpointRequest, + pluginDevice, + } = (socket.request as any).scrypted; + try { if (socket.request.url.indexOf('/engine.io/api') !== -1) { if (socket.request.url.indexOf('/public') !== -1) { @@ -141,7 +148,7 @@ export class PluginHost { return; } - await this.createRpcIoPeer(socket); + await this.createRpcIoPeer(socket, accessControls); return; } } @@ -150,10 +157,6 @@ export class PluginHost { return; } - const { - endpointRequest, - pluginDevice, - } = (socket.request as any).scrypted; const handler = this.scrypted.getDevice(pluginDevice._id); @@ -340,7 +343,7 @@ export class PluginHost { }); this.worker.on('rpc', (message, sendHandle) => { - const socket = sendHandle as net.Socket; + const socket = sendHandle as net.Socket; const { pluginId } = message; const host = this.scrypted.plugins[pluginId]; if (!host) { @@ -355,7 +358,7 @@ export class PluginHost { } } - async createRpcIoPeer(socket: IOServerSocket) { + async createRpcIoPeer(socket: IOServerSocket, accessControls: AccessControls) { const serializer = createRpcSerializer({ sendMessageBuffer: buffer => socket.send(buffer), sendMessageFinish: message => socket.send(JSON.stringify(message)), @@ -378,11 +381,13 @@ export class PluginHost { reject?.(e); } }); + rpcPeer.tags.acl = accessControls; serializer.setupRpcPeer(rpcPeer); // wrap the host api with a connection specific api that can be torn down on disconnect const createMediaManager = await this.peer.getParam('createMediaManager'); const api = new PluginAPIProxy(this.api, await createMediaManager()); + api.acl = accessControls; const kill = () => { serializer.onDisconnected(); api.removeListeners(); diff --git a/server/src/plugin/plugin-http.ts b/server/src/plugin/plugin-http.ts index c106e3852..320975621 100644 --- a/server/src/plugin/plugin-http.ts +++ b/server/src/plugin/plugin-http.ts @@ -95,6 +95,7 @@ export abstract class PluginHttp { url: req.url, isPublicEndpoint, username: res.locals.username, + aclId: res.locals.aclId, }; if (isEngineIOEndpoint && !isUpgrade && isPublicEndpoint) { diff --git a/server/src/plugin/plugin-remote.ts b/server/src/plugin/plugin-remote.ts index b08634305..5572242be 100644 --- a/server/src/plugin/plugin-remote.ts +++ b/server/src/plugin/plugin-remote.ts @@ -1,5 +1,6 @@ -import { Device, DeviceManager, DeviceManifest, DeviceState, EndpointManager, HttpRequest, Logger, MediaManager, ScryptedInterface, ScryptedInterfaceProperty, ScryptedMimeTypes, ScryptedNativeId, ScryptedStatic, SystemDeviceState, SystemManager } from '@scrypted/types'; +import { Device, DeviceManager, DeviceManifest, DeviceState, EndpointManager, Logger, MediaManager, ScryptedInterface, ScryptedInterfaceProperty, ScryptedMimeTypes, ScryptedNativeId, ScryptedStatic, SystemDeviceState, SystemManager } from '@scrypted/types'; import { RpcPeer, RPCResultError } from '../rpc'; +import { AccessControls } from './acl'; import { BufferSerializer } from './buffer-serializer'; import { PluginAPI, PluginLogger, PluginRemote, PluginRemoteLoadZipOptions } from './plugin-api'; import { createWebSocketClass, WebSocketConnectCallbacks, WebSocketConnection, WebSocketMethods, WebSocketSerializer } from './plugin-remote-websocket'; @@ -121,10 +122,6 @@ class EndpointManagerImpl implements EndpointManager { return this.mediaManager.convertMediaObjectToUrl(mo, ScryptedMimeTypes.PushEndpoint); } - async deliverPush(id: string, request: HttpRequest) { - return this.api.deliverPush(id, request); - } - async getPath(nativeId?: string, options?: { public?: boolean; }): Promise { return `/endpoint/${this.getEndpoint(nativeId)}/${options?.public ? 'public/' : ''}` } @@ -133,7 +130,7 @@ class EndpointManagerImpl implements EndpointManager { const protocol = options?.insecure ? 'http' : 'https'; const port = await this.api.getComponent(options?.insecure ? 'SCRYPTED_INSECURE_PORT' : 'SCRYPTED_SECURE_PORT'); const path = await this.getPath(nativeId, options); - const url = `${protocol}://${await this.getUrlSafeIp()}:${port}${path}`; + const url = `${protocol}://${await this.getUrlSafeIp()}:${port}${path}`; return url; } @@ -368,8 +365,48 @@ export async function setupPluginRemote(peer: RpcPeer, api: PluginAPI, pluginId: const getRemote = await peer.getParam('getRemote'); const remote = await getRemote(api, pluginId) as PluginRemote; - await remote.setSystemState(getSystemState()); + const accessControls: AccessControls = peer.tags.acl; + + const getAccessControlDeviceState = (id: string, state?: { [property: string]: SystemDeviceState } ) => { + state = state || getSystemState()[id]; + if (accessControls && state) { + state = Object.assign({}, state); + for (const property of Object.keys(state)) { + if (accessControls.shouldRejectProperty(id, property)) + delete state[property]; + } + let interfaces: ScryptedInterface[] = state.interfaces?.value; + if (interfaces) { + interfaces = interfaces.filter(scryptedInterface => !accessControls.shouldRejectInterface(id, scryptedInterface)); + state.interfaces = { + value: interfaces, + } + } + } + return state; + } + + const getAccessControlSystemState = () => { + let state = getSystemState(); + if (accessControls) { + state = Object.assign({}, state); + for (const id of Object.keys(state)) { + if (accessControls.shouldRejectDevice(id)) { + delete state[id]; + continue; + } + state[id] = getAccessControlDeviceState(id, state[id]); + } + } + + return state; + } + + await remote.setSystemState(getAccessControlSystemState()); api.listen((id, eventDetails, eventData) => { + if (accessControls?.shouldRejectEvent(eventDetails.property === ScryptedInterfaceProperty.id ? eventData : id, eventDetails)) + return; + // ScryptedDevice events will be handled specially and repropagated by the remote. if (eventDetails.eventInterface === ScryptedInterface.ScryptedDevice) { if (eventDetails.property === ScryptedInterfaceProperty.id) { @@ -378,7 +415,7 @@ export async function setupPluginRemote(peer: RpcPeer, api: PluginAPI, pluginId: } else { // a change on anything else is a descriptor update - remote.updateDeviceState(id, getSystemState()[id]); + remote.updateDeviceState(id, getAccessControlDeviceState(id)); } return; } @@ -430,7 +467,7 @@ export function attachPluginRemote(peer: RpcPeer, options?: PluginRemoteAttachOp peer.params.getRemote = async (api: PluginAPI, pluginId: string) => { websocketSerializer.WebSocket = createWebSocketClass((connection, callbacks) => { - const {url} = connection; + const { url } = connection; if (url.startsWith('io://') || url.startsWith('ws://')) { const id = url.substring('xx://'.length); diff --git a/server/src/rpc.ts b/server/src/rpc.ts index 75cb3dc64..d2c7e3eaa 100644 --- a/server/src/rpc.ts +++ b/server/src/rpc.ts @@ -229,11 +229,12 @@ export class RpcPeer { transportSafeArgumentTypes = RpcPeer.getDefaultTransportSafeArgumentTypes(); killed: Promise; killedDeferred: Deferred; + tags: any = {}; static readonly finalizerIdSymbol = Symbol('rpcFinalizerId'); static remotesCollected = 0; static remotesCreated = 0; - + static activeRpcPeer: RpcPeer; static isRpcProxy(value: any) { return !!value?.[RpcPeer.PROPERTY_PROXY_ID]; @@ -507,7 +508,17 @@ export class RpcPeer { return proxy; } - async handleMessage(message: RpcMessage, deserializationContext?: any) { + handleMessage(message: RpcMessage, deserializationContext?: any) { + try { + RpcPeer.activeRpcPeer = this; + this.handleMessageInternal(message, deserializationContext); + } + finally { + RpcPeer.activeRpcPeer = undefined; + } + } + + private async handleMessageInternal(message: RpcMessage, deserializationContext?: any) { if (Object.isFrozen(this.pendingResults)) return; diff --git a/server/src/runtime.ts b/server/src/runtime.ts index 9d545e25e..a60dfd990 100644 --- a/server/src/runtime.ts +++ b/server/src/runtime.ts @@ -1,4 +1,4 @@ -import { Device, DeviceInformation, EngineIOHandler, HttpRequest, HttpRequestHandler, OauthClient, PushHandler, ScryptedDevice, ScryptedInterface, ScryptedInterfaceProperty, ScryptedNativeId } from '@scrypted/types'; +import { Device, DeviceInformation, DeviceProvider, EngineIOHandler, HttpRequest, HttpRequestHandler, OauthClient, ScryptedDevice, ScryptedInterface, ScryptedInterfaceMethod, ScryptedInterfaceProperty, ScryptedNativeId, ScryptedUser as SU } from '@scrypted/types'; import AdmZip from 'adm-zip'; import axios from 'axios'; import * as io from 'engine.io'; @@ -14,13 +14,14 @@ import { PassThrough } from 'stream'; import tar from 'tar'; import { URL } from "url"; import WebSocket, { Server as WebSocketServer } from "ws"; -import { Plugin, PluginDevice, ScryptedAlert } from './db-types'; +import { Plugin, PluginDevice, ScryptedAlert, ScryptedUser } from './db-types'; import { createResponseInterface } from './http-interfaces'; import { getDisplayName, getDisplayRoom, getDisplayType, getProvidedNameOrDefault, getProvidedRoomOrDefault, getProvidedTypeOrDefault } from './infer-defaults'; import { IOServer } from './io'; import { Level } from './level'; import { LogEntry, Logger, makeAlertId } from './logger'; import { hasMixinCycle } from './mixin/mixin-cycle'; +import { AccessControls } from './plugin/acl'; import { PluginDebug } from './plugin/plugin-debug'; import { PluginDeviceProxyHandler } from './plugin/plugin-device'; import { PluginHost } from './plugin/plugin-host'; @@ -34,6 +35,7 @@ import { CORSControl, CORSServer } from './services/cors'; import { Info } from './services/info'; import { PluginComponent } from './services/plugin'; import { ServiceControl } from './services/service-control'; +import { UsersService } from './services/users'; import { getState, ScryptedStateManager, setState } from './state'; interface DeviceProxyPair { @@ -76,6 +78,7 @@ export class ScryptedRuntime extends PluginHttp { alerts = new Alerts(this); corsControl = new CORSControl(this); addressSettings = new AddressSettings(this); + usersService = new UsersService(this); constructor(datastore: Level, insecure: http.Server, secure: https.Server, app: express.Application) { super(app); @@ -91,6 +94,11 @@ export class ScryptedRuntime extends PluginHttp { }); app.all('/engine.io/shell', (req, res) => { + if (res.locals.aclId) { + res.writeHead(401); + res.end(); + return; + } this.shellHandler(req, res); }); @@ -251,21 +259,6 @@ export class ScryptedRuntime extends PluginHttp { }; } - async deliverPush(endpoint: string, request: HttpRequest) { - const { pluginHost, pluginDevice } = await this.getPluginForEndpoint(endpoint); - if (!pluginDevice) { - console.error('plugin device missing for', endpoint); - return; - } - - if (!pluginDevice?.state.interfaces.value.includes(ScryptedInterface.PushHandler)) { - return; - } - - const handler = this.getDevice(pluginDevice._id); - return handler.onPush(request); - } - async shellHandler(req: Request, res: Response) { const isUpgrade = isConnectionUpgrade(req.headers); @@ -377,6 +370,8 @@ export class ScryptedRuntime extends PluginHttp { return this.corsControl; case 'addresses': return this.addressSettings; + case "users": + return this.usersService; } } @@ -392,9 +387,31 @@ export class ScryptedRuntime extends PluginHttp { return packageJson; } - handleEngineIOEndpoint(req: Request, res: ServerResponse, endpointRequest: HttpRequest, pluginData: HttpPluginData) { + async handleEngineIOEndpoint(req: Request, res: ServerResponse & { locals: any }, endpointRequest: HttpRequest, pluginData: HttpPluginData) { const { pluginHost, pluginDevice } = pluginData; + const { username } = res.locals; + let accessControls: AccessControls; + if (username) { + const user = await this.datastore.tryGet(ScryptedUser, username); + if (user.aclId) { + const accessControl = this.getDevice(user.aclId); + try { + const acls = await accessControl.getScryptedUserAccessControl(); + if (acls) { + accessControls = new AccessControls(acls); + if (accessControls.shouldRejectMethod(pluginDevice._id, ScryptedInterfaceMethod.onConnection)) + accessControls.deny(); + } + } + catch (e) { + res.writeHead(401); + res.end(); + return; + } + } + } + if (!pluginHost || !pluginDevice) { console.error('plugin does not exist or is still starting up.'); res.writeHead(500); @@ -405,6 +422,7 @@ export class ScryptedRuntime extends PluginHttp { (req as any).scrypted = { endpointRequest, pluginDevice, + accessControls, }; if ((req as any).upgradeHead) pluginHost.io.handleUpgrade(req, res.socket, (req as any).upgradeHead) @@ -635,7 +653,7 @@ export class ScryptedRuntime extends PluginHttp { this.plugins[pluginId] = pluginHost; for (const pluginDevice of pluginDevices) { - this.getDevice(pluginDevice._id)?.probe().catch(() => {}); + this.getDevice(pluginDevice._id)?.probe().catch(() => { }); } return pluginHost; @@ -691,6 +709,7 @@ export class ScryptedRuntime extends PluginHttp { continue; await this.removeDevice(provided); } + const providerId = device.state?.providerId?.value; device.state = undefined; this.invalidatePluginDevice(device._id); @@ -713,6 +732,8 @@ export class ScryptedRuntime extends PluginHttp { // notify the plugin that a device was removed. const plugin = this.plugins[device.pluginId]; await plugin.remote.setNativeId(device.nativeId, undefined, undefined); + const provider = this.getDevice(providerId); + await provider?.releaseDevice(device._id, device.nativeId); } catch (e) { // may throw if the plugin is killed, etc. diff --git a/server/src/scrypted-server-main.ts b/server/src/scrypted-server-main.ts index e7804feb8..45a74ef23 100644 --- a/server/src/scrypted-server-main.ts +++ b/server/src/scrypted-server-main.ts @@ -25,6 +25,7 @@ import { PluginError } from './plugin/plugin-error'; import { getScryptedVolume } from './plugin/plugin-volume'; import { ONE_DAY_MILLISECONDS, UserToken } from './usertoken'; import os from 'os'; +import { setScryptedUserPassword } from './services/users'; if (!semver.gte(process.version, '16.0.0')) { throw new Error('"node" version out of date. Please update node to v16 or higher.') @@ -123,7 +124,8 @@ async function start() { realm: 'Scrypted', }, async (username, password, callback) => { const user = await db.tryGet(ScryptedUser, username); - if (!user) { + // disallow basic auth for non-admin as it can deploy plugins, etc. + if (!user || user.aclId) { callback(false); return; } @@ -188,7 +190,7 @@ async function start() { const userToken = getSignedLoginUserToken(req); if (userToken) { - const { username } = userToken; + const { username, aclId } = userToken; // this database lookup on every web request is not necessary, the cookie // itself is the auth, and is signed. furthermore, this is currently @@ -202,6 +204,7 @@ async function start() { // } res.locals.username = username; + res.locals.aclId = aclId; } else if (req.headers.authorization?.startsWith('Bearer ')) { const [checkHash, ...tokenParts] = req.headers.authorization.substring('Bearer '.length).split('#'); @@ -216,6 +219,7 @@ async function start() { const userToken = validateToken(tokenPart); if (userToken) res.locals.username = userToken.username; + res.locals.aclId = userToken.aclId; } } } @@ -240,7 +244,7 @@ async function start() { // verify all plugin related requests have some sort of auth app.all('/web/component/*', (req, res, next) => { - if (!res.locals.username) { + if (!res.locals.username || res.locals.aclId) { res.status(401); res.send('Not Authorized'); return; @@ -466,7 +470,7 @@ async function start() { return; } - const userToken = new UserToken(username, timestamp, maxAge); + const userToken = new UserToken(username, user.aclId, timestamp, maxAge); const login_user_token = userToken.toString(); res.cookie(getLoginUserToken(req.secure), login_user_token, { maxAge, @@ -476,9 +480,7 @@ async function start() { }); if (change_password) { - user.salt = crypto.randomBytes(64).toString('base64'); - user.passwordHash = crypto.createHash('sha256').update(user.salt + change_password).digest().toString('hex'); - user.passwordDate = timestamp; + setScryptedUserPassword(user, change_password, timestamp); await db.upsert(user); } @@ -502,14 +504,12 @@ async function start() { const user = new ScryptedUser(); user._id = username; - user.salt = crypto.randomBytes(64).toString('base64'); - user.passwordHash = crypto.createHash('sha256').update(user.salt + password).digest().toString('hex'); - user.passwordDate = timestamp; + setScryptedUserPassword(user, password, timestamp); user.token = crypto.randomBytes(16).toString('hex'); await db.upsert(user); hasLogin = true; - const userToken = new UserToken(username, timestamp); + const userToken = new UserToken(username, user.aclId, timestamp); const login_user_token = userToken.toString(); res.cookie(getLoginUserToken(req.secure), login_user_token, { maxAge, diff --git a/server/src/services/plugin.ts b/server/src/services/plugin.ts index a54c0f922..90e89aacf 100644 --- a/server/src/services/plugin.ts +++ b/server/src/services/plugin.ts @@ -29,12 +29,10 @@ export class PluginComponent { await this.reload(pluginDevice.pluginId); } - getNativeId(id: string) { - return this.scrypted.findPluginDeviceById(id)?.nativeId; - } getStorage(id: string) { return this.scrypted.findPluginDeviceById(id)?.storage || {}; } + async setStorage(id: string, storage: { [key: string]: string }) { const pluginDevice = this.scrypted.findPluginDeviceById(id); pluginDevice.storage = storage; @@ -65,17 +63,9 @@ export class PluginComponent { async getIdForNativeId(pluginId: string, nativeId: ScryptedNativeId) { return this.scrypted.findPluginDevice(pluginId, nativeId)?._id; } - /** - * @deprecated available as device.pluginId now. - * Remove at some point after core/ui rolls out 6/20/2022. - */ - async getPluginId(id: string) { - const pluginDevice = this.scrypted.findPluginDeviceById(id); - return pluginDevice.pluginId; - } async reload(pluginId: string) { const plugin = await this.scrypted.datastore.tryGet(Plugin, pluginId); - await this.scrypted.runPlugin(plugin); + this.scrypted.runPlugin(plugin); } async kill(pluginId: string) { return this.scrypted.plugins[pluginId]?.kill(); diff --git a/server/src/services/users.ts b/server/src/services/users.ts new file mode 100644 index 000000000..89a12400b --- /dev/null +++ b/server/src/services/users.ts @@ -0,0 +1,43 @@ +import { ScryptedUser } from "../db-types"; +import { ScryptedRuntime } from "../runtime"; +import crypto from 'crypto'; + +export class UsersService { + constructor(public scrypted: ScryptedRuntime) { + } + + async getAllUsers() { + const users: ScryptedUser[] = []; + for await (const user of this.scrypted.datastore.getAll(ScryptedUser)) { + users.push(user); + } + + return users.map(user => ({ + username: user._id, + admin: !user.aclId, + })); + } + + async removeUser(username: string) { + await this.scrypted.datastore.removeId(ScryptedUser, username); + } + + async removeAllUsers() { + await this.scrypted.datastore.removeAll(ScryptedUser); + } + + async addUser(username: string, password: string, aclId: string) { + const user = new ScryptedUser(); + user._id = username; + user.aclId = aclId; + setScryptedUserPassword(user, password, Date.now()); + await this.scrypted.datastore.upsert(user); + } +} + +export function setScryptedUserPassword(user: ScryptedUser, password: string, timestamp: number) { + user.salt = crypto.randomBytes(64).toString('base64'); + user.passwordHash = crypto.createHash('sha256').update(user.salt + password).digest().toString('hex'); + user.passwordDate = timestamp; + user.token = crypto.randomBytes(16).toString('hex'); +} diff --git a/server/src/usertoken.ts b/server/src/usertoken.ts index eace8c39a..bb23b4564 100644 --- a/server/src/usertoken.ts +++ b/server/src/usertoken.ts @@ -2,21 +2,27 @@ export const ONE_DAY_MILLISECONDS = 86400000; export const ONE_YEAR_MILLISECONDS = ONE_DAY_MILLISECONDS * 365; export class UserToken { - constructor(public username: string, public timestamp = Date.now(), public duration = ONE_DAY_MILLISECONDS) { + constructor(public username: string, public aclId: string, public timestamp = Date.now(), public duration = ONE_DAY_MILLISECONDS) { } static validateToken(token: string): UserToken { - let json: any; + let json: { + u: string, + a: string, + t: number, + d: number, + }; try { json = JSON.parse(token); } catch (e) { throw new Error('Token malformed, unparseable.'); } - let { u, t, d } = json; + let { u, a, t, d } = json; u = u?.toString(); - t = parseInt(t); - d = parseInt(d); + t = parseInt(t?.toString()); + d = parseInt(d?.toString()); + a = a?.toString(); if (!u || !t || !d) throw new Error('Token malformed, missing properties.'); if (d > ONE_YEAR_MILLISECONDS) @@ -25,12 +31,13 @@ export class UserToken { throw new Error('Token from the future.'); if (t + d < Date.now()) throw new Error('Token expired.'); - return new UserToken(u, t, d); + return new UserToken(u, a, t, d); } toString(): string { return JSON.stringify({ u: this.username, + a: this.aclId, t: this.timestamp, d: this.duration, })