diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 1452a56..c25e26d 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -223,6 +223,7 @@ describe('Fastify API skeleton', () => { 'req.headers.cookie', 'req.headers.x-confirmation-token', 'req.body.password', + 'req.body.newPassword', 'res.headers.set-cookie', ]), ); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ab5b714..eec55cc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -18,6 +18,7 @@ export const REDACT_PATHS = Object.freeze([ 'req.headers.cookie', 'req.headers.x-confirmation-token', 'req.body.password', + 'req.body.newPassword', 'req.body.confirmationToken', 'res.headers.set-cookie', ]); diff --git a/apps/api/src/application/auth/console-auth-service.ts b/apps/api/src/application/auth/console-auth-service.ts new file mode 100644 index 0000000..e0043e6 --- /dev/null +++ b/apps/api/src/application/auth/console-auth-service.ts @@ -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 { + 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 { + 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()); + } +} diff --git a/apps/api/src/canary-gateway.ts b/apps/api/src/canary-gateway.ts index 42085f7..6a31df9 100644 --- a/apps/api/src/canary-gateway.ts +++ b/apps/api/src/canary-gateway.ts @@ -10,7 +10,7 @@ import { } from 'node:http'; import { extname, isAbsolute, relative, resolve, sep } from 'node:path'; 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_PORT = 8789; @@ -98,12 +98,15 @@ function trustedUpstreamHeaders( gatewayToken: string | undefined, upstreamHost: string, upstreamPort: number, + clientIp: string | undefined, ): IncomingHttpHeaders { const sanitized = withoutHopByHop(headers); sanitized.host = `${upstreamHost}:${upstreamPort}`; if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`; delete sanitized[GATEWAY_AUTH_HEADER]; + delete sanitized[GATEWAY_CLIENT_IP_HEADER]; if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken; + if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp; return sanitized; } @@ -160,7 +163,13 @@ async function proxyRequest( port: upstreamPort, method: incoming.method, path: incoming.url, - headers: trustedUpstreamHeaders(incoming.headers, gatewayToken, upstreamHost, upstreamPort), + headers: trustedUpstreamHeaders( + incoming.headers, + gatewayToken, + upstreamHost, + upstreamPort, + incoming.socket.remoteAddress, + ), }, (upstreamResponse) => { response.writeHead( diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index e12daf1..9aaf58d 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -29,6 +29,8 @@ import { registerJobRoutes } from './interface/http/job-routes.js'; import { AuditQueryService } from './application/audit/audit-query-service.js'; import { registerAuditRoutes } from './interface/http/audit-routes.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'; export interface SafeControlPlaneUpstream extends ConnectionTransport { @@ -93,9 +95,14 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane ...(options.now ? { now: options.now } : {}), }); secureExecution.reconcileInterruptedJobs(); + const auth = new ConsoleAuthService({ + db: options.db, + ...(options.now ? { now: options.now } : {}), + }); const app = buildApp({ ...options.app, registerRoutes: (app) => { + registerConsoleAuth(app, auth); registerInstanceRoutes(app, { instances, connections, diff --git a/apps/api/src/infrastructure/database/database.test.ts b/apps/api/src/infrastructure/database/database.test.ts index 1f86d65..e81b614 100644 --- a/apps/api/src/infrastructure/database/database.test.ts +++ b/apps/api/src/infrastructure/database/database.test.ts @@ -62,6 +62,8 @@ describe('database migrations', () => { 'app_settings', 'audit_events', 'capabilities', + 'console_auth_config', + 'console_auth_sessions', 'event_journal', 'instance_tags', 'instances', @@ -118,6 +120,7 @@ describe('database migrations', () => { expect(indexes).toEqual( expect.arrayContaining([ 'idx_audit_events_created_at', + 'idx_console_auth_sessions_expires_at', 'idx_event_journal_sequence', 'idx_jobs_status_created_at', 'idx_status_snapshots_instance_observed_at', diff --git a/apps/api/src/infrastructure/database/migrations.ts b/apps/api/src/infrastructure/database/migrations.ts index f82efa6..aa04e93 100644 --- a/apps/api/src/infrastructure/database/migrations.ts +++ b/apps/api/src/infrastructure/database/migrations.ts @@ -282,6 +282,28 @@ export const MIGRATIONS: readonly Migration[] = [ '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 ( diff --git a/apps/api/src/interface/http/console-auth-routes.test.ts b/apps/api/src/interface/http/console-auth-routes.test.ts new file mode 100644 index 0000000..0878117 --- /dev/null +++ b/apps/api/src/interface/http/console-auth-routes.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/interface/http/console-auth-routes.ts b/apps/api/src/interface/http/console-auth-routes.ts new file mode 100644 index 0000000..a7305f4 --- /dev/null +++ b/apps/api/src/interface/http/console-auth-routes.ts @@ -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(); + 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); + } + }); +} diff --git a/apps/api/src/runtime-config.ts b/apps/api/src/runtime-config.ts index 7885141..5ceb9f7 100644 --- a/apps/api/src/runtime-config.ts +++ b/apps/api/src/runtime-config.ts @@ -2,6 +2,7 @@ import { realpathSync } from 'node:fs'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; 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 { readonly MULTI_SIMADMIN_DATA_ROOT?: string; readonly MULTI_SIMADMIN_DATABASE_PATH?: string; diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 5371de1..9719971 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -1,3 +1,4 @@ +import { ConsoleAuthSettings } from './auth/console-auth-settings.js'; import { AuditPage, type AuditDataSource } from './audit/audit-page.js'; import { createAuditApiDataSource } from './audit/audit-api-data-source.js'; import type { ReactNode } from 'react'; @@ -250,13 +251,7 @@ function Page({ refreshSignal={fleetRefreshSignal} /> ); - if (route.kind === 'settings-system') - return ( -
-

系统设置

-

控制平面尚未提供系统设置契约,因此此功能暂不可用。

-
- ); + if (route.kind === 'settings-system') return ; if (route.kind === 'not-found') return (
@@ -500,7 +495,7 @@ export function AppShell({ {( [ ['fleet', '/fleet', '实例总览'], - ['settings', '/settings/instances', '设置'], + ['settings', '/settings/system', '设置'], ] as const ).map(([key, href, label]) => (
  • diff --git a/apps/web/src/auth/console-auth-settings.tsx b/apps/web/src/auth/console-auth-settings.tsx new file mode 100644 index 0000000..cc222e8 --- /dev/null +++ b/apps/web/src/auth/console-auth-settings.tsx @@ -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(); + 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 ( +
    +
    +

    SYSTEM SETTINGS

    +

    密码保护

    +

    首次密码仅允许从运行主机本机设置;远程访问请先在本机完成初始化。

    +

    当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。

    +

    参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。

    +
    + {error && !status ?

    {error}

    : null} + {status ? ( +
    void save(event)}> + + {enabled && !status.configured ? ( +
    + + setPassword(event.target.value)} + /> + + setConfirmation(event.target.value)} + /> + 至少 8 位,同时包含字母和数字。密码仅保存为不可逆哈希。 +
    + ) : null} + {status.configured ?

    访问密码已配置,不会在页面或 API 中回显。

    : null} + {error ?

    {error}

    : null} + {notice ?

    {notice}

    : null} + + {status.protectionEnabled && status.authenticated ? ( + + ) : null} +
    + ) : !error ? ( +

    正在读取设置…

    + ) : null} +
    + ); +} diff --git a/apps/web/src/auth/console-auth.test.tsx b/apps/web/src/auth/console-auth.test.tsx new file mode 100644 index 0000000..e443817 --- /dev/null +++ b/apps/web/src/auth/console-auth.test.tsx @@ -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( + +

    secret fleet

    +
    , + ); + + 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(fleet); + 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(fleet); + await screen.findByText('fleet'); + const settings = await import('./console-auth-settings.js'); + cleanup(); + render(); + + 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 }); + }); +}); diff --git a/apps/web/src/auth/console-auth.tsx b/apps/web/src/auth/console-auth.tsx new file mode 100644 index 0000000..508e058 --- /dev/null +++ b/apps/web/src/auth/console-auth.tsx @@ -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; + login(password: string): Promise; + logout(): Promise; + update(input: { + readonly enabled: boolean; + readonly newPassword?: string; + }): Promise; +} + +async function responseProblem(response: Response): Promise { + 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 => { + 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(); + 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 ( +
    +
    + 无法检查管理台访问状态,请稍后刷新重试。 +
    +
    + ); + if (!status) + return ( +
    +

    正在检查访问权限…

    +
    + ); + if (status.protectionEnabled && !status.authenticated) + return ( +
    +
    +

    MULTI SIMADMIN

    +

    请输入访问密码

    +

    密码保护已启用。验证通过后才能进入聚合工作台。

    +
    void submit(event)}> + + setPassword(event.target.value)} + autoFocus + /> + {loginFailed ?

    密码错误,请重试。

    : null} + +
    +
    +
    + ); + return children; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 07364e1..ac6b3f1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { ConsoleAuthGate } from './auth/console-auth.js'; import { AppShell } from './app-shell.js'; import './styles.css'; @@ -9,6 +10,8 @@ if (!root) throw new Error('Missing application root'); createRoot(root).render( - + + + , ); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8e7d36f..0e3d7d8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1030,6 +1030,54 @@ main ul[aria-label='已配置实例'] { 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) { *, *::before,