feat(auth): allow trusted remote bootstrap and add installer

This commit is contained in:
chick
2026-07-19 21:10:16 +08:00
parent 49ee4e6570
commit ebed4c1969
8 changed files with 394 additions and 32 deletions
@@ -7,6 +7,7 @@ import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerConsoleAuth } from './console-auth-routes.js';
const PASSWORD = 'StrongPass!9';
const GATEWAY_TOKEN = 'g'.repeat(32);
const dbs: Database.Database[] = [];
function fixture() {
@@ -48,23 +49,41 @@ describe('aggregate-console password protection HTTP boundary', () => {
await app.close();
});
it('rejects initial password takeover from a non-loopback client', async () => {
it('allows an administrator on the trusted gateway to initialize the first password remotely', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
const auth = new ConsoleAuthService({ db });
const app = buildApp({
gatewayToken: GATEWAY_TOKEN,
registerRoutes: (fastify) => registerConsoleAuth(fastify, auth),
});
const response = await app.inject({
const rejected = await app.inject({
method: 'PUT',
url: '/api/v1/auth/settings',
remoteAddress: '127.0.0.1',
headers: { 'x-multi-simadmin-client-ip': '192.168.3.8' },
payload: { enabled: true, newPassword: PASSWORD },
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: 'BOOTSTRAP_LOCAL_ONLY' });
expect(rejected.statusCode).toBe(401);
expect(auth.status()).toMatchObject({ configured: false, protectionEnabled: false });
const response = await app.inject({
method: 'PUT',
url: '/api/v1/auth/settings',
remoteAddress: '127.0.0.1',
headers: {
'x-multi-simadmin-client-ip': '192.168.3.8',
'x-multi-simadmin-gateway-token': GATEWAY_TOKEN,
},
payload: { enabled: true, newPassword: PASSWORD },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
configured: true,
protectionEnabled: true,
authenticated: true,
});
expect(response.headers['set-cookie']).toContain('multi_simadmin_console_session=');
expect(auth.status()).toMatchObject({ configured: true, protectionEnabled: true });
await app.close();
db.close();
});
@@ -82,15 +82,7 @@ const loginSchema = {
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
} as const;
export interface ConsoleAuthRegistrationOptions {
readonly allowRemoteBootstrap?: boolean;
}
export function registerConsoleAuth(
app: FastifyInstance,
auth: ConsoleAuthService,
options: ConsoleAuthRegistrationOptions = {},
): void {
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
app.addHook('preHandler', async (request, reply) => {
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
@@ -142,21 +134,6 @@ export function registerConsoleAuth(
app.put('/api/v1/auth/settings', { schema: { body: settingsSchema } }, async (request, reply) => {
try {
const body = request.body as { enabled: boolean; newPassword?: string };
const status = auth.status(parseCookie(request));
if (
body.enabled &&
body.newPassword !== undefined &&
!status.configured &&
!options.allowRemoteBootstrap &&
clientIp(request) !== '127.0.0.1' &&
clientIp(request) !== '::1'
)
return reply
.code(403)
.type('application/problem+json')
.send(
problem(request, 403, 'BOOTSTRAP_LOCAL_ONLY', 'Initial password setup is local-only'),
);
const result = await auth.updateSettings(body, parseCookie(request));
if (result.sessionToken)
reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request)));