Add local authserver to provide /verify-credentials route
This is used for apps which are using OpenID to login but still need to be able to verify the users password or app password
This commit is contained in:
3
box.js
3
box.js
@@ -4,6 +4,7 @@ import constants from './src/constants.js';
|
||||
import fs from 'node:fs';
|
||||
import ldapServer from './src/ldapserver.js';
|
||||
import net from 'node:net';
|
||||
import authServer from './src/authserver.js';
|
||||
import oidcServer from './src/oidcserver.js';
|
||||
import paths from './src/paths.js';
|
||||
import proxyAuth from './src/proxyauth.js';
|
||||
@@ -72,6 +73,7 @@ process.on('SIGINT', async function () {
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
|
||||
setTimeout(() => {
|
||||
log('Shutdown complete');
|
||||
@@ -87,6 +89,7 @@ process.on('SIGTERM', async function () {
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
|
||||
setTimeout(() => {
|
||||
log('Shutdown complete');
|
||||
|
||||
@@ -113,7 +113,7 @@ $ip6tables -t filter -A CLOUDRON -p udp --sport 547 --dport 546 -j ACCEPT
|
||||
|
||||
ipxtables -t filter -A CLOUDRON -p udp --sport 53 -j ACCEPT
|
||||
# for ldap,dockerproxy server (ipv4 only) to accept connections from apps. for connecting to addons and mail container ports, docker already has rules
|
||||
$iptables -t filter -A CLOUDRON -p tcp -s 172.18.0.0/16 -d 172.18.0.1 -m multiport --dports 3002,3003 -j ACCEPT
|
||||
$iptables -t filter -A CLOUDRON -p tcp -s 172.18.0.0/16 -d 172.18.0.1 -m multiport --dports 3002,3003,3006 -j ACCEPT
|
||||
$iptables -t filter -A CLOUDRON -p udp -s 172.18.0.0/16 --dport 53 -j ACCEPT # dns responses from docker (127.0.0.11)
|
||||
ipxtables -t filter -A CLOUDRON -i lo -j ACCEPT # required for localhost connections (mysql)
|
||||
|
||||
|
||||
81
src/authserver.js
Normal file
81
src/authserver.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert';
|
||||
import apps from './apps.js';
|
||||
import BoxError from './boxerror.js';
|
||||
import constants from './constants.js';
|
||||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
import logger from './logger.js';
|
||||
import middleware from './middleware/index.js';
|
||||
import safe from '@cloudron/safetydance';
|
||||
import users from './users.js';
|
||||
import util from 'node:util';
|
||||
import { HttpError, HttpSuccess } from '@cloudron/connect-lastmile';
|
||||
|
||||
const { trace, log } = logger('authserver');
|
||||
|
||||
let gHttpServer = null;
|
||||
|
||||
async function verifyPost(req, res, next) {
|
||||
if (!req.body || typeof req.body !== 'object') return next(new HttpError(400, 'Body must be a JSON object'));
|
||||
|
||||
const { identifier, password } = req.body;
|
||||
if (typeof identifier !== 'string' || identifier.length === 0) return next(new HttpError(400, 'identifier must be a non-empty string'));
|
||||
if (typeof password !== 'string' || password.length === 0) return next(new HttpError(400, 'password must be a non-empty string'));
|
||||
|
||||
trace(`verifyPost: attempt for ${identifier}`);
|
||||
|
||||
let verifyFunc;
|
||||
if (identifier.startsWith('uid-')) verifyFunc = users.verifyWithId;
|
||||
else if (identifier.includes('@')) verifyFunc = users.verifyWithEmail;
|
||||
else verifyFunc = users.verifyWithUsername;
|
||||
|
||||
const [, connectingApp] = await safe(apps.getByIpAddress(req.socket.remoteAddress));
|
||||
const appId = connectingApp ? connectingApp.id : '';
|
||||
|
||||
// Internal loopback-only password check; skipTotpCheck matches LDAP bind behavior for automated callers.
|
||||
const [verifyError, user] = await safe(verifyFunc(identifier, password, appId, { skipTotpCheck: true }));
|
||||
|
||||
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
|
||||
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
|
||||
if (verifyError) return next(new HttpError(500, verifyError));
|
||||
if (!user) return next(new HttpError(401, 'Username and password does not match'));
|
||||
|
||||
next(new HttpSuccess(200, {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName
|
||||
}));
|
||||
}
|
||||
|
||||
async function start() {
|
||||
assert(gHttpServer === null, 'Auth server already started');
|
||||
|
||||
const app = express();
|
||||
app.enable('trust proxy');
|
||||
|
||||
const json = express.json({ strict: true, limit: '2mb' });
|
||||
|
||||
app.post('/verify-credentials', json, verifyPost);
|
||||
app.use(middleware.lastMile());
|
||||
|
||||
gHttpServer = http.createServer(app);
|
||||
|
||||
// In production the auth HTTP API is only reachable on the docker bridge IP. Tests run on the
|
||||
// host and connect via 127.0.0.1, and app IP checks rely on that remote address.
|
||||
const bindHost = constants.TEST ? '127.0.0.1' : constants.DOCKER_IPv4_GATEWAY;
|
||||
log(`start: listening on ${bindHost}:${constants.AUTH_PORT}`);
|
||||
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.AUTH_PORT, bindHost);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!gHttpServer) return;
|
||||
|
||||
await util.promisify(gHttpServer.close.bind(gHttpServer))();
|
||||
gHttpServer = null;
|
||||
}
|
||||
|
||||
export default {
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
DOCKER_PROXY_PORT: 3003,
|
||||
USER_DIRECTORY_LDAPS_PORT: 3004, // user directory LDAP with TLS rerouting in iptables, public port is 636
|
||||
OIDC_PORT: 3005,
|
||||
AUTH_PORT: 3006,
|
||||
TURN_PORT: 3478, // tcp and udp
|
||||
TURN_TLS_PORT: 5349, // tcp and udp
|
||||
TURN_UDP_PORT_START: 50000,
|
||||
|
||||
@@ -178,7 +178,7 @@ async function start() {
|
||||
});
|
||||
|
||||
log(`start: listening on 172.18.0.1:${constants.DOCKER_PROXY_PORT}`);
|
||||
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, '172.18.0.1');
|
||||
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, constants.DOCKER_IPv4_GATEWAY);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import dockerProxy from './dockerproxy.js';
|
||||
import fs from 'node:fs';
|
||||
import infra from './infra_version.js';
|
||||
import locks from './locks.js';
|
||||
import authServer from './authserver.js';
|
||||
import oidcServer from './oidcserver.js';
|
||||
import paths from './paths.js';
|
||||
import reverseProxy from './reverseproxy.js';
|
||||
@@ -170,6 +171,7 @@ async function onActivated(restoreOptions) {
|
||||
await startInfra(restoreOptions);
|
||||
await cron.startJobs();
|
||||
await dockerProxy.start(); // this relies on the 'cloudron' docker network interface to be available
|
||||
await authServer.start(); // this relies on the 'cloudron' docker network interface to be available
|
||||
|
||||
// disable responding to api calls via IP to not leak domain info. this is carefully placed as the last item, so it buys
|
||||
// the UI some time to query the dashboard domain in the restore code path
|
||||
@@ -185,6 +187,7 @@ async function onDeactivated() {
|
||||
await cron.stopJobs();
|
||||
await dockerProxy.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
}
|
||||
|
||||
async function uninitialize() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
|
||||
import mailer from '../../mailer.js';
|
||||
import nock from 'nock';
|
||||
import oidcClients from '../../oidcclients.js';
|
||||
import authServer from '../../authserver.js';
|
||||
import oidcServer from '../../oidcserver.js';
|
||||
import server from '../../server.js';
|
||||
import settings from '../../settings.js';
|
||||
@@ -103,6 +104,7 @@ async function setupServer() {
|
||||
await database._clear();
|
||||
await appstore._setApiServerOrigin(mockApiServerOrigin);
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
await server.start();
|
||||
log('Set up server complete');
|
||||
}
|
||||
@@ -172,6 +174,7 @@ async function cleanup() {
|
||||
log('Cleaning up');
|
||||
await server.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
if (!nock.isActive()) nock.activate();
|
||||
log('Cleaned up');
|
||||
}
|
||||
|
||||
82
src/test/authserver-test.js
Normal file
82
src/test/authserver-test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, before, after } from 'mocha';
|
||||
import appPasswords from '../apppasswords.js';
|
||||
import apps from '../apps.js';
|
||||
import authServer from '../authserver.js';
|
||||
import constants from '../constants.js';
|
||||
import common from './common.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import superagent from '@cloudron/superagent';
|
||||
|
||||
const authUrl = `http://127.0.0.1:${constants.AUTH_PORT}`;
|
||||
|
||||
function assertVerifyCredentialsProfile(body, expected) {
|
||||
assert.equal(body.id, expected.id);
|
||||
assert.equal(body.username, expected.username);
|
||||
assert.equal(body.email, expected.email);
|
||||
assert.equal(body.displayName, expected.displayName);
|
||||
}
|
||||
|
||||
describe('authserver HTTP', function () {
|
||||
const { setup, cleanup, admin, app } = common;
|
||||
|
||||
before(async function () {
|
||||
await setup();
|
||||
await apps.update(app.id, { containerIp: '127.0.0.1' });
|
||||
await authServer.start();
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await authServer.stop();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('returns 200 with account password from localhost', async function () {
|
||||
const response = await superagent.post(`${authUrl}/verify-credentials`)
|
||||
.send({ identifier: admin.username, password: admin.password })
|
||||
.ok(() => true);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assertVerifyCredentialsProfile(response.body, admin);
|
||||
});
|
||||
|
||||
it('returns 200 with app password when containerIp matches peer', async function () {
|
||||
const { id, password } = await appPasswords.add(admin.id, app.id, 'authserver-test', null);
|
||||
|
||||
const response = await superagent.post(`${authUrl}/verify-credentials`)
|
||||
.send({ identifier: admin.username, password })
|
||||
.ok(() => true);
|
||||
|
||||
await appPasswords.del(id);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assertVerifyCredentialsProfile(response.body, admin);
|
||||
});
|
||||
|
||||
it('returns 401 for app password when containerIp does not match peer', async function () {
|
||||
const { id, password } = await appPasswords.add(admin.id, app.id, 'authserver-test-ip', null);
|
||||
|
||||
await apps.update(app.id, { containerIp: '172.18.88.88' });
|
||||
|
||||
const response = await superagent.post(`${authUrl}/verify-credentials`)
|
||||
.send({ identifier: admin.username, password })
|
||||
.ok(() => true);
|
||||
|
||||
await apps.update(app.id, { containerIp: '127.0.0.1' });
|
||||
await appPasswords.del(id);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
it('returns 200 with account password when containerIp does not match peer', async function () {
|
||||
await apps.update(app.id, { containerIp: '172.18.88.88' });
|
||||
|
||||
const response = await superagent.post(`${authUrl}/verify-credentials`)
|
||||
.send({ identifier: admin.username, password: admin.password })
|
||||
.ok(() => true);
|
||||
|
||||
await apps.update(app.id, { containerIp: '127.0.0.1' });
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assertVerifyCredentialsProfile(response.body, admin);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user