mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +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:
@@ -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 ' +
|
||||
|
||||
Reference in New Issue
Block a user