'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'), constants = require('./constants.js'), debug = require('debug')('box:proxyAuth'), ejs = require('ejs'), express = require('express'), fs = require('fs'), 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'), speakeasy = require('speakeasy'), translation = require('./translation.js'), users = require('./users.js'); let gHttpServer = null; let TOKEN_SECRET = null; const EXPIRY_DAYS = 7; 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 basicAuthVerify(req, res, next) { const appId = req.headers['x-app-id'] || ''; const credentials = basicAuth(req); if (!appId || !credentials) return next(); const api = credentials.name.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername; apps.get(appId, function (error, app) { if (error) return next(new HttpError(503, error.message)); if (!app.manifest.addons.proxyAuth.basicAuth) return next(); api(credentials.name, credentials.pass, appId, function (error, user) { if (error) return next(new HttpError(403, 'Invalid username or password' )); req.user = user; next(); }); }); } function loginPage(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); translation.getTranslations(function (error, translationAssets) { 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 || {}); var finalContent = ''; apps.get(appId, function (error, app) { if (error) return next(new HttpError(503, error.message)); const title = app.label || app.manifest.title; apps.getIconPath(app, {}, function (error, iconPath) { const icon = 'data:image/png;base64,' + safe.fs.readFileSync(iconPath || '', 'base64'); try { finalContent = ejs.render(translatedContent, { title, icon }); } catch (e) { debug('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'); } // 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) { 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 }, 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 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 api = username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername; api(username, password, appId, function (error, user) { 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(); }); } function authorize(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); apps.get(appId, function (error, app) { if (error) return next(new HttpError(403, 'No such app' )); apps.hasAccessTo(app, req.user, function (error, hasAccess) { if (error) return next(new HttpError(403, 'Forbidden' )); if (!hasAccess) return next(new HttpError(403, 'Forbidden' )); const token = jwt.sign({ user: users.removePrivateFields(req.user) }, TOKEN_SECRET, { expiresIn: `${EXPIRY_DAYS}d` }); res.cookie('authToken', token, { httpOnly: true, maxAge: EXPIRY_DAYS * 86400 * 1000, // milliseconds secure: true }); res.redirect(302, '/'); }); }); } function logoutPage(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(new HttpError(503, 'Nginx misconfiguration')); apps.get(appId, function (error, app) { 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() { 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 if (process.env.BOX_ENV !== 'test') app.use(middleware.morgan('proxyauth :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 .use(middleware.timeout(REQUEST_TIMEOUT)) .use(middleware.cookieParser()) .use(router) .use(middleware.lastMile()); router.get ('/login', loginPage); router.get ('/auth', jwtVerify, basicAuthVerify, auth); router.post('/login', json, passwordAuth, authorize); router.get ('/logout', logoutPage); router.post('/logout', json, logout); return httpServer; } function start(callback) { assert.strictEqual(typeof callback, 'function'); assert.strictEqual(gHttpServer, null, 'Authwall is already up and running.'); if (!fs.existsSync(paths.PROXY_AUTH_TOKEN_SECRET_FILE)) { TOKEN_SECRET = hat(64); fs.writeFileSync(paths.PROXY_AUTH_TOKEN_SECRET_FILE, TOKEN_SECRET, 'utf8'); } else { TOKEN_SECRET = fs.readFileSync(paths.PROXY_AUTH_TOKEN_SECRET_FILE, 'utf8').trim(); } 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; }