mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
feat(multiuser): phase 2, multi-user auth pipeline
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>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @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 });
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -375,9 +375,17 @@ describe('types utility functions', () => {
|
||||
expect(ApiErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('should have 9 error codes', () => {
|
||||
it('should have 14 error codes', () => {
|
||||
const codes = Object.values(ApiErrorCode);
|
||||
expect(codes).toHaveLength(9);
|
||||
expect(codes).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('includes the multi-user error codes', () => {
|
||||
expect(ApiErrorCode.FORBIDDEN).toBe('FORBIDDEN');
|
||||
expect(ApiErrorCode.PASSWORD_CHANGE_REQUIRED).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
expect(ApiErrorCode.USER_EXISTS).toBe('USER_EXISTS');
|
||||
expect(ApiErrorCode.USER_NOT_FOUND).toBe('USER_NOT_FOUND');
|
||||
expect(ApiErrorCode.LAST_ADMIN).toBe('LAST_ADMIN');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user