feat(server): randomize the seeded admin initial password (#172)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 16:50:46 +08:00
committed by GitHub
parent e05dea536b
commit 045ac250e0
35 changed files with 302 additions and 58 deletions
+1
View File
@@ -214,6 +214,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
authSessions: authSessionsRepo,
provisionInitialProject: (user, isAdmin) =>
projectService.provisionInitialProject(user, isAdmin),
seedAdminPassword: config.seedAdminPassword,
sessionTtlMs: config.authSessionTtlMs,
sessionRenewMs: config.authSessionRenewMs,
...(overrides.now ? { now: overrides.now } : {}),
+84 -9
View File
@@ -3,15 +3,16 @@
* 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 penguin-2026), and it adopts
* `default_project`; all other users are created by an admin via the user
* backend (admin-service).
* admin `admin` is seeded with a random `penguin-<4 digits>` initial password
* (printed once by the startup entrypoint; PENGUIN_SEED_ADMIN_PASSWORD injects
* a fixed one for tests/e2e), 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 { createHash, randomBytes, randomInt } 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";
@@ -20,9 +21,37 @@ 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). */
/** Built-in admin user_id. */
export const ADMIN_USER_ID = "admin";
export const ADMIN_INITIAL_PASSWORD = "penguin-2026";
/**
* Login throttling (per userId): the seeded initial password is `penguin-<4 digits>` —
* 10,000 combinations — so unthrottled guessing would enumerate it in minutes. After
* LOGIN_FREE_ATTEMPTS consecutive failures, the next attempt is admitted only after an
* exponentially growing delay from the last failure (1s, 2s, … capped at 60s; attempts
* inside the window are 429 `too_many_attempts` and do not extend it). Beyond ~40
* failures that is one guess per minute, so the 10k space stops being enumerable, while
* a legitimate user who mistyped a few times never waits more than the cap. A successful
* login clears the counter. Counters are process memory (a restart clears them —
* restarting is slower than waiting out the cap) and are kept for nonexistent userIds
* too, so throttling is not an account-existence oracle. Known limit: a concurrent burst
* can slip in before its first failure is recorded; the steady-state backoff still
* dominates the search space.
*/
const LOGIN_FREE_ATTEMPTS = 5;
const LOGIN_BACKOFF_START_MS = 1000;
const LOGIN_BACKOFF_CAP_MS = 60_000;
/** Failure entries idle longer than this are swept (bounds the map; far above the cap). */
const LOGIN_FAILURE_IDLE_MS = 15 * 60_000;
/**
* Random initial password for the seeded admin: `penguin-<4 digits>` — brand-related and
* easy to type, shown once in the server startup output (the README, docs and login-page
* hint all describe this form).
*/
export function generateInitialAdminPassword(): string {
return "penguin-" + String(randomInt(0, 10000)).padStart(4, "0");
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
@@ -42,6 +71,8 @@ export interface AuthServiceDeps {
authSessions: AuthSessionsRepo;
/** Provisions the initial Project at signup (injected by project-service, to avoid a circular dependency). */
provisionInitialProject: (user: UserRow, isAdmin: boolean) => Promise<void>;
/** Fixed initial password for the seeded admin (config.seedAdminPassword); null generates a random one at seed time. */
seedAdminPassword: string | null;
sessionTtlMs: number;
sessionRenewMs: number;
now?: () => Date;
@@ -58,12 +89,25 @@ export class AuthService {
* 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.
* Returns the initial password when it actually seeded — the caller prints it,
* the only place a generated password is ever shown — and null when users
* already exist.
*/
async seedAdmin(): Promise<void> {
if (this.deps.users.count() > 0) return;
async seedAdmin(): Promise<string | null> {
if (this.deps.users.count() > 0) return null;
const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword();
// The override (PENGUIN_SEED_ADMIN_PASSWORD) must meet the same policy as every
// other initial/reset password; rejecting it here, before any insert, keeps a
// configuration typo from creating a trivially weak privileged account. Generated
// passwords are always 12 characters and never trip this.
if (password.length < MIN_PASSWORD_LENGTH) {
throw new Error(
`PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`,
);
}
const user: UserRow = {
userId: ADMIN_USER_ID,
passwordHash: await hashPassword(ADMIN_INITIAL_PASSWORD),
passwordHash: await hashPassword(password),
isAdmin: true,
passwordIsInitial: true,
createdAt: this.now().toISOString(),
@@ -75,14 +119,45 @@ export class AuthService {
this.deps.users.delete(user.userId);
throw err;
}
return password;
}
/** Consecutive login failures per userId (see the throttling comment on the constants). */
private readonly loginFailures = new Map<string, { failures: number; lastFailureAt: number }>();
/** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */
private loginDelayMs(failures: number): number {
const excess = failures - LOGIN_FREE_ATTEMPTS;
if (excess <= 0) return 0;
return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);
}
async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {
const nowMs = this.now().getTime();
for (const [key, entry] of this.loginFailures) {
if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);
}
const failed = this.loginFailures.get(userId);
if (failed) {
const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);
if (nowMs < readyAt) {
throw new HttpError(
429,
"too_many_attempts",
`Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1000)}s.`,
);
}
}
const row = this.deps.users.findById(userId);
const ok = row !== null && (await verifyPassword(password, row.passwordHash));
if (!row || !ok) {
this.loginFailures.set(userId, {
failures: (failed?.failures ?? 0) + 1,
lastFailureAt: this.now().getTime(),
});
throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
}
this.loginFailures.delete(userId);
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
}
+9 -1
View File
@@ -32,6 +32,12 @@ export interface ServerConfig {
* derived per request instead. See design § "Workspace 文件预览".
*/
previewOrigin: string | null;
/**
* Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD),
* used by automated tests and e2e; null (the norm) makes the seed generate a random
* `penguin-<4 digits>` password, printed once to the server console.
*/
seedAdminPassword: string | null;
/** Login session validity period (7 days). */
authSessionTtlMs: number;
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
@@ -73,7 +79,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null {
return url.origin;
}
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN). */
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const root = env.PENGUIN_HOME ?? resolveRoot();
// An empty PORT string is treated as unset (the common `.env` case of an empty
@@ -90,6 +96,8 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
// An empty/whitespace value is treated as unset (→ random seed password).
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null,
authSessionTtlMs: 7 * DAY_MS,
authSessionRenewMs: 6 * DAY_MS,
};
+9 -2
View File
@@ -21,8 +21,15 @@ const config = resolveServerConfig();
const deps = buildAppDeps(config);
const app = createApp(deps);
// Built-in admin seed (idempotent): creates admin (initial password penguin-2026) and adopts default_project when the users table is empty.
await deps.authService.seedAdmin();
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
// users table is empty. The returned initial password (random unless pinned via
// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown.
const seededAdminPassword = await deps.authService.seedAdmin();
if (seededAdminPassword !== null) {
console.log(
`Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`,
);
}
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
await deps.scheduler.start();