redis-commander
Version:
Redis web-based management tool written in node.js
340 lines (306 loc) • 11.9 kB
JavaScript
;
let path = require('path');
let express = require('express');
let myUtils = require('./util');
let bodyParser = require('body-parser');
let partials = require('express-partials');
let jwt = require('jsonwebtoken');
let crypto = require('crypto');
let bcrypt;
try {
bcrypt = require('bcrypt');
} catch (e) {
bcrypt = require('bcryptjs');
}
let config = require('config');
function comparePasswords(a, b) {
// make sure booth buffers have same length and make time comparison attacks to
// guess pw length harder by calculation fixed length hmacs first
let key = crypto.pseudoRandomBytes(32);
let bufA = crypto.createHmac('sha256', key).update(a).digest();
let bufB = crypto.createHmac('sha256', key).update(b).digest();
let ret = true;
if (crypto.timingSafeEqual) {
ret = crypto.timingSafeEqual(bufA, bufB);
}
else {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
ret = false;
}
}
}
return ret;
}
let usedTokens = new Set();
// algorithm used for internal jwt session cookie signing
const jwtSessionSignAlgorithm = 'HS256';
function jwtSign(jwtSecret, data) {
return new Promise((resolve, reject) => jwt.sign(data, jwtSecret, {
issuer: 'Redis Commander',
subject: 'Session Token',
expiresIn: 60,
algorithm: jwtSessionSignAlgorithm
}, (err, token) => (err ? reject(err) : resolve(token))));
}
function jwtVerify(jwtSecret, token) {
return new Promise(resolve => {
jwt.verify(token, jwtSecret, {
issuer: 'Redis Commander',
subject: 'Session Token',
algorithms: [jwtSessionSignAlgorithm]
}, (err, decodedToken) => {
if (err) {
return resolve(false);
}
if (decodedToken.singleUse) {
if (usedTokens.has(token)) {
console.log('Single-Usage token already used');
return resolve(false);
}
usedTokens.add(token);
if (decodedToken.exp) {
setTimeout(() => {
usedTokens.delete(token);
}, ((decodedToken.exp * 1 + 10) * 1e3) - (new Date() * 1))
}
}
return resolve(true);
});
})
}
function jwtVerifySso(token) {
return new Promise(resolve => {
let verifyOpts = {
issuer: config.get('sso.allowedIssuer'),
algorithms: config.get('sso.jwtAlgorithms')
};
if (config.get('sso.audience')) {
verifyOpts.audience = config.get('sso.audience');
}
if (config.get('sso.subject')) {
verifyOpts.audience = config.get('sso.subject');
}
jwt.verify(token, config.get('sso.jwtSharedSecret'), verifyOpts, (err, decodedToken) => {
if (err) {
console.log("SSO JWT token invalid: " + err.message);
return resolve(false);
}
if (usedTokens.has(token)) {
console.log("Single-Usage SSO token already used");
return resolve(false);
}
let subject = decodedToken.sub;
let email = decodedToken.email ? decodedToken.email : '<email not set>';
let name = decodedToken.name ? decodedToken.name : '<name not set>';
console.log(`remote sso login for user "${subject}" (${email}, ${name})`);
// sso token should have expiry to be able to free use token list...
usedTokens.add(token);
if (decodedToken.exp) {
setTimeout(() => {
usedTokens.delete(token);
}, ((decodedToken.exp * 1 + 10) * 1e3) - (new Date() * 1))
}
return resolve(true);
});
})
}
/** object of type ConnectionWrapper from lib/connections.js */
let redisConnections;
let viewsPath = path.join(__dirname, '../web/views');
let staticPath = path.join(__dirname, '../web/static');
let modulesPath = function(module) {
return require.resolve(module).replace(/\\/g, '/').match(/.*\/node_modules\/[^/]+\//)[0]
};
module.exports = function (_redisConnections) {
const urlPrefix = config.get('server.urlPrefix') || '';
redisConnections = _redisConnections;
let app = express();
app.disable('x-powered-by');
app.use(partials());
// express 'trust proxy' setting may be a boolean value or a string list with comma separated ip/network addresses
// setting this value Express trusts x-forwarded-for/x-forwarded-host/x-forwarded-proto headers and sets variables accordingly
// see https://expressjs.com/en/guide/behind-proxies.html
if (config.get('server.trustProxy')) {
let trustProxy = config.get('server.trustProxy');
if (typeof trustProxy !== 'boolean' || trustProxy) {
app.set('trust proxy', trustProxy);
}
}
if (!config.get('noSave')) {
app.saveConfig = myUtils.saveConnections;
} else {
app.saveConfig = function (cfg, callback) { callback(null) };
}
app.logout = logout;
app.locals.layoutFilename = path.join(__dirname, '../web/views/layout.ejs');
app.locals.redisConnections = redisConnections;
app.locals.rootPattern = config.get('redis.rootPattern');
app.locals.noLogData = config.get('noLogData');
app.locals.httpAuthDisabled = (!config.get('server.httpAuth.username') || !(config.get('server.httpAuth.passwordHash') || config.get('server.httpAuth.password')));
app.locals.jwtSecret = config.get('server.httpAuth.jwtSecret') || crypto.randomBytes(20).toString('base64');
app.locals.signinPath = config.get('server.signinPath');
app.locals.httpAuthHeaderName = config.get('server.httpAuthHeaderName');
// set here for html client side too, there only to modify displayed stuff
// final read-only checks done at server side!
app.locals.redisReadOnly = config.get('redis.readOnly');
app.set('views', viewsPath);
app.set('view engine', 'ejs');
app.use(urlPrefix, express.static(staticPath));
app.use(`${urlPrefix}/jstree`, express.static(path.join(modulesPath('jstree'), 'dist')));
app.use(`${urlPrefix}/clipboard`, express.static(path.join(modulesPath('clipboard'), 'dist')));
app.use(`${urlPrefix}/dateformat`, express.static(path.join(modulesPath('dateformat'), 'lib')));
app.use(`${urlPrefix}/scripts/ejs.min.js`, express.static(path.join(modulesPath('ejs'), 'ejs.min.js')));
// modulesPath() does not work on following plugin as this is not real npm module wih main script
app.use(`${urlPrefix}/json-viewer`, express.static(path.join(__dirname, '..', 'node_modules', 'jquery.json-viewer', 'json-viewer')));
// removed unmaintained browserify-middleware as it has some insecure dependencies by now
// replaced with call to "npm run build" to create js file manually whenever things change here
// let browserify = require('browserify-middleware');
// app.get(`${urlPrefix}/browserify.js`, browserify(['cmdparser', 'readline-browserify', 'lossless-json']));
app.get(`${urlPrefix}/`, getIndexPage);
app.use(bodyParser.urlencoded({extended: false, limit: config.get('server.clientMaxBodySize')}));
app.use(bodyParser.json({limit: config.get('server.clientMaxBodySize')}));
app.use(`${urlPrefix}/sso`, function(req, res, next) {
if (! (req.method === 'GET' || req.method === 'POST')) return res.status(415).end();
return Promise.resolve()
.then(() => {
if (req.app.locals.httpAuthDisabled) {
return true;
}
if (config.get('sso.enabled')) {
let ssoToken;
if (req.query && req.query.access_token) ssoToken = req.query.access_token;
if (req.body && req.body.access_token) ssoToken = req.body.access_token;
return jwtVerifySso(ssoToken || '').then((success) => {
return success;
});
}
return false;
})
.then((success) => {
if (!success) {
return res.redirect(`${urlPrefix}/`);
}
return Promise.all([jwtSign(req.app.locals.jwtSecret, {}), jwtSign(req.app.locals.jwtSecret, {
"singleUse": true
})])
.then(function([bearerToken, queryToken]) {
res.render('home/home-sso.ejs', {
title: 'Home',
layout: '',
bearerToken: bearerToken,
queryToken: queryToken,
redirectUrl: './'
});
});
});
});
app.post(`${urlPrefix}/${app.locals.signinPath}`, function(req, res, next) {
return Promise.resolve()
.then(() => {
if (req.app.locals.httpAuthDisabled) {
return true;
}
if (req.body && (req.body.username || req.body.password)) {
// signin with username and password
// explicit casts as fix for possible numeric username or password
// no fast exit on wrong username to let evil guy not guess existing ones
let validUser = false;
let validPass = false;
if (comparePasswords(String(req.body.username), String(config.get('server.httpAuth.username')))) {
validUser = true;
}
if (config.get('server.httpAuth.passwordHash')) {
validPass = bcrypt.compareSync(String(req.body.password), String(config.get('server.httpAuth.passwordHash')))
}
else {
// prevent empty passwords
validPass = comparePasswords(String(req.body.password), String(config.get('server.httpAuth.password')));
}
// do log outcome on first login, all following requests use jwt
if (validUser && validPass) {
console.log('Login success for user ' + String(req.body.username) + ' from remote ip ' + req.ip);
}
else {
console.log('Login failed from remote ip ' + req.ip);
}
return validUser && validPass;
}
let authorization = (req.get(req.app.locals.httpAuthHeaderName) || '').split(/\s+/);
if (/^Bearer$/i.test(authorization[0])) {
return jwtVerify(req.app.locals.jwtSecret, authorization[1] || '').then((success) => {
return success;
});
}
return false;
})
.then((success) => {
if (!success) {
return res.json({
"ok": false
});
}
return Promise.all([jwtSign(req.app.locals.jwtSecret, {}), jwtSign(req.app.locals.jwtSecret, {
"singleUse": true
})])
.then(([bearerToken, queryToken]) => res.json({
"ok": true,
"bearerToken": bearerToken,
"queryToken": queryToken
}));
});
});
app.use(verifyAuthorizationToken);
require('./routes')(app, urlPrefix);
return app;
};
function getIndexPage(req, res) {
req.app.locals.sentinelDefaultGroupName = myUtils.getRedisSentinelGroupName();
res.render('home/home.ejs', {
title: 'Home',
layout: req.app.locals.layoutFilename
});
}
function verifyAuthorizationToken(req, res, next) {
if (req.app.locals.httpAuthDisabled) {
return next();
}
let token;
if (req.body && req.body.redisCommanderQueryToken) {
token = req.body.redisCommanderQueryToken;
} else if (req.query.redisCommanderQueryToken) {
token = req.query.redisCommanderQueryToken;
} else {
let authorization = `${req.get(req.app.locals.httpAuthHeaderName) || ''}`.split(/\s+/);
if (/^Bearer$/i.test(authorization[0])) {
token = `${authorization[1] || ''}`;
}
}
if (!token) {
return res.status(401).end('Unauthorized - Missing Token');
}
return jwtVerify(req.app.locals.jwtSecret, token)
.then((success) => {
if (!success) {
return res.status(401).end('Unauthorized - Token Invalid or Expired');
}
return next();
});
}
function logout (connectionId, callback) {
let notRemoved = true;
redisConnections.getList().forEach(function (instance, index) {
if (notRemoved) {
if (instance.options.connectionId === connectionId) {
notRemoved = false;
let connectionToClose = redisConnections.getList().splice(index, 1);
connectionToClose[0].quit();
}
}
});
if (notRemoved) {
return callback(new Error("Could not remove ", hostname, port, "."));
} else {
return callback(null);
}
}