fix(auth): 固定单管理员工作区并撤销旧会话 / stabilize admin workspace and revoke stale sessions
This commit is contained in:
1 parent
3e794e123e
commit
c6e5786bba
24 files changed
+617
-162
No files matched your search
@@ -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
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user