'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 apps = require('./apps.js'), assert = require('assert'), basicAuth = require('basic-auth'), blobs = require('./blobs.js'), constants = require('./constants.js'), debug = require('debug')('box:proxyAuth'), ejs = require('ejs'), express = require('express'), hat = require('./hat.js'), http = require('http'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, jwt = require('jsonwebtoken'), middleware = require('./middleware'), path = require('path'), paths = require('./paths.js'), safe = require('safetydance'), settings = require('./settings.js'), speakeasy = require('speakeasy'), translation = require('./translation.js'), users = require('./users.js'), util = require('util'); let gHttpServer = null; let gTokenSecret = null; function jwtVerify(req, res, next) { const token = req.cookies.authToken; if (!token) return next(); jwt.verify(token, gTokenSecret, function (error, decoded) { if (error) { debug('jwtVerify: malformed token or bad signature', error.message); req.user = null; } else { req.user = decoded.user || null; } next(); }); } async function basicAuthVerify(req, res, next) { const appId = req.headers['x-app-id'] || ''; const credentials = basicAuth(req); if (!appId || !credentials) return next(); const [error, app] = await safe(apps.get(appId)); if (error) return next(new HttpError(503, error.message)); if (!app.manifest.addons.proxyAuth.basicAuth) return next(); const verifyFunc = credentials.name.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername; const [verifyError, user] = await safe(verifyFunc(credentials.name, credentials.pass, appId)); if (verifyError) return next(new HttpError(403, 'Invalid username or password' )); req.user = user; next(); } async function loginPage(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); const [error, translationAssets] = await safe(translation.getTranslations()); if (error) return next(new HttpError(500, 'No translation found')); const raw = safe.fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'templates/proxyauth-login.ejs'), 'utf8'); if (raw === null) return next(new HttpError(500, 'Login template not found')); const translatedContent = translation.translate(raw, translationAssets.translations || {}, translationAssets.fallback || {}); let finalContent = ''; const [getError, app] = await safe(apps.get(appId)); if (getError) return next(new HttpError(503, getError.message)); const title = app.label || app.manifest.title; const [iconError, iconBuffer] = await safe(apps.getIcon(app, {})); if (iconError || !iconBuffer) return next(new HttpError(500, 'Icon rendering error')); const icon = 'data:image/png;base64,' + iconBuffer.toString('base64'); const dashboardOrigin = settings.dashboardOrigin(); try { finalContent = ejs.render(translatedContent, { title, icon, dashboardOrigin }); } catch (e) { debug('loginPage: Error rendering proxyauth-login.ejs', e); return next(new HttpError(500, 'Login template error')); } res.set('Content-Type', 'text/html'); return res.send(finalContent); } // someday this can be more sophisticated and check for a real browser function isBrowser(req) { const userAgent = req.get('user-agent'); if (!userAgent) return false; // https://github.com/docker/engine/blob/master/dockerversion/useragent.go#L18 return !userAgent.toLowerCase().includes('docker') && !userAgent.toLowerCase().includes('container'); } // called by nginx to authorize any protected route. this route must return only 2xx or 401/403 (http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) function auth(req, res, next) { if (!req.user) { res.clearCookie('authToken'); if (isBrowser(req)) return next(new HttpError(401, 'Unauthorized')); // the header has to be generated here and cannot be set in nginx config - https://forum.nginx.org/read.php?2,171461,171469#msg-171469 res.set('www-authenticate', 'Basic realm="Cloudron"'); return next(new HttpError(401, 'Unauthorized')); } // user is already authenticated, refresh cookie const token = jwt.sign({ user: req.user }, gTokenSecret, { expiresIn: `${constants.DEFAULT_TOKEN_EXPIRATION_DAYS}d` }); res.cookie('authToken', token, { httpOnly: true, maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS, secure: true }); return next(new HttpSuccess(200, {})); } // endpoint called by login page, username and password posted as JSON body async function passwordAuth(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 next(new HttpError(400, 'username must be non empty string' )); if (typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be non empty string' )); if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' )); const { username, password, totpToken } = req.body; const verifyFunc = username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername; const [error, user] = await safe(verifyFunc(username, password, appId)); if (error) return next(new HttpError(403, 'Invalid username or password' )); if (!user.ghost && !user.appPassword && user.twoFactorAuthenticationEnabled) { if (!totpToken) return next(new HttpError(403, 'A totpToken must be provided')); let verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: req.body.totpToken, window: 2 }); if (!verified) return next(new HttpError(403, 'Invalid totpToken')); } req.user = user; next(); } async function authorize(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); const [error, app] = await safe(apps.get(appId)); if (error) return next(new HttpError(403, 'No such app' )); if (!apps.canAccess(app, req.user)) return next(new HttpError(403, 'Forbidden' )); const token = jwt.sign({ user: users.removePrivateFields(req.user) }, gTokenSecret, { expiresIn: `${constants.DEFAULT_TOKEN_EXPIRATION_DAYS}d` }); res.cookie('authToken', token, { httpOnly: true, maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS, secure: true }); res.redirect(302, '/'); } async function logoutPage(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); const [error, app] = await safe(apps.get(appId)); if (error) return next(new HttpError(503, error.message)); res.clearCookie('authToken'); // when we have no path, redirect to the login page. we cannot redirect to '/' because browsers will immediately serve up the cached page // if a path is set, we can assume '/' is a public page res.redirect(302, app.manifest.addons.proxyAuth.path ? '/' : '/login'); } function logout(req, res, next) { res.clearCookie('authToken'); next(new HttpSuccess(200, {})); } // provides webhooks for the auth wall function initializeAuthwallExpressSync() { const app = express(); const httpServer = http.createServer(app); const QUERY_LIMIT = '1mb'; // max size for json and urlencoded queries const REQUEST_TIMEOUT = 10000; // timeout for all requests const json = middleware.json({ strict: true, limit: QUERY_LIMIT }); // application/json if (process.env.BOX_ENV !== 'test') { app.use(middleware.morgan(function (tokens, req, res) { return [ 'proxyauth', tokens.method(req, res), tokens.url(req, res), tokens.status(req, res), res.errorBody ? res.errorBody.status : '', // attached by connect-lastmile. can be missing when router errors like 404 res.errorBody ? res.errorBody.message : '', // attached by connect-lastmile. can be missing when router errors like 404 tokens['response-time'](req, res), 'ms', '-', tokens.res(req, res, 'content-length') ].join(' '); }, { immediate: false, // only log failed requests by default skip: function (req, res) { return res.statusCode < 400; } })); } const router = new express.Router(); router.del = router.delete; // amend router.del for readability further on app .use(middleware.timeout(REQUEST_TIMEOUT)) .use(middleware.cookieParser()) .use(router) .use(middleware.lastMile()); router.get ('/login', loginPage); router.get ('/auth', jwtVerify, basicAuthVerify, auth); // called by nginx before accessing protected page router.post('/login', json, passwordAuth, authorize); router.get ('/logout', logoutPage); router.post('/logout', json, logout); return httpServer; } async function start() { assert.strictEqual(gHttpServer, null, 'Authwall is already up and running.'); gTokenSecret = await blobs.getString(blobs.PROXY_AUTH_TOKEN_SECRET); if (!gTokenSecret) { debug('start: generating new token secret'); gTokenSecret = hat(64); await blobs.setString(blobs.PROXY_AUTH_TOKEN_SECRET, gTokenSecret); } gHttpServer = initializeAuthwallExpressSync(); await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.AUTHWALL_PORT, '127.0.0.1'); } async function stop() { if (!gHttpServer) return; await util.promisify(gHttpServer.close.bind(gHttpServer))(); gHttpServer = null; }