sync: update from private repo (484a803)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
547a4bbb00
commit
a0923abc40
@ -338,8 +338,8 @@ tools:
|
|||||||
# ─── 認証 (オプション) ────────────────────────────────────────
|
# ─── 認証 (オプション) ────────────────────────────────────────
|
||||||
# 未設定なら認証なしで動作 (従来互換)。
|
# 未設定なら認証なしで動作 (従来互換)。
|
||||||
# auth:
|
# auth:
|
||||||
# # 空のままだと起動時にプロセスごとのランダム値で代替する (再起動でセッション失効)。
|
# # 空のままだと data/secrets/session-secret.key に固定値を自動生成して使う (再起動でセッション維持)。
|
||||||
# # 本番では `openssl rand -hex 32` 等で固定値を生成して設定すること。
|
# # 明示設定したい場合は `openssl rand -hex 32` 等で生成した固定値を入れる (複数ノードで共有する場合は必須)。
|
||||||
# # ⚠ プレースホルダ文字列をそのまま使わない (公開された secret は偽造・改ざんに使われる)。
|
# # ⚠ プレースホルダ文字列をそのまま使わない (公開された secret は偽造・改ざんに使われる)。
|
||||||
# session_secret: ""
|
# session_secret: ""
|
||||||
# session_max_age: 86400000 # 無操作タイムアウト (ms)。アクセスごとに延長 (rolling)。未設定時は30日
|
# session_max_age: 86400000 # 無操作タイムアウト (ms)。アクセスごとに延長 (rolling)。未設定時は30日
|
||||||
@ -381,6 +381,7 @@ tools:
|
|||||||
# ─── Secrets ─────────────────────────────────────────────────
|
# ─── Secrets ─────────────────────────────────────────────────
|
||||||
# secrets:
|
# secrets:
|
||||||
# master_key_path: ./data/secrets/master.key # 32-byte key, auto-generated on first start (mode 0600)
|
# master_key_path: ./data/secrets/master.key # 32-byte key, auto-generated on first start (mode 0600)
|
||||||
|
# session_secret_path: ./data/secrets/session-secret.key # auth.session_secret 未設定時の自動生成先 (mode 0600)
|
||||||
|
|
||||||
# ─── Reflection ("Hermes" mode) ──────────────────────────────
|
# ─── Reflection ("Hermes" mode) ──────────────────────────────
|
||||||
# default OFF。ON にすると毎ジョブ完了後に user memory を LLM が自動更新する。
|
# default OFF。ON にすると毎ジョブ完了後に user memory を LLM が自動更新する。
|
||||||
|
|||||||
@ -10,11 +10,19 @@
|
|||||||
* - touch() slides the expiry forward (rolling sessions).
|
* - touch() slides the expiry forward (rolling sessions).
|
||||||
* - get() deletes and returns null for an already-expired row.
|
* - get() deletes and returns null for an already-expired row.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
import { mkdtempSync, rmSync, existsSync, statSync, writeFileSync, readFileSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
import { Repository } from '../db/repository.js';
|
import { Repository } from '../db/repository.js';
|
||||||
import { createSqliteSessionStore, DEFAULT_SESSION_MAX_AGE_MS, setupAuth } from './auth.js';
|
import {
|
||||||
|
createSqliteSessionStore,
|
||||||
|
DEFAULT_SESSION_MAX_AGE_MS,
|
||||||
|
setupAuth,
|
||||||
|
loadOrCreateSessionSecret,
|
||||||
|
} from './auth.js';
|
||||||
import type { AuthConfig } from '../config.js';
|
import type { AuthConfig } from '../config.js';
|
||||||
import type { SessionData } from 'express-session';
|
import type { SessionData } from 'express-session';
|
||||||
|
|
||||||
@ -145,3 +153,98 @@ describe('session middleware wiring', () => {
|
|||||||
expect(cookieTtlSec(second.headers['set-cookie'])).toBeGreaterThan(0);
|
expect(cookieTtlSec(second.headers['set-cookie'])).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Persistent session secret (survives restarts) ────────────────────────────
|
||||||
|
|
||||||
|
describe('loadOrCreateSessionSecret', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sess-secret-')); });
|
||||||
|
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
it('generates, persists (0600), and reuses the same secret', () => {
|
||||||
|
const p = join(dir, 'nested', 'session-secret.key');
|
||||||
|
const first = loadOrCreateSessionSecret(p);
|
||||||
|
expect(first).toMatch(/^[0-9a-f]{64}$/); // 32 bytes hex
|
||||||
|
expect(existsSync(p)).toBe(true);
|
||||||
|
expect(statSync(p).mode & 0o777).toBe(0o600);
|
||||||
|
// A second call (≈ a restart) must return the SAME secret, not a new one.
|
||||||
|
expect(loadOrCreateSessionSecret(p)).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours an existing secret file verbatim', () => {
|
||||||
|
const p = join(dir, 'session-secret.key');
|
||||||
|
writeFileSync(p, 'pre-existing-secret\n');
|
||||||
|
expect(loadOrCreateSessionSecret(p)).toBe('pre-existing-secret'); // trimmed
|
||||||
|
expect(readFileSync(p, 'utf8')).toBe('pre-existing-secret\n'); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates over an empty/blank file', () => {
|
||||||
|
const p = join(dir, 'session-secret.key');
|
||||||
|
writeFileSync(p, ' \n'); // blank
|
||||||
|
const secret = loadOrCreateSessionSecret(p);
|
||||||
|
expect(secret).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(readFileSync(p, 'utf8').trim()).toBe(secret); // overwritten on disk
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a random secret (no throw) when the path is unwritable', () => {
|
||||||
|
const blocker = join(dir, 'blocker'); // a regular file …
|
||||||
|
writeFileSync(blocker, 'x');
|
||||||
|
const p = join(blocker, 'session-secret.key'); // … used as a parent dir → ENOTDIR
|
||||||
|
const secret = loadOrCreateSessionSecret(p);
|
||||||
|
expect(secret).toMatch(/^[0-9a-f]{64}$/); // random in-memory fallback
|
||||||
|
expect(existsSync(p)).toBe(false); // nothing persisted
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupAuth persistent secret wiring', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sess-auth-')); });
|
||||||
|
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
it('a session signed before a "restart" is still valid after, when session_secret is unset', async () => {
|
||||||
|
const dbPath = join(dir, 'db.sqlite');
|
||||||
|
const secretPath = join(dir, 'session-secret.key');
|
||||||
|
const cfg = { sessionSecret: '', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig;
|
||||||
|
|
||||||
|
function bootApp() {
|
||||||
|
const repo = new Repository(dbPath); // same on-disk store across "restarts"
|
||||||
|
const { sessionMiddleware } = setupAuth(repo, cfg, undefined, secretPath);
|
||||||
|
const app = express();
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
app.get('/bump', (req, res) => {
|
||||||
|
(req.session as unknown as { n: number }).n = 7;
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.get('/read', (req, res) => res.json({ n: (req.session as unknown as { n?: number }).n ?? null }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = bootApp();
|
||||||
|
const res1 = await request(before).get('/bump'); // signs a session cookie
|
||||||
|
const cookie = res1.headers['set-cookie'];
|
||||||
|
expect(cookie).toBeTruthy();
|
||||||
|
|
||||||
|
// Simulate a process restart: a fresh setupAuth re-reads the persisted
|
||||||
|
// secret. With the old per-process random secret the cookie signature would
|
||||||
|
// no longer verify and the session would be lost.
|
||||||
|
const after = bootApp();
|
||||||
|
const replay = await request(after).get('/read').set('Cookie', cookie);
|
||||||
|
expect(replay.body.n).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT touch the secret file when auth.session_secret is configured', () => {
|
||||||
|
const secretPath = join(dir, 'session-secret.key');
|
||||||
|
const repo = new Repository(':memory:');
|
||||||
|
const cfg = { sessionSecret: 'a-configured-secret', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig;
|
||||||
|
setupAuth(repo, cfg, undefined, secretPath);
|
||||||
|
expect(existsSync(secretPath)).toBe(false); // configured secret wins; no persistence
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a whitespace-only session_secret as unset (persists a real one)', () => {
|
||||||
|
const secretPath = join(dir, 'session-secret.key');
|
||||||
|
const repo = new Repository(':memory:');
|
||||||
|
const cfg = { sessionSecret: ' ', secureCookie: false, adminEmails: [], providers: {} } as AuthConfig;
|
||||||
|
setupAuth(repo, cfg, undefined, secretPath);
|
||||||
|
expect(existsSync(secretPath)).toBe(true); // blank → fell back to a persisted secret
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import type { Request, Response, NextFunction, RequestHandler, Router } from 'express';
|
import type { Request, Response, NextFunction, RequestHandler, Router } from 'express';
|
||||||
@ -263,6 +263,65 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
/** Default on-disk location for the auto-generated session secret. */
|
||||||
|
export const DEFAULT_SESSION_SECRET_PATH = './data/secrets/session-secret.key';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the session secret when auth.session_secret is unset. Reads a stable
|
||||||
|
* secret persisted at `secretPath`, generating + saving one (0600, in a 0700
|
||||||
|
* dir) on first run. This keeps sessions valid across restarts instead of the
|
||||||
|
* old per-process random secret that logged everyone out on every restart.
|
||||||
|
* Mirrors initMasterKey (src/crypto/sessions.ts). Falls back to an in-memory
|
||||||
|
* random secret only if the file can't be read or written.
|
||||||
|
*/
|
||||||
|
export function loadOrCreateSessionSecret(secretPath: string): string {
|
||||||
|
try {
|
||||||
|
if (existsSync(secretPath)) {
|
||||||
|
const existing = readFileSync(secretPath, 'utf8').trim();
|
||||||
|
if (existing.length > 0) {
|
||||||
|
if (existing.length < 16) {
|
||||||
|
logger.warn(
|
||||||
|
`[auth] session secret at ${secretPath} is suspiciously short (${existing.length} chars) — it may be truncated. Delete it to regenerate, or set auth.session_secret.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secret = randomBytes(32).toString('hex');
|
||||||
|
mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||||||
|
if (existsSync(secretPath)) {
|
||||||
|
// File exists but was empty/blank (we'd have returned above otherwise) —
|
||||||
|
// overwrite it; no creation race to worry about since it already exists.
|
||||||
|
writeFileSync(secretPath, secret, { mode: 0o600 });
|
||||||
|
} else {
|
||||||
|
// Atomic create: 'wx' fails if another process won the race to create the
|
||||||
|
// file first; re-read theirs so all processes converge on one secret.
|
||||||
|
try {
|
||||||
|
writeFileSync(secretPath, secret, { mode: 0o600, flag: 'wx' });
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||||
|
const won = readFileSync(secretPath, 'utf8').trim();
|
||||||
|
if (won.length > 0) return won;
|
||||||
|
writeFileSync(secretPath, secret, { mode: 0o600 }); // racer wrote blank — overwrite
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chmodSync(secretPath, 0o600);
|
||||||
|
logger.info(
|
||||||
|
`[auth] generated a persistent session secret at ${secretPath} (sessions now survive restarts). Override with auth.session_secret in Settings → Authentication.`,
|
||||||
|
);
|
||||||
|
return secret;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.warn(
|
||||||
|
`[auth] could not persist a session secret at ${secretPath} (${msg}) — using a random per-process secret; sessions reset on restart. Set auth.session_secret in Settings → Authentication.`,
|
||||||
|
);
|
||||||
|
return randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository の SQLite DB を使ったカスタムセッションストア。
|
* Repository の SQLite DB を使ったカスタムセッションストア。
|
||||||
* sessions テーブル (sid, sess, expired) を直接操作する。
|
* sessions テーブル (sid, sess, expired) を直接操作する。
|
||||||
@ -750,20 +809,20 @@ export function setupAuth(
|
|||||||
repo: Repository,
|
repo: Repository,
|
||||||
authConfig: AuthConfig,
|
authConfig: AuthConfig,
|
||||||
getBranding?: () => LoginBranding,
|
getBranding?: () => LoginBranding,
|
||||||
|
sessionSecretPath: string = DEFAULT_SESSION_SECRET_PATH,
|
||||||
): AuthMiddlewares {
|
): AuthMiddlewares {
|
||||||
const db = repo.getDb();
|
const db = repo.getDb();
|
||||||
|
|
||||||
// express-session throws "secret option required" (→ 500 on every request) if
|
// express-session throws "secret option required" (→ 500 on every request) if
|
||||||
// the secret is empty. Auth can be enabled from the Settings UI before a
|
// the secret is empty. Auth can be enabled from the Settings UI before a
|
||||||
// session_secret is set, so fall back to a random per-process secret with a
|
// session_secret is set, so when it's unset we use a secret persisted on disk
|
||||||
// warning rather than bricking the server. Sessions reset on restart until a
|
// (generated once) rather than a per-process random one — the latter logged
|
||||||
// stable value is configured.
|
// everyone out on every restart. A configured auth.session_secret still wins.
|
||||||
let sessionSecret = authConfig.sessionSecret;
|
// Trim so a whitespace-only YAML value (" ") isn't accepted as a (very weak)
|
||||||
if (!sessionSecret || sessionSecret.length === 0) {
|
// secret — treat it as unset and fall back to the persisted secret.
|
||||||
sessionSecret = randomBytes(32).toString('hex');
|
let sessionSecret = authConfig.sessionSecret?.trim() ?? '';
|
||||||
logger.warn(
|
if (sessionSecret.length === 0) {
|
||||||
'[auth] auth.session_secret is unset — using a random per-process secret; sessions reset on restart. Set a stable value in Settings → Authentication.',
|
sessionSecret = loadOrCreateSessionSecret(sessionSecretPath);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// セッションミドルウェア
|
// セッションミドルウェア
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
|
|||||||
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
|
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
|
||||||
import { setSkillToolDeps } from '../engine/tools/skills.js';
|
import { setSkillToolDeps } from '../engine/tools/skills.js';
|
||||||
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
|
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
|
||||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds } from './auth.js';
|
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
|
||||||
import { canUserSeeTask } from './visibility.js';
|
import { canUserSeeTask } from './visibility.js';
|
||||||
import { mountAdminApi } from './admin-api.js';
|
import { mountAdminApi } from './admin-api.js';
|
||||||
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
||||||
@ -329,6 +329,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
const b = resolveBranding(opts.configManager);
|
const b = resolveBranding(opts.configManager);
|
||||||
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
||||||
},
|
},
|
||||||
|
loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH,
|
||||||
);
|
);
|
||||||
const { sessionMiddleware, passportInit, passportSession, authRouter } = auth;
|
const { sessionMiddleware, passportInit, passportSession, authRouter } = auth;
|
||||||
authenticateUpgrade = auth.authenticateUpgrade;
|
authenticateUpgrade = auth.authenticateUpgrade;
|
||||||
|
|||||||
@ -492,6 +492,7 @@ export interface AppConfig {
|
|||||||
secrets?: {
|
secrets?: {
|
||||||
masterKeyPath?: string; // default './data/secrets/master.key'
|
masterKeyPath?: string; // default './data/secrets/master.key'
|
||||||
mcpKeyPath?: string; // default './data/secrets/mcp.key'
|
mcpKeyPath?: string; // default './data/secrets/mcp.key'
|
||||||
|
sessionSecretPath?: string; // default './data/secrets/session-secret.key' (auto-generated when auth.session_secret unset)
|
||||||
};
|
};
|
||||||
userFolderRoot?: string; // default './data/users'
|
userFolderRoot?: string; // default './data/users'
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user