mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
feat(multiuser): phase 2, multi-user auth pipeline
Adds a parallel multi-user auth branch (the single-user Basic-auth path is left byte-identical). Off unless CODEMAN_MULTIUSER/--multiuser. - middleware/auth.ts: mode-selecting registerAuthMiddleware. New async multi-user hook verifies username:password against the user store (scrypt), mints identity-carrying cookies, decorates req.authUser, enforces a per-IP AND per-username failure bucket, and the mustChangePassword lockbox. The hook-secret loopback bypass is now a single shared helper used by both branches. FastifyRequest.authUser module augmentation. - ports/auth-port.ts: AuthSessionRecord gains username/role/mustChangePassword. - user-store.ts: verifyPassword (timing-equalized against user enumeration). - route-helpers.ts: getAuthUser (synthetic admin fallback), canAccessOwned, requireAdmin, revokeUserSessions; findSessionOrFail gains an optional req for a NOT_FOUND owner check (dormant until phase 3 wires callers). - routes/me-routes.ts: GET /api/me (synthetic admin in single-user) and POST /api/me/password (verify current, min 8, clear mustChangePassword, revoke other sessions). - QR: QrTokenRecord + AuthSessionRecord carry a username; tunnel-manager mintUserToken / consumeTokenWithIdentity / getQrSvgForCode; /q/:code binds the cookie to the token's user (rejects identity-less tokens in multi-user); GET /api/tunnel/qr mints a per-user token. Single-user keeps the rotating token. - server.ts: bootstrap the initial admin from CODEMAN_USERNAME/PASSWORD on first boot (refuse to start with no users); multi-user with >= 1 user satisfies the non-loopback auth requirement and the tunnel-enable guard; userFailures bucket disposal. - types/api.ts: FORBIDDEN, PASSWORD_CHANGE_REQUIRED, USER_EXISTS, USER_NOT_FOUND, LAST_ADMIN error codes (message + status wired). - Session.owner field + getter/setter, SessionState.owner, MuxSession.owner, CreateSessionOptions.owner (foundation for phase 3 ownership threading). Tests: test/multiuser-auth.test.ts (10, live server on 3170/3171). Existing auth suite (auth-security, qr-auth, cod54-hook-event, network-auth-policy) unchanged and green; full test:ci sweep passes (3519 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user