diff --git a/lib/serverroutes.js b/lib/serverroutes.js
index 762e7d230..0f241387f 100644
--- a/lib/serverroutes.js
+++ b/lib/serverroutes.js
@@ -257,6 +257,84 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) {
)
})
+ app.put('/security/access/requests/:identifier/:status', (req, res) => {
+ if (app.securityStrategy.allowConfigure(req)) {
+ var config = getSecurityConfig(app)
+ app.securityStrategy.setAccessRequestStatus(
+ config,
+ req.params.identifier,
+ req.params.status,
+ req.body,
+ (err, config) => {
+ if (err) {
+ console.log(err)
+ res.status(500)
+ res.send(`Unable update request: ${err.message}`)
+ 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('Request updated')
+ })
+ } else {
+ res.send('Request updated')
+ }
+ }
+ )
+ } else {
+ res.status(401).json('Security config not allowed')
+ }
+ })
+
+ app.get('/security/access/requests', (req, res) => {
+ if (app.securityStrategy.allowConfigure(req)) {
+ res.json(app.securityStrategy.getAccessRequestsResponse())
+ } else {
+ res.status(401).json('Security config not allowed')
+ }
+ })
+
+ app.post('/access/requests', (req, res) => {
+ var config = getSecurityConfig(app)
+ let ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress
+ app.securityStrategy.requestAccess(
+ config,
+ req.body,
+ ip,
+ (err, response, config) => {
+ if (err) {
+ console.log(err)
+ res.status(500)
+ res.send(`Unable to create request: ${err.message}`)
+ return
+ }
+ if (!response) {
+ res.send('Request created')
+ } else {
+ res.json(response)
+ }
+ }
+ )
+ })
+
+ app.get('/access/requests/:id', (req, res) => {
+ app.securityStrategy.checkRequest(req.params.id, (err, result) => {
+ if (err) {
+ console.log(err)
+ res.status(500)
+ res.send(`Unable to check request: ${err.message}`)
+ return
+ }
+ res.json(result)
+ })
+ })
+
app.get('/settings', (req, res, next) => {
var settings = {
interfaces: {},
diff --git a/lib/tokensecurity.js b/lib/tokensecurity.js
index 169e6556c..9b755891a 100644
--- a/lib/tokensecurity.js
+++ b/lib/tokensecurity.js
@@ -22,6 +22,7 @@ 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 CONFIG_PLUGINID = 'sk-simple-token-security-config'
const passwordSaltRounds = 10
@@ -30,6 +31,7 @@ const permissionDeniedMessage =
module.exports = function (app, config) {
const strategy = {}
+ let accessRequests = []
let {
allow_readonly = true,
@@ -39,8 +41,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 +69,25 @@ 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,
immutableConfig,
- acls
+ acls,
+ allowDeviceAccessRequests,
+ allowNewUserRegistration
}
// so that enableSecurity gets the defaults to save
@@ -273,7 +290,9 @@ 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
@@ -294,6 +313,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
@@ -701,6 +721,203 @@ module.exports = function (app, config) {
}
}
+ strategy.getAccessRequestsResponse = () => {
+ return accessRequests.filter(r => r.active).map(r => ({
+ identifier: r.identifier,
+ description: r.description,
+ ip: r.ip,
+ expiration: r.expiration,
+ config: r.config,
+ active: r.active
+ }))
+ }
+
+ function sendAccessRequestsUpdate () {
+ app.emit('serverevent', {
+ type: 'ACCESS_REQUEST',
+ from: CONFIG_PLUGINID,
+ data: strategy.getAccessRequestsResponse()
+ })
+ }
+
+ strategy.checkRequest = (requestId, cb) => {
+ var idx = accessRequests.findIndex(r => r.requestId == requestId)
+ if (idx == -1) {
+ cb(new Error('not found'))
+ return
+ }
+
+ var request = accessRequests[idx]
+ var result = {
+ status: request.active
+ ? 'PENDING'
+ : request.approved ? 'APPROVED' : 'DENIED'
+ }
+ if (request.approved) {
+ result.token = request.token
+ delete request.token
+ }
+ cb(null, result)
+ }
+
+ strategy.setAccessRequestStatus = (config, identifier, status, body, cb) => {
+ var idx = accessRequests.findIndex(r => r.identifier == identifier)
+ if (idx == -1) {
+ cb(new Error('not found'))
+ return
+ }
+
+ var request = accessRequests[idx]
+
+ if (status === 'approved') {
+ if (request.uuid) {
+ var payload = { device: identifier }
+ var jwtOptions = {}
+
+ expiration = body.expiration
+ if (expiration !== 'NEVER') {
+ jwtOptions.expiresIn = expiration
+ }
+ debug(`expiration ${expiration} ${body.expiration}`)
+ var token = jwt.sign(payload, config.secretKey, jwtOptions)
+
+ if (!config.devices) {
+ config.devices = []
+ }
+
+ config.devices.push({
+ uuid: request.uuid,
+ permissions: request.permissions,
+ config: body.config
+ })
+ request.token = token
+ } else {
+ config.users.push({
+ username: identifier,
+ password: request.password,
+ type: 'readonly'
+ })
+ }
+ request.approved = true
+ } else if (status === 'denied') {
+ request.approved = false
+ } else {
+ cb(new Error('Unkown status value'), config)
+ return
+ }
+ // accessRequests = accessRequests.filter(r => r.identifier != identifier)
+ request.active = false
+ cb(null, config)
+ options = config
+ sendAccessRequestsUpdate()
+ }
+
+ function validateAccessRequest (request) {
+ if (request.userId) {
+ return !_.isUndefined(request.password)
+ } else if (request.uuid) {
+ return !_.isUndefined(request.description)
+ } else {
+ return false
+ }
+ }
+
+ strategy.requestAccess = (config, request, sourceIp, cb) => {
+ if (!validateAccessRequest(request)) {
+ cb(new Error('Invalid request'))
+ return
+ }
+
+ request.ip = sourceIp
+ request.active = true
+ request.date = new Date()
+
+ var alertMessage
+ var response
+ if (request.uuid) {
+ if (!options.allowDeviceAccessRequests) {
+ cb(new Error('Device access not allowed'))
+ return
+ }
+
+ if (
+ config.devices &&
+ config.devices.find(device => device.uuid == request.uuid)
+ ) {
+ cb(new Error(`A device with uuid '${request.uuid}' already has access`))
+ return
+ }
+
+ if (accessRequests.find(r => r.uuid == request.uuid)) {
+ cb(
+ new Error(
+ `A device with uuid '${request.uuid}' has already requested access`
+ )
+ )
+ return
+ }
+
+ request.identifier = request.uuid
+ request.requestId = uuidv4()
+
+ /*
+ req.on('close', (err) => {
+ accessRequests = accessRequests.filter(r => r.uuid != request.uuid)
+ sendAccessRequestsUpdate()
+ })
+ */
+ response = { requestId: request.requestId }
+
+ alertMessage = `A device with IP ${request.ip} and UUID ${
+ request.uuid
+ } has requested access to the server`
+ debug(alertMessage)
+ } else {
+ if (!options.allowNewUserRegistration) {
+ cb(new Error('new user registration not allowed'))
+ return
+ }
+
+ var existing = options.users.find(user => user.username == request.userId)
+ if (existing) {
+ cb(new Error('User already exists'))
+ return
+ }
+ request.description = 'New User Request'
+ request.identifier = request.userId
+ request.password = bcrypt.hashSync(
+ request.password,
+ bcrypt.genSaltSync(passwordSaltRounds)
+ )
+ alertMessage = `${request.userid} has requested server access`
+ debug(alertMessage)
+ }
+
+ request.permissions = 'readonly'
+ request.expiration = config.expiration
+ accessRequests.push(request)
+ sendAccessRequestsUpdate()
+ app.handleMessage(CONFIG_PLUGINID, {
+ context: 'vessels.' + app.selfId,
+ updates: [
+ {
+ values: [
+ {
+ path: 'notifications.security.accessRequest',
+ value: {
+ state: 'alert',
+ method: ['visual', 'sound'],
+ message: alertMessage,
+ timestamp: new Date().toISOString()
+ }
+ }
+ ]
+ }
+ ]
+ })
+ cb(null, response, config)
+ }
+
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..b9de87501 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,33 @@ 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.allowNewUserRegistration ||
+ state.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..f976340ae 100644
--- a/packages/server-admin-ui/src/containers/Full/Full.js
+++ b/packages/server-admin-ui/src/containers/Full/Full.js
@@ -13,8 +13,11 @@ 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 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 +75,11 @@ class Full extends Component {
path='/serverConfiguration/providers'
component={loginOrOriginal(ProvidersConfiguration)}
/>
-
Identifier | +Description | +Source IP | +
---|---|---|
{req.identifier} | +{req.description} | +{req.ip} | +
{this.state.loginErrorMessage} -
+ + {!this.state.loginErrorMessage && this.props.loginStatus.allowNewUserRegistration && +Your registration has been sent
+ } + {!this.state.registrationSent && +Create your account
+{this.state.errorMessage}
+