feat(auth): add optional GitHub OAuth for a single administrator
新增可选 GitHub OAuth,互斥认证并在切换时保留管理员资料。
This commit is contained in:
1 parent
3fa5257d93
commit
e5bc7090e2
22 files changed
+690
-78
No files matched your search
@@ -0,0 +1,20 @@
|
||||
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';
|
||||
}
|
||||
+13
-14
@@ -5,8 +5,10 @@ import { parse, stringify } from 'smol-toml';
|
||||
import { ensureDatabase } from './cloudflare-d1.ts';
|
||||
import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
|
||||
import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts';
|
||||
import { ensureAccess, resolveWorkerHostname } from './cloudflare-access.ts';
|
||||
import { resolveWorkerHostname } from './cloudflare-access.ts';
|
||||
import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames } from './deployment-secrets.ts';
|
||||
import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts';
|
||||
import { administratorAccountId } from './administrator.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const generatedConfig = '.wrangler.generated.toml';
|
||||
@@ -41,19 +43,14 @@ 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);
|
||||
// 旧部署可能由 Zone 级 Access 管理。未提供邮箱且已有两个 Access Secret 时保留原认证,
|
||||
// 不擅自新建账户级应用覆盖它;新用户仍只需输入邮箱即可走完整自动配置。
|
||||
const preserveAccess = !settings.adminEmail
|
||||
&& existingSecrets.has('ACCESS_TEAM_DOMAIN') && existingSecrets.has('ACCESS_AUD');
|
||||
if (!preserveAccess && !settings.adminEmail) {
|
||||
throw new Error('首次部署需要 ADMIN_EMAIL,请在 Run workflow 输入管理员邮箱,或保存为 Actions 变量。');
|
||||
}
|
||||
const accessSecrets = preserveAccess ? {} : await ensureAccess(api, settings, hostname);
|
||||
console.log(preserveAccess ? '保留已有 Access 认证配置。' : 'Access 应用、身份提供程序与邮箱策略已就绪。');
|
||||
const authentication = await prepareAuthentication(api, settings, hostname, existingSecrets);
|
||||
const database = await ensureDatabase(settings);
|
||||
const secrets = { ...accessSecrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) };
|
||||
const adminAccountId = await administratorAccountId(api, settings, database);
|
||||
const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) };
|
||||
maskSecrets(secrets);
|
||||
const config = createDeploymentConfig(template, settings, database);
|
||||
const config = createDeploymentConfig(template, settings, database, {
|
||||
hostname, adminAccountId, githubAdminId: authentication.githubAdminId,
|
||||
});
|
||||
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
|
||||
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
|
||||
console.log(`部署 Worker ${settings.workerName},使用 D1 ${database.name}(${database.uuid})。`);
|
||||
@@ -63,13 +60,15 @@ async function main(): Promise<void> {
|
||||
await runWrangler(['deploy']);
|
||||
// Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。
|
||||
const deployedSecrets = await readWorkerSecretNames(api, settings);
|
||||
for (const name of ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']) {
|
||||
for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) {
|
||||
if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`);
|
||||
}
|
||||
console.log(`部署完成:https://${hostname}`);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
await appendFile(process.env.GITHUB_STEP_SUMMARY,
|
||||
`## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\nD1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥。\n`);
|
||||
`## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n登录方式:${settings.authProvider}\n\n`
|
||||
+ (settings.authProvider === 'github' ? `GitHub OAuth 回调地址:https://${hostname}/auth/callback\n\n` : '')
|
||||
+ 'D1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥与管理员资料。\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ensureAccess } from './cloudflare-access.ts';
|
||||
import type { CloudflareApi } from './cloudflare-api.ts';
|
||||
import type { DeploymentSettings, RuntimeSecrets } from './deployment-config.ts';
|
||||
|
||||
export function requiredAuthSecrets(provider: DeploymentSettings['authProvider']): string[] {
|
||||
return provider === 'github' ? ['GITHUB_CLIENT_SECRET'] : ['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'];
|
||||
}
|
||||
|
||||
export async function prepareAuthentication(
|
||||
api: CloudflareApi,
|
||||
settings: DeploymentSettings,
|
||||
hostname: string,
|
||||
existing: Set<string>,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<{ secrets: Partial<RuntimeSecrets>; githubAdminId?: string }> {
|
||||
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。');
|
||||
return { secrets: await ensureAccess(api, settings, hostname) };
|
||||
}
|
||||
// GitHub 新部署完全不调用 Zero Trust API。旧域名若仍在 Access 后面,明确停止,
|
||||
// 不擅自删除用户的安全配置,也不把“双重登录”误报为部署成功。
|
||||
if (existing.has('ACCESS_AUD')) {
|
||||
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')) {
|
||||
throw new Error('当前域名仍由 Cloudflare Access 保护。请先在 Access 解除该域名的保护,再切换 GitHub;不会删除主机资料或加密密钥。');
|
||||
}
|
||||
}
|
||||
const response = await fetcher(`https://api.github.com/users/${settings.githubAdmin}`, {
|
||||
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'EdgeSSH' },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`无法确认 GITHUB_ADMIN(HTTP ${response.status}),请检查用户名或稍后重试。`);
|
||||
const user = await response.json() as { id: number; type: string };
|
||||
if (user.type !== 'User' || !Number.isSafeInteger(user.id) || user.id <= 0) {
|
||||
throw new Error('GITHUB_ADMIN 必须是个人 GitHub 账号,不能是组织。');
|
||||
}
|
||||
return { githubAdminId: String(user.id), secrets: { GITHUB_CLIENT_SECRET: settings.secrets.GITHUB_CLIENT_SECRET! } };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TomlTable } from 'smol-toml';
|
||||
|
||||
export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] as const;
|
||||
export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD', 'GITHUB_CLIENT_SECRET'] as const;
|
||||
export type RuntimeSecrets = Record<(typeof runtimeSecretNames)[number], string>;
|
||||
|
||||
export interface DeploymentSettings {
|
||||
@@ -10,6 +10,9 @@ export interface DeploymentSettings {
|
||||
databaseName: string;
|
||||
databaseId?: string;
|
||||
customDomain?: string;
|
||||
authProvider: 'cloudflare' | 'github';
|
||||
githubClientId?: string;
|
||||
githubAdmin?: string;
|
||||
adminEmail?: string;
|
||||
identityProviderIds: string[];
|
||||
secrets: Partial<RuntimeSecrets>;
|
||||
@@ -24,7 +27,9 @@ export function readDeploymentSettings(
|
||||
template: TomlTable,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): DeploymentSettings {
|
||||
const required = ['CLOUDFLARE_API_TOKEN'];
|
||||
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 missing = required.filter((name) => !env[name]?.trim());
|
||||
if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`);
|
||||
|
||||
@@ -52,15 +57,23 @@ export function readDeploymentSettings(
|
||||
throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。');
|
||||
}
|
||||
|
||||
const adminEmail = env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const adminEmail = authProvider === 'cloudflare' ? env.ADMIN_EMAIL?.trim().toLowerCase() : undefined;
|
||||
if (adminEmail && !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(adminEmail)) {
|
||||
throw new Error('ADMIN_EMAIL 必须是一个完整的管理员邮箱,不能填写多个邮箱或整个域名。');
|
||||
}
|
||||
const identityProviderIds = [...new Set((env.ACCESS_IDP_IDS || '').split(',').map((id) => id.trim()).filter(Boolean))];
|
||||
const identityProviderIds = authProvider === 'cloudflare'
|
||||
? [...new Set((env.ACCESS_IDP_IDS || '').split(',').map((id) => id.trim()).filter(Boolean))] : [];
|
||||
if (identityProviderIds.some((id) => !/^[a-f0-9-]{36}$/i.test(id))) {
|
||||
throw new Error('ACCESS_IDP_IDS 必须是逗号分隔的 Cloudflare 身份提供程序 UUID。');
|
||||
}
|
||||
const secrets = Object.fromEntries(runtimeSecretNames
|
||||
const githubClientId = authProvider === 'github' ? env.GITHUB_CLIENT_ID!.trim() : undefined;
|
||||
const githubAdmin = authProvider === 'github' ? env.GITHUB_ADMIN!.trim().toLowerCase() : undefined;
|
||||
if (githubAdmin && !/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(githubAdmin)) {
|
||||
throw new Error('GITHUB_ADMIN 必须是一个 GitHub 用户名。');
|
||||
}
|
||||
const selectedSecrets = authProvider === 'github'
|
||||
? ['ENCRYPTION_KEY', 'GITHUB_CLIENT_SECRET'] : ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'];
|
||||
const secrets = Object.fromEntries(selectedSecrets
|
||||
.filter((name) => env[name]?.trim())
|
||||
.map((name) => [name, env[name]!.trim()])) as Partial<RuntimeSecrets>;
|
||||
if (secrets.ENCRYPTION_KEY) {
|
||||
@@ -80,7 +93,7 @@ export function readDeploymentSettings(
|
||||
|
||||
return {
|
||||
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
|
||||
databaseName, databaseId, customDomain, adminEmail, identityProviderIds, secrets,
|
||||
databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, adminEmail, identityProviderIds, secrets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +101,7 @@ export function createDeploymentConfig(
|
||||
template: TomlTable,
|
||||
settings: DeploymentSettings,
|
||||
database: Database,
|
||||
runtime?: { hostname: string; adminAccountId: string; githubAdminId?: string },
|
||||
): TomlTable {
|
||||
return {
|
||||
...template,
|
||||
@@ -96,6 +110,15 @@ export function createDeploymentConfig(
|
||||
// 自定义入口启用时关闭备用公网入口,避免绕过对应的 Access 登录页。
|
||||
workers_dev: !settings.customDomain,
|
||||
preview_urls: false,
|
||||
vars: {
|
||||
...template.vars as TomlTable,
|
||||
AUTH_PROVIDER: settings.authProvider,
|
||||
...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}`, ADMIN_ACCOUNT_ID: runtime.adminAccountId } : {}),
|
||||
...(settings.authProvider === 'github' ? {
|
||||
GITHUB_CLIENT_ID: settings.githubClientId!,
|
||||
...(runtime?.githubAdminId ? { GITHUB_ADMIN_ID: runtime.githubAdminId } : {}),
|
||||
} : {}),
|
||||
},
|
||||
...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}),
|
||||
d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB'
|
||||
? { ...binding, database_name: database.name, database_id: database.uuid }
|
||||
|
||||
Reference in new issue
Block a user