mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
260 lines
9.4 KiB
JavaScript
260 lines
9.4 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('node:assert'),
|
|
blobs = require('./blobs.js'),
|
|
constants = require('./constants.js'),
|
|
dashboard = require('./dashboard.js'),
|
|
debug = require('debug')('box:proxyAuth'),
|
|
ejs = require('ejs'),
|
|
express = require('express'),
|
|
fs = require('node:fs'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
hat = require('./hat.js'),
|
|
http = require('node:http'),
|
|
HttpError = require('@cloudron/connect-lastmile').HttpError,
|
|
HttpSuccess = require('@cloudron/connect-lastmile').HttpSuccess,
|
|
jwt = require('jsonwebtoken'),
|
|
middleware = require('./middleware'),
|
|
oidcServer = require('./oidcserver.js'),
|
|
safe = require('safetydance'),
|
|
settings = require('./settings.js'),
|
|
users = require('./users.js'),
|
|
util = require('node: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 !/docker|container|libpod|skopeo/i.test(userAgent);
|
|
}
|
|
|
|
// 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, {}));
|
|
}
|
|
|
|
const TEMPLATE_PROXYAUTH = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'proxyauth.html'), 'utf-8');
|
|
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 (typeof req.query.redirect === 'string') {
|
|
res.cookie('cloudronProxyAuthRedirect', req.query.redirect, {
|
|
httpOnly: true,
|
|
maxAge: constants.DEFAULT_TOKEN_EXPIRATION_MSECS,
|
|
secure: true
|
|
});
|
|
}
|
|
|
|
const proxyAuthClientId = `${app.id}-proxyauth`;
|
|
|
|
const data = {
|
|
loginUrl: `https://${dashboardFqdn}/openid/auth?client_id=${proxyAuthClientId}&scope=openid profile email&response_type=code&redirect_uri=https://${app.fqdn}/callback`,
|
|
iconUrl: app.iconUrl ? `https://${dashboardFqdn}${app.iconUrl}` : `https://${dashboardFqdn}/img/appicon_fallback.png`,
|
|
name: app.label || app.subdomain || app.fqdn,
|
|
language: await settings.get(settings.LANGUAGE_KEY),
|
|
apiOrigin: `https://${dashboardFqdn}`,
|
|
};
|
|
|
|
return res.send(ejs.render(TEMPLATE_PROXYAUTH, data));
|
|
}
|
|
|
|
async function callback(req, res, next) {
|
|
if (typeof req.query.code !== 'string') return next(new HttpError(400, 'missing query argument "code"'));
|
|
|
|
debug(`callback: with code ${req.query.code}`);
|
|
|
|
const username = await oidcServer.consumeAuthCode(req.query.code);
|
|
if (!username) return next(new HttpError(400, 'invalid "code"'));
|
|
req.user = await users.getByUsername(username);
|
|
|
|
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;
|
|
}
|