mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Single-user mode has no per-username bucket, so its per-IP bucket is the only limit on guesses against the one password, and behind a tunnel every client shares 127.0.0.1. Put it back on AUTH_FAILURE_MAX (10). AUTH_IP_FAILURE_MAX (50) now gates only the multi-user per-IP pre-gate, which runs before the scrypt verify and so refuses even a correct password; the per-username bucket still caps each account at 10. Move the headroom test out of the single-user suite and add a multi-user test that sprays wrong passwords across usernames (9 each) and expects 401 for the first 50, then 429 for the 51st and for a correct password. Update the docs that still described 10 per IP for every mode, and resync the plugin mirror of the codeman skill.
232 lines
9.8 KiB
TypeScript
232 lines
9.8 KiB
TypeScript
/**
|
|
* @fileoverview Phase 2 multi-user auth integration tests (live server, ephemeral port).
|
|
*
|
|
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
|
|
* identity, wrong-password / disabled-user rejection, the mustChangePassword
|
|
* lockbox + self-service change, per-account rate limiting, and QR identity binding
|
|
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
|
|
*
|
|
* Ports: ephemeral (`new WebServer(0, …)`, read back through `boundPort`).
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { TmuxManager } from '../src/tmux-manager.js';
|
|
import { TunnelManager } from '../src/tunnel-manager.js';
|
|
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
|
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
|
|
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
|
|
|
function basic(user: string, pass: string): string {
|
|
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
|
}
|
|
|
|
function cookieFrom(res: Response): string | null {
|
|
const raw = res.headers.get('set-cookie');
|
|
const m = raw?.match(/codeman_session=([^;]+)/);
|
|
return m ? `codeman_session=${m[1]}` : null;
|
|
}
|
|
|
|
let server: WebServer;
|
|
let rateServer: WebServer;
|
|
let sprayServer: WebServer;
|
|
let dataDir: string;
|
|
let spacesDir: string;
|
|
const saved: Record<string, string | undefined> = {};
|
|
|
|
beforeAll(async () => {
|
|
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-data-'));
|
|
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-spaces-'));
|
|
for (const k of [
|
|
'CODEMAN_DATA_DIR',
|
|
'CODEMAN_USER_SPACES_DIR',
|
|
'CODEMAN_MULTIUSER',
|
|
'CODEMAN_PASSWORD',
|
|
'CODEMAN_USERNAME',
|
|
]) {
|
|
saved[k] = process.env[k];
|
|
}
|
|
process.env.CODEMAN_DATA_DIR = dataDir;
|
|
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
delete process.env.CODEMAN_USERNAME;
|
|
invalidateUsersCache();
|
|
|
|
await createUser({ username: 'alice', role: 'admin', password: 'alicepass1' });
|
|
await createUser({ username: 'bob', role: 'user', password: 'bobpass123' });
|
|
await createUser({ username: 'carol', role: 'user', password: 'carolpass1' });
|
|
await createUser({ username: 'carol', role: 'user', password: 'x' }).catch(() => {}); // no-op dup guard
|
|
await createUser({ username: 'dave', role: 'user', password: 'davepass12', mustChangePassword: true });
|
|
// Disable carol after creation.
|
|
const { updateUser } = await import('../src/user-store.js');
|
|
await updateUser('carol', { disabled: true });
|
|
|
|
server = new WebServer(0, false, true);
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.stop();
|
|
await rateServer?.stop().catch(() => {});
|
|
await sprayServer?.stop().catch(() => {});
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
invalidateUsersCache();
|
|
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
|
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
|
|
|
describe('multi-user auth', () => {
|
|
it('rejects unauthenticated requests', async () => {
|
|
const res = await fetch(url('/api/status'));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('authenticates a valid user and issues an identity cookie', async () => {
|
|
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
|
|
expect(res.status).toBe(200);
|
|
const cookie = cookieFrom(res);
|
|
expect(cookie).toBeTruthy();
|
|
|
|
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
|
expect(me.status).toBe(200);
|
|
const body = await me.json();
|
|
expect(body.data).toMatchObject({ username: 'alice', role: 'admin', mustChangePassword: false });
|
|
});
|
|
|
|
it('reports role for a regular user', async () => {
|
|
const res = await fetch(url('/api/me'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
|
expect(res.status).toBe(200);
|
|
expect((await res.json()).data).toMatchObject({ username: 'bob', role: 'user' });
|
|
});
|
|
|
|
it('rejects a wrong password', async () => {
|
|
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('bob', 'wrongwrong') } });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects a disabled user even with the correct password', async () => {
|
|
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('carol', 'carolpass1') } });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('is case-insensitive on the username', async () => {
|
|
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('ALICE', 'alicepass1') } });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('enforces the mustChangePassword lockbox and clears it on self-service change', async () => {
|
|
// Basic auth as dave succeeds (cookie issued) but non-exempt routes 403.
|
|
const authed = await fetch(url('/api/status'), { headers: { Authorization: basic('dave', 'davepass12') } });
|
|
expect(authed.status).toBe(403);
|
|
const body = await authed.json();
|
|
expect(body.errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
|
const cookie = cookieFrom(authed);
|
|
expect(cookie).toBeTruthy();
|
|
|
|
// /api/me is exempt.
|
|
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
|
expect(me.status).toBe(200);
|
|
expect((await me.json()).data.mustChangePassword).toBe(true);
|
|
|
|
// Wrong current password is refused.
|
|
const bad = await fetch(url('/api/me/password'), {
|
|
method: 'POST',
|
|
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ currentPassword: 'nope', newPassword: 'brandnew123' }),
|
|
});
|
|
expect(bad.status).toBe(403);
|
|
|
|
// Correct change clears the flag.
|
|
const ok = await fetch(url('/api/me/password'), {
|
|
method: 'POST',
|
|
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ currentPassword: 'davepass12', newPassword: 'brandnew123' }),
|
|
});
|
|
expect(ok.status).toBe(200);
|
|
|
|
// Same cookie now reaches a non-exempt route.
|
|
const after = await fetch(url('/api/status'), { headers: { Cookie: cookie! } });
|
|
expect(after.status).toBe(200);
|
|
});
|
|
|
|
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
|
|
rateServer = new WebServer(0, false, true);
|
|
await rateServer.start();
|
|
const rurl = (p: string) => `http://localhost:${rateServer.boundPort}${p}`;
|
|
|
|
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
|
|
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
|
|
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
|
|
expect(res.status).toBe(401);
|
|
}
|
|
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
|
|
// throttle) — the accumulated failures can never lock the account out — and success
|
|
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
|
|
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
|
expect(good.status).toBe(200);
|
|
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
|
|
const afterReset = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'nope') } });
|
|
expect(afterReset.status).toBe(401);
|
|
|
|
// Sustained wrong passwords ARE still throttled: 429 once the cap is reached.
|
|
let limited = false;
|
|
for (let i = 0; i < AUTH_FAILURE_MAX + 1 && !limited; i++) {
|
|
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `x-${i}`) } });
|
|
limited = res.status === 429;
|
|
}
|
|
expect(limited).toBe(true);
|
|
});
|
|
|
|
it('per-IP pre-gate: wrong passwords spread across accounts are throttled at 50, before scrypt', async () => {
|
|
// Fresh server so no earlier test's failures sit in the per-IP bucket.
|
|
sprayServer = new WebServer(0, false, true);
|
|
await sprayServer.start();
|
|
const surl = (p: string) => `http://localhost:${sprayServer.boundPort}${p}`;
|
|
|
|
// Spread the guesses so no single username reaches its own AUTH_FAILURE_MAX bucket:
|
|
// only the per-IP pre-gate can produce a 429 here.
|
|
const perUser = AUTH_FAILURE_MAX - 1;
|
|
for (let i = 0; i < 50; i++) {
|
|
const user = `spray${Math.floor(i / perUser)}`;
|
|
const res = await fetch(surl('/api/status'), { headers: { Authorization: basic(user, `bad-${i}`) } });
|
|
expect(res.status, `attempt ${i + 1}`).toBe(401);
|
|
}
|
|
|
|
// The 51st failure from the same IP is refused by the pre-gate.
|
|
const over = await fetch(surl('/api/status'), { headers: { Authorization: basic('spray-extra', 'bad') } });
|
|
expect(over.status).toBe(429);
|
|
expect(over.headers.get('retry-after')).toMatch(/^\d+$/);
|
|
|
|
// The pre-gate runs before the password check, so even a correct password is refused.
|
|
const good = await fetch(surl('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
|
|
expect(good.status).toBe(429);
|
|
});
|
|
});
|
|
|
|
describe('QR token identity (tunnel-manager)', () => {
|
|
it('binds a minted token to a user and returns it on consume (single-use)', () => {
|
|
const tm = new TunnelManager();
|
|
const code = tm.mintUserToken('alice');
|
|
expect(code).toHaveLength(6);
|
|
const first = tm.consumeTokenWithIdentity(code);
|
|
expect(first).toEqual({ ok: true, username: 'alice' });
|
|
// single-use
|
|
expect(tm.consumeTokenWithIdentity(code)).toEqual({ ok: false });
|
|
});
|
|
|
|
it('unknown code is rejected', () => {
|
|
const tm = new TunnelManager();
|
|
expect(tm.consumeTokenWithIdentity('ZZZZZZ')).toEqual({ ok: false });
|
|
});
|
|
});
|