'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'), blobs = require('./blobs.js'), constants = require('./constants.js'), dashboard = require('./dashboard.js'), debug = require('debug')('box:proxyAuth'), 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'), oidc = require('./oidc.js'), safe = require('safetydance'), 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(); }); } function basicAuth(req) { const CREDENTIALS_REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/; const USER_PASS_REGEXP = /^([^:]*):(.*)$/; const header = req.headers.authorization; if (!header) return null; const match = CREDENTIALS_REGEXP.exec(header); if (!match) return null; const decodedHeader = Buffer.from(match[1], 'base64').toString(); const userPass = USER_PASS_REGEXP.exec(decodedHeader); if (!userPass) return null; return { username: userPass[1], password: userPass[2] }; } async function authorizationHeader(req, res, next) { const appId = req.headers['x-app-id'] || ''; if (!appId) return next(); if (!req.headers.authorization) return next(); const [error, app] = await safe(apps.get(appId)); if (error) return next(new HttpError(503, error.message)); if (!app) return next(new HttpError(503, 'Error getting app')); // only if the app supports bearer auth, pass it through to the app. without this flag, anyone can access the app with Bearer auth! if (req.headers.authorization.startsWith('Bearer ') && app.manifest.addons.proxyAuth.supportsBearerAuth) return next(new HttpSuccess(200, {})); const credentials = basicAuth(req); if (!credentials) return next(); if (!app.manifest.addons.proxyAuth.basicAuth) return next(); // this is a flag because this allows auth to bypass 2FA const verifyFunc = credentials.username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername; const [verifyError, user] = await safe(verifyFunc(credentials.username, credentials.password, appId, { skipTotpCheck: true })); if (verifyError) return next(new HttpError(403, 'Invalid username or password' )); req.user = user; next(); } // 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 }); res.set('x-remote-user', req.user.username); res.set('x-remote-email', req.user.email); // ensure ascii in header, node will crash with ERR_INVALID_CHAR otherwise // eslint-disable-next-line no-control-regex res.set('x-remote-name', /^[\x00-\x7F]*$/.test(req.user.displayName) ? req.user.displayName : req.user.username); next(new HttpSuccess(200, {})); } async function login(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')); const dashboardFqdn = (await dashboard.getLocation()).fqdn; if (req.query.redirect) { res.cookie('cloudronProxyAuthRedirect', req.query.redirect, { httpOnly: true, maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS, secure: true }); } res.redirect(302, `https://${dashboardFqdn}/openid/auth?client_id=${appId}&scope=openid profile email&response_type=code&redirect_uri=https://${app.fqdn}/callback`); } async function callback(req, res, next) { if (!req.query.code) return next(new HttpError(400, 'missing query argument "code"')); debug(`callback: with code ${req.query.code}`); req.user = await oidc.getUserByAuthCode(req.query.code); // this is one-time use await oidc.consumeAuthCode(req.query.code); 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 }); const redirect = req.cookies.cloudronProxyAuthRedirect || '/'; res.clearCookie('cloudronProxyAuthRedirect'); res.redirect(302, redirect); } async function logout(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'); } // provides webhooks for the auth wall function initializeAuthwallExpressSync() { const app = express(); const httpServer = http.createServer(app); const REQUEST_TIMEOUT = 10000; // timeout for all requests 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', login); router.get ('/callback', callback, authorize); router.get ('/auth', jwtVerify, authorizationHeader, auth); // called by nginx before accessing protected page router.get ('/logout', logout); router.post('/logout', 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; }