diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cf7fc06..72543fc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,25 +40,25 @@ jobs: # 准备配置文件 - name: Prepare Wrangler Config env: - CONFIG_FILE: ${{ env.WRANGLER_CONFIG || 'wrangler.jsonc' }} + CONFIG_FILE: ${{ env.WRANGLER_CONFIG || 'wrangler.toml' }} run: | cd backend - cp wrangler.jsonc wrangler.jsonc.bak + cp wrangler.toml wrangler.toml.bak - if [ "$CONFIG_FILE" != "wrangler.jsonc" ]; then - cp "$CONFIG_FILE" wrangler.jsonc + if [ "$CONFIG_FILE" != "wrangler.toml" ]; then + cp "$CONFIG_FILE" wrangler.toml fi # 注入 D1 数据库配置 - name: Inject D1 Database ID env: - CONFIG_FILE: ${{ env.WRANGLER_CONFIG || 'wrangler.jsonc' }} + CONFIG_FILE: ${{ env.WRANGLER_CONFIG || 'wrangler.toml' }} CLOUDFLARE_D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }} CLOUDFLARE_D1_DATABASE_NAME: ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }} run: | # 仅替换第一个匹配到的 database_id (对应生产环境配置),保留 dev 环境配置 - sed -i "0,/\"database_id\":/s/\"database_id\": \".*\"/\"database_id\": \"$CLOUDFLARE_D1_DATABASE_ID\"/" "backend/wrangler.jsonc" - sed -i "0,/\"database_name\":/s/\"database_name\": \".*\"/\"database_name\": \"$CLOUDFLARE_D1_DATABASE_NAME\"/" "backend/wrangler.jsonc" + sed -i "0,/database_id =/s/database_id = \".*\"/database_id = \"$CLOUDFLARE_D1_DATABASE_ID\"/" "backend/wrangler.toml" + sed -i "0,/database_name =/s/database_name = \".*\"/database_name = \"$CLOUDFLARE_D1_DATABASE_NAME\"/" "backend/wrangler.toml" # 更新数据库结构 - name: Update Database Schema @@ -163,6 +163,6 @@ jobs: if: always() run: | cd backend - if [ -f wrangler.jsonc.bak ]; then - mv wrangler.jsonc.bak wrangler.jsonc + if [ -f wrangler.toml.bak ]; then + mv wrangler.toml.bak wrangler.toml fi \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 369b22a..da34493 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,7 +2,7 @@ "name": "backend", "type": "module", "scripts": { - "dev": "wrangler dev --config wrangler.dev.jsonc --host 0.0.0.0 --port 8787 --test-scheduled", + "dev": "wrangler dev --config wrangler.dev.toml --host 0.0.0.0 --port 8787 --test-scheduled", "deploy": "wrangler deploy --minify" }, "dependencies": { @@ -20,4 +20,4 @@ "overrides": { "esbuild": ">=0.25.0" } -} +} \ No newline at end of file diff --git a/backend/src/app/index.ts b/backend/src/app/index.ts index 6ec18c4..0fb0505 100644 --- a/backend/src/app/index.ts +++ b/backend/src/app/index.ts @@ -10,6 +10,8 @@ import vaultRoutes from '@/features/vault/vaultRoutes'; import backupRoutes, { handleScheduledBackup } from '@/features/backup/backupRoutes'; import telegramRoutes from '@/features/telegram/telegramRoutes'; import toolsRoutes from '@/features/tools/toolsRoutes'; +import healthRoutes from '@/features/health/healthRoutes'; +import { runHealthCheck } from '@/shared/utils/health'; // 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets) type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise } }; @@ -45,19 +47,44 @@ app.use('*', secureHeaders({ // 2. 健康检查接口 (用于测试后端是否正常启动) app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!')); -// 3. 挂载子路由 +// 3. 全局安全安检拦截器 (Security Shield Middleware) +app.use('/api/*', async (c, next) => { + // 豁免路由: 允许放行 /api/health 系列接口, 允许已登录用户强制登出 + const path = c.req.path; + if (path.startsWith('/api/health') || path === '/api/oauth/logout') { + await next(); + return; + } + + // 执行安检 + const securityResult = runHealthCheck(c.env); + if (!securityResult.passed) { + // 发现安全环境不合格,阻断此请求 + return c.json({ + code: 403, + success: false, + message: 'health_check_failed', + data: securityResult.issues + }, 403); + } + + await next(); +}); + +// 4. 挂载子路由 +app.route('/api/health', healthRoutes); app.route('/api/oauth', authRoutes); app.route('/api/vault', vaultRoutes); // 'accounts' is now 'vault' app.route('/api/backups', backupRoutes); app.route('/api/telegram', telegramRoutes); app.route('/api/tools', toolsRoutes); -// 4. API 404 处理 (必须在静态资源 fallback 之前,确保 API 路径返回 JSON) +// 5. API 404 处理 (必须在静态资源 fallback 之前,确保 API 路径返回 JSON) app.all('/api/*', (c) => { return c.json({ success: false, error: 'API Not Found' }, 404); }); -// 5. 静态资源托管 (让 Hono 接管所有非 API 请求,以便应用 CSP 安全头) +// 6. 静态资源托管 (让 Hono 接管所有非 API 请求,以便应用 CSP 安全头) app.get('*', async (c) => { const res = await c.env.ASSETS.fetch(c.req.raw); // 关键修复:ASSETS 返回的 Response 可能是不可变的。创建副本以允许 Hono 中间件添加 CSP 头。 diff --git a/backend/src/features/auth/authService.ts b/backend/src/features/auth/authService.ts index 894a899..ed4f7f9 100644 --- a/backend/src/features/auth/authService.ts +++ b/backend/src/features/auth/authService.ts @@ -82,7 +82,7 @@ export class AuthService { private verifyWhitelist(userInfo: OAuthUserInfo, whitelistFields: string[]) { // 如果 explicitly allowed all, 则放行 const allowAllStr = String(this.env.OAUTH_ALLOW_ALL || '').toLowerCase(); - if (allowAllStr === 'true' || allowAllStr === '1') { + if (allowAllStr === 'true' || allowAllStr === '1' || allowAllStr === '2') { return; } diff --git a/backend/src/features/health/healthRoutes.ts b/backend/src/features/health/healthRoutes.ts new file mode 100644 index 0000000..f3d8f40 --- /dev/null +++ b/backend/src/features/health/healthRoutes.ts @@ -0,0 +1,23 @@ +import { Hono } from 'hono'; +import { EnvBindings, AppError } from '@/app/config'; +import { runHealthCheck } from '@/shared/utils/health'; + +const health = new Hono<{ Bindings: EnvBindings }>(); + +// GET /api/health/health-check +// 专门用于系统前端的启动体检 +health.get('/health-check', (c) => { + // 强制不缓存此接口 + c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + c.header('Pragma', 'no-cache'); + c.header('Expires', '0'); + + const result = runHealthCheck(c.env); + + return c.json({ + success: true, + ...result + }); +}); + +export default health; diff --git a/backend/src/shared/utils/health.ts b/backend/src/shared/utils/health.ts new file mode 100644 index 0000000..81e021b --- /dev/null +++ b/backend/src/shared/utils/health.ts @@ -0,0 +1,230 @@ +import { EnvBindings } from '@/app/config'; + +export type HealthIssueLevel = 'critical' | 'error' | 'warning'; + +export interface HealthIssue { + field: string; + level: HealthIssueLevel; + message: string; + suggestion: string; + missingFields?: string[]; +} + +export interface HealthCheckResult { + passed: boolean; + issues: HealthIssue[]; + passedChecks: string[]; +} + +export const runHealthCheck = (env: EnvBindings): HealthCheckResult => { + const issues: HealthIssue[] = []; + const passedChecks: string[] = []; + + // 1. Check ENCRYPTION_KEY + const encKey = env.ENCRYPTION_KEY || ''; + // Note: User requests >= 32 length + if (!encKey || encKey.length < 32) { + issues.push({ + field: 'ENCRYPTION_KEY', + level: 'critical', + message: 'encryption_key_too_short', + suggestion: 'encryption_key_suggestion' + }); + } else { + passedChecks.push('encryption_key_passed'); + } + + // 2. Check JWT_SECRET + const jwtSecret = env.JWT_SECRET || ''; + if (!jwtSecret || jwtSecret.length < 32) { + issues.push({ + field: 'JWT_SECRET', + level: 'critical', + message: 'jwt_secret_too_short', + suggestion: 'jwt_secret_suggestion' + }); + } else { + passedChecks.push('jwt_secret_passed'); + } + + // 3. Check OAUTH_ALLOW_ALL + if (String(env.OAUTH_ALLOW_ALL).toLowerCase() === 'true' || env.OAUTH_ALLOW_ALL === '1') { + issues.push({ + field: 'OAUTH_ALLOW_ALL', + level: 'critical', + message: 'oauth_allow_all_enabled', + suggestion: 'oauth_allow_all_suggestion' + }); + } else { + passedChecks.push('oauth_allow_all_passed'); + } + + // 4. Check OAUTH_ALLOWED_USERS + const allowedUsers = env.OAUTH_ALLOWED_USERS || ''; + if (!allowedUsers || allowedUsers.trim().length === 0) { + issues.push({ + field: 'OAUTH_ALLOWED_USERS', + level: 'error', + message: 'allowed_users_empty', + suggestion: 'allowed_users_suggestion' + }); + } else { + passedChecks.push('allowed_users_passed'); + } + + // 5. Check OAuth Providers Integrity + let hasAtLeastOneProvider = false; + const providerStatus: Record = { + github: 'none', telegram: 'none', google: 'none', nodeloc: 'none', gitee: 'none', cloudflare: 'none' + }; + + // Github + if (env.OAUTH_GITHUB_CLIENT_ID || env.OAUTH_GITHUB_CLIENT_SECRET || env.OAUTH_GITHUB_REDIRECT_URI) { + const missing = []; + if (!env.OAUTH_GITHUB_CLIENT_ID) missing.push('OAUTH_GITHUB_CLIENT_ID'); + if (!env.OAUTH_GITHUB_CLIENT_SECRET) missing.push('OAUTH_GITHUB_CLIENT_SECRET'); + if (!env.OAUTH_GITHUB_REDIRECT_URI) missing.push('OAUTH_GITHUB_REDIRECT_URI'); + + if (missing.length > 0) { + providerStatus.github = 'missing'; + issues.push({ + field: 'OAUTH_GITHUB', + level: 'error', + message: 'github_config_incomplete', + suggestion: 'github_config_suggestion', + missingFields: missing + }); + } else { + hasAtLeastOneProvider = true; + providerStatus.github = 'passed'; + } + } + + // Telegram + if (env.OAUTH_TELEGRAM_BOT_NAME || env.OAUTH_TELEGRAM_BOT_TOKEN) { + const missing = []; + if (!env.OAUTH_TELEGRAM_BOT_NAME) missing.push('OAUTH_TELEGRAM_BOT_NAME'); + if (!env.OAUTH_TELEGRAM_BOT_TOKEN) missing.push('OAUTH_TELEGRAM_BOT_TOKEN'); + + if (missing.length > 0) { + providerStatus.telegram = 'missing'; + issues.push({ + field: 'OAUTH_TELEGRAM', + level: 'error', + message: 'telegram_config_incomplete', + suggestion: 'telegram_config_suggestion', + missingFields: missing + }); + } else { + hasAtLeastOneProvider = true; + providerStatus.telegram = 'passed'; + } + } + + // Google + if (env.OAUTH_GOOGLE_CLIENT_ID || env.OAUTH_GOOGLE_CLIENT_SECRET || env.OAUTH_GOOGLE_REDIRECT_URI) { + const missing = []; + if (!env.OAUTH_GOOGLE_CLIENT_ID) missing.push('OAUTH_GOOGLE_CLIENT_ID'); + if (!env.OAUTH_GOOGLE_CLIENT_SECRET) missing.push('OAUTH_GOOGLE_CLIENT_SECRET'); + if (!env.OAUTH_GOOGLE_REDIRECT_URI) missing.push('OAUTH_GOOGLE_REDIRECT_URI'); + + if (missing.length > 0) { + providerStatus.google = 'missing'; + issues.push({ + field: 'OAUTH_GOOGLE', + level: 'error', + message: 'google_config_incomplete', + suggestion: 'google_config_suggestion', + missingFields: missing + }); + } else { + hasAtLeastOneProvider = true; + providerStatus.google = 'passed'; + } + } + + // NodeLoc + if (env.OAUTH_NODELOC_CLIENT_ID || env.OAUTH_NODELOC_CLIENT_SECRET || env.OAUTH_NODELOC_REDIRECT_URI) { + const missing = []; + if (!env.OAUTH_NODELOC_CLIENT_ID) missing.push('OAUTH_NODELOC_CLIENT_ID'); + if (!env.OAUTH_NODELOC_CLIENT_SECRET) missing.push('OAUTH_NODELOC_CLIENT_SECRET'); + if (!env.OAUTH_NODELOC_REDIRECT_URI) missing.push('OAUTH_NODELOC_REDIRECT_URI'); + + if (missing.length > 0) { + providerStatus.nodeloc = 'missing'; + issues.push({ + field: 'OAUTH_NODELOC', + level: 'error', + message: 'nodeloc_config_incomplete', + suggestion: 'nodeloc_config_suggestion', + missingFields: missing + }); + } else { + hasAtLeastOneProvider = true; + providerStatus.nodeloc = 'passed'; + } + } + + // Gitee + if (env.OAUTH_GITEE_CLIENT_ID || env.OAUTH_GITEE_CLIENT_SECRET || env.OAUTH_GITEE_REDIRECT_URI) { + const missing = []; + if (!env.OAUTH_GITEE_CLIENT_ID) missing.push('OAUTH_GITEE_CLIENT_ID'); + if (!env.OAUTH_GITEE_CLIENT_SECRET) missing.push('OAUTH_GITEE_CLIENT_SECRET'); + if (!env.OAUTH_GITEE_REDIRECT_URI) missing.push('OAUTH_GITEE_REDIRECT_URI'); + + if (missing.length > 0) { + providerStatus.gitee = 'missing'; + issues.push({ + field: 'OAUTH_GITEE', + level: 'error', + message: 'gitee_config_incomplete', + suggestion: 'gitee_config_suggestion', + missingFields: missing + }); + } else { + hasAtLeastOneProvider = true; + providerStatus.gitee = 'passed'; + } + } + + // Cloudflare Access + if (env.OAUTH_CLOUDFLARE_CLIENT_ID || env.OAUTH_CLOUDFLARE_CLIENT_SECRET || env.OAUTH_CLOUDFLARE_ORG_DOMAIN || env.OAUTH_CLOUDFLARE_REDIRECT_URI) { + const missing = []; + if (!env.OAUTH_CLOUDFLARE_CLIENT_ID) missing.push('OAUTH_CLOUDFLARE_CLIENT_ID'); + if (!env.OAUTH_CLOUDFLARE_CLIENT_SECRET) missing.push('OAUTH_CLOUDFLARE_CLIENT_SECRET'); + if (!env.OAUTH_CLOUDFLARE_ORG_DOMAIN) missing.push('OAUTH_CLOUDFLARE_ORG_DOMAIN'); + if (!env.OAUTH_CLOUDFLARE_REDIRECT_URI) missing.push('OAUTH_CLOUDFLARE_REDIRECT_URI'); + + if (missing.length > 0) { + providerStatus.cloudflare = 'missing'; + issues.push({ + field: 'OAUTH_CLOUDFLARE', + level: 'error', + message: 'cloudflare_config_incomplete', + suggestion: 'cloudflare_config_suggestion', + missingFields: missing + }); + } else { + hasAtLeastOneProvider = true; + providerStatus.cloudflare = 'passed'; + } + } + + // 6. Check if any provider exists + if (!hasAtLeastOneProvider) { + issues.push({ + field: 'NO_OAUTH_PROVIDER', + level: 'error', + message: 'no_provider_configured', + suggestion: 'no_provider_suggestion' + }); + } else { + passedChecks.push('oauth_provider_configured'); + } + + return { + passed: issues.length === 0, + issues, + passedChecks + }; +}; diff --git a/backend/wrangler.demo.jsonc b/backend/wrangler.demo.jsonc deleted file mode 100644 index 719d525..0000000 --- a/backend/wrangler.demo.jsonc +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "2fauth-worker-demo", - "main": "src/app/index.ts", - "compatibility_date": "2024-04-01", - "compatibility_flags": [ - "nodejs_compat" - ], - // ====== 静态资源托管 ====== - "assets": { - "binding": "ASSETS", - "directory": "../frontend/dist", - "not_found_handling": "single-page-application", - "run_worker_first": true - }, - // ====== 自动构建前端 ====== - "build": { - "command": "npm run build --prefix ../frontend" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "2fauth-db-demo", - "database_id": "082530be-db1b-4346-b4f2-dbeeb6305c51" - } - ], - // ====== 定时任务 ====== - "triggers": { - "crons": [ - "0 2 * * *" - ] - } -} \ No newline at end of file diff --git a/backend/wrangler.demo.toml b/backend/wrangler.demo.toml new file mode 100644 index 0000000..cbb22ff --- /dev/null +++ b/backend/wrangler.demo.toml @@ -0,0 +1,21 @@ +name = "2fauth-worker-demo" +main = "src/app/index.ts" +compatibility_date = "2024-04-01" +compatibility_flags = ["nodejs_compat"] + +[assets] +binding = "ASSETS" +directory = "../frontend/dist" +not_found_handling = "single-page-application" +run_worker_first = true + +[build] +command = "npm run build --prefix ../frontend" + +[[d1_databases]] +binding = "DB" +database_name = "2fauth-db-demo" +database_id = "082530be-db1b-4346-b4f2-dbeeb6305c51" + +[triggers] +crons = ["0 2 * * *"] diff --git a/backend/wrangler.dev.jsonc b/backend/wrangler.dev.jsonc deleted file mode 100644 index 4c591ad..0000000 --- a/backend/wrangler.dev.jsonc +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "2fauth-worker-dev", - "main": "src/app/index.ts", - "compatibility_date": "2024-04-01", - "compatibility_flags": [ - "nodejs_compat" - ], - // ====== 静态资源托管 ====== - "assets": { - "binding": "ASSETS", - "directory": "../frontend/dist", - "not_found_handling": "single-page-application", - "run_worker_first": true - }, - // ====== 自动构建前端 ====== - "build": { - "command": "npm run build --prefix ../frontend" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "2fauth-db-dev", - "database_id": "9e591736-5dbb-4f09-8769-0270d7257731" - } - ], - // ====== 定时任务 ====== - "triggers": { - "crons": [ - "0 2 * * *" - ] - } -} \ No newline at end of file diff --git a/backend/wrangler.dev.toml b/backend/wrangler.dev.toml new file mode 100644 index 0000000..deec043 --- /dev/null +++ b/backend/wrangler.dev.toml @@ -0,0 +1,21 @@ +name = "2fauth-worker-dev" +main = "src/app/index.ts" +compatibility_date = "2024-04-01" +compatibility_flags = ["nodejs_compat"] + +[assets] +binding = "ASSETS" +directory = "../frontend/dist" +not_found_handling = "single-page-application" +run_worker_first = true + +[build] +command = "npm run build --prefix ../frontend" + +[[d1_databases]] +binding = "DB" +database_name = "2fauth-db-dev" +database_id = "9e591736-5dbb-4f09-8769-0270d7257731" + +[triggers] +crons = ["0 2 * * *"] diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc deleted file mode 100644 index 1995300..0000000 --- a/backend/wrangler.jsonc +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "2fauth-worker", - "main": "src/app/index.ts", - "compatibility_date": "2024-04-01", - "compatibility_flags": [ - "nodejs_compat" - ], - // ====== 静态资源托管 ====== - "assets": { - "binding": "ASSETS", - "directory": "../frontend/dist", - "not_found_handling": "single-page-application", - "run_worker_first": false - }, - // ====== 自动构建前端 ====== - "build": { - "command": "npm run build --prefix ../frontend" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "2fauth-db-dev", - "database_id": "9e591736-5dbb-4f09-8769-0270d7257731" - } - ], - // ====== 定时任务 ====== - "triggers": { - "crons": [ - "0 2 * * *" - ] - }, - // ====== 本地开发环境配置 ====== - "env": { - "dev": { - "d1_databases": [ - { - "binding": "DB", - "database_name": "2fauth-db-dev", - "database_id": "9e591736-5dbb-4f09-8769-0270d7257731" - } - ] - } - } -} \ No newline at end of file diff --git a/backend/wrangler.toml b/backend/wrangler.toml new file mode 100644 index 0000000..d2747cc --- /dev/null +++ b/backend/wrangler.toml @@ -0,0 +1,27 @@ +name = "2fauth-worker" +main = "src/app/index.ts" +compatibility_date = "2024-04-01" +compatibility_flags = ["nodejs_compat"] + +[assets] +binding = "ASSETS" +directory = "../frontend/dist" +not_found_handling = "single-page-application" +run_worker_first = false + +[build] +command = "npm run build --prefix ../frontend" + +[[d1_databases]] +binding = "DB" +database_name = "2fauth-db" +database_id = "9e591736-5dbb-4f09-8769-0270d7257731" + +[triggers] +crons = ["0 2 * * *"] + +[env.dev] +[[env.dev.d1_databases]] +binding = "DB" +database_name = "2fauth-db-dev" +database_id = "9e591736-5dbb-4f09-8769-0270d7257731" diff --git a/frontend/src/app/router.js b/frontend/src/app/router.js index 7d10e42..a931678 100644 --- a/frontend/src/app/router.js +++ b/frontend/src/app/router.js @@ -23,6 +23,12 @@ const routes = [ path: '/callback/:provider', // 兼容特定 Provider 的回调路径 (如 /callback/telegram) name: 'ProviderCallback', component: () => import('@/features/auth/views/oauthCallback.vue') + }, + { + path: '/health', + name: 'HealthCheck', + component: () => import('@/features/health/views/healthCheck.vue'), + meta: { guestOnly: false, requiresAuth: false } } ] diff --git a/frontend/src/features/health/views/healthCheck.vue b/frontend/src/features/health/views/healthCheck.vue new file mode 100644 index 0000000..28341d1 --- /dev/null +++ b/frontend/src/features/health/views/healthCheck.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/frontend/src/features/tools/views/utilityTools.vue b/frontend/src/features/tools/views/utilityTools.vue index e1c3546..720c328 100644 --- a/frontend/src/features/tools/views/utilityTools.vue +++ b/frontend/src/features/tools/views/utilityTools.vue @@ -42,7 +42,7 @@