|
|
|
@@ -206,6 +206,51 @@ class AccessController {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleSetupRoute(request, response, url) {
|
|
|
|
|
try {
|
|
|
|
|
await this.ensureBootstrap();
|
|
|
|
|
if (request.method !== "POST" || url.pathname !== "/v1/setup/first-admin") {
|
|
|
|
|
return sendJson(response, 404, errorPayload("not_found", "Setup route is not implemented", 404));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(await this.setupRequired())) {
|
|
|
|
|
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = await jsonBody(request);
|
|
|
|
|
const password = requiredText(body.password, "password");
|
|
|
|
|
const now = this.now();
|
|
|
|
|
const user = await this.store.createFirstAdmin?.({
|
|
|
|
|
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
|
|
|
|
|
username: textOr(body.username, this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin"),
|
|
|
|
|
displayName: textOr(body.displayName, this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin"),
|
|
|
|
|
passwordHash: hashPassword(password),
|
|
|
|
|
now
|
|
|
|
|
}) ?? null;
|
|
|
|
|
if (!user) {
|
|
|
|
|
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
|
|
|
|
|
}
|
|
|
|
|
const token = randomBytes(32).toString("base64url");
|
|
|
|
|
const session = await this.store.createSession({
|
|
|
|
|
userId: user.id,
|
|
|
|
|
tokenHash: sha256(token),
|
|
|
|
|
now,
|
|
|
|
|
expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString()
|
|
|
|
|
});
|
|
|
|
|
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS);
|
|
|
|
|
return sendJson(response, 201, {
|
|
|
|
|
created: true,
|
|
|
|
|
authenticated: true,
|
|
|
|
|
actor: publicActor(user),
|
|
|
|
|
session: publicSession({ ...session, user }),
|
|
|
|
|
setupRequired: false,
|
|
|
|
|
contractVersion: "user-access-v1"
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
sendAccessError(response, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sessionPayload(request) {
|
|
|
|
|
const auth = await this.authenticate(request, { required: false });
|
|
|
|
|
return {
|
|
|
|
@@ -928,6 +973,10 @@ class MemoryAccessStore {
|
|
|
|
|
this.users.set(user.id, user);
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
async createFirstAdmin(input) {
|
|
|
|
|
if (this.users.size > 0) return null;
|
|
|
|
|
return this.createUser({ ...input, role: "admin", status: "active" });
|
|
|
|
|
}
|
|
|
|
|
async createSession(input) {
|
|
|
|
|
const session = { id: `uss_${randomUUID()}`, userId: input.userId, tokenHash: input.tokenHash, createdAt: input.now, lastSeenAt: input.now, expiresAt: input.expiresAt, revokedAt: null };
|
|
|
|
|
this.sessions.set(session.id, session);
|
|
|
|
@@ -1015,6 +1064,13 @@ class PostgresAccessStore extends MemoryAccessStore {
|
|
|
|
|
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]);
|
|
|
|
|
return pgUser(result.rows?.[0]);
|
|
|
|
|
}
|
|
|
|
|
async createFirstAdmin(input) {
|
|
|
|
|
await this.ensureSchema();
|
|
|
|
|
const now = input.now ?? this.now();
|
|
|
|
|
const user = { id: input.id, username: input.username, displayName: input.displayName ?? "", role: "admin", status: "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now };
|
|
|
|
|
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) SELECT $1,$2,$3,$4,$5,$6,$7,$8 WHERE NOT EXISTS (SELECT 1 FROM users) ON CONFLICT DO NOTHING RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]);
|
|
|
|
|
return pgUser(result.rows?.[0]);
|
|
|
|
|
}
|
|
|
|
|
async createSession(input) { await this.ensureSchema(); const session = await super.createSession(input); await this.query("INSERT INTO user_sessions (id, user_id, session_token_hash, created_at, last_seen_at, expires_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7)", [session.id, session.userId, session.tokenHash, session.createdAt, session.lastSeenAt, session.expiresAt, session.revokedAt]); return session; }
|
|
|
|
|
async findSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); const result = await this.query("SELECT s.id, s.user_id, s.session_token_hash, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.username, u.display_name, u.role, u.status, u.password_hash, u.created_at AS user_created_at, u.updated_at AS user_updated_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2 LIMIT 1", [tokenHash, now]); const row = result.rows?.[0]; return row ? pgSession(row) : null; }
|
|
|
|
|
async touchSession(id, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET last_seen_at = $2 WHERE id = $1", [id, now]); }
|
|
|
|
@@ -1091,7 +1147,7 @@ function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson);
|
|
|
|
|
function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; }
|
|
|
|
|
function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; }
|
|
|
|
|
function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, lease, and job authority are owned by hwlab-cloud-api; this is not fake data." }; }
|
|
|
|
|
function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", devicePods: "/v1/device-pods" }; }
|
|
|
|
|
function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; }
|
|
|
|
|
function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; }
|
|
|
|
|
function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; }
|
|
|
|
|
function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(status, ""); return ["queued", "running", "completed", "failed", "blocked", "canceled"].includes(text) ? text : httpStatus >= 400 ? "blocked" : "running"; }
|
|
|
|
|