From 050cbf65384d36404e8bbc70cfa7c54e7153f767 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Thu, 13 Sep 2018 20:31:46 -0400
Subject: [PATCH 01/21] feature: provide a way for devices to obtain a security
token and new user to sign up
---
lib/deltacache.js | 9 +-
lib/dummysecurity.js | 13 +-
lib/index.js | 38 +-
lib/interfaces/plugins.js | 33 +-
lib/interfaces/rest.js | 9 +-
lib/interfaces/ws.js | 150 ++++--
lib/put.js | 205 ++++---
lib/requestResponse.js | 107 ++++
lib/security.js | 75 ++-
lib/serverroutes.js | 228 ++++----
lib/tokensecurity.js | 498 +++++++++++++++---
packages/server-admin-ui/src/actions.js | 4 +-
.../src/components/Sidebar/Sidebar.js | 43 +-
.../src/containers/Full/Full.js | 13 +-
packages/server-admin-ui/src/index.js | 9 +-
.../src/views/security/AccessRequests.js | 206 ++++++++
.../src/views/security/Devices.js | 275 ++++++++++
.../views/{ => security}/EnableSecurity.js | 4 +-
.../src/views/{ => security}/Login.js | 15 +-
.../src/views/security/Register.js | 114 ++++
.../src/views/security/Settings.js | 196 +++++++
.../views/{Security.js => security/Users.js} | 120 +----
test/security.js | 161 +++++-
23 files changed, 2036 insertions(+), 489 deletions(-)
create mode 100644 lib/requestResponse.js
create mode 100644 packages/server-admin-ui/src/views/security/AccessRequests.js
create mode 100644 packages/server-admin-ui/src/views/security/Devices.js
rename packages/server-admin-ui/src/views/{ => security}/EnableSecurity.js (97%)
rename packages/server-admin-ui/src/views/{ => security}/Login.js (89%)
create mode 100644 packages/server-admin-ui/src/views/security/Register.js
create mode 100644 packages/server-admin-ui/src/views/security/Settings.js
rename packages/server-admin-ui/src/views/{Security.js => security/Users.js} (71%)
diff --git a/lib/deltacache.js b/lib/deltacache.js
index f51cc8e04..0be38fb71 100644
--- a/lib/deltacache.js
+++ b/lib/deltacache.js
@@ -152,11 +152,10 @@ DeltaCache.prototype.getCachedDeltas = function (user, contextFilter, key) {
deltas = deltas.map(toDelta)
- if (this.app.securityStrategy.shouldFilterDeltas()) {
- deltas = deltas.filter(delta => {
- return this.app.securityStrategy.filterReadDelta(user, delta)
- })
- }
+ deltas = deltas.filter(delta => {
+ return this.app.securityStrategy.filterReadDelta(user, delta)
+ })
+
return deltas
}
diff --git a/lib/dummysecurity.js b/lib/dummysecurity.js
index 674430f52..e319cbebe 100644
--- a/lib/dummysecurity.js
+++ b/lib/dummysecurity.js
@@ -86,6 +86,13 @@ module.exports = function(app, config) {
return false;
},
- addAdminMiddleware: () => {}
- };
-};
+ addAdminMiddleware: () => {},
+
+ allowReadOnly: () => {
+ return true;
+ },
+
+ supportsLogin: () => false
+ }
+}
+
diff --git a/lib/index.js b/lib/index.js
index d5fd34dd5..48972d4c4 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -36,7 +36,12 @@ const express = require('express'),
getPrimaryPort = ports.getPrimaryPort,
getSecondaryPort = ports.getSecondaryPort,
getExternalPort = ports.getExternalPort,
- { startSecurity, getCertificateOptions } = require('./security.js'),
+ {
+ startSecurity,
+ getCertificateOptions,
+ getSecurityConfig,
+ saveSecurityConfig
+ } = require('./security.js'),
{ startDeltaStatistics, incDeltaStatistics } = require('./deltastats'),
DeltaChain = require('./deltachain')
@@ -415,34 +420,3 @@ Server.prototype.stop = function(cb) {
}
})
}
-
-function pathForSecurityConfig(app) {
- return path.join(app.config.configPath, 'security.json')
-}
-
-function saveSecurityConfig(app, data, callback) {
- const config = JSON.parse(JSON.stringify(data))
- const path = pathForSecurityConfig(app)
- fs.writeFile(path, JSON.stringify(data, null, 2), err => {
- if (!err) {
- fs.chmodSync(path, '600')
- }
- if (callback) {
- callback(err)
- }
- })
-}
-
-function getSecurityConfig(app) {
- try {
- const optionsAsString = fs.readFileSync(pathForSecurityConfig(app), 'utf8')
- try {
- return JSON.parse(optionsAsString)
- } catch (e) {
- console.error('Could not parse security config')
- return {}
- }
- } catch (e) {
- return {}
- }
-}
diff --git a/lib/interfaces/plugins.js b/lib/interfaces/plugins.js
index 477f55ea6..d3bad336d 100644
--- a/lib/interfaces/plugins.js
+++ b/lib/interfaces/plugins.js
@@ -23,6 +23,7 @@ const modulesWithKeyword = require('../modules').modulesWithKeyword
const getLogger = require('../logging').getLogger
const _putPath = require('../put').putPath
const { getModulePublic } = require('../config/get')
+const { queryRequest } = require('../requestResponse')
// #521 Returns path to load plugin-config assets.
const getPluginConfigPublic = getModulePublic('@signalk/plugin-config')
@@ -177,12 +178,35 @@ module.exports = function (app) {
return _.get(app.signalk.retrieve(), path)
}
- function putSelfPath (path, value) {
- return _putPath(app, `vessels.self.${path}`, { value: value })
+ function putSelfPath (path, value, updateCb) {
+ return _putPath(
+ app,
+ 'vessels.self',
+ path,
+ { value: value },
+ null,
+ null,
+ updateCb
+ )
}
- function putPath (path, value) {
- return _putPath(app, path, { value: value })
+ function putPath (path, value, updateCb) {
+ var parts = path.length > 0 ? path.split('.') : []
+
+ if (parts.length > 2) {
+ var context = `${parts[0]}.${parts[1]}`
+ var skpath = parts.slice(2).join('.')
+ }
+
+ return _putPath(
+ app,
+ context,
+ skpath,
+ { value: value },
+ null,
+ null,
+ updateCb
+ )
}
function registerPlugin (app, pluginName, metadata, location) {
@@ -242,6 +266,7 @@ module.exports = function (app) {
getPath,
putSelfPath,
putPath,
+ queryRequest,
error: msg => {
console.error(`${packageName}:${msg}`)
},
diff --git a/lib/interfaces/rest.js b/lib/interfaces/rest.js
index 8b335496b..44263bd58 100644
--- a/lib/interfaces/rest.js
+++ b/lib/interfaces/rest.js
@@ -106,7 +106,7 @@ module.exports = function (app) {
return
}
var last = app.deltaCache.buildFullFromDeltas(
- req.skUser,
+ req.skPrincipal,
deltas
)
sendResult(last, path)
@@ -114,7 +114,7 @@ module.exports = function (app) {
)
}
} else {
- var last = app.deltaCache.buildFull(req.skUser, path)
+ var last = app.deltaCache.buildFull(req.skPrincipal, path)
sendResult(last, path)
}
})
@@ -149,7 +149,10 @@ module.exports = function (app) {
server: {
id: 'signalk-server-node',
version: app.config.version
- }
+ },
+ authenticationRequired: app.securityStrategy.isDummy()
+ ? 'never'
+ : app.securityStrategy.allowReadOnly() ? 'forWrite' : 'always'
})
})
},
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index a0858410e..39b391293 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -18,6 +18,7 @@ const _ = require('lodash')
const ports = require('../ports')
const cookie = require('cookie')
const { getSourceId } = require('@signalk/signalk-schema')
+const { requestAccess, InvalidTokenError } = require('../security')
var supportedQuerySubscribeValues = ['self', 'all']
@@ -46,7 +47,7 @@ module.exports = function (app) {
return count
}
- api.handlePut = function (context, path, source, value) {
+ api.handlePut = function (requestId, context, path, source, value) {
var sources = pathSources[path]
if (sources) {
var spark
@@ -105,14 +106,18 @@ module.exports = function (app) {
app.securityStrategy.authorizeWS(req)
authorized()
- var username = _.get(req, 'skUser.id')
- if (username) {
- debug(`authorized username: ${username}`)
- req.source = 'ws.' + username.replace(/\./g, '_')
+ var identifier = _.get(req, 'skPrincipal.identifier')
+ if (identifier) {
+ debug(`authorized username: ${identifier}`)
+ req.source = 'ws.' + identifier.replace(/\./g, '_')
}
} catch (error) {
// console.error(error)
- authorized(error)
+ if (error instanceof InvalidTokenError) {
+ authorized(error)
+ } else {
+ authorized()
+ }
}
})
}
@@ -126,7 +131,7 @@ module.exports = function (app) {
var aclFilter = delta => {
var filtered = app.securityStrategy.filterReadDelta(
- spark.request.skUser,
+ spark.request.skPrincipal,
delta
)
if (filtered) {
@@ -134,14 +139,15 @@ module.exports = function (app) {
}
}
- if (app.securityStrategy.shouldFilterDeltas()) {
- onChange = aclFilter
- }
+ onChange = aclFilter
var unsubscribes = []
spark.on('data', function (msg) {
debug('<' + JSON.stringify(msg))
+ if (msg.token) {
+ spark.request.token = msg.token
+ }
if (msg.updates) {
if (!app.securityStrategy.shouldAllowWrite(spark.request, msg)) {
debug('security disallowed update')
@@ -198,18 +204,16 @@ module.exports = function (app) {
msg,
unsubscribes,
spark.write.bind(this),
- app.securityStrategy.shouldFilterDeltas()
- ? msg => {
- var filtered = app.securityStrategy.filterReadDelta(
- spark.request,
- msg
- )
- if (filtered) {
- spark.write(filtered)
- }
+ msg => {
+ var filtered = app.securityStrategy.filterReadDelta(
+ spark.request,
+ msg
+ )
+ if (filtered) {
+ spark.write(filtered)
}
- : spark.write.bind(this),
- spark.request.skUser
+ },
+ spark.request.skPrincipal
)
}
if (
@@ -221,6 +225,13 @@ module.exports = function (app) {
unsubscribes.forEach(unsubscribe => unsubscribe())
app.signalk.removeListener('delta', onChange)
}
+
+ if (msg.accessRequest) {
+ handleAccessRequest(spark, msg)
+ }
+ if (msg.login && app.securityStrategy.supportsLogin()) {
+ handleLoginRequest(spark, msg)
+ }
})
spark.on('end', function () {
@@ -239,11 +250,7 @@ module.exports = function (app) {
if (!spark.query.subscribe || spark.query.subscribe === 'self') {
onChange = function (msg) {
if (!msg.context || msg.context === app.selfContext) {
- if (app.securityStrategy.shouldFilterDeltas()) {
- aclFilter(msg)
- } else {
- spark.write(msg)
- }
+ aclFilter(msg)
}
}
}
@@ -302,13 +309,13 @@ module.exports = function (app) {
if (!spark.query.subscribe || spark.query.subscribe === 'self') {
app.deltaCache
.getCachedDeltas(
- spark.request.skUser,
+ spark.request.skPrincipal,
delta => delta.context === app.selfContext
)
.forEach(delta => spark.write(delta))
} else if (spark.query.subscribe === 'all') {
app.deltaCache
- .getCachedDeltas(spark.request.skUser, delta => true)
+ .getCachedDeltas(spark.request.skPrincipal, delta => true)
.forEach(boundWrite)
}
@@ -337,10 +344,12 @@ module.exports = function (app) {
try {
app.securityStrategy.verifyWS(spark.request)
} catch (error) {
- spark.end(
- '{message: "Connection disconnected by security constraint"}',
- { reconnect: true }
- )
+ if (!spark.skPendingAccessRequest) {
+ spark.end(
+ '{message: "Connection disconnected by security constraint"}',
+ { reconnect: true }
+ )
+ }
return
}
theFunction(msg)
@@ -364,6 +373,83 @@ module.exports = function (app) {
}
}
+ function handleAccessRequest (spark, msg) {
+ if (spark.skPendingAccessRequest) {
+ spark.write({
+ context: app.selfContext,
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ result: 400,
+ message: 'A request has already beem submitted'
+ })
+ } else {
+ requestAccess(
+ app,
+ msg,
+ spark.request.headers['x-forwarded-for'] ||
+ spark.request.connection.remoteAddress,
+ res => {
+ if (res.state === 'COMPLETED') {
+ spark.skPendingAccessRequest = false
+
+ if (res.accessRequest && res.accessRequest.token) {
+ spark.request.token = res.accessRequest.token
+ app.securityStrategy.authorizeWS(spark.request)
+ }
+ }
+ spark.write({
+ context: app.selfContext,
+ ...res
+ })
+ }
+ )
+ .then(res => {
+ if (res.state === 'PENDING') {
+ spark.skPendingAccessRequest = true
+ }
+ // nothing, callback above will get called
+ })
+ .catch(err => {
+ console.log(err.stack)
+ spark.write({
+ context: app.selfContext,
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ result: 502,
+ message: err.message
+ })
+ })
+ }
+ }
+
+ function handleLoginRequest (spark, msg) {
+ app.securityStrategy
+ .login(msg.login.username, msg.login.password)
+ .then(reply => {
+ if (reply.token) {
+ spark.request.token = reply.token
+ app.securityStrategy.authorizeWS(spark.request)
+ }
+ spark.write({
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ result: reply.result,
+ login: {
+ token: reply.token
+ }
+ })
+ })
+ .catch(err => {
+ console.error(err)
+ spark.write({
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ result: 502,
+ message: err.message
+ })
+ })
+ }
+
return api
}
diff --git a/lib/put.js b/lib/put.js
index e329c764d..9822ab875 100644
--- a/lib/put.js
+++ b/lib/put.js
@@ -1,6 +1,7 @@
const _ = require('lodash')
const debug = require('debug')('signalk-server:put')
const uuidv4 = require('uuid/v4')
+const { createRequest, updateRequest } = require('./requestResponse')
const pathPrefix = '/signalk'
const versionPrefix = '/v1'
@@ -57,29 +58,26 @@ module.exports = {
}
path = path.replace(/\/$/, '').replace(/\//g, '.')
- var actionResult = putPath(app, path, value, req)
- if (actionResult.state === State.denied) {
- res.status(403)
- } else if (actionResult.state === State.noSource) {
- res.status(400)
- res.send(
- 'there are multiple sources for the given path, but no source was specified in the request'
- )
- actionResult = null
- } else if (actionResult.state === State.completed) {
- res.status(actionResult.resultStatus || 200)
- } else if (actionResult.state === State.pending) {
- if (req.skUser) {
- actions[actionResult.action.id].user = req.skUser.id
- }
- res.status(202)
- } else {
- res.status(405)
- }
- if (actionResult) {
- res.json(actionResult)
+ var parts = path.length > 0 ? path.split('.') : []
+
+ if (parts.length < 3) {
+ res.status(400).send('invalid path')
+ return
}
+
+ var context = `${parts[0]}.${parts[1]}`
+ var skpath = parts.slice(2).join('.')
+
+ putPath(app, context, skpath, value, req)
+ .then(reply => {
+ res.status(reply.result)
+ res.json(reply)
+ })
+ .catch(err => {
+ console.error(err)
+ res.status(500).send(err.message)
+ })
})
},
@@ -87,69 +85,118 @@ module.exports = {
putPath: putPath
}
-function putPath (app, fullPath, value, req) {
- var path = fullPath.length > 0 ? fullPath.split('.') : []
-
- if (path.length > 2) {
- var context = `${path[0]}.${path[1]}`
- var skpath = path.slice(2).join('.')
-
- if (
- req &&
- app.securityStrategy.shouldAllowPut(req, context, null, skpath) == false
- ) {
- return { state: State.denied }
- }
-
- var handlers = actionHandlers[context]
- ? actionHandlers[context][skpath]
- : null
- var handler
-
- if (_.keys(handlers).length > 0) {
- if (value.source) {
- handler = handlers[value.source]
- } else if (_.keys(handlers).length == 1) {
- handler = _.values(handlers)[0]
- } else {
- return { state: State.noSource }
- }
- }
-
- if (handler) {
- var jobId = uuidv4()
+function putPath (app, context, path, body, req, requestId, updateCb) {
+ debug('received put %s %s %j', context, path, body)
+ return new Promise((resolve, reject) => {
+ createRequest(
+ 'put',
+ {
+ context: context,
+ requestId: requestId,
+ put: { path: path, value: body.value }
+ },
+ req && req.skPrincipal ? req.skPrincipal.identifier : undefined,
+ null,
+ updateCb
+ )
+ .then(request => {
+ if (
+ req &&
+ app.securityStrategy.shouldAllowPut(req, context, null, path) == false
+ ) {
+ updateRequest(request.requestId, 'COMPLETED', { result: 403 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
- var actionResult = handler(context, skpath, value.value, result => {
- asyncCallback(jobId, result)
- })
- if (actionResult.state === State.pending) {
- actions[jobId] = {
- id: jobId,
- path: skpath,
- context: context,
- requestedValue: value.value,
- state: actionResult.state,
- startTime: new Date().toISOString()
+ var handlers = actionHandlers[context]
+ ? actionHandlers[context][path]
+ : null
+ var handler
+
+ if (_.keys(handlers).length > 0) {
+ if (body.source) {
+ handler = handlers[body.source]
+ } else if (_.keys(handlers).length == 1) {
+ handler = _.values(handlers)[0]
+ } else {
+ updateRequest(request.requestId, 'COMPLETED', {
+ result: 400,
+ message:
+ 'there are multiple sources for the given path, but no source was specified in the request'
+ })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
}
- return {
- state: actionResult.state,
- action: {
- id: jobId,
- href: apiPathPrefix + `actions/${jobId}`
+ if (handler) {
+ function fixReply (reply) {
+ if (reply.state === 'FAILURE') {
+ reply.state = 'COMPLETED'
+ reply.result = 502
+ } else if (reply.state === 'SUCCESS') {
+ reply.state = 'COMPLETED'
+ reply.result = 200
+ }
}
+
+ var actionResult = handler(context, path, body.value, reply => {
+ fixReply(reply)
+ updateRequest(request.requestId, reply.state, reply)
+ .then(request => {})
+ .catch(err => {
+ console.error(err)
+ })
+ })
+
+ Promise.resolve(actionResult)
+ .then(result => {
+ fixReply(result)
+ updateRequest(request.requestId, result.state, result)
+ .then(reply => {
+ if (reply.state === 'PENDING') {
+ // backwards compatibility
+ reply.action = { href: reply.href }
+ }
+ resolve(reply)
+ })
+ .catch(reject)
+ })
+ .catch(err => {
+ updateRequest(request.requestId, 'COMPLETED', {
+ result: 500,
+ message: err.message
+ })
+ .then(resolve)
+ .catch(reject)
+ })
+ } else if (
+ app.interfaces['ws'] &&
+ app.interfaces.ws.handlePut(
+ request.requestId,
+ context,
+ path,
+ body.source,
+ body.value
+ )
+ ) {
+ updateRequest(request.requestId, 'PENDING')
+ .then(resolve)
+ .catch(reject)
+ } else {
+ updateRequest(request.requestId, 'COMPLETED', {
+ result: 405,
+ message: `PUT not supported for ${path}`
+ })
+ .then(resolve)
+ .catch(reject)
}
- } else {
- return actionResult
- }
- } else if (
- app.interfaces['ws'] &&
- app.interfaces.ws.handlePut(context, skpath, value.source, value.value)
- ) {
- return { state: State.pending }
- }
- }
- return { state: State.notSupported }
+ })
+ .catch(reject)
+ })
}
function registerActionHandler (context, path, source, callback) {
diff --git a/lib/requestResponse.js b/lib/requestResponse.js
new file mode 100644
index 000000000..edc43b446
--- /dev/null
+++ b/lib/requestResponse.js
@@ -0,0 +1,107 @@
+const uuidv4 = require('uuid/v4')
+const debug = require('debug')('signalk-server:requestResponse')
+const _ = require('lodash')
+
+const requests = {}
+
+function createRequest (type, clientRequest, user, clientIp, updateCb) {
+ return new Promise((resolve, reject) => {
+ let requestId = clientRequest.requestId ? clientRequest.requestId : uuidv4()
+ const request = {
+ requestId: requestId,
+ type: type,
+ clientRequest: clientRequest,
+ ip: clientIp || undefined,
+ date: new Date(),
+ state: 'PENDING',
+ updateCb: updateCb,
+ user: user || undefined
+ }
+ requests[request.requestId] = request
+ debug('createRequest %j', request)
+ resolve(request)
+ })
+}
+
+function createReply (request) {
+ const reply = {
+ state: request.state,
+ requestId: request.requestId,
+ [request.type]: request.data,
+ result: request.result,
+ message: request.message,
+ href: `/signalk/v1/requests/${request.requestId}`,
+ ip: request.ip,
+ user: request.user
+ }
+ debug('createReply %j', reply)
+ return reply
+}
+
+function updateRequest (
+ requestId,
+ state,
+ { result = null, data = null, message = null, percentComplete = null }
+) {
+ return new Promise((resolve, reject) => {
+ const request = requests[requestId]
+
+ if (!request) {
+ reject(new Error('request not found'))
+ } else {
+ if (state) {
+ request.state = state
+ }
+ if (result != null) {
+ request.result = result
+ }
+ if (message) {
+ request.message = message
+ }
+ if (percentComplete != null) {
+ request.percentComplete = percentComplete
+ }
+
+ if (data) {
+ request.data = data
+ }
+
+ const reply = createReply(request)
+ if (request.updateCb) {
+ request.updateCb(reply)
+ }
+ resolve(reply)
+ }
+ })
+}
+
+function queryRequest (requestId) {
+ return new Promise((resolve, reject) => {
+ const request = requests[requestId]
+
+ if (!requestId) {
+ reject(new Error('not found'))
+ return
+ }
+
+ resolve(createReply(request))
+ })
+}
+
+function findRequest (matcher) {
+ return _.values(requests).find(matcher)
+}
+
+function filterRequests (type, state) {
+ return _.values(requests).filter(
+ r => r.type == type && (state === null || r.state == state)
+ )
+}
+
+module.exports = {
+ createRequest,
+ updateRequest,
+ findRequest,
+ filterRequests,
+ queryRequest
+}
diff --git a/lib/security.js b/lib/security.js
index fad21bc3b..ec3cef331 100644
--- a/lib/security.js
+++ b/lib/security.js
@@ -22,9 +22,11 @@ const debug = require('debug')('signalk-server')
const _ = require('lodash')
const dummysecurity = require('./dummysecurity')
-module.exports = {
- startSecurity,
- getCertificateOptions
+class InvalidTokenError extends Error {
+ constructor (...args) {
+ super(...args)
+ Error.captureStackTrace(this, GoodError)
+ }
}
function startSecurity (app, securityConfig) {
@@ -44,21 +46,33 @@ function startSecurity (app, securityConfig) {
securityStrategyModuleName = './tokensecurity'
}
- var config = securityConfig || getSecurityConfig(app)
+ var config = securityConfig || getSecurityConfig(app, true)
app.securityStrategy = require(securityStrategyModuleName)(app, config)
+
+ if (securityConfig) {
+ app.securityStrategy.configFromArguments = true
+ app.securityStrategy.securityConfig = securityConfig
+ }
} else {
app.securityStrategy = dummysecurity((app, config))
}
}
-function getSecurityConfig (app) {
- try {
- const optionsAsString = fs.readFileSync(pathForSecurityConfig(app), 'utf8')
- return JSON.parse(optionsAsString)
- } catch (e) {
- console.error('Could not parse security config')
- console.error(e)
- return {}
+function getSecurityConfig (app, forceRead = false) {
+ if (!forceRead && app.securityStrategy.configFromArguments) {
+ return app.securityStrategy.securityConfig
+ } else {
+ try {
+ const optionsAsString = fs.readFileSync(
+ pathForSecurityConfig(app),
+ 'utf8'
+ )
+ return JSON.parse(optionsAsString)
+ } catch (e) {
+ console.error('Could not parse security config')
+ console.error(e)
+ return {}
+ }
}
}
@@ -67,16 +81,23 @@ function pathForSecurityConfig (app) {
}
function saveSecurityConfig (app, data, callback) {
- const config = JSON.parse(JSON.stringify(data))
- const path = pathForSecurityConfig(app)
- fs.writeFile(path, JSON.stringify(data, null, 2), err => {
- if (!err) {
- fs.chmodSync(path, '600')
- }
+ if (app.securityStrategy.configFromArguments) {
+ app.securityStrategy.securityConfig = data
if (callback) {
- callback(err)
+ callback(null)
}
- })
+ } else {
+ const config = JSON.parse(JSON.stringify(data))
+ const path = pathForSecurityConfig(app)
+ fs.writeFile(path, JSON.stringify(data, null, 2), err => {
+ if (!err) {
+ fs.chmodSync(path, '600')
+ }
+ if (callback) {
+ callback(err)
+ }
+ })
+ }
}
function getCertificateOptions (app, cb) {
@@ -174,3 +195,17 @@ function createCertificateOptions (app, certFile, keyFile, cb) {
}
)
}
+
+function requestAccess (app, request, ip, updateCb) {
+ var config = getSecurityConfig(app)
+ return app.securityStrategy.requestAccess(config, request, ip, updateCb)
+}
+
+module.exports = {
+ startSecurity,
+ getCertificateOptions,
+ getSecurityConfig,
+ saveSecurityConfig,
+ requestAccess,
+ InvalidTokenError
+}
diff --git a/lib/serverroutes.js b/lib/serverroutes.js
index 3688285ac..df467cd73 100644
--- a/lib/serverroutes.js
+++ b/lib/serverroutes.js
@@ -23,8 +23,10 @@ const config = require('./config/config')
const { getHttpPort, getSslPort } = require('./ports')
const express = require('express')
const { getAISShipTypeName } = require('@signalk/signalk-schema')
+const { queryRequest } = require('./requestResponse')
const defaultSecurityStrategy = './tokensecurity'
+const skPrefix = '/signalk/v1'
module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
var securityWasEnabled
@@ -92,141 +94,129 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
}
})
- app.get('/security/users', (req, res, next) => {
+ function getConfigSavingCallback(success, failure, res) {
+ return (err, config) => {
+ if (err) {
+ console.log(err)
+ res.status(500).send(failure)
+ } else if (config) {
+ saveSecurityConfig(app, config, err => {
+ if (err) {
+ console.log(err)
+ res.status(500).send('Unable to save configuration change')
+ return
+ }
+ res.send(success)
+ })
+ } else {
+ res.send(success)
+ }
+ }
+ }
+
+ function checkAllowConfigure(req) {
if (app.securityStrategy.allowConfigure(req)) {
- var config = getSecurityConfig(app)
- res.json(app.securityStrategy.getUsers(config))
+ return true
} else {
res.status(401).json('Security config not allowed')
+ return false
+ }
+ }
+
+ app.get('/security/devices', (req, res, next) => {
+ if (checkAllowConfigure(req)) {
+ var config = getSecurityConfig(app)
+ res.json(app.securityStrategy.getDevices(config))
+ }
+ })
+
+ app.put('/security/devices/:uuid', (req, res, next) => {
+ if (checkAllowConfigure(req)) {
+ var config = getSecurityConfig(app)
+ app.securityStrategy.updateDevice(
+ config,
+ req.params.uuid,
+ req.body,
+ getConfigSavingCallback(
+ 'Device updated',
+ 'Unable to update device',
+ res
+ )
+ )
+ }
+ })
+
+ app.delete('/security/devices/:uuid', (req, res, next) => {
+ if (checkAllowConfigure(req)) {
+ var config = getSecurityConfig(app)
+ app.securityStrategy.deleteDevice(
+ config,
+ req.params.uuid,
+ getConfigSavingCallback(
+ 'Device deleted',
+ 'Unable to delete device',
+ res
+ )
+ )
+ }
+ })
+
+ app.get('/security/users', (req, res, next) => {
+ if (checkAllowConfigure(req)) {
+ var config = getSecurityConfig(app)
+ res.json(app.securityStrategy.getUsers(config))
}
})
app.put('/security/users/:id', (req, res, next) => {
- if (app.securityStrategy.allowConfigure(req)) {
+ if (checkAllowConfigure(req)) {
var config = getSecurityConfig(app)
app.securityStrategy.updateUser(
config,
req.params.id,
req.body,
- (err, config) => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to add user')
- } else if (config) {
- saveSecurityConfig(app, config, err => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to save configuration change')
- return
- }
- res.send('User updated')
- })
- } else {
- res.send('User updated')
- }
- }
+ getConfigSavingCallback('User updated', 'Unable to add user', res)
)
- } else {
- res.status(401).json('security config not allowed')
}
})
app.post('/security/users/:id', (req, res, next) => {
- if (app.securityStrategy.allowConfigure(req)) {
+ if (checkAllowConfigure(req)) {
var config = getSecurityConfig(app)
var user = req.body
user.userId = req.params.id
- app.securityStrategy.addUser(config, user, (err, config) => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to add user')
- } else if (config) {
- saveSecurityConfig(app, config, err => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to save configuration change')
- return
- }
- res.send('User added')
- })
- } else {
- res.send('User added')
- }
- })
- } else {
- res.status(401).json('Security config not allowed')
+ app.securityStrategy.addUser(
+ config,
+ user,
+ getConfigSavingCallback('User added', 'Unable to add user', res)
+ )
}
})
app.put('/security/user/:username/password', (req, res, next) => {
- if (app.securityStrategy.allowConfigure(req)) {
+ if (checkAllowConfigure(req)) {
var config = getSecurityConfig(app)
app.securityStrategy.setPassword(
config,
req.params.username,
req.body,
- (err, config) => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send(err)
- res.send('Unable to change password')
- return
- }
- if (config) {
- saveSecurityConfig(app, config, err => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to save configuration change')
- return
- }
- res.send('Password changed')
- })
- } else {
- res.send('Password changed')
- }
- }
+ getConfigSavingCallback(
+ 'Password changed',
+ 'Unable to change password',
+ err
+ )
)
- } else {
- res.status(401).json('Security config not allowed')
}
})
app.delete('/security/users/:username', (req, res, next) => {
- if (app.securityStrategy.allowConfigure(req)) {
+ if (checkAllowConfigure(req)) {
var config = getSecurityConfig(app)
app.securityStrategy.deleteUser(
config,
req.params.username,
- (err, config) => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to delete user')
- return
- }
- if (config) {
- saveSecurityConfig(app, config, err => {
- if (err) {
- console.log(err)
- res.status(500)
- res.send('Unable to save configuration change')
- return
- }
- res.send('User deleted')
- })
- } else {
- res.send('User deleted')
- }
- }
+ getConfigSavingCallback('User deleted', 'Unable to delete user', res)
)
- } else {
- res.status(401).json('Security config not allowed')
}
})
@@ -240,6 +230,52 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
)
})
+ app.put('/security/access/requests/:identifier/:status', (req, res) => {
+ if (checkAllowConfigure(req)) {
+ var config = getSecurityConfig(app)
+ app.securityStrategy.setAccessRequestStatus(
+ config,
+ req.params.identifier,
+ req.params.status,
+ req.body,
+ getConfigSavingCallback('Request updated', 'Unable update request', res)
+ )
+ }
+ })
+
+ app.get('/security/access/requests', (req, res) => {
+ if (checkAllowConfigure(req)) {
+ res.json(app.securityStrategy.getAccessRequestsResponse())
+ }
+ })
+
+ app.post(`${skPrefix}/access/requests`, (req, res) => {
+ var config = getSecurityConfig(app)
+ let ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress
+ app.securityStrategy
+ .requestAccess(config, { accessRequest: req.body }, ip)
+ .then((reply, config) => {
+ res.status(reply.state === 'PENDING' ? 202 : reply.result)
+ res.json(reply)
+ })
+ .catch(err => {
+ console.log(err.stack)
+ res.status(500).send(err.message)
+ })
+ })
+
+ app.get(`${skPrefix}/requests/:id`, (req, res) => {
+ queryRequest(req.params.id)
+ .then(reply => {
+ res.json(reply)
+ })
+ .catch(err => {
+ console.log(err)
+ res.status(500)
+ res.send(`Unable to check request: ${err.message}`)
+ })
+ })
+
app.get('/settings', (req, res, next) => {
var settings = {
interfaces: {},
diff --git a/lib/tokensecurity.js b/lib/tokensecurity.js
index 169e6556c..5e666bc9c 100644
--- a/lib/tokensecurity.js
+++ b/lib/tokensecurity.js
@@ -22,14 +22,27 @@ const fs = require('fs')
const path = require('path')
const bcrypt = require('bcryptjs')
const getSourceId = require('@signalk/signalk-schema').getSourceId
+const uuidv4 = require('uuid/v4')
+const { InvalidTokenError } = require('./security')
+const {
+ createRequest,
+ updateRequest,
+ findRequest,
+ filterRequests
+} = require('./requestResponse')
const CONFIG_PLUGINID = 'sk-simple-token-security-config'
const passwordSaltRounds = 10
const permissionDeniedMessage =
"You do not have permission to view this resource, Please Login"
+const skPrefix = '/signalk/v1'
+const skAPIPrefix = `${skPrefix}/api`
+const skAuthPrefix = `${skPrefix}/auth`
+
module.exports = function (app, config) {
const strategy = {}
+ let accessRequests = []
let {
allow_readonly = true,
@@ -39,8 +52,11 @@ module.exports = function (app, config) {
.randomBytes(256)
.toString('hex'),
users = [],
+ devices = [],
immutableConfig = false,
- acls = []
+ acls = [],
+ allowDeviceAccessRequests = true,
+ allowNewUserRegistration = true
} = config
if (process.env.ADMINUSER) {
@@ -64,13 +80,26 @@ module.exports = function (app, config) {
immutableConfig = true
}
+ if (process.env.ALLOW_DEVICE_ACCESS_REQUESTS) {
+ allowDeviceAccessRequests =
+ process.env.ALLOW_DEVICE_ACCESS_REQUESTS === 'true'
+ }
+
+ if (process.env.ALLOW_NEW_USER_REGISTRATION) {
+ allowNewUserRegistration =
+ process.env.ALLOW_NEW_USER_REGISTRATION === 'true'
+ }
+
let options = {
allow_readonly,
expiration,
secretKey,
users,
+ devices,
immutableConfig,
- acls
+ acls,
+ allowDeviceAccessRequests,
+ allowNewUserRegistration
}
// so that enableSecurity gets the defaults to save
@@ -109,10 +138,10 @@ module.exports = function (app, config) {
return next()
}
- if (req.skIsAuthenticated && req.skUser) {
- if (req.skUser.type == 'admin') {
+ if (req.skIsAuthenticated && req.skPrincipal) {
+ if (req.skPrincipal.permissions == 'admin') {
return next()
- } else if (req.skUser.id === 'AUTO' && redirect) {
+ } else if (req.skPrincipal.identifier === 'AUTO' && redirect) {
res.redirect('/@signalk/server-admin-ui/#/login')
} else {
handlePermissionDenied(req, res, next)
@@ -130,46 +159,30 @@ module.exports = function (app, config) {
app.use(require('cookie-parser')())
- app.post('/login', function (req, res) {
- try {
- var name = req.body.username
- var password = req.body.password
-
- debug('username: ' + name)
- var configuration = getConfiguration()
-
- var user = configuration.users.find(user => user.username == name)
- if (!user) {
- res.status(401).send('Invalid Username')
- return
- }
- bcrypt.compare(password, user.password, (err, matches) => {
- if (matches == true) {
- var payload = { id: user.username }
- var expiration = configuration.expiration || '1h'
- debug('jwt expiration: ' + expiration)
- var token = jwt.sign(payload, configuration.secretKey, {
- expiresIn: expiration
- })
+ app.post(['/login', `${skAuthPrefix}/login`], (req, res) => {
+ var name = req.body.username
+ var password = req.body.password
- res.cookie('JAUTHENTICATION', token, { httpOnly: true })
+ login(name, password)
+ .then(reply => {
+ if (reply.result === 200) {
+ res.cookie('JAUTHENTICATION', reply.token, { httpOnly: true })
var requestType = req.get('Content-Type')
if (requestType == 'application/json') {
- res.json({ token: token })
+ res.json({ token: reply.token })
} else {
res.redirect(req.body.destination ? req.body.destination : '/')
}
} else {
- debug('password did not match')
- res.status(401).send('Invalid Password')
+ res.status(reply.result).send(reply.message)
}
})
- } catch (err) {
- console.log(err)
- res.status(401).send('Login Failure')
- }
+ .catch(err => {
+ console.log(err)
+ res.status(502).send('Login Failure')
+ })
})
var do_redir = http_authorize(false)
@@ -197,7 +210,10 @@ module.exports = function (app, config) {
debug('skIsAuthenticated: ' + req.skIsAuthenticated)
if (req.skIsAuthenticated) {
- if (req.skUser.type === 'admin' || req.skUser.type === 'readwrite') {
+ if (
+ req.skPrincipal.permissions === 'admin' ||
+ req.skPrincipal.permissions === 'readwrite'
+ ) {
return next()
}
}
@@ -214,7 +230,7 @@ module.exports = function (app, config) {
if (req.skIsAuthenticated) {
if (
['admin', 'readonly', 'readwrite'].find(
- type => req.skUser.type == type
+ type => req.skPrincipal.permissions == type
)
) {
return next()
@@ -235,12 +251,45 @@ module.exports = function (app, config) {
app.use('/loginStatus', http_authorize(false, true))
var no_redir = http_authorize(false)
- app.use('/signalk/v1/*', function (req, res, next) {
+ app.use('/signalk/v1/api/*', function (req, res, next) {
no_redir(req, res, next)
})
app.put('/signalk/v1/*', writeAuthenticationMiddleware(false))
}
+ function login (name, password) {
+ return new Promise((resolve, reject) => {
+ debug('logging in user: ' + name)
+ var configuration = getConfiguration()
+
+ var user = configuration.users.find(user => user.username == name)
+ if (!user) {
+ resolve({ result: 401, message: 'Invalid Username' })
+ return
+ }
+
+ bcrypt.compare(password, user.password, (err, matches) => {
+ if (err) {
+ reject(err)
+ } else if (matches == true) {
+ var payload = { id: user.username }
+ var expiration = configuration.expiration || '1h'
+ debug('jwt expiration: ' + expiration)
+ var token = jwt.sign(payload, configuration.secretKey, {
+ expiresIn: expiration
+ })
+ resolve({ result: 200, token })
+ } else {
+ debug('password did not match')
+ resolve({ result: 401, message: 'Invalid Password' })
+ }
+ })
+ })
+ }
+
+ strategy.supportsLogin = () => true
+ strategy.login = login
+
strategy.addAdminMiddleware = function (path) {
app.use(path, http_authorize(false))
app.use(path, adminAuthenticationMiddleware(false))
@@ -261,11 +310,11 @@ module.exports = function (app, config) {
}
strategy.allowRestart = function (req) {
- return req.skIsAuthenticated && req.skUser.type == 'admin'
+ return req.skIsAuthenticated && req.skPrincipal.permissions == 'admin'
}
strategy.allowConfigure = function (req) {
- return req.skIsAuthenticated && req.skUser.type == 'admin'
+ return req.skIsAuthenticated && req.skPrincipal.permissions == 'admin'
}
strategy.getLoginStatus = function (req) {
@@ -273,11 +322,13 @@ module.exports = function (app, config) {
var result = {
status: req.skIsAuthenticated ? 'loggedIn' : 'notLoggedIn',
readOnlyAccess: configuration.allow_readonly,
- authenticationRequired: true
+ authenticationRequired: true,
+ allowNewUserRegistration: configuration.allowNewUserRegistration,
+ allowDeviceAccessRequests: configuration.allowDeviceAccessRequests
}
if (req.skIsAuthenticated) {
- result.userLevel = req.skUser.type
- result.username = req.skUser.id
+ result.userLevel = req.skPrincipal.permissions
+ result.username = req.skPrincipal.identifier
}
if (configuration.users.length == 0) {
result.noUsers = true
@@ -294,6 +345,7 @@ module.exports = function (app, config) {
strategy.setConfig = (config, newConfig) => {
assertConfigImmutability()
newConfig.users = config.users
+ newConfig.devices = config.devices
newConfig.secretKey = config.secretKey
options = newConfig
return newConfig
@@ -389,10 +441,52 @@ module.exports = function (app, config) {
callback(null, config)
}
+ strategy.getDevices = config => {
+ if (config && config.devices) {
+ return config.devices
+ } else {
+ return []
+ }
+ }
+
+ strategy.deleteDevice = (config, clientId, callback) => {
+ assertConfigImmutability()
+ for (var i = config.devices.length - 1; i >= 0; i--) {
+ if (config.devices[i].clientId == clientId) {
+ config.devices.splice(i, 1)
+ break
+ }
+ }
+ options = config
+ callback(null, config)
+ }
+
+ strategy.updateDevice = (config, clientId, updates, callback) => {
+ assertConfigImmutability()
+ var device = config.devices.find(d => d.clientId == clientId)
+
+ if (!device) {
+ callback(new Error('device not found'))
+ return
+ }
+
+ if (updates.permissions) {
+ device.permissions = updates.permissions
+ }
+
+ if (updates.description) {
+ device.description = updates.description
+ }
+
+ callback(null, config)
+ options = config
+ }
+
strategy.shouldAllowWrite = function (req, delta) {
if (
- req.skUser &&
- (req.skUser.type === 'admin' || req.skUser.type === 'readwrite')
+ req.skPrincipal &&
+ (req.skPrincipal.permissions === 'admin' ||
+ req.skPrincipal.permissions === 'readwrite')
) {
var context =
delta.context === app.selfContext ? 'vessels.self' : delta.context
@@ -405,7 +499,7 @@ module.exports = function (app, config) {
return update.values.find(valuePath => {
return (
strategy.checkACL(
- req.skUser.id,
+ req.skPrincipal.identifier,
context,
valuePath.path,
source,
@@ -423,17 +517,24 @@ module.exports = function (app, config) {
strategy.shouldAllowPut = function (req, context, source, path) {
if (
- req.skUser &&
- (req.skUser.type === 'admin' || req.skUser.type === 'readwrite')
+ req.skPrincipal &&
+ (req.skPrincipal.permissions === 'admin' ||
+ req.skPrincipal.permissions === 'readwrite')
) {
var context = context === app.selfContext ? 'vessels.self' : context
- return strategy.checkACL(req.skUser.id, context, path, source, 'put')
+ return strategy.checkACL(
+ req.skPrincipal.identifier,
+ context,
+ path,
+ source,
+ 'put'
+ )
}
return false
}
- strategy.filterReadDelta = (user, delta) => {
+ strategy.filterReadDelta = (principal, delta) => {
var configuration = getConfiguration()
if (delta.updates && configuration.acls && configuration.acls.length) {
var context =
@@ -444,7 +545,7 @@ module.exports = function (app, config) {
update.values = update.values
.map(valuePath => {
return strategy.checkACL(
- user.id,
+ principal.identifier,
context,
valuePath.path,
update.source,
@@ -458,6 +559,8 @@ module.exports = function (app, config) {
})
.filter(update => update != null)
return delta.updates.length > 0 ? delta : null
+ } else if (!principal) {
+ return null
} else {
return delta
}
@@ -482,7 +585,7 @@ module.exports = function (app, config) {
}
strategy.authorizeWS = function (req) {
- var token = req.query.token,
+ var token = req.token || req.query.token,
error,
payload
@@ -510,20 +613,20 @@ module.exports = function (app, config) {
payload = jwt.decode(token, configuration.secretKey)
if (!payload) {
- error = new Error('Invalid access token')
+ error = new InvalidTokenError('Invalid access token')
} else if (Date.now() / 1000 > payload.exp) {
//
// At this point we have decoded and verified the token. Check if it is
// expired.
//
- error = new Error('Expired access token')
+ error = new InvalidTokenError('Expired access token')
}
}
if (!token || error) {
if (configuration.allow_readonly) {
- req.skUser = { id: 'AUTO', type: 'readonly' }
+ req.skPrincipal = { identifier: 'AUTO', permissions: 'readonly' }
return
} else {
if (!error) {
@@ -535,18 +638,18 @@ module.exports = function (app, config) {
}
//
- // Check if the user is still present and allowed in our db. You could tweak
+ // Check if the user/device is still present and allowed in our db. You could tweak
// this to invalidate a token.
//
- var user = configuration.users.find(user => user.username == payload.id)
- if (!user) {
- error = new Error('Invalid access token')
+
+ var principal = getPrincipal(payload)
+ if (!principal) {
+ error = new InvalidTokenError('Invalid identity')
debug(error.message)
throw error
}
- req.skUser = payload
- req.skUser.type = user.type
+ req.skPrincipal = principal
req.skIsAuthenticated = true
}
@@ -628,6 +731,30 @@ module.exports = function (app, config) {
return configuration.acls && configuration.acls.length > 0
}
+ function getPrincipal (payload) {
+ var principal
+ if (payload.id) {
+ var user = options.users.find(user => user.username == payload.id)
+ if (user) {
+ principal = {
+ identifier: user.username,
+ permissions: user.type
+ }
+ }
+ } else if (payload.device && options.devices) {
+ var device = options.devices.find(
+ device => device.clientId == payload.device
+ )
+ if (device) {
+ principal = {
+ identifier: device.clientId,
+ permissions: device.permissions
+ }
+ }
+ }
+ return principal
+ }
+
function http_authorize (redirect, forLoginStatus) {
// debug('http_authorize: ' + redirect)
return function (req, res, next) {
@@ -652,19 +779,16 @@ module.exports = function (app, config) {
jwt.verify(token, configuration.secretKey, function (err, decoded) {
debug('verify')
if (!err) {
- var user = configuration.users.find(
- user => user.username == decoded.id
- )
- if (user) {
+ var principal = getPrincipal(decoded)
+ if (principal) {
debug('authorized')
- req.skUser = decoded
- req.skUser.type = user.type
+ req.skPrincipal = principal
req.skIsAuthenticated = true
req.userLoggedIn = true
next()
return
} else {
- debug('unknown user: ' + decoded.id)
+ debug('unknown user: ' + (decoded.id || decoded.device))
}
} else {
debug('bad token: ' + req.path)
@@ -682,7 +806,7 @@ module.exports = function (app, config) {
debug('no token')
if (configuration.allow_readonly && !forLoginStatus) {
- req.skUser = { id: 'AUTO', type: 'readonly' }
+ req.skPrincipal = { identifier: 'AUTO', permissions: 'readonly' }
req.skIsAuthenticated = true
return next()
} else {
@@ -701,6 +825,244 @@ module.exports = function (app, config) {
}
}
+ strategy.getAccessRequestsResponse = () => {
+ return filterRequests('accessRequest', 'PENDING')
+ }
+
+ function sendAccessRequestsUpdate () {
+ app.emit('serverevent', {
+ type: 'ACCESS_REQUEST',
+ from: CONFIG_PLUGINID,
+ data: strategy.getAccessRequestsResponse()
+ })
+ }
+
+ strategy.setAccessRequestStatus = (config, identifier, status, body, cb) => {
+ const request = findRequest(
+ r => r.state === 'PENDING' && r.accessIdentifier == identifier
+ )
+ if (!request) {
+ cb(new Error('not found'))
+ return
+ }
+
+ let permissoinPart = request.requestedPermissions
+ ? request.permissions
+ : 'any'
+
+ app.handleMessage(CONFIG_PLUGINID, {
+ context: 'vessels.' + app.selfId,
+ updates: [
+ {
+ values: [
+ {
+ path: `notifications.security.accessRequest.${permissoinPart}.${identifier}`,
+ value: {
+ state: 'normal',
+ method: [],
+ message: `The device "${
+ request.accessDescription
+ }" has been ${status}`,
+ timestamp: new Date().toISOString()
+ }
+ }
+ ]
+ }
+ ]
+ })
+
+ let approved
+ if (status === 'approved') {
+ if (request.clientRequest.accessRequest.clientId) {
+ var payload = { device: identifier }
+ var jwtOptions = {}
+
+ expiration = body.expiration || config.expiration
+ if (expiration !== 'NEVER') {
+ jwtOptions.expiresIn = expiration
+ }
+ var token = jwt.sign(payload, config.secretKey, jwtOptions)
+
+ if (!config.devices) {
+ config.devices = []
+ }
+
+ config.devices = config.devices.filter(d => d.clientId != identifier)
+
+ config.devices.push({
+ clientId: request.accessIdentifier,
+ permissions: !request.requestedPermissions
+ ? body.permissions
+ : request.permissions,
+ config: body.config,
+ description: request.accessDescription,
+ requestedPermissions: request.requestedPermissions
+ })
+ request.token = token
+ } else {
+ config.users.push({
+ username: identifier,
+ password: request.accessPassword,
+ type: body.permissions
+ })
+ }
+ approved = true
+ } else if (status === 'denied') {
+ approved = false
+ } else {
+ cb(new Error('Unkown status value'), config)
+ return
+ }
+
+ updateRequest(request.requestId, 'COMPLETED', {
+ result: 200,
+ data: {
+ permission: approved ? 'APPROVED' : 'DENIED',
+ token: request.token
+ }
+ })
+ .then(reply => {
+ cb(null, config)
+ options = config
+ sendAccessRequestsUpdate()
+ })
+ .catch(err => {
+ cb(err)
+ })
+ }
+
+ function validateAccessRequest (request) {
+ if (request.userId) {
+ return !_.isUndefined(request.password)
+ } else if (request.clientId) {
+ return !_.isUndefined(request.description)
+ } else {
+ return false
+ }
+ }
+
+ strategy.requestAccess = (config, clientRequest, sourceIp, updateCb) => {
+ return new Promise((resolve, reject) => {
+ createRequest('accessRequest', clientRequest, null, sourceIp, updateCb)
+ .then(request => {
+ const accessRequest = clientRequest.accessRequest
+ if (!validateAccessRequest(accessRequest)) {
+ updateRequest(request.requestId, 'COMPLETED', { result: 400 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ request.requestedPermissions = !_.isUndefined(request.permissions)
+ if (!request.requestedPermissions) {
+ request.permissions = 'readonly'
+ }
+
+ var alertMessage
+ var response
+ if (accessRequest.clientId) {
+ if (!options.allowDeviceAccessRequests) {
+ updateRequest(request.requestId, 'COMPLETED', { result: 403 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ if (
+ findRequest(
+ r =>
+ r.state === 'PENDING' &&
+ r.accessIdentifier == accessRequest.clientId
+ )
+ ) {
+ updateRequest(request.requestId, 'COMPLETED', {
+ result: 400,
+ message: `A device with clientId '${
+ accessRequest.clientId
+ }' has already requested access`
+ })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ request.accessIdentifier = accessRequest.clientId
+ request.accessDescription = accessRequest.description
+
+ debug(
+ `A device with IP ${request.ip} and CLIENTID ${
+ accessRequest.clientId
+ } has requested access to the server`
+ )
+ alertMessage = `The device "${
+ accessRequest.description
+ }" has requested access to the server`
+ } else {
+ if (!options.allowNewUserRegistration) {
+ updateRequest(request.requestId, 'COMPLETED', { result: 403 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ var existing = options.users.find(
+ user => user.username == accessRequest.userId
+ )
+ if (existing) {
+ updateRequest(request.requestId, 'COMPLETED', {
+ result: 400,
+ message: 'User already exists'
+ })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+ request.accessDescription = 'New User Request'
+ request.accessIdentifier = accessRequest.userId
+ request.accessPassword = bcrypt.hashSync(
+ request.accessPassword,
+ bcrypt.genSaltSync(passwordSaltRounds)
+ )
+ alertMessage = `${accessRequest.userId} has requested server access`
+ debug(alertMessage)
+ }
+
+ let permissoinPart = request.requestedPermissions
+ ? request.permissions
+ : 'any'
+ sendAccessRequestsUpdate()
+ app.handleMessage(CONFIG_PLUGINID, {
+ context: 'vessels.' + app.selfId,
+ updates: [
+ {
+ values: [
+ {
+ path: `notifications.security.accessRequest.${permissoinPart}.${
+ request.accessIdentifier
+ }`,
+ value: {
+ state: 'alert',
+ method: ['visual', 'sound'],
+ message: alertMessage,
+ timestamp: new Date().toISOString()
+ }
+ }
+ ]
+ }
+ ]
+ })
+ updateRequest(request.requestId, 'PENDING', {
+ href: `${skPrefix}/access/requests/${request.requestId}`
+ })
+ .then(reply => {
+ resolve(reply, config)
+ })
+ .catch(reject)
+ })
+ .catch(reject)
+ })
+ }
+
setupApp()
return strategy
diff --git a/packages/server-admin-ui/src/actions.js b/packages/server-admin-ui/src/actions.js
index d7fdf571d..52e8c00cd 100644
--- a/packages/server-admin-ui/src/actions.js
+++ b/packages/server-admin-ui/src/actions.js
@@ -126,6 +126,7 @@ export const fetchLoginStatus = buildFetchAction('/loginStatus', 'RECEIVE_LOGIN_
export const fetchPlugins = buildFetchAction('/plugins', 'RECEIVE_PLUGIN_LIST')
export const fetchWebapps = buildFetchAction('/webapps', 'RECEIVE_WEBAPPS_LIST')
export const fetchApps = buildFetchAction('/appstore/available', 'RECEIVE_APPSTORE_LIST')
+export const fetchAccessRequests = buildFetchAction('/security/access/requests', 'ACCESS_REQUEST')
export const fetchServerSpecification = buildFetchAction('/signalk', 'RECEIVE_SERVER_SPEC')
export function fetchAllData (dispatch) {
@@ -133,7 +134,8 @@ export function fetchAllData (dispatch) {
fetchWebapps(dispatch)
fetchApps(dispatch)
fetchLoginStatus(dispatch)
- fetchServerSpecification(dispatch)
+ fetchServerSpecification(dispatch),
+ fetchAccessRequests(dispatch)
}
export function openServerEventsConnection (dispatch) {
diff --git a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js
index 6a916544e..e659ed33f 100644
--- a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js
+++ b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js
@@ -154,6 +154,7 @@ const mapStateToProps = state => {
var appUpdates = state.appStore.updates.length
var updatesBadge = null
var availableBadge = null
+ var accessRequestsBadge = null
if (appUpdates > 0) {
updatesBadge = {
variant: 'danger',
@@ -162,6 +163,14 @@ const mapStateToProps = state => {
}
}
+ if ( state.accessRequests.length > 0 ) {
+ accessRequestsBadge = {
+ variant: 'danger',
+ text: `${state.accessRequests.length}`,
+ color: 'danger'
+ }
+ }
+
if (!state.appStore.storeAvailable) {
updatesBadge = availableBadge = {
variant: 'danger',
@@ -244,11 +253,39 @@ const mapStateToProps = state => {
state.loginStatus.authenticationRequired === false ||
state.loginStatus.userLevel == 'admin'
) {
- result.items.push({
+ var security = {
name: 'Security',
url: '/security',
- icon: 'icon-settings'
- })
+ icon: 'icon-settings',
+ badge: accessRequestsBadge,
+ children: [
+ {
+ name: 'Settings',
+ url: '/security/settings'
+ },
+ {
+ name: 'Users',
+ url: '/security/users'
+ }
+ ]
+ }
+ if ( state.loginStatus.allowDeviceAccessRequests ) {
+ security.children.push({
+ name: 'Devices',
+ url: '/security/devices',
+ })
+ }
+ if (
+ state.loginStatus.allowNewUserRegistration ||
+ state.loginStatus.allowDeviceAccessRequests ) {
+ security.children.push({
+ name: 'Access Requests',
+ url: '/security/access/requests',
+ badge: accessRequestsBadge,
+ })
+
+ }
+ result.items.push(security)
}
return result
diff --git a/packages/server-admin-ui/src/containers/Full/Full.js b/packages/server-admin-ui/src/containers/Full/Full.js
index dba53224a..b5701e140 100644
--- a/packages/server-admin-ui/src/containers/Full/Full.js
+++ b/packages/server-admin-ui/src/containers/Full/Full.js
@@ -13,8 +13,12 @@ import Dashboard from '../../views/Dashboard/'
import Webapps from '../../views/Webapps/'
import Apps from '../../views/appstore/Apps/'
import Configuration from '../../views/Configuration'
-import Login from '../../views/Login'
-import Security from '../../views/Security'
+import Login from '../../views/security/Login'
+import SecuritySettings from '../../views/security/Settings'
+import Users from '../../views/security/Users'
+import Devices from '../../views/security/Devices'
+import Register from '../../views/security/Register'
+import AccessRequests from '../../views/security/AccessRequests'
import VesselConfiguration from '../../views/ServerConfig/VesselConfiguration'
import ProvidersConfiguration from '../../views/ServerConfig/ProvidersConfiguration'
import Settings from '../../views/ServerConfig/Settings'
@@ -78,7 +82,12 @@ class Full extends Component {
component={loginOrOriginal(Logging)}
/>
+
+
+
+
+
diff --git a/packages/server-admin-ui/src/index.js b/packages/server-admin-ui/src/index.js
index a893477f0..0d750fca8 100644
--- a/packages/server-admin-ui/src/index.js
+++ b/packages/server-admin-ui/src/index.js
@@ -33,7 +33,8 @@ const state = {
serverSpecification: {},
websocketStatus: 'initial',
webSocket: null,
- restarting: false
+ restarting: false,
+ accessRequests: []
}
let store = createStore(
@@ -159,6 +160,12 @@ let store = createStore(
state.webSocket.close()
}
}
+ if ( action.type === 'ACCESS_REQUEST' ) {
+ return {
+ ...state,
+ accessRequests: action.data
+ }
+ }
return state
},
state,
diff --git a/packages/server-admin-ui/src/views/security/AccessRequests.js b/packages/server-admin-ui/src/views/security/AccessRequests.js
new file mode 100644
index 000000000..217440a38
--- /dev/null
+++ b/packages/server-admin-ui/src/views/security/AccessRequests.js
@@ -0,0 +1,206 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardBody,
+ CardFooter,
+ InputGroup,
+ InputGroupAddon,
+ Input,
+ Form,
+ Col,
+ Label,
+ FormGroup,
+ FormText,
+ Table,
+ Row
+} from 'reactstrap';
+import EnableSecurity from './EnableSecurity'
+
+class AccessRequests extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ selectedRequest: null,
+ accessRequestsApproving: [],
+ accessRequestsDenying: []
+ }
+ this.handleRequestChange = this.handleRequestChange.bind(this)
+ }
+
+ handleAccessRequest(identifier, approved) {
+ var stateKey = approved ? 'accessRequestsApproving' : 'accessRequestsDenying'
+ this.state[stateKey].push(identifier)
+ this.setState({stateKey: this.state})
+
+ var payload = {
+ permissions: this.state.selectedRequest.permissions || 'readonly',
+ config: this.state.selectedRequest.config,
+ expiration: this.state.selectedRequest.expiration || '1y'
+ }
+
+ fetch(`/security/access/requests/${identifier}/${approved ? 'approved' : 'denied'}`, {
+ method: 'PUT',
+ credentials: "include",
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload)
+ }).then(response => response.text())
+ .then(response => {
+ this.state[stateKey] = this.state[stateKey].filter(id => id != identifier)
+ this.setState({
+ stateKey: this.state[stateKey],
+ selectedRequest: null
+ })
+ });
+ }
+
+ requestClicked(event, request, index) {
+ this.setState({
+ selectedRequest: JSON.parse(JSON.stringify(request)),
+ selectedIndex: index
+ }, () => {
+ this.refs['selectedRequest'].scrollIntoView();
+ });
+ }
+
+ handleRequestChange(event) {
+ const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
+ this.state.selectedRequest[event.target.name] = value;
+ this.setState({
+ selectedRequest: this.state.selectedRequest
+ });
+ }
+ handleCancel(event) {
+ this.setState({ selectedRequest: null })
+ }
+
+
+ render () {
+ return (
+
+ {this.props.loginStatus.authenticationRequired === false &&
+
+ }
+ {this.props.loginStatus.authenticationRequired &&
+
+ }
+
+ )
+ }
+}
+
+const mapStateToProps = ({ accessRequests, loginStatus }) => ({ accessRequests, loginStatus })
+
+export default connect(mapStateToProps)(AccessRequests)
+
+function convertPermissions (type) {
+ if (type == 'readonly') {
+ return 'Read Only'
+ } else if (type == 'readwrite') {
+ return 'Read/Write'
+ } else if (type == 'admin') {
+ return 'Admin'
+ } else {
+ return `Unknown ${type}`
+ }
+}
+
+
diff --git a/packages/server-admin-ui/src/views/security/Devices.js b/packages/server-admin-ui/src/views/security/Devices.js
new file mode 100644
index 000000000..5e96af98d
--- /dev/null
+++ b/packages/server-admin-ui/src/views/security/Devices.js
@@ -0,0 +1,275 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardBody,
+ CardFooter,
+ InputGroup,
+ InputGroupAddon,
+ Input,
+ Form,
+ Col,
+ Label,
+ FormGroup,
+ FormText,
+ Table,
+ Row
+} from 'reactstrap'
+import EnableSecurity from './EnableSecurity'
+
+export function fetchSecurityDevices () {
+ fetch(`/security/devices`, {
+ credentials: 'include'
+ })
+ .then(response => response.json())
+ .then(data => {
+ this.setState({ devices: data })
+ })
+}
+
+class Devices extends Component {
+ constructor (props) {
+ super(props)
+ this.state = {
+ devices: [],
+ }
+
+ this.fetchSecurityDevices = fetchSecurityDevices.bind(this)
+ this.handleCancel = this.handleCancel.bind(this)
+ this.handleApply = this.handleApply.bind(this)
+ this.handleDeviceChange = this.handleDeviceChange.bind(this)
+ this.deleteDevice = this.deleteDevice.bind(this)
+ }
+
+ componentDidMount () {
+ if (this.props.loginStatus.authenticationRequired) {
+ this.fetchSecurityDevices()
+ }
+ }
+
+ handleDeviceChange (event) {
+ const value =
+ event.target.type === 'checkbox'
+ ? event.target.checked
+ : event.target.value
+ this.state.selectedDevice[event.target.name] = value
+ this.setState({
+ selectedDevice: this.state.selectedDevice
+ })
+ }
+
+ handleApply (event) {
+ event.preventDefault()
+
+ var payload = {
+ permissions: this.state.selectedDevice.permissions || 'readonly',
+ description: this.state.selectedDevice.description
+ }
+
+ fetch(`/security/devices/${this.state.selectedDevice.clientId}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload),
+ credentials: 'include'
+ })
+ .then(response => response.text())
+ .then(response => {
+ this.setState({
+ selectedDevice: null,
+ selectedIndex: -1
+ })
+ alert(response)
+ this.fetchSecurityDevices()
+ })
+ }
+
+ deleteDevice (event) {
+ fetch(`/security/devices/${this.state.selectedDevice.clientId}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'include'
+ })
+ .then(response => response.text())
+ .then(response => {
+ this.setState({
+ selectedDevice: null,
+ selectedIndex: -1
+ })
+ alert(response)
+ this.fetchSecurityDevices()
+ })
+ }
+
+ deviceClicked (device, index) {
+ this.setState(
+ {
+ selectedDevice: JSON.parse(JSON.stringify(device)),
+ selectedIndex: index
+ },
+ () => {
+ this.refs['selectedDevice'].scrollIntoView()
+ }
+ )
+ }
+
+ handleCancel (event) {
+ this.setState({ selectedDevice: null })
+ }
+ render () {
+ return (
+
+ {this.props.loginStatus.authenticationRequired === false && (
+
+ )}
+ {this.props.loginStatus.authenticationRequired && (
+
+ )}
+
+ )
+ }
+}
+
+const mapStateToProps = ({ securityDevices }) => ({ securityDevices })
+
+export default connect(mapStateToProps)(Devices)
+
+function convertPermissions (type) {
+ if (type == 'readonly') {
+ return 'Read Only'
+ } else if (type == 'readwrite') {
+ return 'Read/Write'
+ } else if (type == 'admin') {
+ return 'Admin'
+ }
+}
diff --git a/packages/server-admin-ui/src/views/EnableSecurity.js b/packages/server-admin-ui/src/views/security/EnableSecurity.js
similarity index 97%
rename from packages/server-admin-ui/src/views/EnableSecurity.js
rename to packages/server-admin-ui/src/views/security/EnableSecurity.js
index 96d206fab..8cc5940f0 100644
--- a/packages/server-admin-ui/src/views/EnableSecurity.js
+++ b/packages/server-admin-ui/src/views/security/EnableSecurity.js
@@ -14,8 +14,8 @@ import {
HelpBlock
} from 'reactstrap'
import { connect } from 'react-redux'
-import { login, enableSecurity, fetchLoginStatus } from '../actions'
-import Dashboard from './Dashboard/'
+import { login, enableSecurity, fetchLoginStatus } from '../../actions'
+import Dashboard from '../Dashboard/'
import Login from './Login'
class EnableSecurity extends Component {
diff --git a/packages/server-admin-ui/src/views/Login.js b/packages/server-admin-ui/src/views/security/Login.js
similarity index 89%
rename from packages/server-admin-ui/src/views/Login.js
rename to packages/server-admin-ui/src/views/security/Login.js
index 24419c017..2118d9c53 100644
--- a/packages/server-admin-ui/src/views/Login.js
+++ b/packages/server-admin-ui/src/views/security/Login.js
@@ -13,10 +13,12 @@ import {
InputGroupAddon,
HelpBlock
} from 'reactstrap'
+import { Link } from 'react-router-dom'
import { connect } from 'react-redux'
-import { login, fetchAllData } from '../actions'
-import Dashboard from './Dashboard/'
+import { login, fetchAllData } from '../../actions'
+import Dashboard from '../Dashboard/'
import EnableSecurity from './EnableSecurity'
+import Register from './Register'
class Login extends Component {
constructor (props) {
@@ -104,7 +106,14 @@ class Login extends Component {
{this.state.loginErrorMessage}
-
+
+ {!this.state.loginErrorMessage && this.props.loginStatus.allowNewUserRegistration &&
+
+
+
+
+
+ }
diff --git a/packages/server-admin-ui/src/views/security/Register.js b/packages/server-admin-ui/src/views/security/Register.js
new file mode 100644
index 000000000..83978ac0f
--- /dev/null
+++ b/packages/server-admin-ui/src/views/security/Register.js
@@ -0,0 +1,114 @@
+import React, {Component} from 'react';
+import {Container, Row, Col, Card, CardBody, CardFooter, Button, Input, InputGroup, InputGroupAddon, FormText} from 'reactstrap';
+
+class Register extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ errorMessage: null,
+ email: '',
+ password: '',
+ confirmPassword: '',
+ registrationSent: false
+ }
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.handleCreate = this.handleCreate.bind(this);
+ }
+
+ handleInputChange(event) {
+ var targetName = event.target.name
+ this.setState({[event.target.name]: event.target.value}, () => {
+ if ( targetName === 'password' ||
+ targetName === 'confirmPassword' &&
+ this.state.password != this.state.confirmPassword ) {
+ this.setState({errorMessage: "Passwords do not match"})
+ } else {
+ this.setState({errorMessage: null})
+ }
+ })
+ }
+
+ handleCreate(event) {
+ if ( this.state.email.length == 0 ) {
+ this.setState({errorMessage: 'Please enter an email address'})
+ } else if ( this.state.password.length == 0 &&
+ this.state.confirmPassword.length == 0 ) {
+ this.setState({errorMessage: 'Please enter and conform your password'})
+ } else if ( this.state.password != this.state.confirmPassword ) {
+ //error message is already thwre
+ return
+ } else {
+ var payload = {
+ userId: this.state.email,
+ password: this.state.password
+ }
+ fetch(`/signalk/v1/access/requests`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ credentials: "include"
+ })
+ .then(response => {
+ if ( response.status != 202 ) {
+ response.json().then(json => {
+ this.setState({errorMessage: json.message ? json.message : json.result})
+ })
+ } else {
+ this.setState({registrationSent: true})
+ }
+ });
+ }
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
+ Register
+ {this.state.registrationSent &&
+ Your registration has been sent
+ }
+ {!this.state.registrationSent &&
+
+ }
+
+ {!this.state.registrationSent &&
+
+
+
+
+
+
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+export default Register;
diff --git a/packages/server-admin-ui/src/views/security/Settings.js b/packages/server-admin-ui/src/views/security/Settings.js
new file mode 100644
index 000000000..5c674b75b
--- /dev/null
+++ b/packages/server-admin-ui/src/views/security/Settings.js
@@ -0,0 +1,196 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+import {
+ Button,
+ Card,
+ CardHeader,
+ CardBody,
+ CardFooter,
+ InputGroup,
+ InputGroupAddon,
+ Input,
+ Form,
+ Col,
+ Label,
+ FormGroup,
+ FormText,
+ Table,
+ Row
+} from 'reactstrap'
+import EnableSecurity from './EnableSecurity'
+
+export function fetchSecurityConfig () {
+ fetch(`/security/config`, {
+ credentials: 'include'
+ })
+ .then(response => response.json())
+ .then(data => {
+ console.log(JSON.stringify(data))
+ this.setState(data)
+ })
+}
+
+class Settings extends Component {
+ constructor (props) {
+ super(props)
+ this.state = {
+ allow_readonly: false,
+ expiration: '',
+ allowNewUserRegistration: true,
+ allowDeviceAccessRequests: true
+ }
+
+ this.handleChange = this.handleChange.bind(this)
+ this.handleSaveConfig = this.handleSaveConfig.bind(this)
+ this.fetchSecurityConfig = fetchSecurityConfig.bind(this)
+ }
+
+ componentDidMount () {
+ if (this.props.loginStatus.authenticationRequired) {
+ this.fetchSecurityConfig()
+ }
+ }
+
+ handleChange (event) {
+ const value =
+ event.target.type === 'checkbox'
+ ? event.target.checked
+ : event.target.value
+ this.setState({ [event.target.name]: value })
+ }
+
+ handleSaveConfig () {
+ var payload = {
+ allow_readonly: this.state.allow_readonly,
+ expiration: this.state.expiration,
+ allowNewUserRegistration: this.state.allowNewUserRegistration,
+ allowDeviceAccessRequests: this.state.allowDeviceAccessRequests
+ }
+ fetch('/security/config', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload),
+ credentials: 'include'
+ })
+ .then(response => response.text())
+ .then(response => {
+ this.fetchSecurityConfig()
+ alert(response)
+ })
+ }
+
+ render () {
+ return (
+
+ {this.props.loginStatus.authenticationRequired === false && (
+
+ )}
+ {this.props.loginStatus.authenticationRequired && (
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+ }
+}
+
+const mapStateToProps = ({ securityConfig }) => ({ securityConfig })
+
+export default connect(mapStateToProps)(Settings)
+
diff --git a/packages/server-admin-ui/src/views/Security.js b/packages/server-admin-ui/src/views/security/Users.js
similarity index 71%
rename from packages/server-admin-ui/src/views/Security.js
rename to packages/server-admin-ui/src/views/security/Users.js
index b655a15db..f9ca76306 100644
--- a/packages/server-admin-ui/src/views/Security.js
+++ b/packages/server-admin-ui/src/views/security/Users.js
@@ -19,16 +19,6 @@ import {
} from 'reactstrap'
import EnableSecurity from './EnableSecurity'
-export function fetchSecurityConfig () {
- fetch(`/security/config`, {
- credentials: 'include'
- })
- .then(response => response.json())
- .then(data => {
- this.setState(data)
- })
-}
-
export function fetchSecurityUsers () {
fetch(`/security/users`, {
credentials: 'include'
@@ -39,20 +29,15 @@ export function fetchSecurityUsers () {
})
}
-class Security extends Component {
+class Users extends Component {
constructor (props) {
super(props)
this.state = {
users: [],
- allow_readonly: false,
- expiration: ''
}
- this.handleChange = this.handleChange.bind(this)
this.handleAddUser = this.handleAddUser.bind(this)
- this.handleSaveConfig = this.handleSaveConfig.bind(this)
this.fetchSecurityUsers = fetchSecurityUsers.bind(this)
- this.fetchSecurityConfig = fetchSecurityConfig.bind(this)
this.handleCancel = this.handleCancel.bind(this)
this.handleApply = this.handleApply.bind(this)
this.handleUserChange = this.handleUserChange.bind(this)
@@ -61,19 +46,10 @@ class Security extends Component {
componentDidMount () {
if (this.props.loginStatus.authenticationRequired) {
- this.fetchSecurityConfig()
this.fetchSecurityUsers()
}
}
- handleChange (event) {
- const value =
- event.target.type === 'checkbox'
- ? event.target.checked
- : event.target.value
- this.setState({ [event.target.name]: value })
- }
-
handleUserChange (event) {
const value =
event.target.type === 'checkbox'
@@ -167,28 +143,7 @@ class Security extends Component {
})
}
- handleSaveConfig () {
- var payload = {
- allow_readonly: this.state.allow_readonly,
- expiration: this.state.expiration
- }
- fetch('/security/config', {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(payload),
- credentials: 'include'
- })
- .then(response => response.text())
- .then(response => {
- this.fetchSecurityConfig()
- alert(response)
- })
- }
-
userClicked (user, index) {
- console.log(JSON.stringify(user))
this.setState(
{
selectedUser: JSON.parse(JSON.stringify(user)),
@@ -211,72 +166,6 @@ class Security extends Component {
)}
{this.props.loginStatus.authenticationRequired && (
-
-
- Settings
-
-
-
-
-
-
-
-
-
Users
@@ -425,12 +314,9 @@ class Security extends Component {
}
}
-const mapStateToProps = ({ securityConfig, securityUsers }) => ({
- securityConfig,
- securityUsers
-})
+const mapStateToProps = ({ securityUsers }) => ({ securityUsers })
-export default connect(mapStateToProps)(Security)
+export default connect(mapStateToProps)(Users)
function convertType (type) {
if (type == 'readonly') {
diff --git a/test/security.js b/test/security.js
index 1fdbde205..a7aa9992b 100644
--- a/test/security.js
+++ b/test/security.js
@@ -47,14 +47,18 @@ const WRITE_USER_NAME = 'writeuser'
const WRITE_USER_PASSWORD = 'writepass'
const LIMITED_USER_NAME = 'testuser'
const LIMITED_USER_PASSWORD = 'verylimited'
+const ADMIN_USER_NAME = 'adminuser'
+const ADMIN_USER_PASSWORD = 'adminpass'
describe('Security', () => {
- var server, url, port, readToken, writeToken
+ var server, url, port, readToken, writeToken, adminToken
before(async function () {
var securityConfig = {
allow_readonly: false,
expiration: '1d',
+ allowNewUserRegistration: true,
+ allowDeviceAccessRequests: true,
secretKey:
'3ad6c2b567c43199e1afd2307ef506ea9fb5f8becada1f86c15213d75124fbaf4647c3f7202b788bba5c01c8bb8fdc52e8ca5bd484be36b6900ac03b88b6063b6157bee1e638acde1936d6ef4717884de63c86e9f50c8ee12b15bf837268b04bc09a461f5dddaf71dfc7205cc549b29810a31515b21d57ac5fdde29628ccff821cfc229004c4864576eb7c238b0cd3a6d774c14854affa1aeedbdb1f47194033f18e50d9dc1171a47e36f26c864080a627c500d1642fc94f71e93ff54022a8d4b00f19e88a0610ef70708ac6a386ba0df7cab201e24d3eb0061ddd0052d3d85cda50ac8d6cafc4ecc43d8db359a85af70d4c977a3d4b0d588f123406dbd57f01',
users: [],
@@ -91,22 +95,20 @@ describe('Security', () => {
port = await freeport()
url = `http://0.0.0.0:${port}`
- const serverApp = new Server(
- {
- config: {
- settings: {
- port,
- interfaces: {
- plugins: false
- },
- security: {
- strategy: './tokensecurity'
- }
+ const serverApp = new Server({
+ config: {
+ settings: {
+ port,
+ interfaces: {
+ plugins: false
+ },
+ security: {
+ strategy: './tokensecurity'
}
}
},
- securityConfig
- )
+ securityConfig: securityConfig
+ })
server = await serverApp.start()
await promisify(server.app.securityStrategy.addUser)(securityConfig, {
@@ -119,8 +121,14 @@ describe('Security', () => {
type: 'readwrite',
password: WRITE_USER_PASSWORD
})
+ await promisify(server.app.securityStrategy.addUser)(securityConfig, {
+ userId: ADMIN_USER_NAME,
+ type: 'admin',
+ password: ADMIN_USER_PASSWORD
+ })
readToken = await login(LIMITED_USER_NAME, LIMITED_USER_PASSWORD)
writeToken = await login(WRITE_USER_NAME, WRITE_USER_PASSWORD)
+ adminToken = await login(ADMIN_USER_NAME, ADMIN_USER_PASSWORD)
})
after(async function () {
@@ -142,7 +150,6 @@ describe('Security', () => {
throw new Error('Login returned ' + result.status)
}
return result.json().then(json => {
- console.log(json)
return json.token
})
}
@@ -244,9 +251,127 @@ describe('Security', () => {
})
it('request after logout fails', async function () {
- var result = await fetch(`${url}/signalk/v1/api/vessels/self`, {
- credentials: 'include'
- })
+ var result = await fetch(`${url}/signalk/v1/api/vessels/self`, {})
result.status.should.equal(401)
})
+
+ it('Device access request and approval works', async function () {
+ var result = await fetch(`${url}/signalk/v1/access/requests`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ clientId: '1235-45653-343453',
+ description: 'My Awesome Sensor',
+ permissions: 'readwrite'
+ })
+ })
+ result.status.should.equal(202)
+ var requestJson = await result.json()
+ requestJson.should.have.property('requestId')
+ requestJson.should.have.property('href')
+
+ var result = await fetch(`${url}${requestJson.href}`)
+ result.status.should.equal(200)
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('PENDING')
+ json.should.have.property('requestId')
+
+ var result = await fetch(
+ `${url}/security/access/requests/1235-45653-343453/approved`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Cookie: `JAUTHENTICATION=${adminToken}`
+ },
+ body: JSON.stringify({
+ expiration: '1y',
+ permissions: 'readwrite'
+ })
+ }
+ )
+ result.status.should.equal(200)
+
+ var result = await fetch(`${url}${requestJson.href}`)
+ result.status.should.equal(200)
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('COMPLETED')
+ json.should.have.property('accessRequest')
+ json.accessRequest.should.have.property('permission')
+ json.accessRequest.permission.should.equal('APPROVED')
+ json.accessRequest.should.have.property('token')
+
+ var result = await fetch(`${url}/security/devices`, {
+ headers: {
+ Cookie: `JAUTHENTICATION=${adminToken}`
+ }
+ })
+ result.status.should.equal(200)
+ var json = await result.json()
+ json.length.should.equal(1)
+ json[0].should.have.property('clientId')
+ json[0].clientId.should.equal('1235-45653-343453')
+ json[0].permissions.should.equal('readwrite')
+ json[0].description.should.equal('My Awesome Sensor')
+ })
+
+ it('Device access request and denial works', async function () {
+ var result = await fetch(`${url}/signalk/v1/access/requests`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ clientId: '1235-45653-343455',
+ description: 'My Awesome Sensor',
+ permissions: 'readwrite'
+ })
+ })
+ result.status.should.equal(202)
+ var requestJson = await result.json()
+ requestJson.should.have.property('requestId')
+ requestJson.should.have.property('href')
+
+ var result = await fetch(`${url}${requestJson.href}`)
+ result.status.should.equal(200)
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('PENDING')
+
+ var result = await fetch(
+ `${url}/security/access/requests/1235-45653-343455/denied`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Cookie: `JAUTHENTICATION=${adminToken}`
+ },
+ body: JSON.stringify({
+ expiration: '1y',
+ permissions: 'readwrite'
+ })
+ }
+ )
+ result.status.should.equal(200)
+
+ var result = await fetch(`${url}${requestJson.href}`)
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('COMPLETED')
+ json.should.have.property('accessRequest')
+ json.accessRequest.should.have.property('permission')
+ json.accessRequest.permission.should.equal('DENIED')
+
+ var result = await fetch(`${url}/security/devices`, {
+ headers: {
+ Cookie: `JAUTHENTICATION=${adminToken}`
+ }
+ })
+ var json = await result.json()
+ json.length.should.equal(1)
+ })
})
From 2f16f921c80c82d4e05947aff8e70651ec9132b0 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Sun, 30 Sep 2018 19:42:35 -0400
Subject: [PATCH 02/21] feature: add ability for ws clients that handle put to
respond
---
lib/interfaces/ws.js | 76 ++++++++++++++++++++++++++++++++++----------
lib/put.js | 19 +++++------
2 files changed, 69 insertions(+), 26 deletions(-)
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index 39b391293..acb9832e7 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -19,6 +19,7 @@ const ports = require('../ports')
const cookie = require('cookie')
const { getSourceId } = require('@signalk/signalk-schema')
const { requestAccess, InvalidTokenError } = require('../security')
+const { findRequest, updateRequest } = require('../requestResponse')
var supportedQuerySubscribeValues = ['self', 'all']
@@ -47,30 +48,71 @@ module.exports = function (app) {
return count
}
+ api.canHandlePut = function (path, source) {
+ const sources = pathSources[path]
+ return sources && (!source || sources[source])
+ }
+
api.handlePut = function (requestId, context, path, source, value) {
- var sources = pathSources[path]
- if (sources) {
- var spark
- if (source) {
- spark = sources[source]
- } else if (_.keys(sources).length == 1) {
- spark = _.values(sources)[0]
- } else {
- console.error(
- 'ERROR: unable to handle put, there are multiple sources, but no source specified in the request'
- )
- return false
- }
+ return new Promise((resolve, reject) => {
+ var sources = pathSources[path]
+ if (sources) {
+ var spark
+ if (source) {
+ spark = sources[source]
+ } else if (_.keys(sources).length == 1) {
+ spark = _.values(sources)[0]
+ } else {
+ updateRequest(requestId, 'COMPLETED', {
+ result: 400,
+ message:
+ 'there are multiple sources for the given path, but no source was specified in the request'
+ })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ if (!spark) {
+ reject(new Error('no spark found'))
+ return
+ }
+
+ var listener = msg => {
+ if (msg.requestId === requestId) {
+ updateRequest(requestId, msg.state, msg)
+ .then(reply => {
+ if (reply.state !== 'PENDING') {
+ spark.removeListener('data', listener)
+ }
+ })
+ .catch(err => {
+ console.error(`could not update requestId ${requestId}`)
+ })
+ }
+ }
+ spark.on('data', listener)
+ setTimeout(() => {
+ const request = findRequest(r => (r.requestId = requestId))
+ if (request && request.state === 'PENDING') {
+ spark.removeListener('data', listener)
+ updateRequest(requestId, 'COMPLETED', { result: 504 })
+ }
+ }, 60 * 1000)
- if (spark) {
spark.write({
+ requestId: requestId,
context: context,
put: [{ path: path, value: value }]
})
- return true
+
+ updateRequest(requestId, 'PENDING', {})
+ .then(resolve)
+ .catch(reject)
+ } else {
+ reject(new Error('no source found'))
}
- }
- return false
+ })
}
api.start = function () {
diff --git a/lib/put.js b/lib/put.js
index 9822ab875..e153326ca 100644
--- a/lib/put.js
+++ b/lib/put.js
@@ -174,16 +174,17 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
.catch(reject)
})
} else if (
- app.interfaces['ws'] &&
- app.interfaces.ws.handlePut(
- request.requestId,
- context,
- path,
- body.source,
- body.value
- )
+ app.interfaces.ws &&
+ app.interfaces.ws.canHandlePut(path, body.source)
) {
- updateRequest(request.requestId, 'PENDING')
+ app.interfaces.ws
+ .handlePut(
+ request.requestId,
+ context,
+ path,
+ body.source,
+ body.value
+ )
.then(resolve)
.catch(reject)
} else {
From a1624b0c82d69729f93dcd886e76ba5453a14775 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Sun, 30 Sep 2018 22:48:01 -0400
Subject: [PATCH 03/21] feature: add support for put via Web Sockets
---
lib/interfaces/ws.js | 41 +++++++++++++++++++++++++++++++++++++----
1 file changed, 37 insertions(+), 4 deletions(-)
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index acb9832e7..8fde292c1 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -20,6 +20,7 @@ const cookie = require('cookie')
const { getSourceId } = require('@signalk/signalk-schema')
const { requestAccess, InvalidTokenError } = require('../security')
const { findRequest, updateRequest } = require('../requestResponse')
+const { putPath } = require('../put')
var supportedQuerySubscribeValues = ['self', 'all']
@@ -269,10 +270,15 @@ module.exports = function (app) {
}
if (msg.accessRequest) {
- handleAccessRequest(spark, msg)
+ processAccessRequest(spark, msg)
}
+
if (msg.login && app.securityStrategy.supportsLogin()) {
- handleLoginRequest(spark, msg)
+ processLoginRequest(spark, msg)
+ }
+
+ if (msg.put) {
+ processPutRequest(spark, msg)
}
})
@@ -415,7 +421,34 @@ module.exports = function (app) {
}
}
- function handleAccessRequest (spark, msg) {
+ function processPutRequest (spark, msg) {
+ putPath(
+ app,
+ msg.context,
+ msg.put.path,
+ msg.put,
+ spark.request,
+ msg.requestId,
+ reply => {
+ debug('sending put update %j', reply)
+ spark.write({
+ context: msg.context,
+ ...reply
+ })
+ }
+ ).catch(err => {
+ console.error(err)
+ spark.write({
+ context: msg.context,
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ result: 502,
+ message: err.message
+ })
+ })
+ }
+
+ function processAccessRequest (spark, msg) {
if (spark.skPendingAccessRequest) {
spark.write({
context: app.selfContext,
@@ -464,7 +497,7 @@ module.exports = function (app) {
}
}
- function handleLoginRequest (spark, msg) {
+ function processLoginRequest (spark, msg) {
app.securityStrategy
.login(msg.login.username, msg.login.password)
.then(reply => {
From 790661224597aa4553a7910807185883ad2ef083 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Sun, 30 Sep 2018 22:49:19 -0400
Subject: [PATCH 04/21] fix: remove href as it's handled by requestResponse
code
---
lib/tokensecurity.js | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/lib/tokensecurity.js b/lib/tokensecurity.js
index 5e666bc9c..bd3ddf5a5 100644
--- a/lib/tokensecurity.js
+++ b/lib/tokensecurity.js
@@ -1051,9 +1051,7 @@ module.exports = function (app, config) {
}
]
})
- updateRequest(request.requestId, 'PENDING', {
- href: `${skPrefix}/access/requests/${request.requestId}`
- })
+ updateRequest(request.requestId, 'PENDING', {})
.then(reply => {
resolve(reply, config)
})
From d85eccd936b52ac19d696f662cdda99f3e4eda79 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 00:08:43 -0400
Subject: [PATCH 05/21] fix: prune old requst data
---
lib/put.js | 48 ------------------------------------------
lib/requestResponse.js | 24 +++++++++++++++++++++
2 files changed, 24 insertions(+), 48 deletions(-)
diff --git a/lib/put.js b/lib/put.js
index e153326ca..c93139274 100644
--- a/lib/put.js
+++ b/lib/put.js
@@ -21,32 +21,12 @@ const Result = {
}
const actionHandlers = {}
-const actions = {}
-var nextActionId = 1
-
-const pruneActionTimeout = 60 * 60 * 1000
-const pruneInterval = 15 * 60 * 1000
module.exports = {
start: function (app) {
app.registerActionHandler = registerActionHandler
app.deRegisterActionHandler = deRegisterActionHandler
- setInterval(pruneActions, pruneInterval)
-
- app.get(apiPathPrefix + 'actions', function (req, res, next) {
- res.json(actions)
- })
-
- app.get(apiPathPrefix + 'actions/:id', function (req, res, next) {
- var action = actions[req.params.id]
- if (!action) {
- res.status(404).send()
- } else {
- res.json(action)
- }
- })
-
app.put(apiPathPrefix + '*', function (req, res, next) {
var path = String(req.path).replace(apiPathPrefix, '')
@@ -225,31 +205,3 @@ function deRegisterActionHandler (context, path, source, callback) {
debug(`de-registered action handler for ${context} ${path} ${source}`)
}
}
-
-function asyncCallback (actionId, status) {
- var action = actions[actionId]
- if (action) {
- action.state = status.state
- action.result = status.result
- action['endTime'] = new Date().toISOString()
- if (status.message) {
- action.message = status.message
- }
- if (status.percentComplete) {
- action.percentComplete = status.percentComplete
- }
- }
-}
-
-function pruneActions () {
- debug('pruning actions')
- _.keys(actions).forEach(id => {
- var action = actions[id]
-
- var diff = new Date() - new Date(action['end-time'])
- if (diff > pruneActionTimeout) {
- delete actions[id]
- debug('pruned action %d', id)
- }
- })
-}
diff --git a/lib/requestResponse.js b/lib/requestResponse.js
index edc43b446..c534ebc3a 100644
--- a/lib/requestResponse.js
+++ b/lib/requestResponse.js
@@ -4,6 +4,12 @@ const _ = require('lodash')
const requests = {}
+// const pruneRequestTimeout = 60 * 60 * 1000
+// const pruneIntervalRate = 15 * 60 * 1000
+const pruneRequestTimeout = 10000
+const pruneIntervalRate = 1000
+var pruneInterval
+
function createRequest (type, clientRequest, user, clientIp, updateCb) {
return new Promise((resolve, reject) => {
let requestId = clientRequest.requestId ? clientRequest.requestId : uuidv4()
@@ -19,6 +25,11 @@ function createRequest (type, clientRequest, user, clientIp, updateCb) {
}
requests[request.requestId] = request
debug('createRequest %j', request)
+
+ if (!pruneInterval) {
+ pruneInterval = setInterval(pruneRequests, pruneIntervalRate)
+ }
+
resolve(request)
})
}
@@ -98,6 +109,19 @@ function filterRequests (type, state) {
)
}
+function pruneRequests () {
+ debug('pruning requests')
+ _.keys(requests).forEach(id => {
+ var request = requests[id]
+
+ var diff = new Date() - new Date(request.date)
+ if (diff > pruneRequestTimeout) {
+ delete requests[id]
+ debug('pruned request %s', id)
+ }
+ })
+}
+
module.exports = {
createRequest,
updateRequest,
From 6d8efaaa1d5a53e6f410a321161fb36d4d42b2d0 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 21:27:48 -0400
Subject: [PATCH 06/21] fix: don't send context in request reponses
---
lib/interfaces/ws.js | 13 ++-----------
1 file changed, 2 insertions(+), 11 deletions(-)
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index 8fde292c1..32beccfeb 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -431,15 +431,11 @@ module.exports = function (app) {
msg.requestId,
reply => {
debug('sending put update %j', reply)
- spark.write({
- context: msg.context,
- ...reply
- })
+ spark.write(reply)
}
).catch(err => {
console.error(err)
spark.write({
- context: msg.context,
requestId: msg.requestId,
state: 'COMPLETED',
result: 502,
@@ -451,7 +447,6 @@ module.exports = function (app) {
function processAccessRequest (spark, msg) {
if (spark.skPendingAccessRequest) {
spark.write({
- context: app.selfContext,
requestId: msg.requestId,
state: 'COMPLETED',
result: 400,
@@ -472,10 +467,7 @@ module.exports = function (app) {
app.securityStrategy.authorizeWS(spark.request)
}
}
- spark.write({
- context: app.selfContext,
- ...res
- })
+ spark.write(res)
}
)
.then(res => {
@@ -487,7 +479,6 @@ module.exports = function (app) {
.catch(err => {
console.log(err.stack)
spark.write({
- context: app.selfContext,
requestId: msg.requestId,
state: 'COMPLETED',
result: 502,
From c9ed7904e4970e07a8eb054f4f1b5d952dfc4fc0 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 21:28:47 -0400
Subject: [PATCH 07/21] fix: ensure result 202 on PENDING put reponses
---
lib/put.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/lib/put.js b/lib/put.js
index c93139274..d67ef181b 100644
--- a/lib/put.js
+++ b/lib/put.js
@@ -134,12 +134,14 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
Promise.resolve(actionResult)
.then(result => {
+ debug('got result: %j', result)
fixReply(result)
updateRequest(request.requestId, result.state, result)
.then(reply => {
if (reply.state === 'PENDING') {
// backwards compatibility
reply.action = { href: reply.href }
+ reply.result = 202
}
resolve(reply)
})
From 8b350fc861f1cdbf938a54dd19477e65d721edae Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 21:29:44 -0400
Subject: [PATCH 08/21] tests: add put tests
---
test/put.js | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 232 insertions(+)
create mode 100644 test/put.js
diff --git a/test/put.js b/test/put.js
new file mode 100644
index 000000000..e98fd4d0c
--- /dev/null
+++ b/test/put.js
@@ -0,0 +1,232 @@
+const chai = require('chai')
+chai.Should()
+chai.use(require('chai-things'))
+const freeport = require('freeport-promise')
+const Server = require('../lib')
+const fetch = require('node-fetch')
+const { registerActionHandler } = require('../lib/put')
+const WebSocket = require('ws')
+const { WsPromiser } = require('./servertestutilities')
+
+const sleep = ms => new Promise(res => setTimeout(res, ms))
+
+describe('Put Requests', () => {
+ var server, url, port
+
+ before(async function () {
+ port = await freeport()
+ url = `http://0.0.0.0:${port}`
+ const serverApp = new Server({
+ config: {
+ settings: {
+ port,
+ interfaces: {
+ plugins: false
+ }
+ }
+ }
+ })
+ server = await serverApp.start()
+
+ function switch2Handler (context, path, value, cb) {
+ if (typeof value !== 'number') {
+ return { state: 'COMPLETED', result: 400, message: 'invalid value' }
+ } else {
+ setTimeout(() => {
+ server.app.handleMessage('test', {
+ updates: [
+ {
+ values: [
+ { path: 'electrical.switches.switch2.state', value: value }
+ ]
+ }
+ ]
+ })
+ cb({ state: 'COMPLETED', result: 200 })
+ }, 100)
+ return { state: 'PENDING' }
+ }
+ }
+
+ registerActionHandler(
+ 'vessels.self',
+ 'electrical.switches.switch2.state',
+ null,
+ switch2Handler
+ )
+ })
+
+ it('HTTP put to unhandled path fails', async function () {
+ var result = await fetch(
+ `${url}/signalk/v1/api/vessels/self/electrical/switches/switch1.state`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ value: 1
+ })
+ }
+ )
+
+ result.status.should.equal(405)
+ })
+
+ it('HTTP successfull put', async function () {
+ var result = await fetch(
+ `${url}/signalk/v1/api/vessels/self/electrical/switches/switch2.state`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ value: 1
+ })
+ }
+ )
+
+ result.status.should.equal(202)
+
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('PENDING')
+ json.should.have.property('href')
+
+ await sleep(200)
+
+ var result = await fetch(`${url}${json.href}`)
+
+ result.status.should.equal(200)
+
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('COMPLETED')
+ })
+
+ it('HTTP failing put', async function () {
+ var result = await fetch(
+ `${url}/signalk/v1/api/vessels/self/electrical/switches/switch2/state`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ value: 'dummy'
+ })
+ }
+ )
+
+ result.status.should.equal(400)
+
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('COMPLETED')
+ json.should.have.property('message')
+ json.message.should.equal('invalid value')
+ })
+
+ it('WS put to unhandled path fails', async function () {
+ var ws = new WsPromiser(
+ `ws://0.0.0.0:${port}/signalk/v1/stream?subsribe=none`
+ )
+ var msg = await ws.nextMsg()
+
+ let readPromise = ws.nextMsg()
+
+ let something = await ws.send({
+ context: 'vessels.self',
+ put: {
+ path: 'electrical.switches.switch1.state',
+ value: 1
+ }
+ })
+
+ msg = await readPromise
+ msg.should.not.equal('timeout')
+ let response = JSON.parse(msg)
+ response.should.have.property('result')
+ response.result.should.equal(405)
+ })
+
+ it('WS successfull put', async function () {
+ var ws = new WsPromiser(
+ `ws://0.0.0.0:${port}/signalk/v1/stream?subsribe=none`
+ )
+ var msg = await ws.nextMsg()
+
+ let readPromise = ws.nextMsg()
+
+ let something = await ws.send({
+ context: 'vessels.self',
+ put: {
+ path: 'electrical.switches.switch2.state',
+ value: 1
+ }
+ })
+
+ msg = await readPromise
+ msg.should.not.equal('timeout')
+ let response = JSON.parse(msg)
+ response.should.have.property('state')
+ response.state.should.equal('PENDING')
+ response.should.have.property('href')
+
+ let resultPromise = ws.nextMsg()
+ let updatePromise = ws.nextMsg()
+
+ response = await resultPromise
+ console.log(response)
+
+ let update = await updatePromise
+ console.log(`HELLO ${update}`)
+
+ /*
+ await sleep(200)
+
+ var result = await fetch(`${url}${json.href}`)
+
+ result.status.should.equal(200)
+
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('COMPLETED')
+ */
+ })
+ /*
+ it('HTTP failing put', async function() {
+ var readPromiser = new WsPromiser(
+ `ws://0.0.0.0:${port}/signalk/v1/stream?subsribe=none`
+ )
+ var msg = await readPromiser.nextMsg()
+
+ await readPromiser.send({
+ context: 'vessels.self',
+ put: {
+ path: 'electrical.switches.switch2.state',
+ value: 'dummy'
+ }
+ })
+
+ var result = await fetch(`${url}/signalk/v1/api/vessels/self/electrical/switches/switch2/state`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ value: 'dummy'
+ })
+ })
+
+ result.status.should.equal(400)
+
+ var json = await result.json()
+ json.should.have.property('state')
+ json.state.should.equal('COMPLETED')
+ json.should.have.property('message')
+ json.message.should.equal('invalid value')
+ })
+ */
+})
From e26382f5ec63dec60222a278bb36d61434af0784 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 22:39:12 -0400
Subject: [PATCH 09/21] test: complete WS put tests
---
test/put.js | 122 +++++++++++++++++++++++++++++++---------------------
1 file changed, 74 insertions(+), 48 deletions(-)
diff --git a/test/put.js b/test/put.js
index e98fd4d0c..6a63611cc 100644
--- a/test/put.js
+++ b/test/put.js
@@ -6,7 +6,7 @@ const Server = require('../lib')
const fetch = require('node-fetch')
const { registerActionHandler } = require('../lib/put')
const WebSocket = require('ws')
-const { WsPromiser } = require('./servertestutilities')
+// const { WsPromiser } = require('./servertestutilities')
const sleep = ms => new Promise(res => setTimeout(res, ms))
@@ -134,8 +134,7 @@ describe('Put Requests', () => {
)
var msg = await ws.nextMsg()
- let readPromise = ws.nextMsg()
-
+ ws.clear()
let something = await ws.send({
context: 'vessels.self',
put: {
@@ -144,9 +143,11 @@ describe('Put Requests', () => {
}
})
+ let readPromise = ws.nextMsg()
msg = await readPromise
msg.should.not.equal('timeout')
let response = JSON.parse(msg)
+ // console.log(msg)
response.should.have.property('result')
response.result.should.equal(405)
})
@@ -157,8 +158,7 @@ describe('Put Requests', () => {
)
var msg = await ws.nextMsg()
- let readPromise = ws.nextMsg()
-
+ ws.clear()
let something = await ws.send({
context: 'vessels.self',
put: {
@@ -167,42 +167,32 @@ describe('Put Requests', () => {
}
})
- msg = await readPromise
+ msg = await ws.nextMsg()
msg.should.not.equal('timeout')
let response = JSON.parse(msg)
response.should.have.property('state')
response.state.should.equal('PENDING')
response.should.have.property('href')
- let resultPromise = ws.nextMsg()
- let updatePromise = ws.nextMsg()
+ msg = await ws.nextMsg() // skip the update
- response = await resultPromise
- console.log(response)
-
- let update = await updatePromise
- console.log(`HELLO ${update}`)
-
- /*
- await sleep(200)
-
- var result = await fetch(`${url}${json.href}`)
-
- result.status.should.equal(200)
-
- var json = await result.json()
- json.should.have.property('state')
- json.state.should.equal('COMPLETED')
- */
+ msg = await ws.nextMsg()
+ msg.should.not.equal('timeout')
+ response = JSON.parse(msg)
+ response.should.have.property('state')
+ response.state.should.equal('COMPLETED')
+ response.should.have.property('result')
+ response.result.should.equal(200)
})
- /*
- it('HTTP failing put', async function() {
- var readPromiser = new WsPromiser(
+
+ it('WS failing put', async function () {
+ var ws = new WsPromiser(
`ws://0.0.0.0:${port}/signalk/v1/stream?subsribe=none`
)
- var msg = await readPromiser.nextMsg()
+ var msg = await ws.nextMsg()
- await readPromiser.send({
+ ws.clear()
+ let something = await ws.send({
context: 'vessels.self',
put: {
path: 'electrical.switches.switch2.state',
@@ -210,23 +200,59 @@ describe('Put Requests', () => {
}
})
- var result = await fetch(`${url}/signalk/v1/api/vessels/self/electrical/switches/switch2/state`, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- value: 'dummy'
- })
- })
-
- result.status.should.equal(400)
-
- var json = await result.json()
- json.should.have.property('state')
- json.state.should.equal('COMPLETED')
- json.should.have.property('message')
- json.message.should.equal('invalid value')
+ msg = await ws.nextMsg()
+ msg.should.not.equal('timeout')
+ let response = JSON.parse(msg)
+ response.should.have.property('state')
+ response.state.should.equal('COMPLETED')
+ response.should.have.property('result')
+ response.result.should.equal(400)
+ response.should.have.property('message')
+ response.message.should.equal('invalid value')
})
- */
})
+
+function WsPromiser (url) {
+ this.ws = new WebSocket(url)
+ this.ws.on('message', this.onMessage.bind(this))
+ this.callees = []
+ this.messages = []
+}
+
+WsPromiser.prototype.clear = function () {
+ this.messages = []
+}
+
+WsPromiser.prototype.nextMsg = function () {
+ const callees = this.callees
+ return new Promise((resolve, reject) => {
+ if (this.messages.length > 0) {
+ const message = this.messages[0]
+ this.messages = this.messages.slice(1)
+ resolve(message)
+ } else {
+ callees.push(resolve)
+ setTimeout(_ => {
+ resolve('timeout')
+ }, 250)
+ }
+ })
+}
+
+WsPromiser.prototype.onMessage = function (message) {
+ const theCallees = this.callees
+ this.callees = []
+ if (theCallees.length > 0) {
+ theCallees.forEach(callee => callee(message))
+ } else {
+ this.messages.push(message)
+ }
+}
+
+WsPromiser.prototype.send = function (message) {
+ const that = this
+ return new Promise((resolve, reject) => {
+ that.ws.send(JSON.stringify(message))
+ setTimeout(() => resolve('wait over'), 100)
+ })
+}
From 916017157cd8c84883496b516740cd38e52261d2 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 23:00:08 -0400
Subject: [PATCH 10/21] docs: update plugin documentation about Put requests
---
SERVERPLUGINS.md | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md
index 113ab926d..0bd0fe079 100644
--- a/SERVERPLUGINS.md
+++ b/SERVERPLUGINS.md
@@ -124,17 +124,20 @@ If the plugin needs to make and save changes to its options
If the plugin needs to read plugin options from disk
-### app.registerActionHandler (context, path, source, callback)
+### app.registerPutHandler (context, path, source, callback)
-If the plugin wants to respond to actions, which are PUT requests for a specific path, it should register an action handler.
+If the plugin wants to respond to PUT requests for a specific path, it should register an action handler.
The action handler can handle the request synchronously or asynchronously.
-For synchronous actions the handler must return a value describing the result of the action: either `{ state: 'SUCCESS' }` or `{ state:'FAILURE', message:'Some Error Message' }`.
+
+The passed callback should be a funtion taking the following arguments: (context, path, value, callback)
+
+For synchronous actions the handler must return a value describing the response of the request: for example `{ state: 'COMPLETED', result:200 }` or `{ state:'COMPLETED', result:400, message:'Some Error Message' }`. The result value can be any valid http response code.
For asynchronous actions that may take considerable time and the requester should not be kept waiting for the result
the handler must return `{ state: 'PENDING' }`. When the action is finished the handler
- should call the `callback` function with the result with `callback({ state: 'SUCCESS' })` or
-`callback({ state:'FAILURE', message:'Some Error Message' })`.
+ should call the `callback` function with the result with `callback({ state: 'COMPLETED', result:200 })` or
+`callback({ state:'COMPLETD', result:400, message:'Some Error Message' })`.
### app.registerDeltaInputHandler ((delta, next) => ...)
From 08457678527dbf0d0bb24fa337ece5b85ede82f9 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 23:01:01 -0400
Subject: [PATCH 11/21] refactor: rename plugin api registerActionHandler to
registerPutHandler
registerActionHandler remains for backwards compatability
---
lib/interfaces/plugins.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/lib/interfaces/plugins.js b/lib/interfaces/plugins.js
index d3bad336d..c79d7afe8 100644
--- a/lib/interfaces/plugins.js
+++ b/lib/interfaces/plugins.js
@@ -312,11 +312,12 @@ module.exports = function (app) {
appCopy.readPluginOptions = () => {
return getPluginOptions(plugin.id)
}
- appCopy.registerActionHandler = (context, path, callback) => {
+ appCopy.registerPutHandler = (context, path, callback) => {
onStopHandlers[plugin.id].push(
app.registerActionHandler(context, path, plugin.id, callback)
)
}
+ appCopy.registerActionHandler = appCopy.registerPutHandler
appCopy.registerHistoryProvider = provider => {
app.registerHistoryProvider(provider)
From 402c4dbd6f2e0053bcc7692e226ad2c4f0f6fcde Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 23:02:30 -0400
Subject: [PATCH 12/21] fix: revert testing settings for pruning requests
---
lib/requestResponse.js | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/lib/requestResponse.js b/lib/requestResponse.js
index c534ebc3a..73cd381b5 100644
--- a/lib/requestResponse.js
+++ b/lib/requestResponse.js
@@ -4,10 +4,8 @@ const _ = require('lodash')
const requests = {}
-// const pruneRequestTimeout = 60 * 60 * 1000
-// const pruneIntervalRate = 15 * 60 * 1000
-const pruneRequestTimeout = 10000
-const pruneIntervalRate = 1000
+const pruneRequestTimeout = 60 * 60 * 1000
+const pruneIntervalRate = 15 * 60 * 1000
var pruneInterval
function createRequest (type, clientRequest, user, clientIp, updateCb) {
From 10322e85e82fec73832f1d391bb2f6d3a7660340 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 1 Oct 2018 23:41:20 -0400
Subject: [PATCH 13/21] fix: make put test stop the server when done
---
test/put.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/test/put.js b/test/put.js
index 6a63611cc..9c4187b8b 100644
--- a/test/put.js
+++ b/test/put.js
@@ -56,6 +56,10 @@ describe('Put Requests', () => {
)
})
+ after(async function () {
+ await server.stop()
+ })
+
it('HTTP put to unhandled path fails', async function () {
var result = await fetch(
`${url}/signalk/v1/api/vessels/self/electrical/switches/switch1.state`,
From 7e757651c7847efffda081510720c01bb08683bd Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Thu, 4 Oct 2018 21:57:18 -0400
Subject: [PATCH 14/21] refactor: rename `result` to `statusCode`
---
lib/interfaces/ws.js | 16 +++++++++-------
lib/put.js | 16 ++++++++--------
lib/requestResponse.js | 8 ++++----
lib/serverroutes.js | 2 +-
lib/tokensecurity.js | 43 ++++++++++++++++++++++++++----------------
5 files changed, 49 insertions(+), 36 deletions(-)
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index 32beccfeb..6db4983f3 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -65,7 +65,7 @@ module.exports = function (app) {
spark = _.values(sources)[0]
} else {
updateRequest(requestId, 'COMPLETED', {
- result: 400,
+ statusCode: 400,
message:
'there are multiple sources for the given path, but no source was specified in the request'
})
@@ -97,7 +97,7 @@ module.exports = function (app) {
const request = findRequest(r => (r.requestId = requestId))
if (request && request.state === 'PENDING') {
spark.removeListener('data', listener)
- updateRequest(requestId, 'COMPLETED', { result: 504 })
+ updateRequest(requestId, 'COMPLETED', { statusCode: 504 })
}
}, 60 * 1000)
@@ -438,7 +438,7 @@ module.exports = function (app) {
spark.write({
requestId: msg.requestId,
state: 'COMPLETED',
- result: 502,
+ statusCode: 502,
message: err.message
})
})
@@ -449,7 +449,7 @@ module.exports = function (app) {
spark.write({
requestId: msg.requestId,
state: 'COMPLETED',
- result: 400,
+ statusCode: 400,
message: 'A request has already beem submitted'
})
} else {
@@ -465,6 +465,8 @@ module.exports = function (app) {
if (res.accessRequest && res.accessRequest.token) {
spark.request.token = res.accessRequest.token
app.securityStrategy.authorizeWS(spark.request)
+ spark.request.source =
+ 'ws.' + spark.request.skPrincipal.identifier.replace(/\./g, '_')
}
}
spark.write(res)
@@ -481,7 +483,7 @@ module.exports = function (app) {
spark.write({
requestId: msg.requestId,
state: 'COMPLETED',
- result: 502,
+ statusCode: 502,
message: err.message
})
})
@@ -499,7 +501,7 @@ module.exports = function (app) {
spark.write({
requestId: msg.requestId,
state: 'COMPLETED',
- result: reply.result,
+ statusCode: reply.statusCode,
login: {
token: reply.token
}
@@ -510,7 +512,7 @@ module.exports = function (app) {
spark.write({
requestId: msg.requestId,
state: 'COMPLETED',
- result: 502,
+ statusCode: 502,
message: err.message
})
})
diff --git a/lib/put.js b/lib/put.js
index d67ef181b..8eb4cfe80 100644
--- a/lib/put.js
+++ b/lib/put.js
@@ -51,7 +51,7 @@ module.exports = {
putPath(app, context, skpath, value, req)
.then(reply => {
- res.status(reply.result)
+ res.status(reply.statusCode)
res.json(reply)
})
.catch(err => {
@@ -84,7 +84,7 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
req &&
app.securityStrategy.shouldAllowPut(req, context, null, path) == false
) {
- updateRequest(request.requestId, 'COMPLETED', { result: 403 })
+ updateRequest(request.requestId, 'COMPLETED', { statusCode: 403 })
.then(resolve)
.catch(reject)
return
@@ -102,7 +102,7 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
handler = _.values(handlers)[0]
} else {
updateRequest(request.requestId, 'COMPLETED', {
- result: 400,
+ statusCode: 400,
message:
'there are multiple sources for the given path, but no source was specified in the request'
})
@@ -116,10 +116,10 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
function fixReply (reply) {
if (reply.state === 'FAILURE') {
reply.state = 'COMPLETED'
- reply.result = 502
+ reply.statusCode = 502
} else if (reply.state === 'SUCCESS') {
reply.state = 'COMPLETED'
- reply.result = 200
+ reply.statusCode = 200
}
}
@@ -141,7 +141,7 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
if (reply.state === 'PENDING') {
// backwards compatibility
reply.action = { href: reply.href }
- reply.result = 202
+ reply.statusCode = 202
}
resolve(reply)
})
@@ -149,7 +149,7 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
})
.catch(err => {
updateRequest(request.requestId, 'COMPLETED', {
- result: 500,
+ statusCode: 500,
message: err.message
})
.then(resolve)
@@ -171,7 +171,7 @@ function putPath (app, context, path, body, req, requestId, updateCb) {
.catch(reject)
} else {
updateRequest(request.requestId, 'COMPLETED', {
- result: 405,
+ statusCode: 405,
message: `PUT not supported for ${path}`
})
.then(resolve)
diff --git a/lib/requestResponse.js b/lib/requestResponse.js
index 73cd381b5..561cbdee1 100644
--- a/lib/requestResponse.js
+++ b/lib/requestResponse.js
@@ -37,7 +37,7 @@ function createReply (request) {
state: request.state,
requestId: request.requestId,
[request.type]: request.data,
- result: request.result,
+ statusCode: request.statusCode,
message: request.message,
href: `/signalk/v1/requests/${request.requestId}`,
ip: request.ip,
@@ -50,7 +50,7 @@ function createReply (request) {
function updateRequest (
requestId,
state,
- { result = null, data = null, message = null, percentComplete = null }
+ { statusCode = null, data = null, message = null, percentComplete = null }
) {
return new Promise((resolve, reject) => {
const request = requests[requestId]
@@ -61,8 +61,8 @@ function updateRequest (
if (state) {
request.state = state
}
- if (result != null) {
- request.result = result
+ if (statusCode != null) {
+ request.statusCode = statusCode
}
if (message) {
request.message = message
diff --git a/lib/serverroutes.js b/lib/serverroutes.js
index df467cd73..2e1f31c64 100644
--- a/lib/serverroutes.js
+++ b/lib/serverroutes.js
@@ -255,7 +255,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
app.securityStrategy
.requestAccess(config, { accessRequest: req.body }, ip)
.then((reply, config) => {
- res.status(reply.state === 'PENDING' ? 202 : reply.result)
+ res.status(reply.state === 'PENDING' ? 202 : reply.statusCode)
res.json(reply)
})
.catch(err => {
diff --git a/lib/tokensecurity.js b/lib/tokensecurity.js
index bd3ddf5a5..316e4438e 100644
--- a/lib/tokensecurity.js
+++ b/lib/tokensecurity.js
@@ -165,7 +165,7 @@ module.exports = function (app, config) {
login(name, password)
.then(reply => {
- if (reply.result === 200) {
+ if (reply.statusCode === 200) {
res.cookie('JAUTHENTICATION', reply.token, { httpOnly: true })
var requestType = req.get('Content-Type')
@@ -176,7 +176,7 @@ module.exports = function (app, config) {
res.redirect(req.body.destination ? req.body.destination : '/')
}
} else {
- res.status(reply.result).send(reply.message)
+ res.status(reply.statusCode).send(reply.message)
}
})
.catch(err => {
@@ -264,7 +264,7 @@ module.exports = function (app, config) {
var user = configuration.users.find(user => user.username == name)
if (!user) {
- resolve({ result: 401, message: 'Invalid Username' })
+ resolve({ statusCode: 401, message: 'Invalid Username' })
return
}
@@ -278,15 +278,19 @@ module.exports = function (app, config) {
var token = jwt.sign(payload, configuration.secretKey, {
expiresIn: expiration
})
- resolve({ result: 200, token })
+ resolve({ statusCode: 200, token })
} else {
debug('password did not match')
- resolve({ result: 401, message: 'Invalid Password' })
+ resolve({ statusCode: 401, message: 'Invalid Password' })
}
})
})
}
+ strategy.getAuthRequiredString = () => {
+ return strategy.allowReadOnly() ? 'forwrite' : 'always'
+ }
+
strategy.supportsLogin = () => true
strategy.login = login
@@ -644,7 +648,9 @@ module.exports = function (app, config) {
var principal = getPrincipal(payload)
if (!principal) {
- error = new InvalidTokenError('Invalid identity')
+ error = new InvalidTokenError(
+ `Invalid identity ${JSON.stringify(payload)}`
+ )
debug(error.message)
throw error
}
@@ -891,12 +897,12 @@ module.exports = function (app, config) {
config.devices.push({
clientId: request.accessIdentifier,
- permissions: !request.requestedPermissions
+ permissions: !request.clientRequest.requestedPermissions
? body.permissions
: request.permissions,
config: body.config,
description: request.accessDescription,
- requestedPermissions: request.requestedPermissions
+ requestedPermissions: request.clientRequest.requestedPermissions
})
request.token = token
} else {
@@ -914,8 +920,10 @@ module.exports = function (app, config) {
return
}
+ options = config
+
updateRequest(request.requestId, 'COMPLETED', {
- result: 200,
+ statusCode: 200,
data: {
permission: approved ? 'APPROVED' : 'DENIED',
token: request.token
@@ -923,7 +931,6 @@ module.exports = function (app, config) {
})
.then(reply => {
cb(null, config)
- options = config
sendAccessRequestsUpdate()
})
.catch(err => {
@@ -947,22 +954,26 @@ module.exports = function (app, config) {
.then(request => {
const accessRequest = clientRequest.accessRequest
if (!validateAccessRequest(accessRequest)) {
- updateRequest(request.requestId, 'COMPLETED', { result: 400 })
+ updateRequest(request.requestId, 'COMPLETED', { statusCode: 400 })
.then(resolve)
.catch(reject)
return
}
- request.requestedPermissions = !_.isUndefined(request.permissions)
+ request.requestedPermissions = !_.isUndefined(
+ accessRequest.permissions
+ )
if (!request.requestedPermissions) {
request.permissions = 'readonly'
+ } else {
+ request.permissions = accessRequest.permissions
}
var alertMessage
var response
if (accessRequest.clientId) {
if (!options.allowDeviceAccessRequests) {
- updateRequest(request.requestId, 'COMPLETED', { result: 403 })
+ updateRequest(request.requestId, 'COMPLETED', { statusCode: 403 })
.then(resolve)
.catch(reject)
return
@@ -976,7 +987,7 @@ module.exports = function (app, config) {
)
) {
updateRequest(request.requestId, 'COMPLETED', {
- result: 400,
+ statusCode: 400,
message: `A device with clientId '${
accessRequest.clientId
}' has already requested access`
@@ -999,7 +1010,7 @@ module.exports = function (app, config) {
}" has requested access to the server`
} else {
if (!options.allowNewUserRegistration) {
- updateRequest(request.requestId, 'COMPLETED', { result: 403 })
+ updateRequest(request.requestId, 'COMPLETED', { statusCode: 403 })
.then(resolve)
.catch(reject)
return
@@ -1010,7 +1021,7 @@ module.exports = function (app, config) {
)
if (existing) {
updateRequest(request.requestId, 'COMPLETED', {
- result: 400,
+ statusCode: 400,
message: 'User already exists'
})
.then(resolve)
From 12c6a53adf3087dd6ed2f59efdc3128ea0e7ebbc Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Thu, 4 Oct 2018 21:58:23 -0400
Subject: [PATCH 15/21] feature: add `authenticationRequired` to ws hello and
`/signalk`
---
lib/dummysecurity.js | 43 ++++++++++++++++++++++--------------------
lib/interfaces/rest.js | 4 +---
lib/interfaces/ws.js | 3 ++-
3 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/lib/dummysecurity.js b/lib/dummysecurity.js
index e319cbebe..1a68d054d 100644
--- a/lib/dummysecurity.js
+++ b/lib/dummysecurity.js
@@ -14,36 +14,36 @@
* limitations under the License.
*/
-module.exports = function(app, config) {
+module.exports = function (app, config) {
return {
getConfiguration: () => {
- return {};
+ return {}
},
allowRestart: req => {
- return false;
+ return false
},
allowConfigure: req => {
- return false;
+ return false
},
getLoginStatus: req => {
return {
- status: "notLoggedIn",
+ status: 'notLoggedIn',
readOnlyAccess: false,
authenticationRequired: false
- };
+ }
},
getConfig: config => {
- return config;
+ return config
},
setConfig: (config, newConfig) => {},
getUsers: config => {
- return [];
+ return []
},
updateUser: (config, username, updates, callback) => {},
@@ -54,16 +54,16 @@ module.exports = function(app, config) {
deleteUser: (config, username, callback) => {},
- shouldAllowWrite: function(req, delta) {
- return true;
+ shouldAllowWrite: function (req, delta) {
+ return true
},
- shouldAllowPut: function(req, context, source, path) {
- return true;
+ shouldAllowPut: function (req, context, source, path) {
+ return true
},
filterReadDelta: (user, delta) => {
- return delta;
+ return delta
},
verifyWS: spark => {},
@@ -71,28 +71,31 @@ module.exports = function(app, config) {
authorizeWS: req => {},
checkACL: (id, context, path, source, operation) => {
- return true;
+ return true
},
isDummy: () => {
- return true;
+ return true
},
canAuthorizeWS: () => {
- return false;
+ return false
},
shouldFilterDeltas: () => {
- return false;
+ return false
},
addAdminMiddleware: () => {},
allowReadOnly: () => {
- return true;
+ return true
},
- supportsLogin: () => false
+ supportsLogin: () => false,
+
+ getAuthRequiredString: () => {
+ return 'never'
+ }
}
}
-
diff --git a/lib/interfaces/rest.js b/lib/interfaces/rest.js
index 44263bd58..4f62f6aee 100644
--- a/lib/interfaces/rest.js
+++ b/lib/interfaces/rest.js
@@ -150,9 +150,7 @@ module.exports = function (app) {
id: 'signalk-server-node',
version: app.config.version
},
- authenticationRequired: app.securityStrategy.isDummy()
- ? 'never'
- : app.securityStrategy.allowReadOnly() ? 'forWrite' : 'always'
+ authenticationRequired: app.securityStrategy.getAuthRequiredString()
})
})
},
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index 6db4983f3..0c283981a 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -319,7 +319,8 @@ module.exports = function (app) {
version: app.config.version,
timestamp: new Date(),
self: `vessels.${app.selfId}`,
- roles: ['master', 'main']
+ roles: ['master', 'main'],
+ authenticationRequired: app.securityStrategy.getAuthRequiredString()
})
if (spark.query.startTime) {
From 98c83ab086de564c993ea47c7464162918e31d28 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Thu, 4 Oct 2018 21:58:46 -0400
Subject: [PATCH 16/21] fix: exception when throwing InvalidTokenError
---
lib/security.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/security.js b/lib/security.js
index ec3cef331..64646a0a8 100644
--- a/lib/security.js
+++ b/lib/security.js
@@ -25,7 +25,7 @@ const dummysecurity = require('./dummysecurity')
class InvalidTokenError extends Error {
constructor (...args) {
super(...args)
- Error.captureStackTrace(this, GoodError)
+ Error.captureStackTrace(this, InvalidTokenError)
}
}
From 888817224c46476addcd14ec22c35f2f55680aa1 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Thu, 4 Oct 2018 22:39:19 -0400
Subject: [PATCH 17/21] tests: reflect rename to `statusCode`
---
test/put.js | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/test/put.js b/test/put.js
index 9c4187b8b..7820ed44a 100644
--- a/test/put.js
+++ b/test/put.js
@@ -30,7 +30,7 @@ describe('Put Requests', () => {
function switch2Handler (context, path, value, cb) {
if (typeof value !== 'number') {
- return { state: 'COMPLETED', result: 400, message: 'invalid value' }
+ return { state: 'COMPLETED', statusCode: 400, message: 'invalid value' }
} else {
setTimeout(() => {
server.app.handleMessage('test', {
@@ -42,7 +42,7 @@ describe('Put Requests', () => {
}
]
})
- cb({ state: 'COMPLETED', result: 200 })
+ cb({ state: 'COMPLETED', statusCode: 200 })
}, 100)
return { state: 'PENDING' }
}
@@ -152,8 +152,8 @@ describe('Put Requests', () => {
msg.should.not.equal('timeout')
let response = JSON.parse(msg)
// console.log(msg)
- response.should.have.property('result')
- response.result.should.equal(405)
+ response.should.have.property('statusCode')
+ response.statusCode.should.equal(405)
})
it('WS successfull put', async function () {
@@ -185,8 +185,8 @@ describe('Put Requests', () => {
response = JSON.parse(msg)
response.should.have.property('state')
response.state.should.equal('COMPLETED')
- response.should.have.property('result')
- response.result.should.equal(200)
+ response.should.have.property('statusCode')
+ response.statusCode.should.equal(200)
})
it('WS failing put', async function () {
@@ -209,8 +209,8 @@ describe('Put Requests', () => {
let response = JSON.parse(msg)
response.should.have.property('state')
response.state.should.equal('COMPLETED')
- response.should.have.property('result')
- response.result.should.equal(400)
+ response.should.have.property('statusCode')
+ response.statusCode.should.equal(400)
response.should.have.property('message')
response.message.should.equal('invalid value')
})
From 801ba47ff7b73ac2363033d32ef854eaba942013 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Fri, 5 Oct 2018 12:29:30 -0400
Subject: [PATCH 18/21] docs: rename `result` to `statusCode`
---
SERVERPLUGINS.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md
index 0bd0fe079..1a57702f4 100644
--- a/SERVERPLUGINS.md
+++ b/SERVERPLUGINS.md
@@ -136,8 +136,8 @@ For synchronous actions the handler must return a value describing the response
For asynchronous actions that may take considerable time and the requester should not be kept waiting for the result
the handler must return `{ state: 'PENDING' }`. When the action is finished the handler
- should call the `callback` function with the result with `callback({ state: 'COMPLETED', result:200 })` or
-`callback({ state:'COMPLETD', result:400, message:'Some Error Message' })`.
+ should call the `callback` function with the result with `callback({ state: 'COMPLETED', statusCode:200 })` or
+`callback({ state:'COMPLETED', statusCode:400, message:'Some Error Message' })`.
### app.registerDeltaInputHandler ((delta, next) => ...)
From 56938355efd4632e96061fe4bca33ab5f371f126 Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Wed, 17 Oct 2018 14:16:51 -0400
Subject: [PATCH 19/21] fix: exception when configuration is not allowed
---
lib/serverroutes.js | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/lib/serverroutes.js b/lib/serverroutes.js
index 2e1f31c64..68a34fdc7 100644
--- a/lib/serverroutes.js
+++ b/lib/serverroutes.js
@@ -114,7 +114,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
}
}
- function checkAllowConfigure(req) {
+ function checkAllowConfigure(req, res) {
if (app.securityStrategy.allowConfigure(req)) {
return true
} else {
@@ -124,14 +124,14 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
}
app.get('/security/devices', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
res.json(app.securityStrategy.getDevices(config))
}
})
app.put('/security/devices/:uuid', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
app.securityStrategy.updateDevice(
config,
@@ -147,7 +147,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.delete('/security/devices/:uuid', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
app.securityStrategy.deleteDevice(
config,
@@ -162,14 +162,14 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.get('/security/users', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
res.json(app.securityStrategy.getUsers(config))
}
})
app.put('/security/users/:id', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
app.securityStrategy.updateUser(
config,
@@ -181,7 +181,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.post('/security/users/:id', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
var user = req.body
user.userId = req.params.id
@@ -194,7 +194,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.put('/security/user/:username/password', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
app.securityStrategy.setPassword(
config,
@@ -210,7 +210,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.delete('/security/users/:username', (req, res, next) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
app.securityStrategy.deleteUser(
config,
@@ -231,7 +231,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.put('/security/access/requests/:identifier/:status', (req, res) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
var config = getSecurityConfig(app)
app.securityStrategy.setAccessRequestStatus(
config,
@@ -244,7 +244,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
})
app.get('/security/access/requests', (req, res) => {
- if (checkAllowConfigure(req)) {
+ if (checkAllowConfigure(req, res)) {
res.json(app.securityStrategy.getAccessRequestsResponse())
}
})
From d20ed9e85607fbf084f2821bc1307501f81f8c5b Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Wed, 17 Oct 2018 14:19:07 -0400
Subject: [PATCH 20/21] fix: exception if incoming request does not have a
query
---
lib/tokensecurity.js | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/lib/tokensecurity.js b/lib/tokensecurity.js
index 316e4438e..904a7d281 100644
--- a/lib/tokensecurity.js
+++ b/lib/tokensecurity.js
@@ -165,18 +165,22 @@ module.exports = function (app, config) {
login(name, password)
.then(reply => {
+ var requestType = req.get('Content-Type')
+
if (reply.statusCode === 200) {
res.cookie('JAUTHENTICATION', reply.token, { httpOnly: true })
- var requestType = req.get('Content-Type')
-
if (requestType == 'application/json') {
res.json({ token: reply.token })
} else {
res.redirect(req.body.destination ? req.body.destination : '/')
}
} else {
- res.status(reply.statusCode).send(reply.message)
+ if (requestType == 'application/json') {
+ res.status(reply.statusCode).send(reply)
+ } else {
+ res.status(reply.statusCode).send(reply.message)
+ }
}
})
.catch(err => {
@@ -589,7 +593,7 @@ module.exports = function (app, config) {
}
strategy.authorizeWS = function (req) {
- var token = req.token || req.query.token,
+ var token = req.token,
error,
payload
@@ -600,9 +604,13 @@ module.exports = function (app, config) {
var configuration = getConfiguration()
if (!token) {
- var header = req.headers.authorization
- if (header && header.startsWith('JWT ')) {
- token = header.substring('JWT '.length)
+ if (req.query && req.query.token) {
+ token = req.query.token
+ } else if (req.headers) {
+ var header = req.headers.authorization
+ if (header && header.startsWith('JWT ')) {
+ token = header.substring('JWT '.length)
+ }
}
}
From f206bb16e71895d03f51edd8541b21a352628dec Mon Sep 17 00:00:00 2001
From: Scott Bender
Date: Mon, 29 Oct 2018 14:46:06 -0400
Subject: [PATCH 21/21] refactor: remove authenticationRequired since it did
not make it into the spec
---
lib/interfaces/rest.js | 3 +--
lib/interfaces/ws.js | 3 +--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/lib/interfaces/rest.js b/lib/interfaces/rest.js
index 4f62f6aee..777cb3bfa 100644
--- a/lib/interfaces/rest.js
+++ b/lib/interfaces/rest.js
@@ -149,8 +149,7 @@ module.exports = function (app) {
server: {
id: 'signalk-server-node',
version: app.config.version
- },
- authenticationRequired: app.securityStrategy.getAuthRequiredString()
+ }
})
})
},
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index 0c283981a..6db4983f3 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -319,8 +319,7 @@ module.exports = function (app) {
version: app.config.version,
timestamp: new Date(),
self: `vessels.${app.selfId}`,
- roles: ['master', 'main'],
- authenticationRequired: app.securityStrategy.getAuthRequiredString()
+ roles: ['master', 'main']
})
if (spark.query.startTime) {