From 4d8857f72a08f971ecf17693992addbd5c2ee661 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 20 Jul 2026 03:26:21 +0200 Subject: [PATCH] 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 --- src/mux-interface.ts | 4 + src/session.ts | 18 ++ src/tunnel-manager.ts | 59 ++++++- src/types/api.ts | 20 +++ src/types/session.ts | 2 + src/user-store.ts | 28 +++ src/web/middleware/auth.ts | 291 +++++++++++++++++++++++++------- src/web/ports/auth-port.ts | 10 ++ src/web/route-helpers.ts | 67 +++++++- src/web/routes/index.ts | 1 + src/web/routes/me-routes.ts | 78 +++++++++ src/web/routes/system-routes.ts | 43 ++++- src/web/server.ts | 33 +++- test/multiuser-auth.test.ts | 191 +++++++++++++++++++++ test/types.test.ts | 12 +- 15 files changed, 780 insertions(+), 77 deletions(-) create mode 100644 src/web/routes/me-routes.ts create mode 100644 test/multiuser-auth.test.ts diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 36c483d2..c8de3dc0 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -39,6 +39,8 @@ export interface MuxSession { remote?: SessionRemote; /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ docker?: SessionDocker; + /** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */ + owner?: string; /** Session mode */ mode: SessionMode; /** Whether webserver is attached to this session */ @@ -84,6 +86,8 @@ export interface CreateSessionOptions { remote?: SessionRemote; /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ docker?: SessionDocker; + /** Owning username in multi-user mode; persisted for recovery. */ + owner?: string; } /** Options for respawning a dead pane. */ diff --git a/src/session.ts b/src/session.ts index dd589f8d..ef762781 100644 --- a/src/session.ts +++ b/src/session.ts @@ -412,6 +412,10 @@ export class Session extends EventEmitter { // local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions). private readonly _docker?: SessionDocker; + // Owning username in multi-user mode (undefined in single-user). Stamped at create + // from req.authUser and round-tripped through recovery like _remote/_docker. + private _owner?: string; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -487,6 +491,8 @@ export class Session extends EventEmitter { remote?: SessionRemote; /** Docker execution metadata for sessions launched inside a container via local tmux. */ docker?: SessionDocker; + /** Owning username (multi-user mode); undefined in single-user. */ + owner?: string; } ) { super(); @@ -561,6 +567,7 @@ export class Session extends EventEmitter { this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; this._docker = config.docker; + this._owner = config.owner; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } @@ -667,6 +674,16 @@ export class Session extends EventEmitter { return this._docker; } + /** Owning username in multi-user mode, else undefined. */ + get owner(): string | undefined { + return this._owner; + } + + /** Set the owning username (used by recovery to restore ownership). */ + set owner(username: string | undefined) { + this._owner = username; + } + // Adopt a Claude conversation ID observed from an external source (e.g. hook // payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so // `_handleJsonMessage` never sees `session_id`; hooks are the only signal @@ -1027,6 +1044,7 @@ export class Session extends EventEmitter { workingDir: this.workingDir, remote: this._remote, docker: this._docker, + owner: this._owner, currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index 083b11d2..9e10764e 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -43,6 +43,8 @@ interface QrTokenRecord { shortCode: string; // 6 chars base62 (for URL path) createdAt: number; // Date.now() consumed: boolean; // single-use flag + /** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */ + username?: string; } /** Rejection-sampled base62 short code — no modulo bias */ @@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter { * Map.get() is hash-based — no timing side-channel from string comparison. */ consumeToken(shortCode: string): boolean { + return this.consumeTokenWithIdentity(shortCode).ok; + } + + /** + * Like consumeToken, but also returns the bound username for multi-user tokens + * (undefined for the rotating global token). Only the identity-less rotating + * token triggers an immediate re-rotation (desktop gets a fresh QR); per-user + * tokens are on-demand and self-expire. + */ + consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } { // Global rate limit (across all IPs) - if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false; + if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false }; this.qrAttemptCount++; const record = this.qrTokensByCode.get(shortCode); - if (!record) return false; - if (record.consumed) return false; + if (!record) return { ok: false }; + if (record.consumed) return { ok: false }; const now = Date.now(); - if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false; + if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false }; // Atomic consume (single-threaded JS = no race) record.consumed = true; - // Immediately rotate so desktop gets a fresh QR - this.rotateToken(); - this.emit('qrTokenRegenerated'); - return true; + const username = record.username; + if (!username) { + // Rotating global token — immediately rotate so desktop gets a fresh QR. + this.rotateToken(); + this.emit('qrTokenRegenerated'); + } else { + this.qrTokensByCode.delete(shortCode); + } + return { ok: true, username }; + } + + /** + * Multi-user: mint a single-use token bound to a specific user (on-demand, no + * rotation). Evicts expired/consumed tokens first. Returns the short code. + */ + mintUserToken(username: string): string { + const now = Date.now(); + for (const [code, rec] of this.qrTokensByCode) { + if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code); + } + const record: QrTokenRecord = { + token: randomBytes(32).toString('hex'), + shortCode: generateShortCode(), + createdAt: Date.now(), + consumed: false, + username, + }; + this.qrTokensByCode.set(record.shortCode, record); + return record.shortCode; + } + + /** Render a QR SVG for an arbitrary short code (used by per-user minting). */ + async getQrSvgForCode(tunnelUrl: string, code: string): Promise { + const QRCode = await import('qrcode'); + return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 }); } /** Force-regenerate (manual revocation via API) */ diff --git a/src/types/api.ts b/src/types/api.ts index d5a4578a..4795caa7 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -37,6 +37,16 @@ export enum ApiErrorCode { RATE_LIMITED = 'RATE_LIMITED', /** Operation could not be completed (well-formed but unprocessable) */ OPERATION_FAILED = 'OPERATION_FAILED', + /** Authenticated but not permitted (e.g. non-admin hitting an admin route) */ + FORBIDDEN = 'FORBIDDEN', + /** User must change their password before any other action (multi-user) */ + PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED', + /** A user with this name already exists (multi-user) */ + USER_EXISTS = 'USER_EXISTS', + /** No user with this name (multi-user) */ + USER_NOT_FOUND = 'USER_NOT_FOUND', + /** Refusing to demote/disable/delete the last enabled admin (multi-user) */ + LAST_ADMIN = 'LAST_ADMIN', /** Internal server error */ INTERNAL_ERROR = 'INTERNAL_ERROR', } @@ -53,6 +63,11 @@ const ErrorMessages: Record = { [ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists', [ApiErrorCode.RATE_LIMITED]: 'Too many requests', [ApiErrorCode.OPERATION_FAILED]: 'The operation failed', + [ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action', + [ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing', + [ApiErrorCode.USER_EXISTS]: 'A user with that name already exists', + [ApiErrorCode.USER_NOT_FOUND]: 'No such user', + [ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin', [ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred', }; @@ -69,6 +84,11 @@ const ErrorStatus: Record = { [ApiErrorCode.CONFLICT]: 409, [ApiErrorCode.ALREADY_EXISTS]: 409, [ApiErrorCode.OPERATION_FAILED]: 422, + [ApiErrorCode.FORBIDDEN]: 403, + [ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403, + [ApiErrorCode.USER_EXISTS]: 409, + [ApiErrorCode.USER_NOT_FOUND]: 404, + [ApiErrorCode.LAST_ADMIN]: 409, [ApiErrorCode.RATE_LIMITED]: 429, [ApiErrorCode.INTERNAL_ERROR]: 500, }; diff --git a/src/types/session.ts b/src/types/session.ts index ad534587..3c943ea3 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -336,6 +336,8 @@ export interface SessionState { remote?: SessionRemote; /** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */ docker?: SessionDocker; + /** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */ + owner?: string; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/src/user-store.ts b/src/user-store.ts index 444d8de9..cca566e6 100644 --- a/src/user-store.ts +++ b/src/user-store.ts @@ -202,6 +202,34 @@ export async function hasUsers(): Promise { return (await readUsers()).length > 0; } +// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work +// as a real verify (defeats username-enumeration by timing). Created once, lazily. +let dummyHashPromise: Promise | null = null; +function getDummyHash(): Promise { + if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder'); + return dummyHashPromise; +} + +/** + * Verify a username/password against the store. Returns the record (plus whether it + * should be rehashed) on success, or null for wrong password / unknown / disabled + * user. Runs a dummy scrypt on the miss path so timing does not reveal which users + * exist. Never writes (the caller decides when to persist lastLogin / rehash). + */ +export async function verifyPassword( + username: string, + password: string +): Promise<{ user: UserRecord; needsRehash: boolean } | null> { + const user = await findUser(username); + if (!user || user.disabled) { + await verifyPasswordHash(password, await getDummyHash()); + return null; + } + const ok = await verifyPasswordHash(password, user.password); + if (!ok) return null; + return { user, needsRehash: needsRehash(user.password) }; +} + export async function findUser(username: string): Promise { const norm = normalizeUsername(username); if (!norm) return undefined; diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 5387a280..061f0745 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -8,7 +8,7 @@ * - CORS (localhost only) */ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { StaleExpirationMap } from '../../utils/index.js'; import type { AuthSessionRecord } from '../ports/auth-port.js'; @@ -20,6 +20,17 @@ import { AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js'; import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { setPassword, touchLastLogin, verifyPassword } from '../../user-store.js'; +import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js'; + +// Request-scoped identity (multi-user). Single-user leaves it undefined and the +// ownership helpers default to a synthetic admin (see route-helpers). +declare module 'fastify' { + interface FastifyRequest { + authUser?: AuthUser; + } +} // Auth session cookie name export const AUTH_COOKIE_NAME = 'codeman_session'; @@ -30,6 +41,80 @@ interface AuthState { authFailures: StaleExpirationMap | null; qrAuthFailures: StaleExpirationMap | null; hookSecretFailures: StaleExpirationMap | null; + /** Per-username Basic-auth failure bucket (multi-user only). */ + userFailures: StaleExpirationMap | null; +} + +/** Rate-limit response for a client that exceeded the failure cap. */ +function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap, key: string): void { + const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS; + const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); + reply.header('Retry-After', String(retryAfterSeconds)); + reply.code(429).send('Too Many Requests — try again later'); +} + +/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */ +function parseBasicAuth(header?: string): { username: string; password: string } | null { + if (!header || !header.startsWith('Basic ')) return null; + try { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8'); + const idx = decoded.indexOf(':'); + if (idx < 0) return null; + return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) }; + } catch { + return null; + } +} + +/** + * The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the + * single-user and multi-user auth hooks so the security-critical logic has ONE + * source of truth. Returns: + * - 'bypass' : loopback + valid hook secret; the caller should allow the request + * - 'rejected' : a reply was already sent (wrong secret rate-limited / 401) + * - 'continue' : not a hook request (or non-loopback); fall through to normal auth + * + * COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass + * (a user's own loopback reverse proxy is indistinguishable from a real local hook). + */ +function checkHookSecretBypass( + req: FastifyRequest, + reply: FastifyReply, + hookSecretFailures: StaleExpirationMap +): 'bypass' | 'rejected' | 'continue' { + if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { + const ip = req.ip; + const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; + if (isLoopback) { + const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); + const expected = Buffer.from(getHookSecret()); + if (presented.length === expected.length && timingSafeEqual(presented, expected)) { + return 'bypass'; + } + const hookIp = req.ip; + const hookFailures = hookSecretFailures.get(hookIp) ?? 0; + if (hookFailures >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, hookSecretFailures, hookIp); + return 'rejected'; + } + hookSecretFailures.set(hookIp, hookFailures + 1); + reply.code(401).send('Unauthorized: hook secret required'); + return 'rejected'; + } + // Non-localhost hook requests fall through to normal auth + } + return 'continue'; +} + +/** + * Requests that a `mustChangePassword` user may still reach: the identity probe, + * the password-change endpoint, and any non-API path (static assets / index.html, + * so the browser can load the app and render the change-password modal). + */ +function isPasswordChangeExempt(req: FastifyRequest): boolean { + const url = (req.url ?? '').split('?')[0]; + if (url === '/api/me' || url === '/api/me/password') return true; + return !url.startsWith('/api/'); } /** @@ -47,13 +132,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au authFailures: null, qrAuthFailures: null, hookSecretFailures: null, + userFailures: null, }; - const authPassword = process.env.CODEMAN_PASSWORD; - if (!authPassword) return state; + // Always declare req.authUser so downstream reads are safe (single-user leaves it + // undefined; the ownership helpers then default to a synthetic admin). + if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined); - const authUsername = process.env.CODEMAN_USERNAME || 'admin'; - const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + const multiUser = isMultiUserMode(); + const authPassword = process.env.CODEMAN_PASSWORD; + + // No auth at all: single-user with no password (byte-identical to legacy). In + // multi-user mode auth is ALWAYS active (users authenticate individually), even + // without CODEMAN_PASSWORD. + if (!multiUser && !authPassword) return state; // Session token store — active sessions extend TTL on access state.authSessions = new StaleExpirationMap({ @@ -87,57 +179,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au const authFailures = state.authFailures; const hookSecretFailures = state.hookSecretFailures; - function sendAuthRateLimit( - reply: FastifyReply, - clientIp: string, - failures: StaleExpirationMap = authFailures - ): void { - const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS; - const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); - reply.header('Retry-After', String(retryAfterSeconds)); - reply.code(429).send('Too Many Requests — try again later'); + if (multiUser) { + // Per-username failure bucket: a botnet can't brute-force one account across + // many IPs, and one user behind a NAT can't lock out everyone else. + state.userFailures = new StaleExpirationMap({ + ttlMs: AUTH_FAILURE_WINDOW_MS, + refreshOnGet: false, + }); + registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures); + return state; } + // ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ── + const authUsername = process.env.CODEMAN_USERNAME || 'admin'; + const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + app.addHook('onRequest', (req, reply, done) => { - // Hook events + statusline telemetry come from local Claude Code (curl from - // localhost) — no Basic-Auth credentials available. Validated downstream by - // HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate. - // - // COD-54: the bare localhost bypass is unsafe while a tunnel is running, because - // `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the - // loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and - // would pass. COD-91: require the shared hook secret on the loopback bypass - // UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect - // a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale - // serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain - // bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret, - // from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it - // always closes the gap without breaking the legitimate hook channel. - if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { - const ip = req.ip; - const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; - if (isLoopback) { - // Always require the shared secret (constant-time compare). - const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); - const expected = Buffer.from(getHookSecret()); - if (presented.length === expected.length && timingSafeEqual(presented, expected)) { - done(); - return; - } - // Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket - // (never authFailures, which would lock out the login path). - const hookIp = req.ip; - const hookFailures = hookSecretFailures.get(hookIp) ?? 0; - if (hookFailures >= AUTH_FAILURE_MAX) { - sendAuthRateLimit(reply, hookIp, hookSecretFailures); - return; - } - hookSecretFailures.set(hookIp, hookFailures + 1); - reply.code(401).send('Unauthorized: hook secret required'); - return; - } - // Non-localhost hook requests fall through to normal auth + const bypass = checkHookSecretBypass(req, reply, hookSecretFailures); + if (bypass === 'bypass') { + done(); + return; } + if (bypass === 'rejected') return; // QR auth path — handled by the route itself (token validation + rate limiting) if (req.url?.startsWith('/q/')) { @@ -153,10 +216,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au if (sessionToken && authSessions.get(sessionToken) !== undefined) { // Sliding cookie: re-issue on every authenticated request so the browser // cookie lifetime tracks the server-side sliding TTL (refreshOnGet above). - // Without this the cookie has a fixed lifetime from login; the browser - // drops it mid-use, the next request arrives cookie-less and falls through - // to Basic Auth — popping the native username/password dialog, which reads - // as a random logout while actively working. reply.setCookie(AUTH_COOKIE_NAME, sessionToken, { httpOnly: true, secure: https, @@ -206,7 +265,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au // Rate limit only requests that failed to authenticate on this attempt. const failures = authFailures.get(clientIp) ?? 0; if (failures >= AUTH_FAILURE_MAX) { - sendAuthRateLimit(reply, clientIp); + sendAuthRateLimit(reply, authFailures, clientIp); return; } @@ -220,6 +279,124 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au return state; } +/** + * Multi-user auth hook (async, because password verification runs scrypt). Verifies + * `username:password` against the user store, mints an identity-carrying cookie, + * decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the + * `mustChangePassword` lockbox. The single-user hook above is left untouched. + */ +function registerMultiUserAuthHook( + app: FastifyInstance, + https: boolean, + authSessions: StaleExpirationMap, + authFailures: StaleExpirationMap, + hookSecretFailures: StaleExpirationMap, + userFailures: StaleExpirationMap +): void { + const setSessionCookie = (reply: FastifyReply, token: string) => + reply.setCookie(AUTH_COOKIE_NAME, token, { + httpOnly: true, + secure: https, + sameSite: 'lax', + maxAge: AUTH_SESSION_TTL_MS / 1000, + path: '/', + }); + + // Evict the oldest cookie session of the SAME user first (so one user logging in + // 100 times cannot flush everyone else's sessions), falling back to global-oldest. + const evictForCapacity = (username: string) => { + let userKey: string | undefined; + let userTs = Infinity; + let globalKey: string | undefined; + let globalTs = Infinity; + for (const [k, v] of authSessions) { + if (v.createdAt < globalTs) { + globalTs = v.createdAt; + globalKey = k; + } + if (v.username === username && v.createdAt < userTs) { + userTs = v.createdAt; + userKey = k; + } + } + const key = userKey ?? globalKey; + if (key !== undefined) authSessions.delete(key); + }; + + const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => { + if (mustChange && !isPasswordChangeExempt(req)) { + reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED)); + return true; + } + return false; + }; + + app.addHook('onRequest', async (req, reply) => { + const bypass = checkHookSecretBypass(req, reply, hookSecretFailures); + if (bypass === 'bypass' || bypass === 'rejected') return; + + // QR redemption path — handled by the route itself. + if (req.url?.startsWith('/q/')) return; + + const clientIp = req.ip; + + // 1. Cookie session (carries identity + mustChangePassword snapshot). + const sessionToken = req.cookies[AUTH_COOKIE_NAME]; + const record = sessionToken ? authSessions.get(sessionToken) : undefined; + if (record && record.username) { + req.authUser = { username: record.username, role: record.role ?? 'user' }; + setSessionCookie(reply, sessionToken!); // sliding re-issue + enforcePasswordChange(req, reply, !!record.mustChangePassword); + return; + } + + // 2. Basic Auth against the user store (scrypt verify). + const ipFail = authFailures.get(clientIp) ?? 0; + if (ipFail >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, authFailures, clientIp); + return; + } + const creds = parseBasicAuth(req.headers.authorization); + if (creds) { + const normUser = creds.username.trim().toLowerCase(); + const uFail = userFailures.get(normUser) ?? 0; + if (uFail >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, userFailures, normUser); + return; + } + const result = await verifyPassword(creds.username, creds.password); + if (result) { + const { user, needsRehash: rehash } = result; + if (rehash) void setPassword(user.username, creds.password).catch(() => {}); + void touchLastLogin(user.username).catch(() => {}); + authFailures.delete(clientIp); + userFailures.delete(normUser); + + const token = randomBytes(32).toString('hex'); + if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username); + authSessions.set(token, { + ip: clientIp, + ua: req.headers['user-agent'] ?? '', + createdAt: Date.now(), + method: 'basic', + username: user.username, + role: user.role, + mustChangePassword: !!user.mustChangePassword, + }); + req.authUser = { username: user.username, role: user.role }; + setSessionCookie(reply, token); + enforcePasswordChange(req, reply, !!user.mustChangePassword); + return; + } + userFailures.set(normUser, uFail + 1); + } + + authFailures.set(clientIp, ipFail + 1); + reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); + reply.code(401).send('Unauthorized'); + }); +} + /** Methods that don't change server state and so skip the cross-site Origin check. */ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); diff --git a/src/web/ports/auth-port.ts b/src/web/ports/auth-port.ts index ef022230..f1f06198 100644 --- a/src/web/ports/auth-port.ts +++ b/src/web/ports/auth-port.ts @@ -11,6 +11,16 @@ export interface AuthSessionRecord { ua: string; createdAt: number; method: 'qr' | 'basic'; + /** + * Multi-user identity carried by the cookie (single-user leaves these unset). + * Snapshotted at mint time; state transitions that would change them (password + * reset, disable, delete) revoke the user's sessions so a stale snapshot can't + * outlive the change. See docs/multi-user-plan.md section 5. + */ + username?: string; + role?: 'admin' | 'user'; + /** Whether this user must change their password before other actions are allowed. */ + mustChangePassword?: boolean; } export interface AuthPort { diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index a6ecc516..922ffd8b 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -10,13 +10,18 @@ import { realpathSync } from 'node:fs'; import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import type { z } from 'zod'; +import type { FastifyReply, FastifyRequest } from 'fastify'; import { Session } from '../session.js'; -import { ApiErrorCode, createErrorResponse } from '../types.js'; +import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { SseEvent } from './sse-events.js'; import type { SessionPort } from './ports/session-port.js'; import type { EventPort } from './ports/event-port.js'; +import type { AuthSessionRecord } from './ports/auth-port.js'; +import type { StaleExpirationMap } from '../utils/index.js'; import { dataPath } from '../config/instance.js'; +import { isMultiUserMode } from '../config/multiuser.js'; +import { SYNTHETIC_ADMIN } from '../user-store.js'; // Shared path constants used across route modules. CASES_DIR (project folders) // stays shared across instances; SETTINGS_PATH is per-instance runtime state. @@ -79,13 +84,69 @@ export function validateSessionFilePath( // Maximum hook data size (prevents oversized SSE broadcasts) const MAX_HOOK_DATA_SIZE = 8 * 1024; +/** + * Effective identity for a request. In multi-user mode this is the auth-decorated + * user; in single-user mode (or when unset) it defaults to a synthetic admin so + * downstream ownership checks are no-ops and there is ONE code path. + */ +export function getAuthUser(req: FastifyRequest): AuthUser { + return req.authUser ?? SYNTHETIC_ADMIN; +} + +/** + * Whether an identity may see/act on a resource with the given owner. Always true + * in single-user mode; in multi-user, admins see everything and regular users only + * their own (an absent owner is legacy/unassigned = admin-only). + */ +export function canAccessOwned(user: AuthUser, owner: string | undefined): boolean { + if (!isMultiUserMode()) return true; + if (user.role === 'admin') return true; + return !!owner && owner === user.username; +} + +/** + * First line of admin-only handlers: 403 FORBIDDEN + returns false when the caller + * is not an admin. Always true in single-user mode (the sole user is the admin). + */ +export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean { + if (!isMultiUserMode()) return true; + if (getAuthUser(req).role === 'admin') return true; + reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN)); + return false; +} + +/** + * Revoke every cookie session belonging to a user (optionally keeping one token, + * e.g. the caller's own during a self-service password change). Returns the count. + */ +export function revokeUserSessions( + authSessions: StaleExpirationMap | null, + username: string, + exceptToken?: string +): number { + if (!authSessions) return 0; + const norm = username.trim().toLowerCase(); + let removed = 0; + for (const [token, record] of authSessions) { + if (record.username === norm && token !== exceptToken) { + authSessions.delete(token); + removed++; + } + } + return removed; +} + /** * Look up a session by ID or throw a structured error. * Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`. + * + * When `req` is passed in multi-user mode, a session the caller does not own is + * reported as NOT_FOUND (never 403), so existence of other users' sessions is not + * leaked. Single-user / admin callers are unaffected. */ -export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session { +export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session { const session = ctx.sessions.get(sessionId); - if (!session) { + if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) { throw Object.assign(new Error(`Session ${sessionId} not found`), { statusCode: 404, body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 62108a11..747aea36 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -19,4 +19,5 @@ export { registerPlanRoutes } from './plan-routes.js'; export { registerOrchestratorRoutes } from './orchestrator-routes.js'; export { registerClipboardRoutes } from './clipboard-routes.js'; export { registerSearchRoutes } from './search-routes.js'; +export { registerMeRoutes } from './me-routes.js'; export { registerWsRoutes } from './ws-routes.js'; diff --git a/src/web/routes/me-routes.ts b/src/web/routes/me-routes.ts new file mode 100644 index 00000000..f3f06987 --- /dev/null +++ b/src/web/routes/me-routes.ts @@ -0,0 +1,78 @@ +/** + * @fileoverview Self-service identity routes (multi-user + single-user). + * + * - GET /api/me : who am I ({ username, role, mustChangePassword }). + * Works in single-user mode too, returning the synthetic + * admin so the frontend has one "am I admin" code path. + * - POST /api/me/password : change my own password (verifies the current one, + * clears mustChangePassword, revokes my OTHER sessions). + * + * These are the two endpoints a `mustChangePassword` user may still reach (the auth + * middleware's lockbox exempts them). See docs/multi-user-plan.md sections 5, 8. + */ + +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { findUser, setPassword, verifyPassword } from '../../user-store.js'; +import { getAuthUser, revokeUserSessions } from '../route-helpers.js'; +import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; +import type { AuthPort } from '../ports/auth-port.js'; + +const PasswordChangeSchema = z.object({ + currentPassword: z.string().min(1).max(1024), + newPassword: z.string().min(8).max(1024), +}); + +export function registerMeRoutes(app: FastifyInstance, ctx: AuthPort): void { + // GET /api/me — identity probe. Synthetic admin in single-user mode. + app.get('/api/me', async (req) => { + if (!isMultiUserMode()) { + return { success: true, data: { username: 'admin', role: 'admin', mustChangePassword: false } }; + } + const user = getAuthUser(req); + const record = await findUser(user.username); + return { + success: true, + data: { + username: user.username, + role: user.role, + mustChangePassword: !!record?.mustChangePassword, + }, + }; + }); + + // POST /api/me/password — self-service password change. + app.post('/api/me/password', async (req, reply) => { + if (!isMultiUserMode()) { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Multi-user mode is not enabled'); + } + const parsed = PasswordChangeSchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + parsed.error.issues[0]?.message ?? 'New password must be at least 8 characters' + ); + } + const { username } = getAuthUser(req); + const verified = await verifyPassword(username, parsed.data.currentPassword); + if (!verified) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Current password is incorrect'); + } + await setPassword(username, parsed.data.newPassword, { mustChangePassword: false }); + + // Revoke this user's OTHER cookie sessions; keep the caller's own session alive + // and clear its mustChangePassword snapshot so they aren't re-locked immediately. + const currentToken = req.cookies[AUTH_COOKIE_NAME]; + revokeUserSessions(ctx.authSessions, username, currentToken); + if (currentToken) { + const rec = ctx.authSessions?.get(currentToken); + if (rec) rec.mustChangePassword = false; + } + return { success: true }; + }); +} diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 17484463..6a90e6d1 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -15,6 +15,9 @@ import { randomBytes } from 'node:crypto'; import { dataPath } from '../../config/instance.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { findUser } from '../../user-store.js'; +import { getAuthUser } from '../route-helpers.js'; import { ConfigUpdateSchema, SettingsUpdateSchema, @@ -159,12 +162,19 @@ export function registerSystemRoutes( }; }); - app.get('/api/tunnel/qr', async (_req, reply) => { + app.get('/api/tunnel/qr', async (req, reply) => { const url = ctx.tunnelManager.getUrl(); if (!url) { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); } try { + if (isMultiUserMode()) { + // A rotating global token cannot carry identity — mint a single-use token + // bound to the requesting user so the scanned code logs THEM in. + const shortCode = ctx.tunnelManager.mintUserToken(getAuthUser(req).username); + const svg = await ctx.tunnelManager.getQrSvgForCode(url, shortCode); + return { svg, authEnabled: true }; + } const authPassword = process.env.CODEMAN_PASSWORD; if (authPassword) { // Auth enabled — use cached SVG with embedded short code @@ -188,10 +198,11 @@ export function registerSystemRoutes( app.get('/q/:code', async (req, reply) => { const shortCode = (req.params as { code: string }).code; + const multiUser = isMultiUserMode(); const authPassword = process.env.CODEMAN_PASSWORD; - // No point if auth isn't enabled — just redirect - if (!authPassword) { + // No point if auth isn't enabled — just redirect. Multi-user is always "enabled". + if (!multiUser && !authPassword) { return reply.redirect('/'); } @@ -203,12 +214,26 @@ export function registerSystemRoutes( return reply.code(429).send('Too Many Requests'); } - // Validate and atomically consume the token - if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) { + // Validate and atomically consume the token (with any bound identity). + const consumed = shortCode ? ctx.tunnelManager.consumeTokenWithIdentity(shortCode) : { ok: false }; + // In multi-user mode a token MUST carry an identity (an identity-less rotating + // token can't create a scoped session), so reject those too. + if (!consumed.ok || (multiUser && !consumed.username)) { ctx.qrAuthFailures?.set(clientIp, qrFailures + 1); return reply.code(401).send('Invalid or expired QR code'); } + // Resolve the role for the bound user (disabled/deleted users fail closed). + let identity: { username: string; role: 'admin' | 'user' } | undefined; + if (multiUser && consumed.username) { + const user = await findUser(consumed.username); + if (!user || user.disabled) { + ctx.qrAuthFailures?.set(clientIp, qrFailures + 1); + return reply.code(401).send('Invalid or expired QR code'); + } + identity = { username: user.username, role: user.role }; + } + // Issue session cookie (same pattern as Basic Auth success path) const sessionToken = randomBytes(32).toString('hex'); const clientUA = req.headers['user-agent'] ?? ''; @@ -217,6 +242,9 @@ export function registerSystemRoutes( ua: clientUA, createdAt: Date.now(), method: 'qr', + username: identity?.username, + role: identity?.role, + mustChangePassword: false, }); ctx.qrAuthFailures?.delete(clientIp); @@ -585,7 +613,10 @@ export function registerSystemRoutes( // letting an operator opt in from the browser without setting the env var. // Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved. if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) { - const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true; + // Multi-user mode makes the tunnel authenticated (every person has their own + // credential), so it satisfies the same requirement as CODEMAN_PASSWORD. + const acknowledged = + isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true; if (!acknowledged) { const msg = 'Refusing to start the Cloudflare tunnel without authentication: it would publish ' + diff --git a/src/web/server.ts b/src/web/server.ts index 649929eb..d07e483b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -135,6 +135,8 @@ import { SseEvent } from './sse-events.js'; import { getLatestPlanUsage } from './plan-usage-latest.js'; import type { ScheduledRun } from './ports/index.js'; import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js'; +import { isMultiUserMode } from '../config/multiuser.js'; +import { bootstrapInitialAdmin, hasUsers } from '../user-store.js'; import { installRouteErrorHandler } from './route-error-handler.js'; import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js'; import { @@ -155,6 +157,7 @@ import { registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, + registerMeRoutes, registerWsRoutes, } from './routes/index.js'; import { CronService } from '../cron/cron-service.js'; @@ -279,6 +282,7 @@ export class WebServer extends EventEmitter { private authFailures: StaleExpirationMap | null = null; private qrAuthFailures: StaleExpirationMap | null = null; private hookSecretFailures: StaleExpirationMap | null = null; + private userFailures: StaleExpirationMap | null = null; private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; @@ -680,6 +684,7 @@ export class WebServer extends EventEmitter { this.authFailures = authState.authFailures; this.qrAuthFailures = authState.qrAuthFailures; this.hookSecretFailures = authState.hookSecretFailures; + this.userFailures = authState.userFailures; } // WebSocket support (terminal I/O — low-latency bidirectional channel) @@ -899,6 +904,7 @@ export class WebServer extends EventEmitter { registerPlanRoutes(this.app, ctx); registerClipboardRoutes(this.app, ctx); registerSearchRoutes(this.app, ctx); + registerMeRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx); // Cron: build the service from the same context, recompute @@ -1930,6 +1936,24 @@ export class WebServer extends EventEmitter { } async start(): Promise { + // Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD + // if there are no users yet, else refuse to start (there would be no way in). + if (isMultiUserMode() && !this.testMode) { + const boot = await bootstrapInitialAdmin(); + if (boot.status === 'missing-env') { + throw new Error( + 'Multi-user mode is enabled but users.json has no users. Create the first admin with ' + + '`codeman users add --admin` (or set CODEMAN_USERNAME/CODEMAN_PASSWORD for one-time bootstrap).' + ); + } + if (boot.status === 'created') { + console.log( + `✓ Multi-user: bootstrapped initial admin "${boot.username}" from CODEMAN_USERNAME/CODEMAN_PASSWORD` + ); + } + console.log('✓ Multi-user mode active (per-user accounts in users.json; CODEMAN_PASSWORD is ignored for login)'); + } + await this.setupRoutes(); const lifecycleLog = getLifecycleLog(); @@ -2006,7 +2030,10 @@ export class WebServer extends EventEmitter { // "just worked" before. Instead we start and warn loudly, pointing at the ways // to secure it. --allow-unauthenticated-network just acknowledges the risk (a // terser note). See docs/security-architecture.md. - if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD) { + // Multi-user mode with >= 1 enabled user satisfies the auth requirement even + // without CODEMAN_PASSWORD (every person has their own credential). + const authActive = !!process.env.CODEMAN_PASSWORD || (isMultiUserMode() && (await hasUsers())); + if (!isLoopbackBindHost(this.host) && !authActive) { if (this.allowUnauthenticatedNetwork) { console.warn( `\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` + @@ -2637,6 +2664,10 @@ export class WebServer extends EventEmitter { this.hookSecretFailures.dispose(); this.hookSecretFailures = null; } + if (this.userFailures) { + this.userFailures.dispose(); + this.userFailures = null; + } this.activePlanOrchestrators.clear(); this.cleaningUp.clear(); diff --git a/test/multiuser-auth.test.ts b/test/multiuser-auth.test.ts new file mode 100644 index 00000000..0271f912 --- /dev/null +++ b/test/multiuser-auth.test.ts @@ -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 = {}; + +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 }); + }); +}); diff --git a/test/types.test.ts b/test/types.test.ts index 81c2ffab..5a73a10e 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -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'); }); }); });