243 lines
8.6 KiB
JavaScript
243 lines
8.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'),
|
|
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'),
|
|
oidcServer = require('./oidcserver.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}`);
|
|
|
|
const userId = await oidcServer.consumeAuthCode(req.query.code);
|
|
if (userId) req.user = await users.get(userId);
|
|
|
|
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;
|
|
}
|