mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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:
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
+49
-6
@@ -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
|
||||
const username = record.username;
|
||||
if (!username) {
|
||||
// Rotating global token — immediately rotate so desktop gets a fresh QR.
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
return true;
|
||||
} 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<string> {
|
||||
const QRCode = await import('qrcode');
|
||||
return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
|
||||
}
|
||||
|
||||
/** Force-regenerate (manual revocation via API) */
|
||||
|
||||
@@ -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, string> = {
|
||||
[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, number> = {
|
||||
[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,
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -202,6 +202,34 @@ export async function hasUsers(): Promise<boolean> {
|
||||
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<PasswordHash> | null = null;
|
||||
function getDummyHash(): Promise<PasswordHash> {
|
||||
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<UserRecord | undefined> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!norm) return undefined;
|
||||
|
||||
+232
-55
@@ -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<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-username Basic-auth failure bucket (multi-user only). */
|
||||
userFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/** Rate-limit response for a client that exceeded the failure cap. */
|
||||
function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap<string, number>, 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<string, number>
|
||||
): '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<string, AuthSessionRecord>({
|
||||
@@ -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<string, number> = 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<string, number>({
|
||||
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)) {
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass') {
|
||||
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
|
||||
}
|
||||
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<string, AuthSessionRecord>,
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>
|
||||
): 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']);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, AuthSessionRecord> | 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`),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 ' +
|
||||
|
||||
+32
-1
@@ -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<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private userFailures: StaleExpirationMap<string, number> | 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<void> {
|
||||
// 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 <name> --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();
|
||||
|
||||
|
||||
@@ -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