UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

1,236 lines (1,235 loc) 57.4 kB
"use strict"; /* * Copyright 2017 Scott Bender <scott@scottbender.net> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const lodash_1 = __importDefault(require("lodash")); const bcryptjs_1 = __importDefault(require("bcryptjs")); const server_api_1 = require("@signalk/server-api"); const ms_1 = __importDefault(require("ms")); const cookie_parser_1 = __importDefault(require("cookie-parser")); const crypto_1 = require("crypto"); const debug_1 = require("./debug"); const login_rate_limiter_1 = require("./login-rate-limiter"); const security_1 = require("./security"); // requestResponse is still CommonJS /* eslint-disable @typescript-eslint/no-require-imports */ const { createRequest, updateRequest, findRequest, filterRequests } = require('./requestResponse'); /* eslint-enable @typescript-eslint/no-require-imports */ const oidc_1 = require("./oidc"); const constants_1 = require("./constants"); const debug = (0, debug_1.createDebug)('signalk-server:tokensecurity'); const CONFIG_PLUGINID = 'sk-simple-token-security-config'; const passwordSaltRounds = 10; const permissionDeniedMessage = "You do not have permission to view this resource, <a href='/admin/#/login'>Please Login</a>"; const skPrefix = '/signalk/v1'; const skAuthPrefix = `${skPrefix}/auth`; // Cookie to hold login info for webapps to use const BROWSER_LOGININFO_COOKIE_NAME = 'skLoginInfo'; const LOGIN_FAILED_MESSAGE = 'Invalid username/password'; const VALID_PERMISSIONS = new Set(['readonly', 'readwrite', 'admin']); // Dummy hash for timing attack prevention - pre-generated bcrypt hash const DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012'; function isNever(expiration) { return expiration?.toUpperCase() === 'NEVER'; } function tokenSecurityFactory(app, config) { const strategy = {}; const { expiration = 'NEVER' } = config; let { users = [], immutableConfig = false, allowDeviceAccessRequests = true, allowNewUserRegistration = true } = config; const { allow_readonly = false, secretKey = process.env.SECRETKEY || (0, crypto_1.randomBytes)(256).toString('hex'), devices = [], acls = [] } = config; /** * Derive a domain-specific secret from the master key. * Used to provide isolated secrets for different subsystems * without exposing the JWT signing key. */ function deriveSecret(domain) { return (0, crypto_1.createHash)('sha256').update(secretKey).update(domain).digest('hex'); } /** * Crypto service for OIDC state encryption. * Provides derived secret - tokensecurity knows nothing about OIDC internals. */ const oidcCryptoService = { getStateEncryptionSecret: () => deriveSecret('signalk-oidc') }; /** * User service for external authentication providers (OIDC, etc.). * Abstracts user storage so auth providers don't need to know about * the underlying storage mechanism (currently array, could be SQLite etc). */ const externalUserService = { async findUserByProvider(lookup) { // Currently only OIDC is supported if (lookup.provider === 'oidc') { const { sub, issuer } = lookup.criteria; const user = options.users.find((u) => u.oidc?.sub === sub && u.oidc?.issuer === issuer); if (user) { return { username: user.username, type: user.type, providerData: user.oidc }; } } return null; }, async findUserByUsername(username) { const user = options.users.find((u) => u.username === username); if (user) { return { username: user.username, type: user.type, providerData: user.oidc }; } return null; }, async createUser(externalUser) { const newUser = { username: externalUser.username, type: externalUser.type }; if ((0, security_1.isOIDCUserIdentifier)(externalUser.providerData?.oidc)) { newUser.oidc = externalUser.providerData.oidc; } options.users.push(newUser); return new Promise((resolve, reject) => { (0, security_1.saveSecurityConfig)(app, options, (err) => { if (err) { reject(err); } else { resolve(); } }); }); }, async updateUser(username, updates) { const user = options.users.find((u) => u.username === username); if (!user) { throw new Error(`User not found: ${username}`); } if (updates.type) { user.type = updates.type; } if ((0, security_1.isOIDCUserIdentifier)(updates.providerData?.oidc)) { user.oidc = updates.providerData.oidc; } return new Promise((resolve, reject) => { (0, security_1.saveSecurityConfig)(app, options, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } }; if (process.env.ADMINUSER) { const adminUserParts = process.env.ADMINUSER.split(':'); if (adminUserParts.length !== 2) { console.error('ADMINUSER env parameters must be in username:password format'); process.exit(-1); } users = [ { username: adminUserParts[0], type: 'admin', password: bcryptjs_1.default.hashSync(adminUserParts[1], bcryptjs_1.default.genSaltSync(passwordSaltRounds)) } ]; 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, allowDeviceAccessRequests, allowNewUserRegistration, oidc: config.oidc // Include OIDC config from security.json }; // so that enableSecurity gets the defaults to save lodash_1.default.merge(config, options); function getConfiguration() { return options; } strategy.getConfiguration = getConfiguration; // Parse and cache OIDC configuration eagerly so that invalid config // is detected at startup rather than on first request (see #2594) let cachedOIDCConfig = null; function parseAndCacheOIDCConfig() { try { cachedOIDCConfig = (0, oidc_1.parseOIDCConfig)(options); } catch (err) { if (err instanceof oidc_1.OIDCError) { console.error(`OIDC configuration error [${err.code}]: ${err.message}`); console.error('OIDC will be disabled. Fix the configuration and restart.'); } else { console.error('Unexpected error parsing OIDC configuration:', err); console.error('OIDC will be disabled.'); } cachedOIDCConfig = { ...oidc_1.OIDC_DEFAULTS, enabled: false }; } } parseAndCacheOIDCConfig(); function getOIDCConfig() { return cachedOIDCConfig; } function updateOIDCConfig(newOidcConfig) { options.oidc = newOidcConfig; parseAndCacheOIDCConfig(); } strategy.updateOIDCConfig = updateOIDCConfig; function getSessionCookieOptions(req, rememberMe = false) { const configuration = getConfiguration(); const cookieOptions = { sameSite: 'strict', secure: req.secure || req.headers['x-forwarded-proto'] === 'https' }; if (rememberMe) { const expValue = isNever(configuration.expiration) ? '10y' : configuration.expiration || '1h'; cookieOptions.maxAge = (0, ms_1.default)(expValue); } return cookieOptions; } function setSessionCookie(res, req, token, username, sessionOptions = {}) { const cookieOptions = getSessionCookieOptions(req, sessionOptions.rememberMe); // Auth cookie must be httpOnly for security const authCookieOptions = { ...cookieOptions, httpOnly: true }; res.cookie('JAUTHENTICATION', token, authCookieOptions); // Login info cookie must NOT be httpOnly so JS can access it res.cookie(BROWSER_LOGININFO_COOKIE_NAME, JSON.stringify({ status: 'loggedIn', user: username }), cookieOptions); } function clearSessionCookie(res) { res.clearCookie('JAUTHENTICATION'); res.clearCookie(BROWSER_LOGININFO_COOKIE_NAME); } function generateJWT(userId, tokenExpiration, rememberMe) { const configuration = getConfiguration(); const theExpiration = tokenExpiration || configuration.expiration || '1h'; const payload = { id: userId }; if (rememberMe) { payload.rememberMe = true; } const jwtOptions = {}; if (!isNever(theExpiration)) { jwtOptions.expiresIn = theExpiration; } return jsonwebtoken_1.default.sign(payload, configuration.secretKey, jwtOptions); } function getIsEnabled() { // var options = getOptions(); // return typeof options.enabled !== 'undefined' && options.enabled; return true; } function assertConfigImmutability() { if (options.immutableConfig) { throw new Error('Configuration is immutable'); } } function handlePermissionDenied(req, res) { res.status(401); if (req.accepts('application/json') && !req.accepts('text/html')) { res.set('Content-Type', 'application/json'); res.json({ error: 'Permission Denied' }); } else { res.type('text/plain').send(permissionDeniedMessage); } } function hasAdminAccess(req) { const skReq = req; return (skReq.skIsAuthenticated === true && skReq.skPrincipal !== undefined && skReq.skPrincipal.permissions === 'admin'); } strategy.hasAdminAccess = hasAdminAccess; function writeAuthenticationMiddleware() { return function (req, res, next) { const skReq = req; if (!getIsEnabled()) { return next(); } debug('skIsAuthenticated: ' + skReq.skIsAuthenticated); if (skReq.skIsAuthenticated) { if (skReq.skPrincipal?.permissions === 'admin' || skReq.skPrincipal?.permissions === 'readwrite') { return next(); } } handlePermissionDenied(req, res); }; } function adminAuthenticationMiddleware(redirect) { return function (req, res, next) { const skReq = req; if (!getIsEnabled()) { return next(); } if (hasAdminAccess(req)) { return next(); } if (skReq.skIsAuthenticated && skReq.skPrincipal) { if (skReq.skPrincipal.identifier === 'AUTO' && redirect) { res.redirect('/admin/#/login'); } else { handlePermissionDenied(req, res); } } else if (redirect) { res.redirect('/admin/#/login'); } else { handlePermissionDenied(req, res); } }; } function setupApp() { const rawHttpRateLimits = process.env.HTTP_RATE_LIMITS; const parsedParts = typeof rawHttpRateLimits === 'string' ? rawHttpRateLimits .split(/[\s,]+/) .map((p) => p.trim()) .filter(Boolean) : []; let loginWindowMs = 10 * 60 * 1000; let loginMax = 100; for (const part of parsedParts) { const eqIndex = part.indexOf('='); if (eqIndex === -1) { continue; } const key = part.slice(0, eqIndex).trim().toLowerCase(); const value = part.slice(eqIndex + 1).trim(); const parsed = Number.parseInt(value, 10); if ((key === 'windowms' || key === 'window') && Number.isFinite(parsed)) { loginWindowMs = parsed; } else if ((key === 'login' || key === 'loginmax') && Number.isFinite(parsed)) { loginMax = parsed; } } const loginRateLimiter = (0, login_rate_limiter_1.createLoginRateLimiter)(loginWindowMs, loginMax); strategy.loginRateLimiter = loginRateLimiter; const loginLimiter = (req, res, next) => { const { allowed, retryAfterMs } = loginRateLimiter.check(req.ip ?? ''); if (!allowed) { res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000))); res.status(429).json({ message: login_rate_limiter_1.LOGIN_RATE_LIMIT_MESSAGE }); return; } next(); }; app.use((0, cookie_parser_1.default)()); function getSafeDestination(destination) { if (typeof destination !== 'string') { return '/'; } const dest = destination.trim(); // Allow only relative redirects. Reject protocol-relative URLs (//evil.com). if (!dest.startsWith('/') || dest.startsWith('//')) { return '/'; } return dest; } app.post(['/login', `${skAuthPrefix}/login`], loginLimiter, (req, res) => { const name = req.body.username; const password = req.body.password; const remember = req.body.rememberMe; login(name, password, remember) .then((reply) => { const wantsJson = req.is('application/json'); if (reply.statusCode === 200 && reply.token && reply.user) { setSessionCookie(res, req, reply.token, reply.user, { rememberMe: remember }); if (wantsJson) { res.json({ timeToLive: reply.timeToLive, token: reply.token }); } else { res.redirect(getSafeDestination(req.body.destination)); } } else { if (wantsJson) { res.status(reply.statusCode).send(reply); } else { res.status(reply.statusCode).send(reply.message); } } }) .catch((err) => { console.log(err); res.status(502).send('Login Failure'); }); }); app.use('/', http_authorize(false, true)) //semicolon required ; [ '/apps', '/appstore', '/plugins', '/restart', '/runDiscovery', '/security', '/vessel', '/providers', '/settings', '/webapps', '/availablePaths', '/hasAnalyzer', '/skServer/inputTest' ].forEach((p) => app.use(`${constants_1.SERVERROUTESPREFIX}${p}`, http_authorize(false))); app.put(['/logout', `${skAuthPrefix}/logout`], function (req, res) { clearSessionCookie(res); res.json('Logout OK'); }); // Register OIDC authentication routes (0, oidc_1.registerOIDCRoutes)(app, { getOIDCConfig, setSessionCookie, clearSessionCookie, generateJWT, cryptoService: oidcCryptoService, userService: externalUserService }); // Register OIDC admin routes (GET/PUT /security/oidc, POST /security/oidc/test) (0, oidc_1.registerOIDCAdminRoutes)(app, { allowConfigure: (req) => strategy.allowConfigure(req), getSecurityConfig: () => options, saveSecurityConfig: (securityConfig, callback) => (0, security_1.saveSecurityConfig)(app, securityConfig, callback), updateOIDCConfig }); [ '/restart', '/runDiscovery', '/plugins', '/appstore', '/security', '/settings', '/backup', '/restore', '/providers', '/vessel', '/serialports' ].forEach((p) => app.use(`${constants_1.SERVERROUTESPREFIX}${p}`, adminAuthenticationMiddleware(false))); app.use('/plugins', adminAuthenticationMiddleware(false)); //TODO remove after grace period app.use('/loginStatus', http_authorize(false, true)); app.use(`${constants_1.SERVERROUTESPREFIX}/loginStatus`, http_authorize(false, true)); const no_redir = http_authorize(false); app.use('/signalk/v1/api/*', function (req, res, next) { no_redir(req, res, next); }); app.use('/signalk/v1/snapshot/*', function (req, res, next) { no_redir(req, res, next); }); app.use('/signalk/v2/api/*', function (req, res, next) { no_redir(req, res, next); }); app.put('/signalk/v1/*', writeAuthenticationMiddleware()); app.put('/signalk/v2/*', writeAuthenticationMiddleware()); app.post('/signalk/v2/*', writeAuthenticationMiddleware()); app.delete('/signalk/v2/*', writeAuthenticationMiddleware()); } function login(name, password, rememberMe) { return new Promise((resolve, reject) => { debug('handing login for user: ' + name); // Validate input to prevent crashes on malformed requests if (typeof name !== 'string' || typeof password !== 'string') { // Still run bcrypt to prevent timing attacks on input validation bcryptjs_1.default.compare('dummy', DUMMY_HASH, () => { resolve({ statusCode: 401, message: LOGIN_FAILED_MESSAGE }); }); return; } // Trim to match the stored username, which addUser trims on // registration, so a stray space typed into the login box still logs in. const trimmedName = name.trim(); const configuration = getConfiguration(); const user = configuration.users.find((aUser) => aUser.username === trimmedName); // Always run bcrypt.compare to prevent timing attacks that reveal // whether a username exists. Use a dummy hash if user not found. const hashToCompare = user && user.password ? user.password : DUMMY_HASH; bcryptjs_1.default.compare(password, hashToCompare, (err, matches) => { if (err) { reject(err); } else if (matches === true && user && user.password) { // Only succeed if user exists AND password matched real hash const payload = { id: user.username }; if (rememberMe) { payload.rememberMe = true; } const theExpiration = configuration.expiration || '1h'; const jwtOptions = {}; if (!isNever(theExpiration)) { jwtOptions.expiresIn = theExpiration; } debug.enabled && debug(`jwt expiration:${JSON.stringify(jwtOptions)}`); try { const token = jsonwebtoken_1.default.sign(payload, configuration.secretKey, jwtOptions); const timeToLive = !isNever(theExpiration) ? Math.floor((0, ms_1.default)(theExpiration) / 1000) : null; resolve({ statusCode: 200, token, user: user.username, timeToLive }); } catch (signErr) { resolve({ statusCode: 500, message: 'Unable to sign token: ' + signErr.message }); } } else { debug('password did not match'); resolve({ statusCode: 401, message: LOGIN_FAILED_MESSAGE }); } }); }); } strategy.validateConfiguration = (newConfiguration) => { const configuration = getConfiguration(); const theExpiration = newConfiguration.expiration || '1h'; if (!isNever(theExpiration)) { jsonwebtoken_1.default.sign({ dummy: 'payload' }, configuration.secretKey, { expiresIn: theExpiration }); } }; strategy.getAuthRequiredString = () => { return strategy.allowReadOnly() ? 'forwrite' : 'always'; }; strategy.supportsLogin = () => true; strategy.login = login; strategy.addAdminMiddleware = function (aPath) { app.use(aPath, http_authorize(false)); app.use(aPath, adminAuthenticationMiddleware(false)); }; strategy.addAdminWriteMiddleware = function (aPath) { app.use(aPath, http_authorize(false)); app.put(aPath, adminAuthenticationMiddleware(false)); app.post(aPath, adminAuthenticationMiddleware(false)); app.delete(aPath, adminAuthenticationMiddleware(false)); }; strategy.addWriteMiddleware = function (aPath) { app.use(aPath, http_authorize(false)); app.put(aPath, writeAuthenticationMiddleware()); app.post(aPath, writeAuthenticationMiddleware()); }; strategy.generateToken = function (req, res, next, id, theExpiration) { const configuration = getConfiguration(); const payload = { id: id }; const token = jsonwebtoken_1.default.sign(payload, configuration.secretKey, { expiresIn: theExpiration }); res.type('text/plain').send(token); }; strategy.allowReadOnly = function () { const configuration = getConfiguration(); return configuration.allow_readonly; }; strategy.allowRestart = function (req) { return hasAdminAccess(req); }; strategy.allowConfigure = function (req) { return hasAdminAccess(req); }; strategy.getLoginStatus = function (req) { const skReq = req; const configuration = getConfiguration(); const result = { status: skReq.skIsAuthenticated ? 'loggedIn' : 'notLoggedIn', readOnlyAccess: configuration.allow_readonly, authenticationRequired: true, allowNewUserRegistration: configuration.allowNewUserRegistration, allowDeviceAccessRequests: configuration.allowDeviceAccessRequests }; if (skReq.skIsAuthenticated && skReq.skPrincipal) { result.userLevel = skReq.skPrincipal.permissions; result.username = skReq.skPrincipal.identifier; } if (configuration.users.length === 0) { result.noUsers = true; } // Add OIDC status const oidcConfig = getOIDCConfig(); if (oidcConfig.enabled) { result.oidcEnabled = true; result.oidcAutoLogin = oidcConfig.autoLogin || false; result.oidcLoginUrl = '/signalk/v1/auth/oidc/login'; if (oidcConfig.providerName) { result.oidcProviderName = oidcConfig.providerName; } } return result; }; strategy.getConfig = (aConfig) => { // Note: This mutates the input object, matching original JS behavior. // Callers may depend on this side effect. const mutableConfig = aConfig; delete mutableConfig.users; delete mutableConfig.secretKey; return aConfig; }; strategy.setConfig = (aConfig, newConfig) => { assertConfigImmutability(); newConfig.users = aConfig.users; newConfig.devices = aConfig.devices; newConfig.secretKey = aConfig.secretKey; options = newConfig; parseAndCacheOIDCConfig(); return newConfig; }; strategy.getUsers = (aConfig) => { if (aConfig && aConfig.users) { return aConfig.users.map((user) => { const userData = { userId: user.username, type: user.type, isOIDC: !!user.oidc }; // Include OIDC metadata for OIDC users if (user.oidc) { userData.oidc = { issuer: user.oidc.issuer, email: user.oidc.email, name: user.oidc.name }; } return userData; }); } else { return []; } }; function addUser(theConfig, user, callback) { assertConfigImmutability(); // userId arrives from request bodies, so guard the type before trimming // to keep malformed input on the callback error path rather than throwing. if (typeof user.userId !== 'string') { callback(new Error('Username is required')); return; } // Trim so a stray leading/trailing space (browser autofill, paste, // mobile autocapitalize) doesn't get baked into the stored username, // which would then never match the trimmed value typed at login. const username = user.userId.trim(); if (username.length === 0) { callback(new Error('Username cannot be empty')); return; } if (theConfig.users?.find((u) => u.username === username)) { callback(new Error('User already exists')); return; } const newUser = { username, type: user.type }; function finish(finalUser, err) { if (!theConfig.users) { theConfig.users = []; } theConfig.users.push(finalUser); options = theConfig; callback(err, theConfig); } if (user.password) { bcryptjs_1.default.hash(user.password, passwordSaltRounds, (err, hash) => { if (err) { callback(err); } else { newUser.password = hash; finish(newUser, undefined); } }); } else { finish(newUser, undefined); } } strategy.updateUser = (theConfig, username, updates, callback) => { assertConfigImmutability(); const user = theConfig.users.find((aUser) => aUser.username === username); if (!user) { callback(new Error('user not found')); return; } if (updates.type) { user.type = updates.type; } if (updates.password) { bcryptjs_1.default.hash(updates.password, passwordSaltRounds, (err, hash) => { if (err) { callback(err); } else { user.password = hash; callback(null, theConfig); } }); } else { callback(null, theConfig); } options = theConfig; }; // The addUser interface expects User with 'username', but callers pass objects // with 'userId'. We cast to match the interface signature. strategy.addUser = addUser; strategy.setPassword = (theConfig, username, password, callback) => { assertConfigImmutability(); bcryptjs_1.default.hash(password, passwordSaltRounds, (err, hash) => { if (err) { callback(err); } else { const user = theConfig.users.find((u) => u.username === username); if (user) { user.password = hash; } options = theConfig; callback(null, theConfig); } }); }; strategy.deleteUser = (theConfig, username, callback) => { assertConfigImmutability(); for (let i = theConfig.users.length - 1; i >= 0; i--) { if (theConfig.users[i].username === username) { theConfig.users.splice(i, 1); break; } } options = theConfig; callback(null, theConfig); }; strategy.getDevices = (theConfig) => { if (theConfig && theConfig.devices) { return theConfig.devices; } else { return []; } }; strategy.deleteDevice = (theConfig, clientId, callback) => { assertConfigImmutability(); for (let i = theConfig.devices.length - 1; i >= 0; i--) { if (theConfig.devices[i].clientId === clientId) { theConfig.devices.splice(i, 1); break; } } options = theConfig; callback(null, theConfig); }; strategy.updateDevice = (theConfig, clientId, updates, callback) => { assertConfigImmutability(); const device = theConfig.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, theConfig); options = theConfig; }; strategy.shouldAllowWrite = function (req, delta) { const skReq = req; if (skReq.skPrincipal && (skReq.skPrincipal.permissions === 'admin' || skReq.skPrincipal.permissions === 'readwrite')) { const context = delta.context === app.selfContext ? 'vessels.self' : delta.context || 'vessels.self'; const notAllowed = delta.updates.find((update) => { let source = update.$source; if (!source) { source = (0, server_api_1.getSourceId)(update.source); } if ((0, server_api_1.hasValues)(update)) { return update.values.find((valuePath) => { if (valuePath === null || valuePath === undefined || typeof valuePath.path !== 'string') { return true; } return (strategy.checkACL(skReq.skPrincipal.identifier, context, valuePath.path, source, 'write') === false); }); } else if ((0, server_api_1.hasMeta)(update)) { return update.meta.find((metaPath) => { if (metaPath === null || metaPath === undefined || typeof metaPath.path !== 'string') { return true; } return (strategy.checkACL(skReq.skPrincipal.identifier, context, metaPath.path, source, 'write') === false); }); } return false; }); // true if we did not find anything disallowing the write return lodash_1.default.isUndefined(notAllowed); } return false; }; strategy.shouldAllowPut = function (req, _context, source, thePath) { const skReq = req; if (skReq.skPrincipal && (skReq.skPrincipal.permissions === 'admin' || skReq.skPrincipal.permissions === 'readwrite')) { const context = _context === app.selfContext ? 'vessels.self' : _context; return strategy.checkACL(skReq.skPrincipal.identifier, context, thePath, source, 'put'); } return false; }; strategy.anyACLs = () => { const configuration = getConfiguration(); return !!(configuration.acls && configuration.acls.length); }; strategy.filterReadDelta = (principal, delta) => { const configuration = getConfiguration(); if (delta.updates && configuration.acls && configuration.acls.length && principal) { const filtered = { ...delta }; const context = delta.context === app.selfContext ? 'vessels.self' : delta.context || 'vessels.self'; filtered.updates = delta.updates .map((update) => { if ((0, server_api_1.hasValues)(update)) { const res = update.values .map((valuePath) => { return strategy.checkACL(principal.identifier, context, valuePath.path, update.source, 'read') ? valuePath : null; }) .filter((vp) => vp !== null); const updatedUpdate = { ...update, values: res }; return res.length > 0 ? updatedUpdate : null; } else if ((0, server_api_1.hasMeta)(update)) { const res = update.meta .map((metaPath) => { return strategy.checkACL(principal.identifier, context, metaPath.path, update.source, 'read') ? metaPath : null; }) .filter((mp) => mp !== null); const updatedUpdate = { ...update, meta: res }; return res.length > 0 ? updatedUpdate : null; } return update; }) .filter((update) => update !== null); return filtered.updates.length > 0 ? filtered : null; } else if (!principal) { return null; } else { return delta; } }; strategy.verifyWS = function (spark) { if (!spark.lastTokenVerify) { spark.lastTokenVerify = Date.now(); return; } if (!getIsEnabled()) { return; } const now = Date.now(); if (now - spark.lastTokenVerify > 60 * 1000) { debug('verify token'); spark.lastTokenVerify = now; strategy.authorizeWS(spark); } }; function getAuthorizationFromHeaders(req) { if (req.headers) { let header = req.headers.authorization; if (!header) { header = req.headers['x-authorization']; } // Handle array values (take first element) const headerValue = Array.isArray(header) ? header[0] : header; if (headerValue && headerValue.startsWith('Bearer ')) { return headerValue.substring('Bearer '.length); } if (headerValue && headerValue.startsWith('JWT ')) { return headerValue.substring('JWT '.length); } } return undefined; } strategy.authorizeWS = function (req) { let token = req.token; let error; let payload; if (!getIsEnabled()) { return; } const configuration = getConfiguration(); if (!token) { if (req.query && req.query.token) { token = req.query.token; } else { token = getAuthorizationFromHeaders(req); } } if (!token) { token = req.cookies && req.cookies.JAUTHENTICATION; } // // `jwt-simple` throws errors if something goes wrong when decoding the JWT. // if (token) { payload = jsonwebtoken_1.default.verify(token, configuration.secretKey); if (!payload) { error = new security_1.InvalidTokenError('Invalid access token'); } else if (payload.exp && Date.now() / 1000 > payload.exp) { // // At this point we have decoded and verified the token. Check if it is // expired. // error = new security_1.InvalidTokenError('Expired access token'); } } if (error) { // Token was provided but is invalid — always reject debug(error.message); throw error; } if (!token) { if (configuration.allow_readonly) { req.skPrincipal = { identifier: 'AUTO', permissions: 'readonly' }; return; } else { error = new Error('Missing access token'); debug(error.message); throw error; } } // // Check if the user/device is still present and allowed in our db. You could tweak // this to invalidate a token. // const principal = getPrincipal(payload); if (!principal) { error = new security_1.InvalidTokenError(`Invalid identity ${JSON.stringify(payload)}`); debug(error.message); throw error; } req.skPrincipal = principal; req.skIsAuthenticated = true; }; strategy.checkACL = (id, context, thePath, source, operation) => { const configuration = getConfiguration(); if (!configuration.acls || configuration.acls.length === 0) { // no acls, so allow anything return true; } const acl = configuration.acls.find((theAcl) => { const pattern = theAcl.context .replace(/[\\^$+?()[\]{}|]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*/g, '.*'); const matcher = new RegExp('^' + pattern + '$'); return matcher.test(context); }); if (acl) { const pathPerms = acl.resources.find((p) => { let perms; if (p.paths) { perms = p.paths.find((aPath) => { const pattern = aPath .replace(/[\\^$+?()[\]{}|]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*/g, '.*'); const matcher = new RegExp('^' + pattern + '$'); return matcher.test(thePath); }); } else if (p.sources) { perms = p.sources.find((s) => { const pattern = s .replace(/[\\^$+?()[\]{}|]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*/g, '.*'); const matcher = new RegExp('^' + pattern + '$'); return matcher.test(source); }); } return perms; }); if (pathPerms) { let perms = pathPerms.permissions.filter((p) => p.subject === id); perms = perms.concat(pathPerms.permissions.filter((p) => p.subject === 'any')); if (perms.length === 0) { return false; } return (perms.find((perm) => { if (operation === 'read' && (perm.permission === 'write' || perm.permission === 'read')) { return true; } else if (operation === 'write' && perm.permission === 'write') { return true; } else if (operation === 'put' && perm.permission === 'put') { return true; } else { return false; } }) !== undefined); } } return false; }; strategy.isDummy = () => { return false; }; strategy.canAuthorizeWS = () => { return true; }; strategy.shouldFilterDeltas = () => { const configuration = getConfiguration(); return !!(configuration.acls && configuration.acls.length > 0); }; function getPrincipal(payload) { let principal; if (payload.id) { const user = options.users.find((theUser) => theUser.username === payload.id); if (user) { principal = { identifier: user.username, permissions: user.type }; } } else if (payload.device && options.devices) { const device = options.devices.find((aDevice) => aDevice.clientId === payload.device); if (device) { principal = { identifier: device.clientId, permissions: device.permissions }; } } return principal; } function sanitizeLogField(value) { return typeof value === 'string' ? value.replace(/[\r\n\t]/g, ' ').slice(0, 128) : 'unknown'; } function logUnknownUser(jwtPayload) { const identity = sanitizeLogField(jwtPayload.id ?? jwtPayload.device); console.warn(`unknown user: ${identity}`); } function logBadToken(token, path, err) { // jwt.decode returns null instead of throwing when the token is malformed const payload = jsonwebtoken_1.default.decode(token); const id = sanitizeLogField(payload?.id); const device = sanitizeLogField(payload?.device); console.warn(`bad token: ${err.message} (user: ${id}, device: ${device}, path: ${path})`); } function http_authorize(redirect, forLoginStatus) { // debug('http_authorize: ' + redirect) return function (req, res, next) { const skReq = req; let token = skReq.cookies?.JAUTHENTICATION; debug(`http_authorize: ${req.path} (forLogin: ${forLoginStatus})`); if (!getIsEnabled()) { return next(); } const configuration = getConfiguration(); if (!token) { token = getAuthorizationFromHeaders(req); } if (token) { jsonwebtoken_1.default.verify(token, configuration.secretKey, function (err, decoded) { debug('verify'); if (!err) { const principal = getPrincipal(decoded); if (principal) { debug('authorized'); skReq.skPrincipal = principal; skReq.skIsAuthenticated = true; skReq.userLoggedIn = true; const jwtPayload = decoded; if (jwtPayload.id && jwtPayload.exp && jwtPayload.iat && Date.now() / 1000 > jwtPayload.iat + (jwtPayload.exp - jwtPayload.iat) / 2) { const newToken = generateJWT(jwtPayload.id, undefined, jwtPayload.rememberMe); setSessionCookie(res, req, newToken, jwtPayload.id, { rememberMe: jwtPayload.rememberMe }); debug('token refreshed for %s', jwtPayload.id); } next(); return; } else { logUnknownUser(decoded); } } else { logBadToken(token, req.path, err); } // Token was provided but is invalid/revoked — always reject. // allow_readonly only applies when no token is provided at all. console.warn('force clearing invalid/revoked auth cookie for %s', req.path); clearSessionCookie(res); if (forLoginStatus) { skReq.skIsAuthenticated = false; return next(); } res.status(401).send('bad auth token'); }); } else { debug('no token'); if (configuration.allow_readonly && !forLoginStatus) { skReq.skPrincipal = { identifier: 'AUTO', permissions: 'readonly' }; skReq.skIsAuthenticated = true; return next(); } else { skReq.skIsAuthenticated = false; if (forLoginStatus) { next(); } else if (redirect) { debug('redirecting to login'); res.redirect('/admin/#/login'); } else { res.status(401).send('Unauthorized'); } } } }; } strategy.getAccessRequestsResponse = () => { return filterRequests('accessRequest', 'PENDING'); }; function sendAccessRequestsUpdate() { app.emit('serverAdminEvent', { type: 'ACCESS_REQUEST', from: CONFIG_PLUGINID, data: strategy.getAccessRequestsResponse() }); } strategy.setAccessRequestStatus = (theConfig, identifier, status, body, cb) => { const request = findRequest((r) => r.state === 'PENDING' && r.accessIdentifier === identifier); if (!request) { cb(new Error('not found')); return; } const permissionPart = request.requestedPermissions ? request.permissions : 'any'; app.handleMessage(CONFIG_PLUGINID, { context: ('vessels.' + app.selfId), updates: [ { values: [ { path: `notifications.security.accessRequest.${permissionPart}.${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.requestedPermissions && !VALID_PERMISSIONS.has(body.permissions)) { cb(new Error('Invalid permissions value'), theConfig); return; } if (request.clientRequest.accessRequest.clientId) { const payload = { device: identifier }; const jwtOptions = {}; const expiresIn = body.expiration || theConfig.expiration; if (!isNever(expiresIn)) { jwtOptions.expiresIn = expiresIn; } const token = jsonwebtoken_1.default.sign(payload, theConfig.secretKey, jwtOptions); const decoded = jsonwebtoken_1.default.decode(token); if (!theConfig.devices) { theConfig.devices = []; } theConfig.devices = theConfig.devices.filter((d) => d.clientId !== identifier); theConfig.devices.push(