mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Adds a parallel multi-user auth branch (the single-user Basic-auth path is left byte-identical). Off unless CODEMAN_MULTIUSER/--multiuser. - middleware/auth.ts: mode-selecting registerAuthMiddleware. New async multi-user hook verifies username:password against the user store (scrypt), mints identity-carrying cookies, decorates req.authUser, enforces a per-IP AND per-username failure bucket, and the mustChangePassword lockbox. The hook-secret loopback bypass is now a single shared helper used by both branches. FastifyRequest.authUser module augmentation. - ports/auth-port.ts: AuthSessionRecord gains username/role/mustChangePassword. - user-store.ts: verifyPassword (timing-equalized against user enumeration). - route-helpers.ts: getAuthUser (synthetic admin fallback), canAccessOwned, requireAdmin, revokeUserSessions; findSessionOrFail gains an optional req for a NOT_FOUND owner check (dormant until phase 3 wires callers). - routes/me-routes.ts: GET /api/me (synthetic admin in single-user) and POST /api/me/password (verify current, min 8, clear mustChangePassword, revoke other sessions). - QR: QrTokenRecord + AuthSessionRecord carry a username; tunnel-manager mintUserToken / consumeTokenWithIdentity / getQrSvgForCode; /q/:code binds the cookie to the token's user (rejects identity-less tokens in multi-user); GET /api/tunnel/qr mints a per-user token. Single-user keeps the rotating token. - server.ts: bootstrap the initial admin from CODEMAN_USERNAME/PASSWORD on first boot (refuse to start with no users); multi-user with >= 1 user satisfies the non-loopback auth requirement and the tunnel-enable guard; userFailures bucket disposal. - types/api.ts: FORBIDDEN, PASSWORD_CHANGE_REQUIRED, USER_EXISTS, USER_NOT_FOUND, LAST_ADMIN error codes (message + status wired). - Session.owner field + getter/setter, SessionState.owner, MuxSession.owner, CreateSessionOptions.owner (foundation for phase 3 ownership threading). Tests: test/multiuser-auth.test.ts (10, live server on 3170/3171). Existing auth suite (auth-security, qr-auth, cod54-hook-event, network-auth-policy) unchanged and green; full test:ci sweep passes (3519 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
/**
|
|
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
|
|
*
|
|
* 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: 3170 (multi-user server), 3171 (rate-limit server).
|
|
*/
|
|
|
|
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';
|
|
|
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
|
|
|
const PORT = 3170;
|
|
const RATE_PORT = 3171;
|
|
|
|
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 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(PORT, false, true);
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.stop();
|
|
await rateServer?.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:${PORT}${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('rate-limits repeated failures for an account', async () => {
|
|
rateServer = new WebServer(RATE_PORT, false, true);
|
|
await rateServer.start();
|
|
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
|
|
for (let i = 0; i < 10; i++) {
|
|
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
|
|
expect(res.status).toBe(401);
|
|
}
|
|
// 11th attempt (even with correct creds) is rate-limited.
|
|
const limited = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
|
expect(limited.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 });
|
|
});
|
|
});
|