feat(auth): protect aggregate console with password login

This commit is contained in:
chick
2026-07-19 17:13:43 +08:00
parent 75682b8134
commit f2803896a8
16 changed files with 1165 additions and 11 deletions
+1
View File
@@ -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',
]),
);
+1
View File
@@ -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',
]);
@@ -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());
}
}
+11 -2
View File
@@ -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(
+7
View File
@@ -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,
@@ -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',
@@ -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 (
@@ -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);
}
});
}
+1
View File
@@ -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;