feat(auth): allow trusted remote bootstrap and add installer
This commit is contained in:
@@ -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)));
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ConsoleAuthSettings({
|
||||
<header className="page-heading">
|
||||
<p className="eyebrow">SYSTEM SETTINGS</p>
|
||||
<h1 id="password-protection-title">密码保护</h1>
|
||||
<p>首次密码仅允许从运行主机本机设置;远程访问请先在本机完成初始化。</p>
|
||||
<p>可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。</p>
|
||||
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
||||
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
||||
</header>
|
||||
|
||||
@@ -79,6 +79,8 @@ describe('aggregate-console authentication UI', () => {
|
||||
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
||||
expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).toBeNull();
|
||||
expect(screen.getByText(/可在当前管理台直接完成首次密码设置/)).toBeTruthy();
|
||||
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
await user.click(toggle);
|
||||
|
||||
Reference in New Issue
Block a user