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 0c8447cc3..40f6db4f2 100644
--- a/lib/dummysecurity.js
+++ b/lib/dummysecurity.js
@@ -84,6 +84,12 @@ module.exports = function (app, config) {
shouldFilterDeltas: () => {
return false
- }
+ },
+
+ 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 c1e6c3c29..3e8776be6 100644
--- a/lib/interfaces/plugins.js
+++ b/lib/interfaces/plugins.js
@@ -23,6 +23,7 @@ const modulesWithKeyword = require('../modules').modulesWithKeyword
const getLogger = require('../logging')
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 762e7d230..c4530a119 100644
--- a/lib/serverroutes.js
+++ b/lib/serverroutes.js
@@ -21,8 +21,10 @@ const path = require('path')
const _ = require('lodash')
const config = require('./config/config')
const { getHttpPort, getSslPort } = require('./ports')
+const { queryRequest } = require('./requestResponse')
const defaultSecurityStrategy = './tokensecurity'
+const skPrefix = '/signalk/v1'
module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
var securityWasEnabled
@@ -109,141 +111,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')
}
})
@@ -257,6 +247,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 b3d13c75b..3c5ea4cdc 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',
@@ -240,11 +249,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 2725b8ec2..53005fcd3 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'
@@ -72,8 +76,12 @@ class Full extends Component {
path='/serverConfiguration/providers'
component={loginOrOriginal(ProvidersConfiguration)}
/>
-
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}
+