Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator, apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock the address or the name for 15 minutes. Logins, logouts and every refused request go to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are listed in routes.ts with their kind and capability; writes stay refused for now. Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan is in docs/hub-management-plan.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/*
|
|
The hub's own accounts: roles, password hashes and the users file.
|
|
|
|
`${HUB_DATA}/users.json` holds `{ users: [{ name, role, hash, disabled? }] }`, where hash is
|
|
`scrypt$N$r$p$saltB64$keyB64`. The pure functions below are what the CLI (users-cli.ts)
|
|
and the tests call; `Users` is what the running hub asks.
|
|
*/
|
|
import { randomBytes, scrypt, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import type { Bootstrap } from './config.ts'
|
|
|
|
export type Role = 'viewer' | 'operator' | 'apps-admin' | 'admin'
|
|
export type Capability = 'read' | 'apps.write' | 'operate' | 'audit.read' | 'admin'
|
|
|
|
export const ROLES: readonly Role[] = ['viewer', 'operator', 'apps-admin', 'admin']
|
|
|
|
/* A simple rank. operator and apps-admin are siblings: use `can` to ask about a capability. */
|
|
const RANK: Record<Role, number> = { viewer: 1, operator: 2, 'apps-admin': 2, admin: 3 }
|
|
|
|
const CAPABILITIES: Record<Role, readonly Capability[]> = {
|
|
viewer: ['read'],
|
|
operator: ['read', 'operate'],
|
|
'apps-admin': ['read', 'apps.write'],
|
|
admin: ['read', 'apps.write', 'operate', 'audit.read', 'admin'],
|
|
}
|
|
|
|
export const isRole = (value: unknown): value is Role => typeof value === 'string' && (ROLES as readonly string[]).includes(value)
|
|
|
|
export const rank = (role: Role): number => RANK[role]
|
|
|
|
/** True when `role` ranks at least as high as `min`. */
|
|
export const requires = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]
|
|
|
|
export const can = (role: Role, capability: Capability): boolean => CAPABILITIES[role].includes(capability)
|
|
|
|
export interface UserRecord {
|
|
name: string
|
|
role: Role
|
|
hash: string
|
|
disabled?: boolean
|
|
}
|
|
|
|
/* ---- Passwords ---- */
|
|
|
|
const N = 16384
|
|
const R = 8
|
|
const P = 1
|
|
const KEYLEN = 64
|
|
|
|
export function hashPassword(password: string): string {
|
|
const salt = randomBytes(16)
|
|
const key = scryptSync(password, salt, KEYLEN, { N, r: R, p: P })
|
|
return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${key.toString('base64')}`
|
|
}
|
|
|
|
interface Parsed {
|
|
n: number
|
|
r: number
|
|
p: number
|
|
salt: Buffer
|
|
key: Buffer
|
|
}
|
|
|
|
function parseHash(stored: string): Parsed | null {
|
|
const parts = stored.split('$')
|
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return null
|
|
const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]
|
|
if (![n, r, p].every((v) => Number.isInteger(v) && v > 0) || n > 1 << 20 || r > 32 || p > 16) return null
|
|
const salt = Buffer.from(parts[4], 'base64')
|
|
const key = Buffer.from(parts[5], 'base64')
|
|
return salt.length === 0 || key.length === 0 ? null : { n, r, p, salt, key }
|
|
}
|
|
|
|
export const isHash = (stored: string): boolean => parseHash(stored) !== null
|
|
|
|
function derive(password: string, h: Parsed): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
scrypt(password, h.salt, h.key.length, { N: h.n, r: h.r, p: h.p, maxmem: 256 * h.n * h.r }, (err, key) =>
|
|
err ? reject(err) : resolve(key),
|
|
)
|
|
})
|
|
}
|
|
|
|
/** Checks a password against a stored hash; false for a malformed hash. Does not block the loop. */
|
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
const h = parseHash(stored)
|
|
if (h === null) return false
|
|
try {
|
|
return timingSafeEqual(await derive(password, h), h.key)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/* ---- The file: parse and pure edits ---- */
|
|
|
|
const NAME = /^[A-Za-z0-9._@-]{1,64}$/
|
|
|
|
export function validateName(name: string): void {
|
|
if (!NAME.test(name)) throw new Error('A user name is 1 to 64 letters, digits, . _ @ -.')
|
|
}
|
|
|
|
const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
|
|
|
|
export function parseUsersFile(text: string): UserRecord[] {
|
|
let data: unknown
|
|
try {
|
|
data = JSON.parse(text)
|
|
} catch {
|
|
throw new Error('users.json is not valid JSON.')
|
|
}
|
|
const list = (data as { users?: unknown } | null)?.users
|
|
if (!Array.isArray(list)) throw new Error('users.json must be {"users": [...]}.')
|
|
const users: UserRecord[] = []
|
|
for (const item of list as Record<string, unknown>[]) {
|
|
const name = item?.name
|
|
if (typeof name !== 'string' || !NAME.test(name)) throw new Error(`users.json: invalid user name ${JSON.stringify(name)}.`)
|
|
if (!isRole(item.role)) throw new Error(`users.json: user "${name}" has an unknown role.`)
|
|
if (typeof item.hash !== 'string' || !isHash(item.hash)) throw new Error(`users.json: user "${name}" has no valid hash.`)
|
|
if (users.some((u) => same(u.name, name))) throw new Error(`users.json lists "${name}" twice.`)
|
|
const record: UserRecord = { name, role: item.role, hash: item.hash }
|
|
if (item.disabled === true) record.disabled = true
|
|
users.push(record)
|
|
}
|
|
return users
|
|
}
|
|
|
|
export const serializeUsers = (users: UserRecord[]): string => JSON.stringify({ users }, null, 2) + '\n'
|
|
|
|
export const findUser = (users: UserRecord[], name: string): UserRecord | undefined => users.find((u) => same(u.name, name))
|
|
|
|
function must(users: UserRecord[], name: string): UserRecord {
|
|
const found = findUser(users, name)
|
|
if (found === undefined) throw new Error(`No user "${name}".`)
|
|
return found
|
|
}
|
|
|
|
export function checkPassword(password: string): void {
|
|
if (password.length < 8) throw new Error('The password must be at least 8 characters.')
|
|
}
|
|
|
|
function checkRole(role: string): Role {
|
|
if (!isRole(role)) throw new Error(`The role must be one of: ${ROLES.join(', ')}.`)
|
|
return role
|
|
}
|
|
|
|
/* Each edit returns a new list; the caller writes it. */
|
|
|
|
export function addUser(users: UserRecord[], name: string, role: string, password: string): UserRecord[] {
|
|
validateName(name)
|
|
const checked = checkRole(role)
|
|
checkPassword(password)
|
|
if (findUser(users, name) !== undefined) throw new Error(`User "${name}" already exists.`)
|
|
return [...users, { name, role: checked, hash: hashPassword(password) }]
|
|
}
|
|
|
|
export function setPassword(users: UserRecord[], name: string, password: string): UserRecord[] {
|
|
checkPassword(password)
|
|
const target = must(users, name)
|
|
return users.map((u) => (u === target ? { ...u, hash: hashPassword(password) } : u))
|
|
}
|
|
|
|
export function setRole(users: UserRecord[], name: string, role: string): UserRecord[] {
|
|
const checked = checkRole(role)
|
|
const target = must(users, name)
|
|
return users.map((u) => (u === target ? { ...u, role: checked } : u))
|
|
}
|
|
|
|
export function setDisabled(users: UserRecord[], name: string, disabled: boolean): UserRecord[] {
|
|
const target = must(users, name)
|
|
return users.map((u) => {
|
|
if (u !== target) return u
|
|
const { disabled: _old, ...rest } = u
|
|
return disabled ? { ...rest, disabled: true } : rest
|
|
})
|
|
}
|
|
|
|
/** One line per user, never the hash. */
|
|
export const describeUsers = (users: UserRecord[]): string[] =>
|
|
users.map((u) => `${u.name}\t${u.role}${u.disabled === true ? '\tdisabled' : ''}`)
|
|
|
|
/* ---- The file: disk ---- */
|
|
|
|
export const usersPath = (dir: string): string => path.join(dir, 'users.json')
|
|
|
|
/** The users, or null when the file does not exist. Throws when it exists but is invalid. */
|
|
export function readUsersFile(dir: string): UserRecord[] | null {
|
|
const file = usersPath(dir)
|
|
if (!existsSync(file)) return null
|
|
return parseUsersFile(readFileSync(file, 'utf8'))
|
|
}
|
|
|
|
/** Writes a temp file and renames it over users.json; mode 0600 where the platform honours it. */
|
|
export function writeUsersFile(dir: string, users: UserRecord[]): void {
|
|
mkdirSync(dir, { recursive: true })
|
|
const file = usersPath(dir)
|
|
const temp = `${file}.${process.pid}.tmp`
|
|
try {
|
|
writeFileSync(temp, serializeUsers(users), { mode: 0o600 })
|
|
try {
|
|
chmodSync(temp, 0o600)
|
|
} catch {
|
|
// Not every platform has modes.
|
|
}
|
|
renameSync(temp, file)
|
|
} catch (e) {
|
|
rmSync(temp, { force: true })
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/* ---- The running hub's view ---- */
|
|
|
|
/* Compared when the role, hash or disabled flag changes, so a session can tell its user was edited. */
|
|
export const signature = (u: UserRecord): string => `${u.role}|${u.hash}|${u.disabled === true ? 'x' : ''}`
|
|
|
|
export class Users {
|
|
#dir: string
|
|
#users: UserRecord[]
|
|
#fromFile: boolean
|
|
#mtime = 0
|
|
#warned = false
|
|
/** A one-line notice for the start-up log, or null. */
|
|
readonly notice: string | null
|
|
/* Checked when a name is unknown, so an unknown name costs the same as a wrong password. */
|
|
#dummy = hashPassword(randomBytes(8).toString('hex'))
|
|
|
|
private constructor(dir: string, users: UserRecord[], fromFile: boolean, notice: string | null) {
|
|
this.#dir = dir
|
|
this.#users = users
|
|
this.#fromFile = fromFile
|
|
this.notice = notice
|
|
if (fromFile) this.#mtime = this.#stat()
|
|
}
|
|
|
|
/*
|
|
users.json when it exists (HUB_USER and HUB_PASSWORD are then ignored); else the one admin
|
|
from the environment; else an error. The file is never written here.
|
|
*/
|
|
static load(dir: string, bootstrap: Bootstrap | null): Users {
|
|
const fromFile = readUsersFile(dir)
|
|
if (fromFile !== null) {
|
|
const notice =
|
|
bootstrap === null ? null : `${usersPath(dir)} exists: HUB_USER and HUB_PASSWORD are ignored.`
|
|
return new Users(dir, fromFile, true, notice)
|
|
}
|
|
if (bootstrap === null) {
|
|
throw new Error(
|
|
`No users: ${usersPath(dir)} does not exist and HUB_USER / HUB_PASSWORD are not set. ` +
|
|
'Create a user with: node --experimental-strip-types hub/users-cli.ts add <name> admin',
|
|
)
|
|
}
|
|
validateName(bootstrap.user)
|
|
const record: UserRecord = { name: bootstrap.user, role: 'admin', hash: hashPassword(bootstrap.password) }
|
|
return new Users(dir, [record], false, `No ${usersPath(dir)}: the single admin from HUB_USER / HUB_PASSWORD is in use.`)
|
|
}
|
|
|
|
#stat(): number {
|
|
try {
|
|
return statSync(usersPath(this.#dir)).mtimeMs
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/* Cheap enough to run on every request: one stat. A broken file keeps the last good list. */
|
|
refresh(): void {
|
|
if (!this.#fromFile) return
|
|
const mtime = this.#stat()
|
|
if (mtime === this.#mtime) return
|
|
this.#mtime = mtime
|
|
try {
|
|
const next = readUsersFile(this.#dir)
|
|
this.#users = next ?? []
|
|
this.#warned = false
|
|
} catch (e) {
|
|
if (!this.#warned) console.error(`hub: ${e instanceof Error ? e.message : String(e)} Keeping the previous users.`)
|
|
this.#warned = true
|
|
}
|
|
}
|
|
|
|
find(name: string): UserRecord | undefined {
|
|
this.refresh()
|
|
return findUser(this.#users, name)
|
|
}
|
|
|
|
/** The enabled user when the password is right, else null. Costs the same either way. */
|
|
async authenticate(name: string, password: string): Promise<UserRecord | null> {
|
|
const user = this.find(name.trim())
|
|
const good = await verifyPassword(password, user?.hash ?? this.#dummy)
|
|
return user !== undefined && good && user.disabled !== true ? user : null
|
|
}
|
|
|
|
/** True when the user was found and is disabled (for the audit log only). */
|
|
isDisabled(name: string): boolean {
|
|
return this.find(name.trim())?.disabled === true
|
|
}
|
|
}
|