Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Auth middleware: cookie -> auth_session ->
|
||||
* user injected into c.var.
|
||||
*
|
||||
* Accessing a protected API while logged out -> 401 `{error:{code:"unauthorized"}}`.
|
||||
* CSRF (MVP): SameSite=Lax cookie + write requests only accept
|
||||
* `Content-Type: application/json` (an HTML form can't forge that Content-Type),
|
||||
* see the README security notes.
|
||||
*/
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import type { UserRow } from "../db/repos/users.js";
|
||||
import type { AuthService } from "./service.js";
|
||||
|
||||
/** Session cookie name. */
|
||||
export const SESSION_COOKIE = "penguin_session";
|
||||
|
||||
/** Hono env: variables injected by the auth middleware. */
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
user: UserRow;
|
||||
};
|
||||
};
|
||||
|
||||
/** Gets the current user (available after authMiddleware). */
|
||||
export function currentUser(c: { var: { user: UserRow } }): UserRow {
|
||||
return c.var.user;
|
||||
}
|
||||
|
||||
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
const user = token ? auth.authenticate(token) : null;
|
||||
if (!user) {
|
||||
throw new HttpError(401, "unauthorized", "未登录或登录已过期。");
|
||||
}
|
||||
c.set("user", user);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
/**
|
||||
* Content-Type defense for write requests: a write request with a Content-Type
|
||||
* other than application/json is rejected (a request with no Content-Type and an
|
||||
* empty body is let through — an HTML form always carries a form-type Content-Type).
|
||||
*/
|
||||
export const jsonOnlyWrites: MiddlewareHandler = async (c, next) => {
|
||||
if (WRITE_METHODS.has(c.req.method)) {
|
||||
const contentType = c.req.header("content-type");
|
||||
if (contentType && !contentType.toLowerCase().startsWith("application/json")) {
|
||||
throw new HttpError(415, "unsupported_media_type", "写请求仅接受 application/json。");
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Password hashing (slow-hash storage).
|
||||
*
|
||||
* Uses node:crypto's scrypt (built-in, no extra dependency, meets the same
|
||||
* slow-hash requirement as bcrypt/argon2). Storage format:
|
||||
* `scrypt$N$r$p$<salt b64>$<hash b64>` — parameters are stored alongside the hash,
|
||||
* so old hashes remain verifiable after future parameter tuning; comparison uses
|
||||
* timingSafeEqual to guard against timing side-channels.
|
||||
*/
|
||||
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const SCRYPT_N = 16384;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const SALT_BYTES = 16;
|
||||
const KEY_BYTES = 64;
|
||||
|
||||
function scryptAsync(
|
||||
password: string,
|
||||
salt: Buffer,
|
||||
keyLen: number,
|
||||
n: number,
|
||||
r: number,
|
||||
p: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
|
||||
if (err) reject(err);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Generates a password hash in `scrypt$N$r$p$salt$hash` format. */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const key = await scryptAsync(password, salt, KEY_BYTES, SCRYPT_N, SCRYPT_R, SCRYPT_P);
|
||||
return [
|
||||
"scrypt",
|
||||
String(SCRYPT_N),
|
||||
String(SCRYPT_R),
|
||||
String(SCRYPT_P),
|
||||
salt.toString("base64"),
|
||||
key.toString("base64"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
/** Verifies a password; returns false if the stored string has an invalid format (never throws, so the login path can uniformly treat it as a credential error). */
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
const n = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
salt = Buffer.from(parts[4]!, "base64");
|
||||
expected = Buffer.from(parts[5]!, "base64");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (salt.length === 0 || expected.length === 0) return false;
|
||||
let actual: Buffer;
|
||||
try {
|
||||
actual = await scryptAsync(password, salt, expected.length, n, r, p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Auth service: built-in admin seeding /
|
||||
* login / logout / password change / session validation.
|
||||
*
|
||||
* - No open registration: on startup, if there are no users at all, the built-in
|
||||
* admin `admin` is seeded (initial password admin123), and it adopts
|
||||
* `default_project`; all other users are created by an admin via the user
|
||||
* backend (admin-service).
|
||||
* - An initial password (whether seeded or set by an admin) is flagged with
|
||||
* password_is_initial, which the frontend uses to prompt for a password change soon.
|
||||
* - Sessions: a 32-byte random token, with only its sha256 hash stored in the DB;
|
||||
* valid for 7 days, with sliding renewal once less than 6 days remain.
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { UserInfo } from "../api/types.js";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import type { AuthSessionsRepo } from "../db/repos/auth-sessions.js";
|
||||
import type { UserRow, UsersRepo } from "../db/repos/users.js";
|
||||
import { hashPassword, verifyPassword } from "./password.js";
|
||||
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/** Built-in admin: user_id and initial password (matches the README and login-page hint). */
|
||||
export const ADMIN_USER_ID = "admin";
|
||||
export const ADMIN_INITIAL_PASSWORD = "admin123";
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function toUserInfo(row: UserRow): UserInfo {
|
||||
return {
|
||||
userId: row.userId,
|
||||
isAdmin: row.isAdmin,
|
||||
passwordIsInitial: row.passwordIsInitial,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthServiceDeps {
|
||||
users: UsersRepo;
|
||||
authSessions: AuthSessionsRepo;
|
||||
/** Provisions the initial Project at signup (injected by project-service, to avoid a circular dependency). */
|
||||
provisionInitialProject: (user: UserRow, isAdmin: boolean) => Promise<void>;
|
||||
sessionTtlMs: number;
|
||||
sessionRenewMs: number;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(private readonly deps: AuthServiceDeps) {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup seeding (idempotent): creates the built-in admin and adopts
|
||||
* default_project when the users table is empty; if the initial Project fails,
|
||||
* the user row is rolled back and the server retries on next startup.
|
||||
*/
|
||||
async seedAdmin(): Promise<void> {
|
||||
if (this.deps.users.count() > 0) return;
|
||||
const user: UserRow = {
|
||||
userId: ADMIN_USER_ID,
|
||||
passwordHash: await hashPassword(ADMIN_INITIAL_PASSWORD),
|
||||
isAdmin: true,
|
||||
passwordIsInitial: true,
|
||||
createdAt: this.now().toISOString(),
|
||||
};
|
||||
this.deps.users.insert(user);
|
||||
try {
|
||||
await this.deps.provisionInitialProject(user, true);
|
||||
} catch (err) {
|
||||
this.deps.users.delete(user.userId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {
|
||||
const row = this.deps.users.findById(userId);
|
||||
const ok = row !== null && (await verifyPassword(password, row.passwordHash));
|
||||
if (!row || !ok) {
|
||||
throw new HttpError(401, "invalid_credentials", "用户名或密码错误。");
|
||||
}
|
||||
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
|
||||
}
|
||||
|
||||
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||
const row = this.deps.users.findById(userId);
|
||||
if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) {
|
||||
throw new HttpError(400, "password_mismatch", "当前密码不正确。");
|
||||
}
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
|
||||
}
|
||||
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
||||
}
|
||||
|
||||
logout(token: string): void {
|
||||
this.deps.authSessions.delete(sha256Hex(token));
|
||||
}
|
||||
|
||||
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
|
||||
authenticate(token: string): UserRow | null {
|
||||
const tokenHash = sha256Hex(token);
|
||||
const session = this.deps.authSessions.findByTokenHash(tokenHash);
|
||||
if (!session) return null;
|
||||
const now = this.now();
|
||||
const expiresAt = Date.parse(session.expiresAt);
|
||||
if (!(expiresAt > now.getTime())) {
|
||||
this.deps.authSessions.delete(tokenHash);
|
||||
return null;
|
||||
}
|
||||
if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
|
||||
this.deps.authSessions.touch(
|
||||
tokenHash,
|
||||
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
||||
);
|
||||
}
|
||||
return this.deps.users.findById(session.userId);
|
||||
}
|
||||
|
||||
private issueSession(userId: string): string {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const now = this.now();
|
||||
this.deps.authSessions.insert({
|
||||
tokenHash: sha256Hex(token),
|
||||
userId,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
||||
});
|
||||
return token;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user