fix(auth): 固定单管理员工作区并撤销旧会话 / stabilize admin workspace and revoke stale sessions

This commit is contained in:
gdz66601 committed 2026-09-23 07:08:40 +08:00
1 parent 3e794e123e
commit c6e5786bba
24 files changed
+617 -162

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
import type { CloudflareApi } from './cloudflare-api.ts';
import type { Database, DeploymentSettings } from './deployment-config.ts';
export async function administratorAccountId(api: CloudflareApi, settings: DeploymentSettings, database: Database): Promise<string> {
const path = `/accounts/${settings.accountId}/d1/database/${database.uuid}/query`;
const tables = await api.request<{ success: boolean; results: { name: string }[] }[]>(path, 'POST', {
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'hosts'",
});
if (!tables[0].success) throw new Error('无法读取管理员资料归属,请重试。');
if (!tables[0].results.length) return 'admin';
const result = await api.request<{ success: boolean; results: { account_id: string }[] }[]>(path, 'POST', {
sql: 'SELECT DISTINCT account_id FROM hosts LIMIT 2',
});
if (!result[0].success) throw new Error('无法读取管理员资料归属,请重试。');
const owners = result[0].results;
// 沿用旧库唯一所有者,既不搬数据,也不改变 AES-GCM 的 AAD;新库统一使用 admin。
// 发现多所有者时不猜测,更不能把本来隔离的数据自动交给一个账号。
if (owners.length > 1) throw new Error('数据库存在多个资料所有者,无法自动转为单管理员;请先明确资料归属。');
return owners[0]?.account_id || 'admin';
}
+16 -5
View File
@@ -6,9 +6,9 @@ import { ensureDatabase } from './cloudflare-d1.ts';
import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts';
import { resolveWorkerHostname } from './cloudflare-access.ts';
import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames } from './deployment-secrets.ts';
import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts';
import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts';
import { administratorAccountId } from './administrator.ts';
import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const generatedConfig = '.wrangler.generated.toml';
@@ -43,13 +43,22 @@ async function main(): Promise<void> {
process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId;
const existingSecrets = await readWorkerSecretNames(api, settings);
const hostname = settings.customDomain || await resolveWorkerHostname(api, settings);
const authentication = await prepareAuthentication(api, settings, hostname, existingSecrets);
const database = await ensureDatabase(settings);
const adminAccountId = await administratorAccountId(api, settings, database);
const workspace = await readWorkspaceState(api, settings, database);
const deployedGitHubId = workspace.githubAdminId
?? (settings.authProvider === 'github' ? await readWorkerVariable(api, settings, 'GITHUB_ADMIN_ID') : undefined);
if (deployedGitHubId && !/^[1-9]\d*$/.test(deployedGitHubId)) {
throw new Error('已部署的 GitHub 管理员数字 ID 无效,停止自动覆盖。');
}
const authentication = await prepareAuthentication(api, settings, hostname, {
existingSecrets,
fixedGithubAdminId: deployedGitHubId,
previousProvider: workspace.authProvider,
});
const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) };
maskSecrets(secrets);
const config = createDeploymentConfig(template, settings, database, {
hostname, adminAccountId, githubAdminId: authentication.githubAdminId,
hostname, githubAdminId: authentication.githubAdminId,
});
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
@@ -58,6 +67,8 @@ async function main(): Promise<void> {
// Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。
if (Object.keys(secrets).length) await runWrangler(['secret', 'bulk'], JSON.stringify(secrets));
await runWrangler(['deploy']);
// Worker 成功发布后再切换状态;失败的发布不会让旧版本提前失去当前认证方式。
await updateWorkspaceState(api, settings, database, workspace, authentication.githubAdminId);
// Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。
const deployedSecrets = await readWorkerSecretNames(api, settings);
for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) {
+27 -10
View File
@@ -2,6 +2,13 @@ import { ensureAccess } from './cloudflare-access.ts';
import type { CloudflareApi } from './cloudflare-api.ts';
import type { DeploymentSettings, RuntimeSecrets } from './deployment-config.ts';
interface AuthenticationOptions {
existingSecrets?: Set<string>;
fixedGithubAdminId?: string;
previousProvider?: DeploymentSettings['authProvider'];
fetcher?: typeof fetch;
}
export function requiredAuthSecrets(provider: DeploymentSettings['authProvider']): string[] {
return provider === 'github' ? ['GITHUB_CLIENT_SECRET'] : ['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'];
}
@@ -10,26 +17,36 @@ export async function prepareAuthentication(
api: CloudflareApi,
settings: DeploymentSettings,
hostname: string,
existing: Set<string>,
fetcher: typeof fetch = fetch,
options: AuthenticationOptions = {},
): Promise<{ secrets: Partial<RuntimeSecrets>; githubAdminId?: string }> {
const fetcher = options.fetcher ?? fetch;
if (settings.authProvider === 'cloudflare') {
const preserve = !settings.adminEmail && requiredAuthSecrets('cloudflare').every((name) => existing.has(name));
if (preserve) {
console.log('保留已有 Cloudflare Access 认证配置。');
return { secrets: {} };
}
if (!settings.adminEmail) throw new Error('首次配置 Cloudflare 登录需要 ADMIN_EMAIL。');
// 每次从对应 Access 应用刷新 Team Domain/AUD;Secret 名称本身不能证明入口仍然有效。
return { secrets: await ensureAccess(api, settings, hostname) };
}
// GitHub 新部署完全不调用 Zero Trust API。旧域名若仍在 Access 后面,明确停止,
// 不擅自删除用户的安全配置,也不把“双重登录”误报为部署成功。
if (existing.has('ACCESS_AUD')) {
try {
const response = await fetcher(`https://${hostname}/api/auth/me`, { redirect: 'manual', signal: AbortSignal.timeout(30_000) });
const location = response.headers.get('Location');
if (location && new URL(location, `https://${hostname}`).hostname.endsWith('.cloudflareaccess.com')) {
const target = location ? new URL(location, `https://${hostname}`) : null;
if (target && (target.hostname.endsWith('.cloudflareaccess.com')
|| target.pathname.startsWith('/cdn-cgi/access/'))) {
throw new Error('当前域名仍由 Cloudflare Access 保护。请先在 Access 解除该域名的保护,再切换 GitHub;不会删除主机资料或加密密钥。');
}
} catch (error) {
if (error instanceof Error && error.message.includes('仍由 Cloudflare Access 保护')) throw error;
if (options.previousProvider === 'cloudflare'
|| [...(options.existingSecrets ?? [])].some((name) => name.startsWith('ACCESS_'))) {
throw new Error('无法确认入口是否已解除 Cloudflare Access 保护,请检查域名后重试;不会自动删除现有策略。');
}
}
const fixed = settings.githubAdminId ?? options.fixedGithubAdminId;
if (fixed) {
return { githubAdminId: fixed, secrets: { GITHUB_CLIENT_SECRET: settings.secrets.GITHUB_CLIENT_SECRET! } };
}
if (!settings.githubAdmin) {
throw new Error('首次配置 GitHub 登录需要 GITHUB_ADMIN 用户名,或显式设置 GITHUB_ADMIN_ID。');
}
const response = await fetcher(`https://api.github.com/users/${settings.githubAdmin}`, {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'EdgeSSH' },
+11 -5
View File
@@ -13,6 +13,7 @@ export interface DeploymentSettings {
authProvider: 'cloudflare' | 'github';
githubClientId?: string;
githubAdmin?: string;
githubAdminId?: string;
adminEmail?: string;
identityProviderIds: string[];
secrets: Partial<RuntimeSecrets>;
@@ -29,7 +30,7 @@ export function readDeploymentSettings(
): DeploymentSettings {
const authProvider = env.AUTH_PROVIDER?.trim() || 'cloudflare';
if (authProvider !== 'cloudflare' && authProvider !== 'github') throw new Error('AUTH_PROVIDER 只能是 cloudflare 或 github。');
const required = ['CLOUDFLARE_API_TOKEN', ...(authProvider === 'github' ? ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET', 'GITHUB_ADMIN'] : [])];
const required = ['CLOUDFLARE_API_TOKEN', ...(authProvider === 'github' ? ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'] : [])];
const missing = required.filter((name) => !env[name]?.trim());
if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`);
@@ -67,10 +68,14 @@ export function readDeploymentSettings(
throw new Error('ACCESS_IDP_IDS 必须是逗号分隔的 Cloudflare 身份提供程序 UUID。');
}
const githubClientId = authProvider === 'github' ? env.GITHUB_CLIENT_ID!.trim() : undefined;
const githubAdmin = authProvider === 'github' ? env.GITHUB_ADMIN!.trim().toLowerCase() : undefined;
const githubAdmin = authProvider === 'github' ? env.GITHUB_ADMIN?.trim().toLowerCase() || undefined : undefined;
if (githubAdmin && !/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(githubAdmin)) {
throw new Error('GITHUB_ADMIN 必须是一个 GitHub 用户名。');
}
const githubAdminId = authProvider === 'github' ? env.GITHUB_ADMIN_ID?.trim() || undefined : undefined;
if (githubAdminId && !/^[1-9]\d*$/.test(githubAdminId)) {
throw new Error('GITHUB_ADMIN_ID 必须是 GitHub 个人账号的数字用户 ID。');
}
const selectedSecrets = authProvider === 'github'
? ['ENCRYPTION_KEY', 'GITHUB_CLIENT_SECRET'] : ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'];
const secrets = Object.fromEntries(selectedSecrets
@@ -93,7 +98,8 @@ export function readDeploymentSettings(
return {
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, adminEmail, identityProviderIds, secrets,
databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, githubAdminId,
adminEmail, identityProviderIds, secrets,
};
}
@@ -101,7 +107,7 @@ export function createDeploymentConfig(
template: TomlTable,
settings: DeploymentSettings,
database: Database,
runtime?: { hostname: string; adminAccountId: string; githubAdminId?: string },
runtime?: { hostname: string; githubAdminId?: string },
): TomlTable {
return {
...template,
@@ -113,7 +119,7 @@ export function createDeploymentConfig(
vars: {
...template.vars as TomlTable,
AUTH_PROVIDER: settings.authProvider,
...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}`, ADMIN_ACCOUNT_ID: runtime.adminAccountId } : {}),
...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}` } : {}),
...(settings.authProvider === 'github' ? {
GITHUB_CLIENT_ID: settings.githubClientId!,
...(runtime?.githubAdminId ? { GITHUB_ADMIN_ID: runtime.githubAdminId } : {}),
+15
View File
@@ -11,6 +11,21 @@ export async function readWorkerSecretNames(api: CloudflareApi, settings: Deploy
return new Set(secrets.map((secret) => secret.name));
}
export async function readWorkerVariable(
api: CloudflareApi,
settings: DeploymentSettings,
name: string,
): Promise<string | undefined> {
const base = `/accounts/${settings.accountId}/workers/scripts`;
const workers = await api.request<{ id: string }[]>(base);
if (!workers.some((worker) => worker.id === settings.workerName)) return undefined;
const configuration = await api.request<{ bindings: { name: string; type: string; text?: string }[] }>(
`${base}/${settings.workerName}/settings`,
);
const binding = configuration.bindings.find((entry) => entry.name === name && entry.type === 'plain_text');
return binding?.text;
}
export async function prepareEncryptionSecret(
api: CloudflareApi,
settings: DeploymentSettings,
+116
View File
@@ -0,0 +1,116 @@
import type { CloudflareApi } from './cloudflare-api.ts';
import type { Database, DeploymentSettings } from './deployment-config.ts';
export interface DeploymentWorkspaceState {
accountId: string;
authProvider?: 'cloudflare' | 'github';
authRevision: number;
accessNotBefore: number;
githubAdminId?: string;
}
interface StateRow {
account_id: string;
auth_provider: string;
auth_revision: number;
access_not_before: number;
github_admin_id: string | null;
}
interface QueryResult<T> { success: boolean; results: T[] }
function databasePath(settings: DeploymentSettings, database: Database): string {
return `/accounts/${settings.accountId}/d1/database/${database.uuid}/query`;
}
async function query<T>(
api: CloudflareApi,
settings: DeploymentSettings,
database: Database,
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const result = await api.request<QueryResult<T>[]>(databasePath(settings, database), 'POST', { sql, params });
if (result.length !== 1 || !result[0].success) throw new Error('无法读取管理员工作区状态,请重试。');
return result[0].results;
}
export async function readWorkspaceState(
api: CloudflareApi,
settings: DeploymentSettings,
database: Database,
): Promise<DeploymentWorkspaceState> {
const tables = await query<{ name: string }>(api, settings, database,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('hosts', 'workspace_state')");
const names = new Set(tables.map((table) => table.name));
const owners = names.has('hosts')
? await query<{ account_id: string }>(api, settings, database, 'SELECT DISTINCT account_id FROM hosts LIMIT 2') : [];
if (owners.length > 1) {
throw new Error('数据库存在多个资料所有者,无法自动转为单管理员;请先明确资料归属。');
}
if (names.has('workspace_state')) {
const rows = await query<StateRow>(api, settings, database,
'SELECT account_id, auth_provider, auth_revision, access_not_before, github_admin_id FROM workspace_state WHERE id = 1');
const row = rows[0];
if (row) {
if (row.auth_provider !== 'cloudflare' && row.auth_provider !== 'github') {
throw new Error('管理员工作区中的认证方式无效,请检查数据库状态。');
}
if (owners[0] && owners[0].account_id !== row.account_id) {
throw new Error('数据库资料所有者与固定管理员工作区不一致,停止自动部署。');
}
return {
accountId: row.account_id,
authProvider: row.auth_provider,
authRevision: row.auth_revision,
accessNotBefore: row.access_not_before,
githubAdminId: row.github_admin_id ?? undefined,
};
}
}
// 只在首次初始化读取旧 hosts;状态落库后,即使删除最后一台主机也不会改变 owner。
return {
accountId: owners[0]?.account_id || 'admin',
authRevision: 0,
accessNotBefore: 0,
};
}
export async function updateWorkspaceState(
api: CloudflareApi,
settings: DeploymentSettings,
database: Database,
previous: DeploymentWorkspaceState,
githubAdminId?: string,
now = Math.floor(Date.now() / 1000),
): Promise<DeploymentWorkspaceState> {
const rows = await query<StateRow>(api, settings, database,
'SELECT account_id, auth_provider, auth_revision, access_not_before, github_admin_id FROM workspace_state WHERE id = 1');
const current = rows[0];
if (!current) throw new Error('数据库迁移未建立管理员工作区状态,停止部署。');
if (current.account_id !== previous.accountId) throw new Error('管理员工作区在部署期间发生变化,停止部署。');
const retainedGitHubId = settings.authProvider === 'github' ? githubAdminId : (current.github_admin_id ?? undefined);
if (settings.authProvider === 'github' && !retainedGitHubId) {
throw new Error('GitHub 管理员数字 ID 尚未固定,停止部署。');
}
const providerChanged = current.auth_provider !== settings.authProvider;
const administratorChanged = settings.authProvider === 'github'
&& current.github_admin_id !== null && current.github_admin_id !== retainedGitHubId;
const revision = current.auth_revision + (providerChanged || administratorChanged ? 1 : 0);
const accessNotBefore = providerChanged ? now + 1 : current.access_not_before;
await query(api, settings, database,
`UPDATE workspace_state
SET auth_provider = ?, auth_revision = ?, access_not_before = ?, github_admin_id = ?
WHERE id = 1 AND account_id = ?`,
[settings.authProvider, revision, accessNotBefore, retainedGitHubId ?? null, previous.accountId]);
return {
accountId: previous.accountId,
authProvider: settings.authProvider,
authRevision: revision,
accessNotBefore,
githubAdminId: retainedGitHubId,
};
}