Files
cloudron-box/src/proxyauth.js
2021-02-23 21:54:49 -08:00

267 lines
9.6 KiB
JavaScript

'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;
}