signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
1,236 lines (1,235 loc) • 57.4 kB
JavaScript
"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(