diff --git a/CHANGES b/CHANGES index babf8f172..8e9af4325 100644 --- a/CHANGES +++ b/CHANGES @@ -2134,4 +2134,5 @@ * s3: diasble per-chunk timeout * logs: more descriptive log file names on download * collectd: remove collectd config when app stopped (and add it back when started) +* Apps can optionally request an authwall to be installed in front of them diff --git a/box.js b/box.js index 5460919d4..01e0c8f17 100755 --- a/box.js +++ b/box.js @@ -3,6 +3,7 @@ 'use strict'; let async = require('async'), + authwall = require('./src/authwall.js'), dockerProxy = require('./src/dockerproxy.js'), fs = require('fs'), ldap = require('./src/ldap.js'), @@ -22,7 +23,8 @@ function setupLogging(callback) { async.series([ setupLogging, - server.start, + server.start, // do this first since it also inits the database + authwall.start, ldap.start, dockerProxy.start ], function (error) { @@ -38,6 +40,7 @@ async.series([ process.on('SIGINT', function () { debug('Received SIGINT. Shutting down.'); + authwall.stop(NOOP_CALLBACK); server.stop(NOOP_CALLBACK); ldap.stop(NOOP_CALLBACK); dockerProxy.stop(NOOP_CALLBACK); @@ -47,6 +50,7 @@ async.series([ process.on('SIGTERM', function () { debug('Received SIGTERM. Shutting down.'); + authwall.stop(NOOP_CALLBACK); server.stop(NOOP_CALLBACK); ldap.stop(NOOP_CALLBACK); dockerProxy.stop(NOOP_CALLBACK); diff --git a/package-lock.json b/package-lock.json index 9325b1b37..9a9411ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -501,7 +501,7 @@ }, "backoff": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "resolved": false, "integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=", "requires": { "precond": "0.2" @@ -950,6 +950,20 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz", + "integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6" + } + }, "cookie-session": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-1.4.0.tgz", @@ -2629,6 +2643,49 @@ "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -2747,6 +2804,41 @@ "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", "integrity": "sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E=" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -3115,6 +3207,28 @@ } } }, + "mustache": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-3.2.1.tgz", + "integrity": "sha512-RERvMFdLpaFfSRIEe632yDm5nsd0SDKn8hGmcUwswnyiE5mtdZLDybtHAz6hjJhawokF0hXvGLtx9mrQfm6FkA==" + }, + "mustache-express": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/mustache-express/-/mustache-express-1.3.0.tgz", + "integrity": "sha512-JWG8Rzxh9tpoLEH0NZ2u/caDiwhIkW+50IOBrcO+lHya3tCYj41bYPDEHCxPbKXvPrSyMNpI6ly4xdU2zpNQtg==", + "requires": { + "async": "~3.1.0", + "lru-cache": "~5.1.1", + "mustache": "^3.1.0" + }, + "dependencies": { + "async": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async/-/async-3.1.1.tgz", + "integrity": "sha512-X5Dj8hK1pJNC2Wzo2Rcp9FBVdJMGRR/S7V+lH46s8GVFhtbo5O4Le5GECCF/8PISVdkUA6mMPvgz7qTTD1rf1g==" + } + } + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -3704,7 +3818,7 @@ }, "precond": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "resolved": false, "integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw=" }, "pretty-bytes": { diff --git a/package.json b/package.json index 5f70c23d0..6064a84a5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "connect": "^3.7.0", "connect-lastmile": "^2.0.0", "connect-timeout": "^1.9.0", + "cookie-parser": "^1.4.5", "cookie-session": "^1.4.0", "cron": "^1.8.2", "db-migrate": "^0.11.11", @@ -36,6 +37,7 @@ "ipaddr.js": "^2.0.0", "js-yaml": "^3.14.0", "json": "^9.0.6", + "jsonwebtoken": "^8.5.1", "ldapjs": "^2.2.0", "lodash": "^4.17.20", "lodash.chunk": "^4.2.0", @@ -44,6 +46,7 @@ "moment-timezone": "^0.5.31", "morgan": "^1.10.0", "multiparty": "^4.2.2", + "mustache-express": "^1.3.0", "mysql": "^2.18.1", "nodemailer": "^6.4.11", "nodemailer-smtp-transport": "^2.7.4", diff --git a/src/apps.js b/src/apps.js index b78a10622..e5ca5eeee 100644 --- a/src/apps.js +++ b/src/apps.js @@ -168,7 +168,7 @@ function validatePortBindings(portBindings, manifest) { 2004, /* graphite (lo) */ 2514, /* cloudron-syslog (lo) */ constants.PORT, /* app server (lo) */ - constants.SYSADMIN_PORT, /* sysadmin app server (lo) */ + constants.AUTHWALL_PORT, /* protected sites */ constants.INTERNAL_SMTP_PORT, /* internal smtp port (lo) */ constants.LDAP_PORT, 3306, /* mysql (lo) */ diff --git a/src/authwall.js b/src/authwall.js new file mode 100644 index 000000000..9f8a1a849 --- /dev/null +++ b/src/authwall.js @@ -0,0 +1,165 @@ +'use strict'; + +// heavily inspired from https://gock.net/blog/2020/nginx-subrequest-authentication-server/ and https://github.com/andygock/auth-server + +exports = module.exports = { + start, + stop +}; + +const assert = require('assert'), + BoxError = require('./boxerror.js'), + constants = require('./constants.js'), + debug = require('debug')('box:authwall'), + express = require('express'), + http = require('http'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + jwt = require('jsonwebtoken'), + middleware = require('./middleware'), + mustacheExpress = require('mustache-express'), + path = require('path'), + users = require('./users.js'); + +let gHttpServer = null; +const TOKEN_SECRET = 'somerandomsecret'; +const EXPIRY_DAYS = 7; + +// middleware to check auth status +function jwtVerify(req, res, next) { + const token = req.cookies.authToken; + + if (!token) return next(); + + jwt.verify(token, TOKEN_SECRET, function (error, decoded) { + if (error) { + debug('clearing token', error); + res.clearCookie('authToken'); + return next(new HttpError(403, 'Malformed token or bad signature')); + } + + req.user = decoded.user || null; + next(); + }); +} + +function loginPage(req, res) { + const requestUri = req.headers['x-original-uri']; + const host = req.headers['x-original-host']; + + return res.render('login', { + referer: requestUri ? `${host}/${requestUri}` : '/', + }); +} + +// called by nginx to authorize any protected route +function auth(req, res, next) { + if (!req.user) return next(new HttpError(401, 'Unauthorized')); + + // user is already authenticated, refresh cookie + const token = jwt.sign({ user: req.user }, TOKEN_SECRET, { expiresIn: `${EXPIRY_DAYS}d` }); + + res.cookie('authToken', token, { + httpOnly: true, + maxAge: EXPIRY_DAYS * 86400 * 1000, // milliseconds + secure: true + }); + + return next(new HttpSuccess(200, {})); +} + +// endpoint called by login page, username and password posted as JSON body +function login(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + const appId = req.headers['x-app-id'] || ''; + if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); + + if (typeof req.body.username !== 'string') return res.render('login', { error: 'username must be non empty string' }); + if (typeof req.body.password !== 'string') return res.render('login', { error: 'password must be non empty string' }); + + const { username, password } = req.body; + + const api = username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername; + + api(username, password, appId, function (error, user) { + if (error) return res.render('login', { error: 'Invalid username or password' }); + + const token = jwt.sign({ user: users.removePrivateFields(user) }, TOKEN_SECRET, { expiresIn: `${EXPIRY_DAYS}d` }); + + res.cookie('authToken', token, { + httpOnly: true, + maxAge: EXPIRY_DAYS * 86400 * 1000, // milliseconds + secure: true + }); + + res.redirect('/'); + }); +} + +function logoutPage(req, res) { + res.clearCookie('authToken'); + res.redirect('/login'); +} + +function logout(req, res, next) { + res.clearCookie('authToken'); + next(new HttpSuccess(200, {})); +} + +// provides webhooks for the auth wall +function initializeAuthwallExpressSync() { + let app = express(); + let httpServer = http.createServer(app); + + let QUERY_LIMIT = '1mb'; // max size for json and urlencoded queries + let REQUEST_TIMEOUT = 10000; // timeout for all requests + + let json = middleware.json({ strict: true, limit: QUERY_LIMIT }), // application/json + urlencoded = middleware.urlencoded({ extended: false, limit: QUERY_LIMIT }); // application/x-www-form-urlencoded + + if (process.env.BOX_ENV !== 'test') app.use(middleware.morgan('Authwall :method :url :status :response-time ms - :res[content-length]', { immediate: false })); + + var router = new express.Router(); + router.del = router.delete; // amend router.del for readability further on + + app.engine('html', mustacheExpress()); + app.set('views', path.join(__dirname, 'authwall')); + app.set('view engine', 'html'); + + app + .use(middleware.timeout(REQUEST_TIMEOUT)) + .use(middleware.cookieParser()) + .use(json) + .use(urlencoded) + .use(jwtVerify) + .use(router) + .use(middleware.lastMile()); + + router.get ('/', (req, res) => { res.redirect('/login'); }); + router.get ('/login', loginPage); + router.get ('/auth', auth); + router.post('/login', login); + router.get ('/logout', logoutPage); + router.post('/logout', logout); + + return httpServer; +} + +function start(callback) { + assert.strictEqual(typeof callback, 'function'); + assert.strictEqual(gHttpServer, null, 'Authwall is already up and running.'); + + gHttpServer = initializeAuthwallExpressSync(); + + gHttpServer.listen(constants.AUTHWALL_PORT, '127.0.0.1', callback); +} + +function stop(callback) { + assert.strictEqual(typeof callback, 'function'); + + if (!gHttpServer) return callback(null); + + gHttpServer.close(callback); + gHttpServer = null; +} diff --git a/src/authwall/login.html b/src/authwall/login.html new file mode 100644 index 000000000..f80bc624e --- /dev/null +++ b/src/authwall/login.html @@ -0,0 +1,83 @@ + +
+{{{ error }}}
+ + +