From 43c7021496ff6e028514f8cbb4bb49c545cebce0 Mon Sep 17 00:00:00 2001 From: Scott Bender Date: Thu, 13 Sep 2018 20:31:46 -0400 Subject: [PATCH] feature: provide a way for devices to obtain a security token and new user to sign up --- lib/deltacache.js | 9 +- lib/dummysecurity.js | 8 +- lib/index.js | 38 +- lib/interfaces/plugins.js | 33 +- lib/interfaces/rest.js | 9 +- lib/interfaces/ws.js | 150 ++++-- lib/put.js | 205 ++++--- lib/requestResponse.js | 107 ++++ lib/security.js | 75 ++- lib/serverroutes.js | 228 ++++---- lib/tokensecurity.js | 498 +++++++++++++++--- packages/server-admin-ui/src/actions.js | 4 +- .../src/components/Sidebar/Sidebar.js | 43 +- .../src/containers/Full/Full.js | 14 +- packages/server-admin-ui/src/index.js | 9 +- .../src/views/security/AccessRequests.js | 206 ++++++++ .../src/views/security/Devices.js | 275 ++++++++++ .../views/{ => security}/EnableSecurity.js | 4 +- .../src/views/{ => security}/Login.js | 15 +- .../src/views/security/Register.js | 114 ++++ .../src/views/security/Settings.js | 196 +++++++ .../views/{Security.js => security/Users.js} | 120 +---- test/security.js | 161 +++++- 23 files changed, 2033 insertions(+), 488 deletions(-) create mode 100644 lib/requestResponse.js create mode 100644 packages/server-admin-ui/src/views/security/AccessRequests.js create mode 100644 packages/server-admin-ui/src/views/security/Devices.js rename packages/server-admin-ui/src/views/{ => security}/EnableSecurity.js (97%) rename packages/server-admin-ui/src/views/{ => security}/Login.js (89%) create mode 100644 packages/server-admin-ui/src/views/security/Register.js create mode 100644 packages/server-admin-ui/src/views/security/Settings.js rename packages/server-admin-ui/src/views/{Security.js => security/Users.js} (71%) diff --git a/lib/deltacache.js b/lib/deltacache.js index f51cc8e04..0be38fb71 100644 --- a/lib/deltacache.js +++ b/lib/deltacache.js @@ -152,11 +152,10 @@ DeltaCache.prototype.getCachedDeltas = function (user, contextFilter, key) { deltas = deltas.map(toDelta) - if (this.app.securityStrategy.shouldFilterDeltas()) { - deltas = deltas.filter(delta => { - return this.app.securityStrategy.filterReadDelta(user, delta) - }) - } + deltas = deltas.filter(delta => { + return this.app.securityStrategy.filterReadDelta(user, delta) + }) + return deltas } diff --git a/lib/dummysecurity.js b/lib/dummysecurity.js index 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)} /> - + + + + + diff --git a/packages/server-admin-ui/src/index.js b/packages/server-admin-ui/src/index.js index a893477f0..0d750fca8 100644 --- a/packages/server-admin-ui/src/index.js +++ b/packages/server-admin-ui/src/index.js @@ -33,7 +33,8 @@ const state = { serverSpecification: {}, websocketStatus: 'initial', webSocket: null, - restarting: false + restarting: false, + accessRequests: [] } let store = createStore( @@ -159,6 +160,12 @@ let store = createStore( state.webSocket.close() } } + if ( action.type === 'ACCESS_REQUEST' ) { + return { + ...state, + accessRequests: action.data + } + } return state }, state, diff --git a/packages/server-admin-ui/src/views/security/AccessRequests.js b/packages/server-admin-ui/src/views/security/AccessRequests.js new file mode 100644 index 000000000..217440a38 --- /dev/null +++ b/packages/server-admin-ui/src/views/security/AccessRequests.js @@ -0,0 +1,206 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { + Button, + Card, + CardHeader, + CardBody, + CardFooter, + InputGroup, + InputGroupAddon, + Input, + Form, + Col, + Label, + FormGroup, + FormText, + Table, + Row +} from 'reactstrap'; +import EnableSecurity from './EnableSecurity' + +class AccessRequests extends Component { + constructor(props) { + super(props); + this.state = { + selectedRequest: null, + accessRequestsApproving: [], + accessRequestsDenying: [] + } + this.handleRequestChange = this.handleRequestChange.bind(this) + } + + handleAccessRequest(identifier, approved) { + var stateKey = approved ? 'accessRequestsApproving' : 'accessRequestsDenying' + this.state[stateKey].push(identifier) + this.setState({stateKey: this.state}) + + var payload = { + permissions: this.state.selectedRequest.permissions || 'readonly', + config: this.state.selectedRequest.config, + expiration: this.state.selectedRequest.expiration || '1y' + } + + fetch(`/security/access/requests/${identifier}/${approved ? 'approved' : 'denied'}`, { + method: 'PUT', + credentials: "include", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }).then(response => response.text()) + .then(response => { + this.state[stateKey] = this.state[stateKey].filter(id => id != identifier) + this.setState({ + stateKey: this.state[stateKey], + selectedRequest: null + }) + }); + } + + requestClicked(event, request, index) { + this.setState({ + selectedRequest: JSON.parse(JSON.stringify(request)), + selectedIndex: index + }, () => { + this.refs['selectedRequest'].scrollIntoView(); + }); + } + + handleRequestChange(event) { + const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + this.state.selectedRequest[event.target.name] = value; + this.setState({ + selectedRequest: this.state.selectedRequest + }); + } + handleCancel(event) { + this.setState({ selectedRequest: null }) + } + + + render () { + return ( +
+ {this.props.loginStatus.authenticationRequired === false && + + } + {this.props.loginStatus.authenticationRequired && +
+ + + Access Requests + + + + + + + + + + + + {(this.props.accessRequests || []).map((req, index) => { + return ( + + + + + + ) + })} + +
IdentifierDescriptionSource IP
{req.accessIdentifier}{req.accessDescription}{req.ip}
+
+
+ + {this.state.selectedRequest && +
+ + + Request + + + + + + + + + + + + + + + + + + + + + + + + + Exmaples: 60s, 1m, 1h, 1d, NEVER + + + + + + + + {!this.state.selectedRequest.requestedPermissions && ( + + + + + + )} + {this.state.selectedRequest.requestedPermissions && ( + + )} + + + + + + + + + + + + + + + +
+ } + +
+ } +
+ ) + } +} + +const mapStateToProps = ({ accessRequests, loginStatus }) => ({ accessRequests, loginStatus }) + +export default connect(mapStateToProps)(AccessRequests) + +function convertPermissions (type) { + if (type == 'readonly') { + return 'Read Only' + } else if (type == 'readwrite') { + return 'Read/Write' + } else if (type == 'admin') { + return 'Admin' + } else { + return `Unknown ${type}` + } +} + + diff --git a/packages/server-admin-ui/src/views/security/Devices.js b/packages/server-admin-ui/src/views/security/Devices.js new file mode 100644 index 000000000..5e96af98d --- /dev/null +++ b/packages/server-admin-ui/src/views/security/Devices.js @@ -0,0 +1,275 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { + Button, + Card, + CardHeader, + CardBody, + CardFooter, + InputGroup, + InputGroupAddon, + Input, + Form, + Col, + Label, + FormGroup, + FormText, + Table, + Row +} from 'reactstrap' +import EnableSecurity from './EnableSecurity' + +export function fetchSecurityDevices () { + fetch(`/security/devices`, { + credentials: 'include' + }) + .then(response => response.json()) + .then(data => { + this.setState({ devices: data }) + }) +} + +class Devices extends Component { + constructor (props) { + super(props) + this.state = { + devices: [], + } + + this.fetchSecurityDevices = fetchSecurityDevices.bind(this) + this.handleCancel = this.handleCancel.bind(this) + this.handleApply = this.handleApply.bind(this) + this.handleDeviceChange = this.handleDeviceChange.bind(this) + this.deleteDevice = this.deleteDevice.bind(this) + } + + componentDidMount () { + if (this.props.loginStatus.authenticationRequired) { + this.fetchSecurityDevices() + } + } + + handleDeviceChange (event) { + const value = + event.target.type === 'checkbox' + ? event.target.checked + : event.target.value + this.state.selectedDevice[event.target.name] = value + this.setState({ + selectedDevice: this.state.selectedDevice + }) + } + + handleApply (event) { + event.preventDefault() + + var payload = { + permissions: this.state.selectedDevice.permissions || 'readonly', + description: this.state.selectedDevice.description + } + + fetch(`/security/devices/${this.state.selectedDevice.clientId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + credentials: 'include' + }) + .then(response => response.text()) + .then(response => { + this.setState({ + selectedDevice: null, + selectedIndex: -1 + }) + alert(response) + this.fetchSecurityDevices() + }) + } + + deleteDevice (event) { + fetch(`/security/devices/${this.state.selectedDevice.clientId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include' + }) + .then(response => response.text()) + .then(response => { + this.setState({ + selectedDevice: null, + selectedIndex: -1 + }) + alert(response) + this.fetchSecurityDevices() + }) + } + + deviceClicked (device, index) { + this.setState( + { + selectedDevice: JSON.parse(JSON.stringify(device)), + selectedIndex: index + }, + () => { + this.refs['selectedDevice'].scrollIntoView() + } + ) + } + + handleCancel (event) { + this.setState({ selectedDevice: null }) + } + render () { + return ( +
+ {this.props.loginStatus.authenticationRequired === false && ( + + )} + {this.props.loginStatus.authenticationRequired && ( +
+ + + Devices + + + + + + + + + + + + {(this.state.devices || []).map((device, index) => { + return ( + + + + + + ) + })} + +
Client IDDescriptionType
{device.clientId}{device.description}{convertPermissions(device.permissions)}
+
+ + +
+ + {this.state.selectedDevice && ( +
+ + + Device + + + + + + + + + + + + + + + + + + + + + + + + {!this.state.selectedDevice.requestedPermissions && ( + + + + + + )} + {this.state.selectedDevice.requestedPermissions && ( + + )} + + + + + + + + + + + + + + + + + +
+ )} +
+ )} +
+ ) + } +} + +const mapStateToProps = ({ securityDevices }) => ({ securityDevices }) + +export default connect(mapStateToProps)(Devices) + +function convertPermissions (type) { + if (type == 'readonly') { + return 'Read Only' + } else if (type == 'readwrite') { + return 'Read/Write' + } else if (type == 'admin') { + return 'Admin' + } +} diff --git a/packages/server-admin-ui/src/views/EnableSecurity.js b/packages/server-admin-ui/src/views/security/EnableSecurity.js similarity index 97% rename from packages/server-admin-ui/src/views/EnableSecurity.js rename to packages/server-admin-ui/src/views/security/EnableSecurity.js index 96d206fab..8cc5940f0 100644 --- a/packages/server-admin-ui/src/views/EnableSecurity.js +++ b/packages/server-admin-ui/src/views/security/EnableSecurity.js @@ -14,8 +14,8 @@ import { HelpBlock } from 'reactstrap' import { connect } from 'react-redux' -import { login, enableSecurity, fetchLoginStatus } from '../actions' -import Dashboard from './Dashboard/' +import { login, enableSecurity, fetchLoginStatus } from '../../actions' +import Dashboard from '../Dashboard/' import Login from './Login' class EnableSecurity extends Component { diff --git a/packages/server-admin-ui/src/views/Login.js b/packages/server-admin-ui/src/views/security/Login.js similarity index 89% rename from packages/server-admin-ui/src/views/Login.js rename to packages/server-admin-ui/src/views/security/Login.js index 24419c017..2118d9c53 100644 --- a/packages/server-admin-ui/src/views/Login.js +++ b/packages/server-admin-ui/src/views/security/Login.js @@ -13,10 +13,12 @@ import { InputGroupAddon, HelpBlock } from 'reactstrap' +import { Link } from 'react-router-dom' import { connect } from 'react-redux' -import { login, fetchAllData } from '../actions' -import Dashboard from './Dashboard/' +import { login, fetchAllData } from '../../actions' +import Dashboard from '../Dashboard/' import EnableSecurity from './EnableSecurity' +import Register from './Register' class Login extends Component { constructor (props) { @@ -104,7 +106,14 @@ class Login extends Component {

{this.state.loginErrorMessage} -

+

+ {!this.state.loginErrorMessage && this.props.loginStatus.allowNewUserRegistration && +
+ + + +
+ } diff --git a/packages/server-admin-ui/src/views/security/Register.js b/packages/server-admin-ui/src/views/security/Register.js new file mode 100644 index 000000000..83978ac0f --- /dev/null +++ b/packages/server-admin-ui/src/views/security/Register.js @@ -0,0 +1,114 @@ +import React, {Component} from 'react'; +import {Container, Row, Col, Card, CardBody, CardFooter, Button, Input, InputGroup, InputGroupAddon, FormText} from 'reactstrap'; + +class Register extends Component { + constructor(props) { + super(props); + this.state = { + errorMessage: null, + email: '', + password: '', + confirmPassword: '', + registrationSent: false + } + this.handleInputChange = this.handleInputChange.bind(this); + this.handleCreate = this.handleCreate.bind(this); + } + + handleInputChange(event) { + var targetName = event.target.name + this.setState({[event.target.name]: event.target.value}, () => { + if ( targetName === 'password' || + targetName === 'confirmPassword' && + this.state.password != this.state.confirmPassword ) { + this.setState({errorMessage: "Passwords do not match"}) + } else { + this.setState({errorMessage: null}) + } + }) + } + + handleCreate(event) { + if ( this.state.email.length == 0 ) { + this.setState({errorMessage: 'Please enter an email address'}) + } else if ( this.state.password.length == 0 && + this.state.confirmPassword.length == 0 ) { + this.setState({errorMessage: 'Please enter and conform your password'}) + } else if ( this.state.password != this.state.confirmPassword ) { + //error message is already thwre + return + } else { + var payload = { + userId: this.state.email, + password: this.state.password + } + fetch(`/signalk/v1/access/requests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + credentials: "include" + }) + .then(response => { + if ( response.status != 202 ) { + response.json().then(json => { + this.setState({errorMessage: json.message ? json.message : json.result}) + }) + } else { + this.setState({registrationSent: true}) + } + }); + } + } + + render() { + return ( +
+ + + + + +

Register

+ {this.state.registrationSent && +

Your registration has been sent

+ } + {!this.state.registrationSent && +
+

Create your account

+ + @ + + + + + + + + + + +

{this.state.errorMessage}

+
+ } +
+ {!this.state.registrationSent && + + + + + + + + } +
+ +
+
+
+ ); + } +} + +export default Register; diff --git a/packages/server-admin-ui/src/views/security/Settings.js b/packages/server-admin-ui/src/views/security/Settings.js new file mode 100644 index 000000000..5c674b75b --- /dev/null +++ b/packages/server-admin-ui/src/views/security/Settings.js @@ -0,0 +1,196 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { + Button, + Card, + CardHeader, + CardBody, + CardFooter, + InputGroup, + InputGroupAddon, + Input, + Form, + Col, + Label, + FormGroup, + FormText, + Table, + Row +} from 'reactstrap' +import EnableSecurity from './EnableSecurity' + +export function fetchSecurityConfig () { + fetch(`/security/config`, { + credentials: 'include' + }) + .then(response => response.json()) + .then(data => { + console.log(JSON.stringify(data)) + this.setState(data) + }) +} + +class Settings extends Component { + constructor (props) { + super(props) + this.state = { + allow_readonly: false, + expiration: '', + allowNewUserRegistration: true, + allowDeviceAccessRequests: true + } + + this.handleChange = this.handleChange.bind(this) + this.handleSaveConfig = this.handleSaveConfig.bind(this) + this.fetchSecurityConfig = fetchSecurityConfig.bind(this) + } + + componentDidMount () { + if (this.props.loginStatus.authenticationRequired) { + this.fetchSecurityConfig() + } + } + + handleChange (event) { + const value = + event.target.type === 'checkbox' + ? event.target.checked + : event.target.value + this.setState({ [event.target.name]: value }) + } + + handleSaveConfig () { + var payload = { + allow_readonly: this.state.allow_readonly, + expiration: this.state.expiration, + allowNewUserRegistration: this.state.allowNewUserRegistration, + allowDeviceAccessRequests: this.state.allowDeviceAccessRequests + } + fetch('/security/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + credentials: 'include' + }) + .then(response => response.text()) + .then(response => { + this.fetchSecurityConfig() + alert(response) + }) + } + + render () { + return ( +
+ {this.props.loginStatus.authenticationRequired === false && ( + + )} + {this.props.loginStatus.authenticationRequired && ( +
+ + + Settings + + +
+ + + + + + +
+ +
+
+ +
+ + + + +
+ +
+
+ +
+ + + + +
+ +
+
+ +
+ + + + + + + + Exmaples: 60s, 1m, 1h, 1d + + + +
+
+ + + +
+ +
+ )} +
+ ) + } +} + +const mapStateToProps = ({ securityConfig }) => ({ securityConfig }) + +export default connect(mapStateToProps)(Settings) + diff --git a/packages/server-admin-ui/src/views/Security.js b/packages/server-admin-ui/src/views/security/Users.js similarity index 71% rename from packages/server-admin-ui/src/views/Security.js rename to packages/server-admin-ui/src/views/security/Users.js index b655a15db..f9ca76306 100644 --- a/packages/server-admin-ui/src/views/Security.js +++ b/packages/server-admin-ui/src/views/security/Users.js @@ -19,16 +19,6 @@ import { } from 'reactstrap' import EnableSecurity from './EnableSecurity' -export function fetchSecurityConfig () { - fetch(`/security/config`, { - credentials: 'include' - }) - .then(response => response.json()) - .then(data => { - this.setState(data) - }) -} - export function fetchSecurityUsers () { fetch(`/security/users`, { credentials: 'include' @@ -39,20 +29,15 @@ export function fetchSecurityUsers () { }) } -class Security extends Component { +class Users extends Component { constructor (props) { super(props) this.state = { users: [], - allow_readonly: false, - expiration: '' } - this.handleChange = this.handleChange.bind(this) this.handleAddUser = this.handleAddUser.bind(this) - this.handleSaveConfig = this.handleSaveConfig.bind(this) this.fetchSecurityUsers = fetchSecurityUsers.bind(this) - this.fetchSecurityConfig = fetchSecurityConfig.bind(this) this.handleCancel = this.handleCancel.bind(this) this.handleApply = this.handleApply.bind(this) this.handleUserChange = this.handleUserChange.bind(this) @@ -61,19 +46,10 @@ class Security extends Component { componentDidMount () { if (this.props.loginStatus.authenticationRequired) { - this.fetchSecurityConfig() this.fetchSecurityUsers() } } - handleChange (event) { - const value = - event.target.type === 'checkbox' - ? event.target.checked - : event.target.value - this.setState({ [event.target.name]: value }) - } - handleUserChange (event) { const value = event.target.type === 'checkbox' @@ -167,28 +143,7 @@ class Security extends Component { }) } - handleSaveConfig () { - var payload = { - allow_readonly: this.state.allow_readonly, - expiration: this.state.expiration - } - fetch('/security/config', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload), - credentials: 'include' - }) - .then(response => response.text()) - .then(response => { - this.fetchSecurityConfig() - alert(response) - }) - } - userClicked (user, index) { - console.log(JSON.stringify(user)) this.setState( { selectedUser: JSON.parse(JSON.stringify(user)), @@ -211,72 +166,6 @@ class Security extends Component { )} {this.props.loginStatus.authenticationRequired && (
- - - Settings - - -
- - - - - - -
- -
-
- -
- - - - - - - - Exmaples: 60s, 1m, 1h, 1d - - - -
-
- - - -
- Users @@ -425,12 +314,9 @@ class Security extends Component { } } -const mapStateToProps = ({ securityConfig, securityUsers }) => ({ - securityConfig, - securityUsers -}) +const mapStateToProps = ({ securityUsers }) => ({ securityUsers }) -export default connect(mapStateToProps)(Security) +export default connect(mapStateToProps)(Users) function convertType (type) { if (type == 'readonly') { diff --git a/test/security.js b/test/security.js index 1fdbde205..a7aa9992b 100644 --- a/test/security.js +++ b/test/security.js @@ -47,14 +47,18 @@ const WRITE_USER_NAME = 'writeuser' const WRITE_USER_PASSWORD = 'writepass' const LIMITED_USER_NAME = 'testuser' const LIMITED_USER_PASSWORD = 'verylimited' +const ADMIN_USER_NAME = 'adminuser' +const ADMIN_USER_PASSWORD = 'adminpass' describe('Security', () => { - var server, url, port, readToken, writeToken + var server, url, port, readToken, writeToken, adminToken before(async function () { var securityConfig = { allow_readonly: false, expiration: '1d', + allowNewUserRegistration: true, + allowDeviceAccessRequests: true, secretKey: '3ad6c2b567c43199e1afd2307ef506ea9fb5f8becada1f86c15213d75124fbaf4647c3f7202b788bba5c01c8bb8fdc52e8ca5bd484be36b6900ac03b88b6063b6157bee1e638acde1936d6ef4717884de63c86e9f50c8ee12b15bf837268b04bc09a461f5dddaf71dfc7205cc549b29810a31515b21d57ac5fdde29628ccff821cfc229004c4864576eb7c238b0cd3a6d774c14854affa1aeedbdb1f47194033f18e50d9dc1171a47e36f26c864080a627c500d1642fc94f71e93ff54022a8d4b00f19e88a0610ef70708ac6a386ba0df7cab201e24d3eb0061ddd0052d3d85cda50ac8d6cafc4ecc43d8db359a85af70d4c977a3d4b0d588f123406dbd57f01', users: [], @@ -91,22 +95,20 @@ describe('Security', () => { port = await freeport() url = `http://0.0.0.0:${port}` - const serverApp = new Server( - { - config: { - settings: { - port, - interfaces: { - plugins: false - }, - security: { - strategy: './tokensecurity' - } + const serverApp = new Server({ + config: { + settings: { + port, + interfaces: { + plugins: false + }, + security: { + strategy: './tokensecurity' } } }, - securityConfig - ) + securityConfig: securityConfig + }) server = await serverApp.start() await promisify(server.app.securityStrategy.addUser)(securityConfig, { @@ -119,8 +121,14 @@ describe('Security', () => { type: 'readwrite', password: WRITE_USER_PASSWORD }) + await promisify(server.app.securityStrategy.addUser)(securityConfig, { + userId: ADMIN_USER_NAME, + type: 'admin', + password: ADMIN_USER_PASSWORD + }) readToken = await login(LIMITED_USER_NAME, LIMITED_USER_PASSWORD) writeToken = await login(WRITE_USER_NAME, WRITE_USER_PASSWORD) + adminToken = await login(ADMIN_USER_NAME, ADMIN_USER_PASSWORD) }) after(async function () { @@ -142,7 +150,6 @@ describe('Security', () => { throw new Error('Login returned ' + result.status) } return result.json().then(json => { - console.log(json) return json.token }) } @@ -244,9 +251,127 @@ describe('Security', () => { }) it('request after logout fails', async function () { - var result = await fetch(`${url}/signalk/v1/api/vessels/self`, { - credentials: 'include' - }) + var result = await fetch(`${url}/signalk/v1/api/vessels/self`, {}) result.status.should.equal(401) }) + + it('Device access request and approval works', async function () { + var result = await fetch(`${url}/signalk/v1/access/requests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + clientId: '1235-45653-343453', + description: 'My Awesome Sensor', + permissions: 'readwrite' + }) + }) + result.status.should.equal(202) + var requestJson = await result.json() + requestJson.should.have.property('requestId') + requestJson.should.have.property('href') + + var result = await fetch(`${url}${requestJson.href}`) + result.status.should.equal(200) + var json = await result.json() + json.should.have.property('state') + json.state.should.equal('PENDING') + json.should.have.property('requestId') + + var result = await fetch( + `${url}/security/access/requests/1235-45653-343453/approved`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Cookie: `JAUTHENTICATION=${adminToken}` + }, + body: JSON.stringify({ + expiration: '1y', + permissions: 'readwrite' + }) + } + ) + result.status.should.equal(200) + + var result = await fetch(`${url}${requestJson.href}`) + result.status.should.equal(200) + var json = await result.json() + json.should.have.property('state') + json.state.should.equal('COMPLETED') + json.should.have.property('accessRequest') + json.accessRequest.should.have.property('permission') + json.accessRequest.permission.should.equal('APPROVED') + json.accessRequest.should.have.property('token') + + var result = await fetch(`${url}/security/devices`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + result.status.should.equal(200) + var json = await result.json() + json.length.should.equal(1) + json[0].should.have.property('clientId') + json[0].clientId.should.equal('1235-45653-343453') + json[0].permissions.should.equal('readwrite') + json[0].description.should.equal('My Awesome Sensor') + }) + + it('Device access request and denial works', async function () { + var result = await fetch(`${url}/signalk/v1/access/requests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + clientId: '1235-45653-343455', + description: 'My Awesome Sensor', + permissions: 'readwrite' + }) + }) + result.status.should.equal(202) + var requestJson = await result.json() + requestJson.should.have.property('requestId') + requestJson.should.have.property('href') + + var result = await fetch(`${url}${requestJson.href}`) + result.status.should.equal(200) + var json = await result.json() + json.should.have.property('state') + json.state.should.equal('PENDING') + + var result = await fetch( + `${url}/security/access/requests/1235-45653-343455/denied`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Cookie: `JAUTHENTICATION=${adminToken}` + }, + body: JSON.stringify({ + expiration: '1y', + permissions: 'readwrite' + }) + } + ) + result.status.should.equal(200) + + var result = await fetch(`${url}${requestJson.href}`) + var json = await result.json() + json.should.have.property('state') + json.state.should.equal('COMPLETED') + json.should.have.property('accessRequest') + json.accessRequest.should.have.property('permission') + json.accessRequest.permission.should.equal('DENIED') + + var result = await fetch(`${url}/security/devices`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + var json = await result.json() + json.length.should.equal(1) + }) })