feat(auth): protect aggregate console with password login
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user