diff --git a/box.js b/box.js index d705b65eb..a4cd37a42 100755 --- a/box.js +++ b/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'); diff --git a/setup/start/cloudron-firewall.sh b/setup/start/cloudron-firewall.sh index 919cacfd5..748317596 100755 --- a/setup/start/cloudron-firewall.sh +++ b/setup/start/cloudron-firewall.sh @@ -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) diff --git a/src/authserver.js b/src/authserver.js new file mode 100644 index 000000000..ad1b5decf --- /dev/null +++ b/src/authserver.js @@ -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, +}; diff --git a/src/constants.js b/src/constants.js index 224b84302..5ad1d2ecb 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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, diff --git a/src/dockerproxy.js b/src/dockerproxy.js index 805b6a2a1..8c063036d 100644 --- a/src/dockerproxy.js +++ b/src/dockerproxy.js @@ -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() { diff --git a/src/platform.js b/src/platform.js index 157995c79..e83f2a698 100644 --- a/src/platform.js +++ b/src/platform.js @@ -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() { diff --git a/src/routes/test/common.js b/src/routes/test/common.js index 0c6ef11c2..522dc65f1 100644 --- a/src/routes/test/common.js +++ b/src/routes/test/common.js @@ -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'); } diff --git a/src/test/authserver-test.js b/src/test/authserver-test.js new file mode 100644 index 000000000..aa9cf942e --- /dev/null +++ b/src/test/authserver-test.js @@ -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); + }); +});