Hub stage 0: own users with roles, login limit, audit log, permission table
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
042cb116ea
commit
0ef2d53a65
17 files changed
+1497
-79
No files matched your search
+40
-33
@@ -1,18 +1,23 @@
|
||||
/*
|
||||
The hub's own sign-in. One user from the environment, sessions kept in memory with an
|
||||
idle expiry, and the `info` object the console reads from `user/login` and
|
||||
`user/session/get` (flat, as the real server sends it).
|
||||
The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who
|
||||
signed in and what that user looked like (role, hash, disabled flag) at that moment, so a
|
||||
change to the users file ends the sessions of the users it touched. `info` is the object
|
||||
the console reads from `user/login` and `user/session/get` (flat, as the real server sends it).
|
||||
*/
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { HUB_VERSION, type HubConfig } from './config.ts'
|
||||
import { signature, type Role, type UserRecord, type Users } from './users.ts'
|
||||
|
||||
export const IDLE_MS = 30 * 60_000
|
||||
|
||||
/* Compared as hashes so the lengths always match and the time does not depend on the content. */
|
||||
function sameSecret(a: string, b: string): boolean {
|
||||
const ha = createHash('sha256').update(a).digest()
|
||||
const hb = createHash('sha256').update(b).digest()
|
||||
return timingSafeEqual(ha, hb)
|
||||
export interface Identity {
|
||||
user: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
interface Session extends Identity {
|
||||
last: number
|
||||
sig: string
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -28,39 +33,39 @@ const VIEWABLE = new Set(['Dashboard', 'Logs'])
|
||||
|
||||
export class Sessions {
|
||||
#config: HubConfig
|
||||
#users: Users
|
||||
#now: () => number
|
||||
#started: string
|
||||
#seen = new Map<string, number>()
|
||||
#seen = new Map<string, Session>()
|
||||
|
||||
constructor(config: HubConfig, now: () => number = Date.now) {
|
||||
constructor(config: HubConfig, users: Users, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#users = users
|
||||
this.#now = now
|
||||
this.#started = new Date(now()).toISOString()
|
||||
}
|
||||
|
||||
/** A new session token, or null when the credentials are wrong. */
|
||||
login(user: string, password: string): string | null {
|
||||
// Both are always compared, so a wrong user and a wrong password cost the same.
|
||||
const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase())
|
||||
const passOk = sameSecret(password, this.#config.password)
|
||||
if (!(userOk && passOk)) return null
|
||||
/** Opens a session for a user already authenticated by `Users.authenticate`. */
|
||||
open(user: UserRecord): string {
|
||||
this.#sweep()
|
||||
const token = randomBytes(32).toString('hex')
|
||||
this.#seen.set(token, this.#now())
|
||||
this.#seen.set(token, { user: user.name, role: user.role, last: this.#now(), sig: signature(user) })
|
||||
return token
|
||||
}
|
||||
|
||||
/** True while the token is valid; each use extends it. */
|
||||
touch(token: string | null | undefined): boolean {
|
||||
if (!token) return false
|
||||
const last = this.#seen.get(token)
|
||||
if (last === undefined) return false
|
||||
if (this.#now() - last > IDLE_MS) {
|
||||
/* The session's identity while it is valid (extending it), else null. A user who was removed,
|
||||
disabled or edited since sign-in ends the session. */
|
||||
touch(token: string | null | undefined): Identity | null {
|
||||
if (!token) return null
|
||||
const session = this.#seen.get(token)
|
||||
if (session === undefined) return null
|
||||
const record = this.#users.find(session.user)
|
||||
if (this.#now() - session.last > IDLE_MS || record === undefined || signature(record) !== session.sig) {
|
||||
this.#seen.delete(token)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
this.#seen.set(token, this.#now())
|
||||
return true
|
||||
session.last = this.#now()
|
||||
return { user: session.user, role: session.role }
|
||||
}
|
||||
|
||||
logout(token: string): void {
|
||||
@@ -69,10 +74,10 @@ export class Sessions {
|
||||
|
||||
#sweep(): void {
|
||||
const now = this.#now()
|
||||
for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token)
|
||||
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
|
||||
}
|
||||
|
||||
info() {
|
||||
info(role: Role) {
|
||||
const { servers, domain } = this.#config
|
||||
return {
|
||||
version: HUB_VERSION,
|
||||
@@ -92,20 +97,22 @@ export class Sessions {
|
||||
useSoaSerialDateScheme: true,
|
||||
dnssecValidation: true,
|
||||
hub: true,
|
||||
hubRole: role,
|
||||
}
|
||||
}
|
||||
|
||||
/** The flat body of `user/login` and `user/session/get`. */
|
||||
body(token: string) {
|
||||
body(token: string, identity: Identity) {
|
||||
return {
|
||||
status: 'ok',
|
||||
token,
|
||||
username: this.#config.user,
|
||||
displayName: this.#config.user,
|
||||
username: identity.user,
|
||||
displayName: identity.user,
|
||||
type: 'Local',
|
||||
isSsoUser: false,
|
||||
totpEnabled: false,
|
||||
info: this.info(),
|
||||
hubRole: identity.role,
|
||||
info: this.info(identity.role),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user