feat(auth): protect aggregate console with password login
This commit is contained in:
@@ -223,6 +223,7 @@ describe('Fastify API skeleton', () => {
|
|||||||
'req.headers.cookie',
|
'req.headers.cookie',
|
||||||
'req.headers.x-confirmation-token',
|
'req.headers.x-confirmation-token',
|
||||||
'req.body.password',
|
'req.body.password',
|
||||||
|
'req.body.newPassword',
|
||||||
'res.headers.set-cookie',
|
'res.headers.set-cookie',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const REDACT_PATHS = Object.freeze([
|
|||||||
'req.headers.cookie',
|
'req.headers.cookie',
|
||||||
'req.headers.x-confirmation-token',
|
'req.headers.x-confirmation-token',
|
||||||
'req.body.password',
|
'req.body.password',
|
||||||
|
'req.body.newPassword',
|
||||||
'req.body.confirmationToken',
|
'req.body.confirmationToken',
|
||||||
'res.headers.set-cookie',
|
'res.headers.set-cookie',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { scrypt as deriveScrypt } from 'node:crypto';
|
||||||
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
const MAX_PASSWORD_BYTES = 1024;
|
||||||
|
const SCRYPT_KEY_LENGTH = 32;
|
||||||
|
|
||||||
|
interface AuthConfigRow {
|
||||||
|
readonly protection_enabled: 0 | 1;
|
||||||
|
readonly password_salt: string | null;
|
||||||
|
readonly password_hash: string | null;
|
||||||
|
readonly password_revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleAuthStatus {
|
||||||
|
readonly configured: boolean;
|
||||||
|
readonly protectionEnabled: boolean;
|
||||||
|
readonly authenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleAuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code:
|
||||||
|
| 'AUTH_REQUIRED'
|
||||||
|
| 'INVALID_CREDENTIALS'
|
||||||
|
| 'PASSWORD_REQUIRED'
|
||||||
|
| 'PASSWORD_POLICY_FAILED',
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleAuthServiceOptions {
|
||||||
|
readonly db: Database.Database;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleAuthService {
|
||||||
|
private readonly db: Database.Database;
|
||||||
|
private readonly now: () => Date;
|
||||||
|
|
||||||
|
constructor(options: ConsoleAuthServiceOptions) {
|
||||||
|
this.db = options.db;
|
||||||
|
this.now = options.now ?? (() => new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
status(sessionToken?: string): ConsoleAuthStatus {
|
||||||
|
this.pruneExpired();
|
||||||
|
const config = this.config();
|
||||||
|
const configured = config?.password_hash !== null && config?.password_hash !== undefined;
|
||||||
|
const protectionEnabled = config?.protection_enabled === 1;
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
protectionEnabled,
|
||||||
|
authenticated: !protectionEnabled || this.isAuthenticated(sessionToken, config),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isProtectedPath(pathname: string): boolean {
|
||||||
|
if (!pathname.startsWith('/api/v1/')) return false;
|
||||||
|
return !pathname.startsWith('/api/v1/auth/');
|
||||||
|
}
|
||||||
|
|
||||||
|
requireSession(sessionToken?: string): void {
|
||||||
|
const status = this.status(sessionToken);
|
||||||
|
if (status.protectionEnabled && !status.authenticated)
|
||||||
|
throw new ConsoleAuthError('AUTH_REQUIRED');
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(password: string): Promise<string> {
|
||||||
|
const config = this.config();
|
||||||
|
if (
|
||||||
|
!config ||
|
||||||
|
config.protection_enabled !== 1 ||
|
||||||
|
!config.password_salt ||
|
||||||
|
!config.password_hash
|
||||||
|
)
|
||||||
|
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
||||||
|
const actual = await this.derive(password, config.password_salt);
|
||||||
|
const expected = Buffer.from(config.password_hash, 'hex');
|
||||||
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||||
|
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
||||||
|
return this.createSession(config.password_revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(
|
||||||
|
input: { readonly enabled: boolean; readonly newPassword?: string },
|
||||||
|
sessionToken?: string,
|
||||||
|
): Promise<{ readonly status: ConsoleAuthStatus; readonly sessionToken?: string }> {
|
||||||
|
const current = this.config();
|
||||||
|
if (current?.protection_enabled === 1) this.requireSession(sessionToken);
|
||||||
|
|
||||||
|
if (input.enabled) {
|
||||||
|
if (input.newPassword !== undefined) {
|
||||||
|
this.validatePassword(input.newPassword);
|
||||||
|
if (current?.password_hash && current.protection_enabled !== 1)
|
||||||
|
throw new ConsoleAuthError('AUTH_REQUIRED');
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const hash = (await this.derive(input.newPassword, salt)).toString('hex');
|
||||||
|
const token = this.db.transaction(() => {
|
||||||
|
const latest = this.config();
|
||||||
|
if (latest?.password_hash && latest.password_revision !== current?.password_revision)
|
||||||
|
throw new ConsoleAuthError('AUTH_REQUIRED');
|
||||||
|
const revision = (latest?.password_revision ?? 0) + 1;
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO console_auth_config
|
||||||
|
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
|
||||||
|
VALUES (1,1,?,?,?,?)
|
||||||
|
ON CONFLICT(singleton) DO UPDATE SET
|
||||||
|
protection_enabled=1,password_salt=excluded.password_salt,
|
||||||
|
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
|
||||||
|
updated_at=excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run(salt, hash, revision, this.now().toISOString());
|
||||||
|
this.db.prepare('DELETE FROM console_auth_sessions').run();
|
||||||
|
return this.createSession(revision);
|
||||||
|
})();
|
||||||
|
return { status: this.status(token), sessionToken: token };
|
||||||
|
}
|
||||||
|
if (!current?.password_hash) throw new ConsoleAuthError('PASSWORD_REQUIRED');
|
||||||
|
const changed = this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE console_auth_config SET protection_enabled=1,updated_at=?
|
||||||
|
WHERE singleton=1 AND password_revision=?`,
|
||||||
|
)
|
||||||
|
.run(this.now().toISOString(), current.password_revision);
|
||||||
|
if (changed.changes !== 1) throw new ConsoleAuthError('AUTH_REQUIRED');
|
||||||
|
return { status: this.status(sessionToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db.transaction(() => {
|
||||||
|
if (!current) {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO console_auth_config
|
||||||
|
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
|
||||||
|
VALUES (1,0,NULL,NULL,0,?)`,
|
||||||
|
)
|
||||||
|
.run(this.now().toISOString());
|
||||||
|
} else {
|
||||||
|
const changed = this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE console_auth_config SET protection_enabled=0,updated_at=?
|
||||||
|
WHERE singleton=1 AND password_revision=?`,
|
||||||
|
)
|
||||||
|
.run(this.now().toISOString(), current.password_revision);
|
||||||
|
if (changed.changes !== 1) throw new ConsoleAuthError('AUTH_REQUIRED');
|
||||||
|
}
|
||||||
|
this.db.prepare('DELETE FROM console_auth_sessions').run();
|
||||||
|
})();
|
||||||
|
return { status: this.status() };
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(sessionToken?: string): void {
|
||||||
|
if (sessionToken)
|
||||||
|
this.db
|
||||||
|
.prepare('DELETE FROM console_auth_sessions WHERE session_hash=?')
|
||||||
|
.run(this.digest(sessionToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private config(): AuthConfigRow | undefined {
|
||||||
|
return this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT protection_enabled,password_salt,password_hash,password_revision
|
||||||
|
FROM console_auth_config WHERE singleton=1`,
|
||||||
|
)
|
||||||
|
.get() as AuthConfigRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePassword(password: string): void {
|
||||||
|
const bytes = Buffer.byteLength(password, 'utf8');
|
||||||
|
if (
|
||||||
|
password.length < MIN_PASSWORD_LENGTH ||
|
||||||
|
bytes > MAX_PASSWORD_BYTES ||
|
||||||
|
/[\0\r\n]/u.test(password) ||
|
||||||
|
!/[A-Za-z]/u.test(password) ||
|
||||||
|
!/\d/u.test(password)
|
||||||
|
)
|
||||||
|
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
private derive(password: string, salt: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
deriveScrypt(
|
||||||
|
password,
|
||||||
|
Buffer.from(salt, 'hex'),
|
||||||
|
SCRYPT_KEY_LENGTH,
|
||||||
|
{ N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
|
||||||
|
(error, derivedKey) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolvePromise(derivedKey);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private digest(token: string): string {
|
||||||
|
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSession(passwordRevision: number): string {
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
const now = this.now();
|
||||||
|
const expires = new Date(now.getTime() + SESSION_TTL_MS);
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO console_auth_sessions
|
||||||
|
(session_hash,password_revision,created_at,expires_at) VALUES (?,?,?,?)`,
|
||||||
|
)
|
||||||
|
.run(this.digest(token), passwordRevision, now.toISOString(), expires.toISOString());
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAuthenticated(token: string | undefined, config: AuthConfigRow | undefined): boolean {
|
||||||
|
if (!token || !config || config.password_revision <= 0) return false;
|
||||||
|
const row = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM console_auth_sessions
|
||||||
|
WHERE session_hash=? AND password_revision=? AND expires_at>?`,
|
||||||
|
)
|
||||||
|
.get(this.digest(token), config.password_revision, this.now().toISOString());
|
||||||
|
return row !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpired(): void {
|
||||||
|
this.db
|
||||||
|
.prepare('DELETE FROM console_auth_sessions WHERE expires_at<=?')
|
||||||
|
.run(this.now().toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from 'node:http';
|
} from 'node:http';
|
||||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
|
import { GATEWAY_AUTH_HEADER, GATEWAY_CLIENT_IP_HEADER } from './runtime-config.js';
|
||||||
|
|
||||||
export const CANARY_DEFAULT_HOST = '127.0.0.1';
|
export const CANARY_DEFAULT_HOST = '127.0.0.1';
|
||||||
export const CANARY_DEFAULT_PORT = 8789;
|
export const CANARY_DEFAULT_PORT = 8789;
|
||||||
@@ -98,12 +98,15 @@ function trustedUpstreamHeaders(
|
|||||||
gatewayToken: string | undefined,
|
gatewayToken: string | undefined,
|
||||||
upstreamHost: string,
|
upstreamHost: string,
|
||||||
upstreamPort: number,
|
upstreamPort: number,
|
||||||
|
clientIp: string | undefined,
|
||||||
): IncomingHttpHeaders {
|
): IncomingHttpHeaders {
|
||||||
const sanitized = withoutHopByHop(headers);
|
const sanitized = withoutHopByHop(headers);
|
||||||
sanitized.host = `${upstreamHost}:${upstreamPort}`;
|
sanitized.host = `${upstreamHost}:${upstreamPort}`;
|
||||||
if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`;
|
if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`;
|
||||||
delete sanitized[GATEWAY_AUTH_HEADER];
|
delete sanitized[GATEWAY_AUTH_HEADER];
|
||||||
|
delete sanitized[GATEWAY_CLIENT_IP_HEADER];
|
||||||
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
|
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
|
||||||
|
if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp;
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +163,13 @@ async function proxyRequest(
|
|||||||
port: upstreamPort,
|
port: upstreamPort,
|
||||||
method: incoming.method,
|
method: incoming.method,
|
||||||
path: incoming.url,
|
path: incoming.url,
|
||||||
headers: trustedUpstreamHeaders(incoming.headers, gatewayToken, upstreamHost, upstreamPort),
|
headers: trustedUpstreamHeaders(
|
||||||
|
incoming.headers,
|
||||||
|
gatewayToken,
|
||||||
|
upstreamHost,
|
||||||
|
upstreamPort,
|
||||||
|
incoming.socket.remoteAddress,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
(upstreamResponse) => {
|
(upstreamResponse) => {
|
||||||
response.writeHead(
|
response.writeHead(
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { registerJobRoutes } from './interface/http/job-routes.js';
|
|||||||
import { AuditQueryService } from './application/audit/audit-query-service.js';
|
import { AuditQueryService } from './application/audit/audit-query-service.js';
|
||||||
import { registerAuditRoutes } from './interface/http/audit-routes.js';
|
import { registerAuditRoutes } from './interface/http/audit-routes.js';
|
||||||
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
|
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
|
||||||
|
import { ConsoleAuthService } from './application/auth/console-auth-service.js';
|
||||||
|
import { registerConsoleAuth } from './interface/http/console-auth-routes.js';
|
||||||
import { InstanceMessageService } from './application/messages/instance-message-service.js';
|
import { InstanceMessageService } from './application/messages/instance-message-service.js';
|
||||||
|
|
||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
@@ -93,9 +95,14 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
...(options.now ? { now: options.now } : {}),
|
...(options.now ? { now: options.now } : {}),
|
||||||
});
|
});
|
||||||
secureExecution.reconcileInterruptedJobs();
|
secureExecution.reconcileInterruptedJobs();
|
||||||
|
const auth = new ConsoleAuthService({
|
||||||
|
db: options.db,
|
||||||
|
...(options.now ? { now: options.now } : {}),
|
||||||
|
});
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
...options.app,
|
...options.app,
|
||||||
registerRoutes: (app) => {
|
registerRoutes: (app) => {
|
||||||
|
registerConsoleAuth(app, auth);
|
||||||
registerInstanceRoutes(app, {
|
registerInstanceRoutes(app, {
|
||||||
instances,
|
instances,
|
||||||
connections,
|
connections,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ describe('database migrations', () => {
|
|||||||
'app_settings',
|
'app_settings',
|
||||||
'audit_events',
|
'audit_events',
|
||||||
'capabilities',
|
'capabilities',
|
||||||
|
'console_auth_config',
|
||||||
|
'console_auth_sessions',
|
||||||
'event_journal',
|
'event_journal',
|
||||||
'instance_tags',
|
'instance_tags',
|
||||||
'instances',
|
'instances',
|
||||||
@@ -118,6 +120,7 @@ describe('database migrations', () => {
|
|||||||
expect(indexes).toEqual(
|
expect(indexes).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'idx_audit_events_created_at',
|
'idx_audit_events_created_at',
|
||||||
|
'idx_console_auth_sessions_expires_at',
|
||||||
'idx_event_journal_sequence',
|
'idx_event_journal_sequence',
|
||||||
'idx_jobs_status_created_at',
|
'idx_jobs_status_created_at',
|
||||||
'idx_status_snapshots_instance_observed_at',
|
'idx_status_snapshots_instance_observed_at',
|
||||||
|
|||||||
@@ -282,6 +282,28 @@ export const MIGRATIONS: readonly Migration[] = [
|
|||||||
'CREATE INDEX idx_event_journal_sequence ON event_journal(sequence)',
|
'CREATE INDEX idx_event_journal_sequence ON event_journal(sequence)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'aggregate-console-password-protection',
|
||||||
|
statements: [
|
||||||
|
`CREATE TABLE console_auth_config (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
protection_enabled INTEGER NOT NULL DEFAULT 0 CHECK (protection_enabled IN (0, 1)),
|
||||||
|
password_salt TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
password_revision INTEGER NOT NULL DEFAULT 0 CHECK (password_revision >= 0),
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK ((password_salt IS NULL) = (password_hash IS NULL))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE console_auth_sessions (
|
||||||
|
session_hash TEXT PRIMARY KEY CHECK (length(session_hash) = 64),
|
||||||
|
password_revision INTEGER NOT NULL CHECK (password_revision > 0),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
'CREATE INDEX idx_console_auth_sessions_expires_at ON console_auth_sessions(expires_at)',
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildApp } from '../../app.js';
|
||||||
|
import { ConsoleAuthService } from '../../application/auth/console-auth-service.js';
|
||||||
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||||
|
import { registerConsoleAuth } from './console-auth-routes.js';
|
||||||
|
|
||||||
|
const PASSWORD = 'StrongPass!9';
|
||||||
|
const dbs: Database.Database[] = [];
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
migrateDatabase(db);
|
||||||
|
dbs.push(db);
|
||||||
|
let now = new Date('2026-07-19T00:00:00.000Z');
|
||||||
|
const auth = new ConsoleAuthService({ db, now: () => now });
|
||||||
|
const app = buildApp({
|
||||||
|
registerRoutes: (fastify) => {
|
||||||
|
registerConsoleAuth(fastify, auth);
|
||||||
|
fastify.get('/api/v1/protected-probe', async () => ({ ok: true }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
auth,
|
||||||
|
advance: (milliseconds: number) => (now = new Date(now.getTime() + milliseconds)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const db of dbs.splice(0)) if (db.open) db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregate-console password protection HTTP boundary', () => {
|
||||||
|
it('starts migration-safe as disabled and never returns password material', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
configured: false,
|
||||||
|
protectionEnabled: false,
|
||||||
|
authenticated: true,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(response.json())).not.toMatch(/hash|salt|password/i);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects initial password takeover from a non-loopback client', async () => {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
migrateDatabase(db);
|
||||||
|
const auth = new ConsoleAuthService({ db });
|
||||||
|
const app = buildApp({
|
||||||
|
registerRoutes: (fastify) => registerConsoleAuth(fastify, auth),
|
||||||
|
});
|
||||||
|
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' },
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toMatchObject({ code: 'BOOTSTRAP_LOCAL_ONLY' });
|
||||||
|
expect(auth.status()).toMatchObject({ configured: false, protectionEnabled: false });
|
||||||
|
await app.close();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables protection with a policy-compliant password and blocks protected APIs', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: {},
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(enable.statusCode).toBe(200);
|
||||||
|
expect(enable.json()).toMatchObject({ configured: true, protectionEnabled: true });
|
||||||
|
expect(enable.headers['set-cookie']).toContain('multi_simadmin_console_session=');
|
||||||
|
expect(enable.headers['set-cookie']).toContain('HttpOnly');
|
||||||
|
expect(enable.headers['set-cookie']).toContain('SameSite=Strict');
|
||||||
|
|
||||||
|
const blocked = await app.inject({ method: 'GET', url: '/api/v1/protected-probe' });
|
||||||
|
expect(blocked.statusCode).toBe(401);
|
||||||
|
expect(blocked.json()).toMatchObject({ code: 'CONSOLE_AUTH_REQUIRED' });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects weak passwords and unknown body fields without persisting a credential', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
for (const payload of [
|
||||||
|
{ enabled: true, newPassword: 'weak' },
|
||||||
|
{ enabled: true, newPassword: PASSWORD, extra: true },
|
||||||
|
]) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: {},
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
}
|
||||||
|
expect((await app.inject({ method: 'GET', url: '/api/v1/auth/status' })).json()).toMatchObject({
|
||||||
|
configured: false,
|
||||||
|
protectionEnabled: false,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a server-side hashed session, logs in, logs out, and expires sessions', async () => {
|
||||||
|
const { app, advance } = fixture();
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: {},
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
const initialCookie = String(enable.headers['set-cookie']).split(';', 1)[0] ?? '';
|
||||||
|
|
||||||
|
const wrong = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
headers: {},
|
||||||
|
payload: { password: 'WrongPass!9' },
|
||||||
|
});
|
||||||
|
expect(wrong.statusCode).toBe(401);
|
||||||
|
expect(JSON.stringify(wrong.json())).not.toContain('WrongPass');
|
||||||
|
|
||||||
|
const login = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
headers: {},
|
||||||
|
payload: { password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(login.statusCode).toBe(204);
|
||||||
|
const cookie = String(login.headers['set-cookie']).split(';', 1)[0] ?? '';
|
||||||
|
expect(cookie).not.toBe(initialCookie);
|
||||||
|
expect(
|
||||||
|
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
|
||||||
|
).toMatchObject({ authenticated: true });
|
||||||
|
|
||||||
|
const stored = dbs[0]!
|
||||||
|
.prepare('SELECT session_hash FROM console_auth_sessions ORDER BY created_at DESC LIMIT 1')
|
||||||
|
.get() as { session_hash: string };
|
||||||
|
expect(cookie).not.toContain(stored.session_hash);
|
||||||
|
expect(stored.session_hash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
const logout = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/logout',
|
||||||
|
headers: { cookie },
|
||||||
|
});
|
||||||
|
expect(logout.statusCode).toBe(204);
|
||||||
|
expect(logout.headers['set-cookie']).toContain('Max-Age=0');
|
||||||
|
expect(
|
||||||
|
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
|
||||||
|
).toMatchObject({ authenticated: false });
|
||||||
|
|
||||||
|
const relogin = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
headers: {},
|
||||||
|
payload: { password: PASSWORD },
|
||||||
|
});
|
||||||
|
const expiringCookie = String(relogin.headers['set-cookie']).split(';', 1)[0] ?? '';
|
||||||
|
advance(7 * 24 * 60 * 60 * 1000 + 1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/status',
|
||||||
|
headers: { cookie: expiringCookie },
|
||||||
|
})
|
||||||
|
).json(),
|
||||||
|
).toMatchObject({ authenticated: false });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limits repeated invalid login attempts without exposing password material', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(enable.statusCode).toBe(200);
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const failed = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { password: 'WrongPass!9' },
|
||||||
|
});
|
||||||
|
expect(failed.statusCode).toBe(401);
|
||||||
|
}
|
||||||
|
const limited = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { password: 'WrongPass!9' },
|
||||||
|
});
|
||||||
|
expect(limited.statusCode).toBe(429);
|
||||||
|
expect(limited.headers['retry-after']).toBeDefined();
|
||||||
|
expect(limited.body).not.toContain('WrongPass');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anonymous password replacement while a stored credential is disabled', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
const cookie = String(enable.headers['set-cookie']).split(';', 1)[0] ?? '';
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: { cookie },
|
||||||
|
payload: { enabled: false },
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
const replacement = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
payload: { enabled: true, newPassword: 'AnotherPass!8' },
|
||||||
|
});
|
||||||
|
expect(replacement.statusCode).toBe(401);
|
||||||
|
const originalLogin = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(originalLogin.statusCode).toBe(401);
|
||||||
|
expect((await app.inject({ method: 'GET', url: '/api/v1/auth/status' })).json()).toMatchObject({
|
||||||
|
configured: true,
|
||||||
|
protectionEnabled: false,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires an authenticated session to disable protection and revokes every session', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: {},
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
const cookie = String(enable.headers['set-cookie']).split(';', 1)[0] ?? '';
|
||||||
|
|
||||||
|
const anonymous = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: {},
|
||||||
|
payload: { enabled: false },
|
||||||
|
});
|
||||||
|
expect(anonymous.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const disable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
headers: { cookie },
|
||||||
|
payload: { enabled: false },
|
||||||
|
});
|
||||||
|
expect(disable.statusCode).toBe(200);
|
||||||
|
expect(disable.json()).toMatchObject({ configured: true, protectionEnabled: false });
|
||||||
|
expect(dbs[0]!.prepare('SELECT COUNT(*) AS count FROM console_auth_sessions').get()).toEqual({
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import { GATEWAY_CLIENT_IP_HEADER } from '../../runtime-config.js';
|
||||||
|
import {
|
||||||
|
ConsoleAuthError,
|
||||||
|
type ConsoleAuthService,
|
||||||
|
} from '../../application/auth/console-auth-service.js';
|
||||||
|
|
||||||
|
export const CONSOLE_SESSION_COOKIE = 'multi_simadmin_console_session';
|
||||||
|
const COOKIE_PATH = '/';
|
||||||
|
const MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||||
|
const LOGIN_WINDOW_MS = 60_000;
|
||||||
|
const LOGIN_LIMIT = 5;
|
||||||
|
|
||||||
|
const clientIp = (request: FastifyRequest): string => {
|
||||||
|
const forwarded = request.headers[GATEWAY_CLIENT_IP_HEADER];
|
||||||
|
return typeof forwarded === 'string' ? forwarded : request.ip;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCookie = (request: FastifyRequest): string | undefined => {
|
||||||
|
const raw = request.headers.cookie;
|
||||||
|
if (!raw) return undefined;
|
||||||
|
for (const part of raw.split(';')) {
|
||||||
|
const separator = part.indexOf('=');
|
||||||
|
if (separator < 0) continue;
|
||||||
|
const name = part.slice(0, separator).trim();
|
||||||
|
if (name === CONSOLE_SESSION_COOKIE) return part.slice(separator + 1).trim() || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookie = (token: string, secure: boolean): string =>
|
||||||
|
`${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
|
||||||
|
const clearCookie = (secure: boolean): string =>
|
||||||
|
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`;
|
||||||
|
const usesSecureCookie = (request: FastifyRequest): boolean => request.protocol === 'https';
|
||||||
|
|
||||||
|
const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
|
||||||
|
type: 'about:blank',
|
||||||
|
title: status === 401 ? 'Unauthorized' : 'Bad Request',
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
detail,
|
||||||
|
requestId: request.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusFor = (error: ConsoleAuthError): number =>
|
||||||
|
error.code === 'AUTH_REQUIRED' || error.code === 'INVALID_CREDENTIALS' ? 401 : 400;
|
||||||
|
|
||||||
|
const handle = (request: FastifyRequest, reply: FastifyReply, error: unknown) => {
|
||||||
|
if (!(error instanceof ConsoleAuthError)) throw error;
|
||||||
|
const status = statusFor(error);
|
||||||
|
const code =
|
||||||
|
error.code === 'AUTH_REQUIRED'
|
||||||
|
? 'CONSOLE_AUTH_REQUIRED'
|
||||||
|
: error.code === 'INVALID_CREDENTIALS'
|
||||||
|
? 'INVALID_CREDENTIALS'
|
||||||
|
: error.code;
|
||||||
|
const detail =
|
||||||
|
status === 401
|
||||||
|
? 'Authentication is required.'
|
||||||
|
: 'The password must be 8 or more characters and contain both letters and numbers.';
|
||||||
|
return reply
|
||||||
|
.code(status)
|
||||||
|
.type('application/problem+json')
|
||||||
|
.send(problem(request, status, code, detail));
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['enabled'],
|
||||||
|
properties: {
|
||||||
|
enabled: { type: 'boolean' },
|
||||||
|
newPassword: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const loginSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['password'],
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
if (!auth.isProtectedPath(pathname)) return;
|
||||||
|
try {
|
||||||
|
auth.requireSession(parseCookie(request));
|
||||||
|
} catch (error) {
|
||||||
|
return handle(request, reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/auth/status', async (request) => auth.status(parseCookie(request)));
|
||||||
|
|
||||||
|
app.post('/api/v1/auth/login', { schema: { body: loginSchema } }, async (request, reply) => {
|
||||||
|
const key = clientIp(request);
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = loginAttempts.get(key);
|
||||||
|
if (attempts && attempts.resetAt > now && attempts.count >= LOGIN_LIMIT)
|
||||||
|
return reply
|
||||||
|
.header('Retry-After', String(Math.ceil((attempts.resetAt - now) / 1000)))
|
||||||
|
.code(429)
|
||||||
|
.type('application/problem+json')
|
||||||
|
.send(problem(request, 429, 'RATE_LIMITED', 'Too many login attempts'));
|
||||||
|
try {
|
||||||
|
const token = await auth.login((request.body as { password: string }).password);
|
||||||
|
loginAttempts.delete(key);
|
||||||
|
return reply
|
||||||
|
.header('Set-Cookie', cookie(token, usesSecureCookie(request)))
|
||||||
|
.code(204)
|
||||||
|
.send();
|
||||||
|
} catch (error) {
|
||||||
|
const active =
|
||||||
|
attempts && attempts.resetAt > now
|
||||||
|
? attempts
|
||||||
|
: { count: 0, resetAt: now + LOGIN_WINDOW_MS };
|
||||||
|
loginAttempts.set(key, { count: active.count + 1, resetAt: active.resetAt });
|
||||||
|
return handle(request, reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/auth/logout', async (request, reply) => {
|
||||||
|
auth.logout(parseCookie(request));
|
||||||
|
return reply
|
||||||
|
.header('Set-Cookie', clearCookie(usesSecureCookie(request)))
|
||||||
|
.code(204)
|
||||||
|
.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
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)));
|
||||||
|
else if (!result.status.protectionEnabled)
|
||||||
|
reply.header('Set-Cookie', clearCookie(usesSecureCookie(request)));
|
||||||
|
return result.status;
|
||||||
|
} catch (error) {
|
||||||
|
return handle(request, reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { realpathSync } from 'node:fs';
|
|||||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
|
|
||||||
export const GATEWAY_AUTH_HEADER = 'x-multi-simadmin-gateway-token' as const;
|
export const GATEWAY_AUTH_HEADER = 'x-multi-simadmin-gateway-token' as const;
|
||||||
|
export const GATEWAY_CLIENT_IP_HEADER = 'x-multi-simadmin-client-ip' as const;
|
||||||
export interface RuntimeEnvironment {
|
export interface RuntimeEnvironment {
|
||||||
readonly MULTI_SIMADMIN_DATA_ROOT?: string;
|
readonly MULTI_SIMADMIN_DATA_ROOT?: string;
|
||||||
readonly MULTI_SIMADMIN_DATABASE_PATH?: string;
|
readonly MULTI_SIMADMIN_DATABASE_PATH?: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ConsoleAuthSettings } from './auth/console-auth-settings.js';
|
||||||
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
||||||
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -250,13 +251,7 @@ function Page({
|
|||||||
refreshSignal={fleetRefreshSignal}
|
refreshSignal={fleetRefreshSignal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (route.kind === 'settings-system')
|
if (route.kind === 'settings-system') return <ConsoleAuthSettings />;
|
||||||
return (
|
|
||||||
<section aria-labelledby="system-settings-title">
|
|
||||||
<h1 id="system-settings-title">系统设置</h1>
|
|
||||||
<p role="status">控制平面尚未提供系统设置契约,因此此功能暂不可用。</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
if (route.kind === 'not-found')
|
if (route.kind === 'not-found')
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -500,7 +495,7 @@ export function AppShell({
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['fleet', '/fleet', '实例总览'],
|
['fleet', '/fleet', '实例总览'],
|
||||||
['settings', '/settings/instances', '设置'],
|
['settings', '/settings/system', '设置'],
|
||||||
] as const
|
] as const
|
||||||
).map(([key, href, label]) => (
|
).map(([key, href, label]) => (
|
||||||
<li key={key}>
|
<li key={key}>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { ConsoleAuthDataSource, ConsoleAuthStatus } from './console-auth.js';
|
||||||
|
import { createConsoleAuthApiDataSource } from './console-auth.js';
|
||||||
|
|
||||||
|
export function ConsoleAuthSettings({
|
||||||
|
dataSource,
|
||||||
|
}: {
|
||||||
|
readonly dataSource?: ConsoleAuthDataSource;
|
||||||
|
}) {
|
||||||
|
const [source] = useState(() => dataSource ?? createConsoleAuthApiDataSource());
|
||||||
|
const [status, setStatus] = useState<ConsoleAuthStatus>();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmation, setConfirmation] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void source.status().then(
|
||||||
|
(next) => {
|
||||||
|
if (!active) return;
|
||||||
|
setStatus(next);
|
||||||
|
setEnabled(next.protectionEnabled);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (active) setError('无法读取密码保护设置。');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
const save = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!status || saving) return;
|
||||||
|
setNotice('');
|
||||||
|
setError('');
|
||||||
|
if (enabled && !status.configured) {
|
||||||
|
if (password.length < 8 || !/[A-Za-z]/u.test(password) || !/\d/u.test(password)) {
|
||||||
|
setError('密码至少 8 位,并同时包含字母和数字。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmation) {
|
||||||
|
setError('两次输入的密码不一致。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const input =
|
||||||
|
enabled && !status.configured ? { enabled: true, newPassword: password } : { enabled };
|
||||||
|
const next = await source.update(input);
|
||||||
|
setStatus(next);
|
||||||
|
setEnabled(next.protectionEnabled);
|
||||||
|
setPassword('');
|
||||||
|
setConfirmation('');
|
||||||
|
setNotice(next.protectionEnabled ? '密码保护已启用。' : '密码保护已关闭。');
|
||||||
|
} catch {
|
||||||
|
setError('保存失败,请确认当前登录状态后重试。');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-page" aria-labelledby="password-protection-title">
|
||||||
|
<header className="page-heading">
|
||||||
|
<p className="eyebrow">SYSTEM SETTINGS</p>
|
||||||
|
<h1 id="password-protection-title">密码保护</h1>
|
||||||
|
<p>首次密码仅允许从运行主机本机设置;远程访问请先在本机完成初始化。</p>
|
||||||
|
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
||||||
|
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
||||||
|
</header>
|
||||||
|
{error && !status ? <p role="alert">{error}</p> : null}
|
||||||
|
{status ? (
|
||||||
|
<form className="settings-card auth-settings" onSubmit={(event) => void save(event)}>
|
||||||
|
<label className="toggle-row">
|
||||||
|
<span>
|
||||||
|
<strong>启用密码保护</strong>
|
||||||
|
<small>启用后,访问实例、短信和设置前都需要先登录。</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
setEnabled(event.target.checked);
|
||||||
|
setNotice('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{enabled && !status.configured ? (
|
||||||
|
<div className="auth-password-fields">
|
||||||
|
<label htmlFor="new-console-password">设置访问密码</label>
|
||||||
|
<input
|
||||||
|
id="new-console-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="confirm-console-password">确认访问密码</label>
|
||||||
|
<input
|
||||||
|
id="confirm-console-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(event) => setConfirmation(event.target.value)}
|
||||||
|
/>
|
||||||
|
<small>至少 8 位,同时包含字母和数字。密码仅保存为不可逆哈希。</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{status.configured ? <p>访问密码已配置,不会在页面或 API 中回显。</p> : null}
|
||||||
|
{error ? <p role="alert">{error}</p> : null}
|
||||||
|
{notice ? <p role="status">{notice}</p> : null}
|
||||||
|
<button type="submit" disabled={saving}>
|
||||||
|
{saving ? '正在保存…' : '保存密码保护设置'}
|
||||||
|
</button>
|
||||||
|
{status.protectionEnabled && status.authenticated ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void source.logout().then(() => window.location.reload());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
) : !error ? (
|
||||||
|
<p role="status">正在读取设置…</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ConsoleAuthGate, type ConsoleAuthDataSource } from './console-auth.js';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
configured: true,
|
||||||
|
protectionEnabled: true,
|
||||||
|
authenticated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('aggregate-console authentication UI', () => {
|
||||||
|
it('gates the entire console with the single-SimAdmin-style password form', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const login = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const dataSource: ConsoleAuthDataSource = {
|
||||||
|
status: vi.fn().mockResolvedValue(status),
|
||||||
|
login,
|
||||||
|
logout: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConsoleAuthGate dataSource={dataSource}>
|
||||||
|
<p>secret fleet</p>
|
||||||
|
</ConsoleAuthGate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '请输入访问密码' })).toBeTruthy();
|
||||||
|
expect(screen.queryByText('secret fleet')).toBeNull();
|
||||||
|
await user.type(screen.getByLabelText('访问密码'), 'StrongPass!9');
|
||||||
|
await user.click(screen.getByRole('button', { name: '进入管理台' }));
|
||||||
|
expect(login).toHaveBeenCalledWith('StrongPass!9');
|
||||||
|
expect(await screen.findByText('secret fleet')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a generic login failure without echoing credentials', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const dataSource: ConsoleAuthDataSource = {
|
||||||
|
status: vi.fn().mockResolvedValue(status),
|
||||||
|
login: vi.fn().mockRejectedValue(new Error('internal password hash mismatch')),
|
||||||
|
logout: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
render(<ConsoleAuthGate dataSource={dataSource}>fleet</ConsoleAuthGate>);
|
||||||
|
await screen.findByRole('heading', { name: '请输入访问密码' });
|
||||||
|
await user.type(screen.getByLabelText('访问密码'), 'WrongPass!9');
|
||||||
|
await user.click(screen.getByRole('button', { name: '进入管理台' }));
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toContain('密码错误,请重试');
|
||||||
|
expect(screen.getByRole('alert').textContent).not.toContain('WrongPass');
|
||||||
|
expect(screen.getByRole('alert').textContent).not.toContain('hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configures and toggles password protection under system settings', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const update = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ configured: true, protectionEnabled: true, authenticated: true })
|
||||||
|
.mockResolvedValueOnce({ configured: true, protectionEnabled: false, authenticated: true });
|
||||||
|
const dataSource: ConsoleAuthDataSource = {
|
||||||
|
status: vi.fn().mockResolvedValue({
|
||||||
|
configured: false,
|
||||||
|
protectionEnabled: false,
|
||||||
|
authenticated: true,
|
||||||
|
}),
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ConsoleAuthGate dataSource={dataSource}>fleet</ConsoleAuthGate>);
|
||||||
|
await screen.findByText('fleet');
|
||||||
|
const settings = await import('./console-auth-settings.js');
|
||||||
|
cleanup();
|
||||||
|
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
||||||
|
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
||||||
|
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||||
|
await user.click(toggle);
|
||||||
|
await user.type(screen.getByLabelText('设置访问密码'), 'StrongPass!9');
|
||||||
|
await user.type(screen.getByLabelText('确认访问密码'), 'StrongPass!9');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
|
||||||
|
expect(update).toHaveBeenNthCalledWith(1, { enabled: true, newPassword: 'StrongPass!9' });
|
||||||
|
expect(await screen.findByText('密码保护已启用。')).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(toggle);
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存密码保护设置' }));
|
||||||
|
expect(update).toHaveBeenNthCalledWith(2, { enabled: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export interface ConsoleAuthStatus {
|
||||||
|
readonly configured: boolean;
|
||||||
|
readonly protectionEnabled: boolean;
|
||||||
|
readonly authenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleAuthDataSource {
|
||||||
|
status(): Promise<ConsoleAuthStatus>;
|
||||||
|
login(password: string): Promise<void>;
|
||||||
|
logout(): Promise<void>;
|
||||||
|
update(input: {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly newPassword?: string;
|
||||||
|
}): Promise<ConsoleAuthStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseProblem(response: Response): Promise<Error> {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { detail?: unknown };
|
||||||
|
if (typeof body.detail === 'string') return new Error(body.detail);
|
||||||
|
} catch {
|
||||||
|
// Keep the browser boundary deliberately generic.
|
||||||
|
}
|
||||||
|
return new Error(`Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConsoleAuthApiDataSource(
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): ConsoleAuthDataSource {
|
||||||
|
const request = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||||
|
const response = await fetcher(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...(init?.method ? { method: init.method } : {}),
|
||||||
|
...(init?.body ? { body: init.body, headers: { 'content-type': 'application/json' } } : {}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await responseProblem(response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
async status() {
|
||||||
|
const response = await request('/api/v1/auth/status');
|
||||||
|
return (await response.json()) as ConsoleAuthStatus;
|
||||||
|
},
|
||||||
|
async login(password) {
|
||||||
|
await request('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
await request('/api/v1/auth/logout', { method: 'POST' });
|
||||||
|
},
|
||||||
|
async update(input) {
|
||||||
|
const response = await request('/api/v1/auth/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return (await response.json()) as ConsoleAuthStatus;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleAuthGateProps {
|
||||||
|
readonly children: ReactNode;
|
||||||
|
readonly dataSource?: ConsoleAuthDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsoleAuthGate({ children, dataSource }: ConsoleAuthGateProps) {
|
||||||
|
const defaultDataSource = useMemo(() => createConsoleAuthApiDataSource(), []);
|
||||||
|
const source = dataSource ?? defaultDataSource;
|
||||||
|
const [status, setStatus] = useState<ConsoleAuthStatus>();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loginFailed, setLoginFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void source.status().then(
|
||||||
|
(next) => {
|
||||||
|
if (active) setStatus(next);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (active) setFailed(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!password || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setLoginFailed(false);
|
||||||
|
try {
|
||||||
|
await source.login(password);
|
||||||
|
setStatus({ configured: true, protectionEnabled: true, authenticated: true });
|
||||||
|
setPassword('');
|
||||||
|
} catch {
|
||||||
|
setLoginFailed(true);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (failed)
|
||||||
|
return (
|
||||||
|
<main className="auth-screen">
|
||||||
|
<section className="auth-card" role="alert">
|
||||||
|
无法检查管理台访问状态,请稍后刷新重试。
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
if (!status)
|
||||||
|
return (
|
||||||
|
<main className="auth-screen">
|
||||||
|
<p role="status">正在检查访问权限…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
if (status.protectionEnabled && !status.authenticated)
|
||||||
|
return (
|
||||||
|
<main className="auth-screen">
|
||||||
|
<section className="auth-card" aria-labelledby="console-login-title">
|
||||||
|
<p className="eyebrow">MULTI SIMADMIN</p>
|
||||||
|
<h1 id="console-login-title">请输入访问密码</h1>
|
||||||
|
<p>密码保护已启用。验证通过后才能进入聚合工作台。</p>
|
||||||
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
|
<label htmlFor="console-password">访问密码</label>
|
||||||
|
<input
|
||||||
|
id="console-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{loginFailed ? <p role="alert">密码错误,请重试。</p> : null}
|
||||||
|
<button type="submit" disabled={!password || submitting}>
|
||||||
|
{submitting ? '正在验证…' : '进入管理台'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { ConsoleAuthGate } from './auth/console-auth.js';
|
||||||
import { AppShell } from './app-shell.js';
|
import { AppShell } from './app-shell.js';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ if (!root) throw new Error('Missing application root');
|
|||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppShell pathname={window.location.pathname} />
|
<ConsoleAuthGate>
|
||||||
|
<AppShell pathname={window.location.pathname} />
|
||||||
|
</ConsoleAuthGate>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1030,6 +1030,54 @@ main ul[aria-label='已配置实例'] {
|
|||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 10%, rgba(242, 169, 59, 0.18), transparent 34%), var(--background);
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: min(100%, 27rem);
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.auth-card form,
|
||||||
|
.auth-password-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
.auth-card input,
|
||||||
|
.auth-settings input[type='password'] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.auth-settings {
|
||||||
|
max-width: 45rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.toggle-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
.toggle-row input[type='checkbox'] {
|
||||||
|
width: 2.875rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
Reference in New Issue
Block a user