diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md
index 113ab926d..1a57702f4 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', statusCode:200 })` or
+`callback({ state:'COMPLETED', statusCode:400, message:'Some Error Message' })`.
### app.registerDeltaInputHandler ((delta, next) => ...)
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..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,21 +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: () => {}
- };
-};
+ addAdminMiddleware: () => {},
+
+ allowReadOnly: () => {
+ return true
+ },
+
+ supportsLogin: () => false,
+
+ getAuthRequiredString: () => {
+ return 'never'
+ }
+ }
+}
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..c79d7afe8 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}`)
},
@@ -287,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)
diff --git a/lib/interfaces/rest.js b/lib/interfaces/rest.js
index 8b335496b..777cb3bfa 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)
}
})
diff --git a/lib/interfaces/ws.js b/lib/interfaces/ws.js
index a0858410e..6db4983f3 100644
--- a/lib/interfaces/ws.js
+++ b/lib/interfaces/ws.js
@@ -18,6 +18,9 @@ const _ = require('lodash')
const ports = require('../ports')
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']
@@ -46,30 +49,71 @@ module.exports = function (app) {
return count
}
- api.handlePut = function (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
- }
+ api.canHandlePut = function (path, source) {
+ const sources = pathSources[path]
+ return sources && (!source || sources[source])
+ }
+
+ api.handlePut = function (requestId, context, path, source, value) {
+ 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', {
+ statusCode: 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', { statusCode: 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 () {
@@ -105,14 +149,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 +174,7 @@ module.exports = function (app) {
var aclFilter = delta => {
var filtered = app.securityStrategy.filterReadDelta(
- spark.request.skUser,
+ spark.request.skPrincipal,
delta
)
if (filtered) {
@@ -134,14 +182,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 +247,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 +268,18 @@ module.exports = function (app) {
unsubscribes.forEach(unsubscribe => unsubscribe())
app.signalk.removeListener('delta', onChange)
}
+
+ if (msg.accessRequest) {
+ processAccessRequest(spark, msg)
+ }
+
+ if (msg.login && app.securityStrategy.supportsLogin()) {
+ processLoginRequest(spark, msg)
+ }
+
+ if (msg.put) {
+ processPutRequest(spark, msg)
+ }
})
spark.on('end', function () {
@@ -239,11 +298,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 +357,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 +392,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 +421,103 @@ module.exports = function (app) {
}
}
+ 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(reply)
+ }
+ ).catch(err => {
+ console.error(err)
+ spark.write({
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ statusCode: 502,
+ message: err.message
+ })
+ })
+ }
+
+ function processAccessRequest (spark, msg) {
+ if (spark.skPendingAccessRequest) {
+ spark.write({
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ statusCode: 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.request.source =
+ 'ws.' + spark.request.skPrincipal.identifier.replace(/\./g, '_')
+ }
+ }
+ spark.write(res)
+ }
+ )
+ .then(res => {
+ if (res.state === 'PENDING') {
+ spark.skPendingAccessRequest = true
+ }
+ // nothing, callback above will get called
+ })
+ .catch(err => {
+ console.log(err.stack)
+ spark.write({
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ statusCode: 502,
+ message: err.message
+ })
+ })
+ }
+ }
+
+ function processLoginRequest (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',
+ statusCode: reply.statusCode,
+ login: {
+ token: reply.token
+ }
+ })
+ })
+ .catch(err => {
+ console.error(err)
+ spark.write({
+ requestId: msg.requestId,
+ state: 'COMPLETED',
+ statusCode: 502,
+ message: err.message
+ })
+ })
+ }
+
return api
}
diff --git a/lib/put.js b/lib/put.js
index e329c764d..8eb4cfe80 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'
@@ -20,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, '')
@@ -57,29 +38,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.statusCode)
+ res.json(reply)
+ })
+ .catch(err => {
+ console.error(err)
+ res.status(500).send(err.message)
+ })
})
},
@@ -87,69 +65,121 @@ 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', { statusCode: 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', {
+ statusCode: 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.statusCode = 502
+ } else if (reply.state === 'SUCCESS') {
+ reply.state = 'COMPLETED'
+ reply.statusCode = 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 => {
+ 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.statusCode = 202
+ }
+ resolve(reply)
+ })
+ .catch(reject)
+ })
+ .catch(err => {
+ updateRequest(request.requestId, 'COMPLETED', {
+ statusCode: 500,
+ message: err.message
+ })
+ .then(resolve)
+ .catch(reject)
+ })
+ } else if (
+ app.interfaces.ws &&
+ app.interfaces.ws.canHandlePut(path, body.source)
+ ) {
+ app.interfaces.ws
+ .handlePut(
+ request.requestId,
+ context,
+ path,
+ body.source,
+ body.value
+ )
+ .then(resolve)
+ .catch(reject)
+ } else {
+ updateRequest(request.requestId, 'COMPLETED', {
+ statusCode: 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) {
@@ -177,31 +207,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
new file mode 100644
index 000000000..561cbdee1
--- /dev/null
+++ b/lib/requestResponse.js
@@ -0,0 +1,129 @@
+const uuidv4 = require('uuid/v4')
+const debug = require('debug')('signalk-server:requestResponse')
+const _ = require('lodash')
+
+const requests = {}
+
+const pruneRequestTimeout = 60 * 60 * 1000
+const pruneIntervalRate = 15 * 60 * 1000
+var pruneInterval
+
+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)
+
+ if (!pruneInterval) {
+ pruneInterval = setInterval(pruneRequests, pruneIntervalRate)
+ }
+
+ resolve(request)
+ })
+}
+
+function createReply (request) {
+ const reply = {
+ state: request.state,
+ requestId: request.requestId,
+ [request.type]: request.data,
+ statusCode: request.statusCode,
+ 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,
+ { statusCode = 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 (statusCode != null) {
+ request.statusCode = statusCode
+ }
+ 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)
+ )
+}
+
+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,
+ findRequest,
+ filterRequests,
+ queryRequest
+}
diff --git a/lib/security.js b/lib/security.js
index fad21bc3b..64646a0a8 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, InvalidTokenError)
+ }
}
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..68a34fdc7 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, res) {
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, res)) {
+ var config = getSecurityConfig(app)
+ res.json(app.securityStrategy.getDevices(config))
+ }
+ })
+
+ app.put('/security/devices/:uuid', (req, res, next) => {
+ if (checkAllowConfigure(req, res)) {
+ 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, res)) {
+ 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, res)) {
+ 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, res)) {
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, res)) {
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, res)) {
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, res)) {
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, res)) {
+ 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)) {
+ 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.statusCode)
+ 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..904a7d281 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,34 @@ 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
+ app.post(['/login', `${skAuthPrefix}/login`], (req, res) => {
+ var name = req.body.username
+ var password = req.body.password
- debug('username: ' + name)
- var configuration = getConfiguration()
+ login(name, password)
+ .then(reply => {
+ var requestType = req.get('Content-Type')
- 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
- })
-
- res.cookie('JAUTHENTICATION', token, { httpOnly: true })
-
- var requestType = req.get('Content-Type')
+ if (reply.statusCode === 200) {
+ res.cookie('JAUTHENTICATION', reply.token, { httpOnly: true })
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')
+ if (requestType == 'application/json') {
+ res.status(reply.statusCode).send(reply)
+ } else {
+ res.status(reply.statusCode).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 +214,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 +234,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 +255,49 @@ 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({ statusCode: 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({ statusCode: 200, token })
+ } else {
+ debug('password did not match')
+ resolve({ statusCode: 401, message: 'Invalid Password' })
+ }
+ })
+ })
+ }
+
+ strategy.getAuthRequiredString = () => {
+ return strategy.allowReadOnly() ? 'forwrite' : 'always'
+ }
+
+ strategy.supportsLogin = () => true
+ strategy.login = login
+
strategy.addAdminMiddleware = function (path) {
app.use(path, http_authorize(false))
app.use(path, adminAuthenticationMiddleware(false))
@@ -261,11 +318,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 +330,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 +353,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 +449,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 +507,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 +525,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 +553,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 +567,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 +593,7 @@ module.exports = function (app, config) {
}
strategy.authorizeWS = function (req) {
- var token = req.query.token,
+ var token = req.token,
error,
payload
@@ -493,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)
+ }
}
}
@@ -510,20 +625,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 +650,20 @@ 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 ${JSON.stringify(payload)}`
+ )
debug(error.message)
throw error
}
- req.skUser = payload
- req.skUser.type = user.type
+ req.skPrincipal = principal
req.skIsAuthenticated = true
}
@@ -628,6 +745,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 +793,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 +820,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 +839,247 @@ 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.clientRequest.requestedPermissions
+ ? body.permissions
+ : request.permissions,
+ config: body.config,
+ description: request.accessDescription,
+ requestedPermissions: request.clientRequest.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
+ }
+
+ options = config
+
+ updateRequest(request.requestId, 'COMPLETED', {
+ statusCode: 200,
+ data: {
+ permission: approved ? 'APPROVED' : 'DENIED',
+ token: request.token
+ }
+ })
+ .then(reply => {
+ cb(null, 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', { statusCode: 400 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ 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', { statusCode: 403 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ if (
+ findRequest(
+ r =>
+ r.state === 'PENDING' &&
+ r.accessIdentifier == accessRequest.clientId
+ )
+ ) {
+ updateRequest(request.requestId, 'COMPLETED', {
+ statusCode: 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', { statusCode: 403 })
+ .then(resolve)
+ .catch(reject)
+ return
+ }
+
+ var existing = options.users.find(
+ user => user.username == accessRequest.userId
+ )
+ if (existing) {
+ updateRequest(request.requestId, 'COMPLETED', {
+ statusCode: 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', {})
+ .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)}
/>
| Identifier | +Description | +Source IP | +
|---|---|---|
| {req.accessIdentifier} | +{req.accessDescription} | +{req.ip} | +
| Client ID | +Description | +Type | +
|---|---|---|
| {device.clientId} | +{device.description} | +{convertPermissions(device.permissions)} | +
{this.state.loginErrorMessage} -
+ + {!this.state.loginErrorMessage && this.props.loginStatus.allowNewUserRegistration && +Your registration has been sent
+ } + {!this.state.registrationSent && +Create your account
+{this.state.errorMessage}
+