Files
cloudron-box/src/proxyauth.js
T

274 lines
10 KiB
JavaScript
Raw Normal View History

2020-11-09 20:34:48 -08:00
'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'),
2020-11-11 13:25:52 -08:00
basicAuth = require('basic-auth'),
2022-02-01 17:56:40 -08:00
blobs = require('./blobs.js'),
2020-11-09 20:34:48 -08:00
constants = require('./constants.js'),
2023-08-11 19:41:05 +05:30
dashboard = require('./dashboard.js'),
2020-11-10 09:59:28 -08:00
debug = require('debug')('box:proxyAuth'),
2020-11-24 20:57:13 +01:00
ejs = require('ejs'),
2020-11-09 20:34:48 -08:00
express = require('express'),
2020-11-10 17:10:57 -08:00
hat = require('./hat.js'),
2020-11-09 20:34:48 -08:00
http = require('http'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
jwt = require('jsonwebtoken'),
middleware = require('./middleware'),
oidc = require('./oidc.js'),
2020-11-09 20:34:48 -08:00
path = require('path'),
2020-11-10 17:10:57 -08:00
paths = require('./paths.js'),
safe = require('safetydance'),
2020-11-24 20:57:13 +01:00
translation = require('./translation.js'),
2021-08-22 16:19:22 -07:00
users = require('./users.js'),
util = require('util');
2020-11-09 20:34:48 -08:00
let gHttpServer = null;
2022-02-01 17:16:25 -08:00
let gTokenSecret = null;
2020-11-09 20:34:48 -08:00
function jwtVerify(req, res, next) {
const token = req.cookies.authToken;
if (!token) return next();
2022-02-01 17:16:25 -08:00
jwt.verify(token, gTokenSecret, function (error, decoded) {
2020-11-09 20:34:48 -08:00
if (error) {
2022-02-01 17:56:40 -08:00
debug('jwtVerify: malformed token or bad signature', error.message);
req.user = null;
} else {
req.user = decoded.user || null;
2020-11-09 20:34:48 -08:00
}
next();
});
}
2022-08-25 16:12:41 +02:00
async function authorizationHeader(req, res, next) {
2020-11-11 13:25:52 -08:00
const appId = req.headers['x-app-id'] || '';
2022-08-25 16:12:41 +02:00
if (!appId) return next();
if (!req.headers.authorization) return next();
2020-11-11 13:25:52 -08:00
2021-08-20 09:19:44 -07:00
const [error, app] = await safe(apps.get(appId));
if (error) return next(new HttpError(503, error.message));
2022-08-25 16:12:41 +02:00
if (!app) return next(new HttpError(503, 'Error getting app'));
2022-08-25 16:36:57 +02:00
// only if the app supports bearer auth, pass it through to the app. without this flag, anyone can access the app with Bearer auth!
2022-08-25 16:12:41 +02:00
if (req.headers.authorization.startsWith('Bearer ') && app.manifest.addons.proxyAuth.supportsBearerAuth) return next(new HttpSuccess(200, {}));
const credentials = basicAuth(req);
if (!credentials) return next();
2020-11-11 13:25:52 -08:00
2022-08-25 16:12:41 +02:00
if (!app.manifest.addons.proxyAuth.basicAuth) return next(); // this is a flag because this allows auth to bypass 2FA
2021-08-20 09:19:44 -07:00
const verifyFunc = credentials.name.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
2024-01-07 22:01:57 +01:00
const [verifyError, user] = await safe(verifyFunc(credentials.name, credentials.pass, appId, { skipTotpCheck: true }));
2021-08-20 09:19:44 -07:00
if (verifyError) return next(new HttpError(403, 'Invalid username or password' ));
2021-08-20 09:19:44 -07:00
req.user = user;
next();
2020-11-11 13:25:52 -08:00
}
2021-08-20 09:19:44 -07:00
async function loginPage(req, res, next) {
const appId = req.headers['x-app-id'] || '';
if (!appId) return next(new HttpError(503, 'Nginx misconfiguration'));
2021-08-20 09:19:44 -07:00
const [error, translationAssets] = await safe(translation.getTranslations());
if (error) return next(new HttpError(500, 'No translation found'));
2020-11-24 20:57:13 +01:00
2021-08-20 09:19:44 -07:00
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'));
2020-11-24 20:57:13 +01:00
2021-08-20 09:19:44 -07:00
const translatedContent = translation.translate(raw, translationAssets.translations || {}, translationAssets.fallback || {});
let finalContent = '';
2020-11-24 20:57:13 +01:00
2021-08-20 09:19:44 -07:00
const [getError, app] = await safe(apps.get(appId));
if (getError) return next(new HttpError(503, getError.message));
2021-08-20 09:19:44 -07:00
const title = app.label || app.manifest.title;
let [iconError, iconBuffer] = await safe(apps.getIcon(app, {}));
if (iconError) return next(new HttpError(500, `Error getting app icon: ${error.message}`));
if (!iconBuffer) {
iconBuffer = safe.fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'img/appicon_fallback.png'));
if (!iconBuffer) return next(new HttpError(500, 'App icon and fallback icon is missing'));
}
2021-08-20 09:19:44 -07:00
const icon = 'data:image/png;base64,' + iconBuffer.toString('base64');
2023-08-11 19:41:05 +05:30
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
const dashboardOrigin = `https://${dashboardFqdn}`;
2020-11-24 20:57:13 +01:00
2021-08-20 09:19:44 -07:00
try {
finalContent = ejs.render(translatedContent, { title, icon, dashboardOrigin });
2021-08-20 09:19:44 -07:00
} catch (e) {
2022-02-01 17:16:25 -08:00
debug('loginPage: Error rendering proxyauth-login.ejs', e);
2021-08-20 09:19:44 -07:00
return next(new HttpError(500, 'Login template error'));
}
res.set('Content-Type', 'text/html');
return res.send(finalContent);
2020-11-09 20:34:48 -08:00
}
// 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;
2021-02-09 13:44:34 -08:00
// https://github.com/docker/engine/blob/master/dockerversion/useragent.go#L18
2021-08-13 09:36:06 -07:00
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)
2020-11-09 20:34:48 -08:00
function auth(req, res, next) {
if (!req.user) {
2022-02-01 17:56:40 -08:00
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'));
}
2020-11-09 20:34:48 -08:00
// user is already authenticated, refresh cookie
2022-02-01 17:16:25 -08:00
const token = jwt.sign({ user: req.user }, gTokenSecret, { expiresIn: `${constants.DEFAULT_TOKEN_EXPIRATION_DAYS}d` });
2020-11-09 20:34:48 -08:00
res.cookie('authToken', token, {
httpOnly: true,
maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS,
2020-11-09 20:34:48 -08:00
secure: true
});
res.set('x-remote-user', req.user.username);
res.set('x-remote-email', req.user.email);
2023-01-30 12:27:58 +01:00
// 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);
2020-11-09 20:34:48 -08:00
2022-08-25 16:12:41 +02:00
next(new HttpSuccess(200, {}));
2020-11-09 20:34:48 -08:00
}
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();
}
2020-11-09 20:34:48 -08:00
// endpoint called by login page, username and password posted as JSON body
2021-07-15 09:50:11 -07:00
async function passwordAuth(req, res, next) {
2020-11-09 20:34:48 -08:00
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' ));
2020-12-20 14:41:16 -08:00
if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' ));
2020-11-09 20:34:48 -08:00
2020-12-20 14:41:16 -08:00
const { username, password, totpToken } = req.body;
2020-11-09 20:34:48 -08:00
2021-07-15 09:50:11 -07:00
const verifyFunc = username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
const [error, user] = await safe(verifyFunc(username, password, appId, { totpToken, skipTotpCheck: false }));
2023-03-12 15:09:20 +01:00
if (error) return next(new HttpError(403, error.message));
2020-12-20 13:13:36 -08:00
2021-07-15 09:50:11 -07:00
req.user = user;
next();
2020-11-20 17:54:17 -08:00
}
2020-11-09 20:34:48 -08:00
2021-08-20 09:19:44 -07:00
async function authorize(req, res, next) {
2020-11-20 17:54:17 -08:00
const appId = req.headers['x-app-id'] || '';
if (!appId) return next(new HttpError(503, 'Nginx misconfiguration'));
2020-11-09 20:34:48 -08:00
2021-08-20 09:19:44 -07:00
const [error, app] = await safe(apps.get(appId));
if (error) return next(new HttpError(403, 'No such app' ));
2020-11-20 17:54:17 -08:00
2021-09-21 10:00:47 -07:00
if (!apps.canAccess(app, req.user)) return next(new HttpError(403, 'Forbidden' ));
2020-11-20 17:54:17 -08:00
2022-02-01 17:16:25 -08:00
const token = jwt.sign({ user: users.removePrivateFields(req.user) }, gTokenSecret, { expiresIn: `${constants.DEFAULT_TOKEN_EXPIRATION_DAYS}d` });
2020-11-20 17:54:17 -08:00
2021-08-20 09:19:44 -07:00
res.cookie('authToken', token, {
httpOnly: true,
maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS,
secure: true
2020-11-09 20:34:48 -08:00
});
2021-08-20 09:19:44 -07:00
res.redirect(302, '/');
2020-11-09 20:34:48 -08:00
}
2021-08-20 09:19:44 -07:00
async function logoutPage(req, res, next) {
2020-12-19 12:30:06 -08:00
const appId = req.headers['x-app-id'] || '';
if (!appId) return next(new HttpError(503, 'Nginx misconfiguration'));
2021-08-20 09:19:44 -07:00
const [error, app] = await safe(apps.get(appId));
if (error) return next(new HttpError(503, error.message));
2020-12-19 12:30:06 -08:00
2021-08-20 09:19:44 -07:00
res.clearCookie('authToken');
2020-12-19 12:30:06 -08:00
2021-08-20 09:19:44 -07:00
// 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');
2020-11-09 20:34:48 -08:00
}
// provides webhooks for the auth wall
function initializeAuthwallExpressSync() {
2021-09-07 09:57:49 -07:00
const app = express();
const httpServer = http.createServer(app);
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
const QUERY_LIMIT = '1mb'; // max size for json and urlencoded queries
const REQUEST_TIMEOUT = 10000; // timeout for all requests
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
const json = middleware.json({ strict: true, limit: QUERY_LIMIT }); // application/json
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
const router = new express.Router();
2020-11-09 20:34:48 -08:00
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 ('/callback', callback, authorize);
2020-11-09 20:34:48 -08:00
router.get ('/login', loginPage);
2022-08-25 16:12:41 +02:00
router.get ('/auth', jwtVerify, authorizationHeader, auth); // called by nginx before accessing protected page
2020-12-20 13:13:36 -08:00
router.post('/login', json, passwordAuth, authorize);
2020-11-09 20:34:48 -08:00
router.get ('/logout', logoutPage);
router.post('/logout', json, logoutPage);
2020-11-09 20:34:48 -08:00
return httpServer;
}
2021-09-07 09:57:49 -07:00
async function start() {
2020-11-09 20:34:48 -08:00
assert.strictEqual(gHttpServer, null, 'Authwall is already up and running.');
2022-02-01 17:16:25 -08:00
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);
2020-11-10 17:10:57 -08:00
}
2020-11-09 20:34:48 -08:00
gHttpServer = initializeAuthwallExpressSync();
2021-09-07 09:57:49 -07:00
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.AUTHWALL_PORT, '127.0.0.1');
2020-11-09 20:34:48 -08:00
}
2021-09-07 09:57:49 -07:00
async function stop() {
if (!gHttpServer) return;
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
await util.promisify(gHttpServer.close.bind(gHttpServer))();
2020-11-09 20:34:48 -08:00
gHttpServer = null;
}