feat: 增加配置安全检测页,加固系统安全
This commit is contained in:
1 parent
790984ad0c
commit
8b5b44363f
19 files changed
+918
-135
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Response> } };
|
||||
@@ -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 头。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, 'passed' | 'missing' | 'none'> = {
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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 * * *"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 * * *"]
|
||||
@@ -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 * * *"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 * * *"]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div class="health-check-container" v-loading.fullscreen.lock="loading">
|
||||
<el-card v-if="!loading && !passed" class="health-card">
|
||||
|
||||
<template #header>
|
||||
<div class="health-header">
|
||||
<h2 class="alert-title">
|
||||
<el-icon :size="24" color="var(--el-color-danger)"><WarningFilled /></el-icon>
|
||||
{{ $t('healthCheck.title') }}
|
||||
</h2>
|
||||
<el-alert type="warning" :closable="false" center class="subtitle">{{ $t('healthCheck.subtitle') }}</el-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="issues-list">
|
||||
<el-alert
|
||||
v-for="(issue, index) in issues"
|
||||
:key="index"
|
||||
:title="$t(`healthCheck.issues.${issue.message}.title`)"
|
||||
:type="issue.level === 'critical' ? 'error' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="issue-alert"
|
||||
>
|
||||
<template #default>
|
||||
<div class="issue-content">
|
||||
<p class="issue-desc">
|
||||
{{ $t(`healthCheck.issues.${issue.message}.desc`) }}
|
||||
<template v-if="issue.missingFields && issue.missingFields.length > 0">
|
||||
<br/>
|
||||
<span class="missing-fields-text">
|
||||
<strong>{{ $t('healthCheck.missing_fields') }}</strong> {{ issue.missingFields.join(', ') }}
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<!-- 修复建议区域 -->
|
||||
<div class="suggestion-box">
|
||||
<div class="suggestion-title">{{ $t('healthCheck.how_to_fix') }}</div>
|
||||
|
||||
<!-- 针对短密码的特殊处理:提供一键生成 -->
|
||||
<div v-if="issue.message === 'encryption_key_too_short' || issue.message === 'jwt_secret_too_short'" class="fix-action">
|
||||
<p>{{ $t(`healthCheck.suggestions.${issue.suggestion}`) }}</p>
|
||||
<div class="key-generator">
|
||||
<el-input v-model="generatedKeys[issue.field]" readonly class="mono-font">
|
||||
<template #append>
|
||||
<el-button @click="copyKey(issue.field)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="success" plain size="small" @click="generateNewKey(issue.field)" class="mt-2">
|
||||
<el-icon><Refresh /></el-icon> {{ $t('healthCheck.generate_new') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 针对 ALLOW_ALL 的警告 -->
|
||||
<div v-else-if="issue.message === 'oauth_allow_all_enabled'" class="fix-action text-danger">
|
||||
<p>{{ $t(`healthCheck.suggestions.${issue.suggestion}`) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 默认建议展示 -->
|
||||
<div v-else class="fix-action">
|
||||
<p>{{ $t(`healthCheck.suggestions.${issue.suggestion}`) }}</p>
|
||||
<div v-if="issue.message.includes('incomplete')" class="doc-link mt-2">
|
||||
<el-link type="primary" href="https://github.com/nap0o/2fauth-worker#三配置第三方登录" target="_blank">
|
||||
<el-icon><Document /></el-icon> {{ $t('healthCheck.view_docs') }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div v-if="passedChecks.length > 0" class="passed-checks-section">
|
||||
<h3><el-icon color="#67C23A"><Select /></el-icon> {{ $t('healthCheck.passed_checks_title') }}</h3>
|
||||
<ul>
|
||||
<li v-for="check in passedChecks" :key="check">
|
||||
<el-icon color="#67C23A"><Select /></el-icon> {{ $t(`healthCheck.passed_checks.${check}`) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { WarningFilled, CopyDocument, Refresh, Document, Select } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const router = useRouter()
|
||||
const { copy, isSupported } = useClipboard()
|
||||
|
||||
const loading = ref(true)
|
||||
const passed = ref(false)
|
||||
const issues = ref([])
|
||||
const passedChecks = ref([])
|
||||
const generatedKeys = ref({
|
||||
ENCRYPTION_KEY: '',
|
||||
JWT_SECRET: ''
|
||||
})
|
||||
|
||||
// 生成 64 字节高强度随机 Hex 字符
|
||||
const generateHexKey = (length = 64) => {
|
||||
// 定义字符集:大写字母、数字、特殊字符
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
const array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(array);
|
||||
|
||||
return Array.from(array, byte => {
|
||||
return charset[byte % charset.length];
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const generateNewKey = (field) => {
|
||||
generatedKeys.value[field] = generateHexKey()
|
||||
}
|
||||
|
||||
const copyKey = async (field) => {
|
||||
if (!isSupported.value) {
|
||||
ElMessage.error('您的浏览器不支持自动复制,请手动选中复制。')
|
||||
return
|
||||
}
|
||||
await copy(generatedKeys.value[field])
|
||||
ElMessage.success('安全密钥已复制到剪贴板')
|
||||
}
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
// silent: true 用于防止 request 拦截器对于 403 直接弹出全局红条
|
||||
// 这里我们主动捕获错误做 UI 渲染
|
||||
const res = await request('/api/health/health-check', { silent: true })
|
||||
|
||||
// API endpoint returns 200 OK regardless of passed or failed
|
||||
passed.value = res.passed === true
|
||||
if (passed.value) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
issues.value = res.issues || []
|
||||
passedChecks.value = res.passedChecks || []
|
||||
|
||||
if (!passed.value) {
|
||||
// 为需要生成密码的问题预先生成好密码
|
||||
issues.value.forEach(issue => {
|
||||
if (issue.field === 'ENCRYPTION_KEY' || issue.field === 'JWT_SECRET') {
|
||||
generateNewKey(issue.field)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
// 即使拦截器抛出了 403 error 也有可能是包含 data (issues) 的 AppException
|
||||
passed.value = false
|
||||
if (err.data && Array.isArray(err.data)) {
|
||||
issues.value = err.data
|
||||
issues.value.forEach(issue => {
|
||||
if (issue.field === 'ENCRYPTION_KEY' || issue.field === 'JWT_SECRET') {
|
||||
generateNewKey(issue.field)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ElMessage.error('无法连接到服务端进行安全校验')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
checkHealth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-check-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
width: 95%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:global(html.dark) .health-card {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #2c2c2c;
|
||||
}
|
||||
|
||||
.health-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.alert-divider {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 2px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 15px 0px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.subtitle :deep(.el-alert__content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.subtitle :deep(.el-alert__title) {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.issue-alert {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.issue-content {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.issue-desc {
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggestion-box {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(html.dark) .suggestion-box {
|
||||
background-color: rgba(64, 158, 255, 0.08);
|
||||
border: 1px solid rgba(64, 158, 255, 0.2);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(html.dark) .issue-alert {
|
||||
background-color: rgba(245, 108, 108, 0.05);
|
||||
border: 1px solid rgba(245, 108, 108, 0.2);
|
||||
}
|
||||
|
||||
:global(html.dark) .missing-fields-text {
|
||||
background-color: rgba(245, 108, 108, 0.1) !important;
|
||||
border: 1px solid rgba(245, 108, 108, 0.3) !important;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.fix-action p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.text-danger p {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.key-generator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mono-font :deep(.el-input__inner) {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.passed-checks-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.passed-checks-section h3 {
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.passed-checks-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.passed-checks-section li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.missing-fields-text {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--el-color-danger);
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, defineAsyncComponent } from 'vue'
|
||||
import { Key, ArrowLeft, Timer, Lock, Camera } from '@element-plus/icons-vue'
|
||||
import { Key, ArrowLeft, Timer, Lock, Camera, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { i18n } from '@/locales'
|
||||
const ToolPassword = defineAsyncComponent(() => import('@/features/tools/components/passwordGenerator.vue'))
|
||||
const ToolTimeSync = defineAsyncComponent(() => import('@/features/tools/components/timeSync.vue'))
|
||||
|
||||
@@ -372,7 +372,9 @@
|
||||
"totp_algo_sha1_default": "SHA-1 (Default)",
|
||||
"totp_digits": "Digits",
|
||||
"totp_period": "Period",
|
||||
"totp_scan_qr_title": "Scan QR Code"
|
||||
"totp_scan_qr_title": "Scan QR Code",
|
||||
"health_check_title": "System Security Check",
|
||||
"health_check_desc": "Check core environment security configurations and provide repair suggestions"
|
||||
},
|
||||
"auth": {
|
||||
"subtitle": "A Secure 2FA Management Tool",
|
||||
@@ -442,6 +444,85 @@
|
||||
"telegram_missing_hash": "Missing hash signature",
|
||||
"telegram_signature_failed": "Telegram signature verification failed",
|
||||
"telegram_login_expired": "Telegram login data has expired",
|
||||
"unsupported_provider": "Unsupported provider"
|
||||
"unsupported_provider": "Unsupported provider",
|
||||
"health_check_failed": "Security vulnerability detected! System access is locked."
|
||||
},
|
||||
"healthCheck": {
|
||||
"title": "Intercept: Security Check Failed",
|
||||
"subtitle": "We detected critical vulnerabilities or incomplete initialization in the environment configuration. System access is locked to protect your data.",
|
||||
"passed_title": "Check Passed",
|
||||
"passed_subtitle": "Excellent! Your 2FAuth Worker security defense is operating at optimal conditions.",
|
||||
"go_to_login": "Go to Login",
|
||||
"how_to_fix": "How to Fix?",
|
||||
"generate_new": "Generate New",
|
||||
"view_docs": "View Configuration Docs",
|
||||
"missing_fields": "Missing the following environment variables:",
|
||||
"passed_checks_title": "Passed Checks",
|
||||
"passed_checks": {
|
||||
"encryption_key_passed": "ENCRYPTION_KEY length is sufficient",
|
||||
"jwt_secret_passed": "JWT_SECRET length is sufficient",
|
||||
"oauth_allow_all_passed": "Global Access is successfully blocked",
|
||||
"allowed_users_passed": "Login whitelist accounts are configured",
|
||||
"oauth_provider_configured": "OAuth2 provider is correctly configured"
|
||||
},
|
||||
"issues": {
|
||||
"encryption_key_too_short": {
|
||||
"title": "Encryption Key Compliance Error (ENCRYPTION_KEY)",
|
||||
"desc": "Your ENCRYPTION_KEY is empty or shorter than 32 characters! This key encrypts all vault seeds and is highly susceptible to brute-forcing."
|
||||
},
|
||||
"jwt_secret_too_short": {
|
||||
"title": "Weak Session Secret (JWT_SECRET)",
|
||||
"desc": "Your JWT_SECRET is shorter than 32 characters. Hackers could forge identity credentials and bypass logins."
|
||||
},
|
||||
"oauth_allow_all_enabled": {
|
||||
"title": "CRITICAL: Global Access Enabled",
|
||||
"desc": "OAUTH_ALLOW_ALL=true is active! This is not a drill. Anyone visiting the site can instantly govern all your 2FA data!"
|
||||
},
|
||||
"allowed_users_empty": {
|
||||
"title": "Login Whitelist is Empty",
|
||||
"desc": "The system is locked but OAUTH_ALLOWED_USERS is empty. Nobody can successfully login to this system."
|
||||
},
|
||||
"github_config_incomplete": {
|
||||
"title": "Incomplete GitHub Config",
|
||||
"desc": "You configured GitHub parameters but are missing the Client ID, Secret, or Redirect URI."
|
||||
},
|
||||
"telegram_config_incomplete": {
|
||||
"title": "Incomplete Telegram Config",
|
||||
"desc": "Missing Bot Name or Bot Token."
|
||||
},
|
||||
"google_config_incomplete": {
|
||||
"title": "Incomplete Google Config",
|
||||
"desc": "Missing required parameters."
|
||||
},
|
||||
"nodeloc_config_incomplete": {
|
||||
"title": "Incomplete NodeLoc Config",
|
||||
"desc": "Missing required parameters."
|
||||
},
|
||||
"gitee_config_incomplete": {
|
||||
"title": "Incomplete Gitee Config",
|
||||
"desc": "Missing required parameters."
|
||||
},
|
||||
"cloudflare_config_incomplete": {
|
||||
"title": "Incomplete Cloudflare Config",
|
||||
"desc": "Missing required parameters."
|
||||
},
|
||||
"no_provider_configured": {
|
||||
"title": "No OAuth Provider Configured",
|
||||
"desc": "You must configure at least one active OAuth2 provider (like GitHub or Telegram) to access the UI."
|
||||
}
|
||||
},
|
||||
"suggestions": {
|
||||
"encryption_key_suggestion": "Copy the secure 32-byte key we generated locally below. Paste it into your Cloudflare -> Workers -> Settings -> Variables as ENCRYPTION_KEY.",
|
||||
"jwt_secret_suggestion": "Copy the pre-generated cryptographically secure key below. Set it as your JWT_SECRET in Cloudflare.",
|
||||
"oauth_allow_all_suggestion": "Immediate Action Required: Go to your Cloudflare dashboard and delete the OAUTH_ALLOW_ALL variable, or set it to false.",
|
||||
"allowed_users_suggestion": "Add OAUTH_ALLOWED_USERS in your Cloudflare environment variables, and populate it with your GitHub (or respective platform) registered emails, separated by commas.",
|
||||
"github_config_suggestion": "Verify your GitHub OAuth App secrets and keys.",
|
||||
"telegram_config_suggestion": "Check your BotFather configurations and ensure all tokens are present.",
|
||||
"google_config_suggestion": "Visit the Google Cloud Console and verify credentials.",
|
||||
"nodeloc_config_suggestion": "Visit the NodeLoc Developer Dashboard and verify credentials.",
|
||||
"gitee_config_suggestion": "Verify your Gitee developer credentials.",
|
||||
"cloudflare_config_suggestion": "Four parameters are required: Client ID, Secret, Org Domain, and Redirect URI.",
|
||||
"no_provider_suggestion": "Consult the README to pick the easiest provider. Retrieve the Client ID and Secret and configure them in Cloudflare's dashboard."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@
|
||||
"support_desc": "支持情况说明",
|
||||
"ga_auth_desc": "请在 App 内导出\"迁移二维码\",将截图直接上传 (.png / .jpg)",
|
||||
"ms_auth_desc": "Microsoft Authenticator (仅支持 Android)",
|
||||
"ms_auth_detail": "Android 手机导出以下3个文件后,同时上传即可导入:",
|
||||
"ms_auth_detail": "Android 手机导出以下3个文件后,同时选中上传即可导入",
|
||||
"batch_import_processing": "📦 批量导入处理中",
|
||||
"batch_progress": "{processed} / {total} 任务已完成",
|
||||
"decrypt_backup_title": "🔓 解密备份文件",
|
||||
@@ -372,7 +372,9 @@
|
||||
"totp_algo_sha1_default": "SHA1 (默认)",
|
||||
"totp_digits": "位数",
|
||||
"totp_period": "周期",
|
||||
"totp_scan_qr_title": "扫描二维码"
|
||||
"totp_scan_qr_title": "扫描二维码",
|
||||
"health_check_title": "系统安全体检",
|
||||
"health_check_desc": "检查服务器核心环境安全配置并给出漏洞修复建议"
|
||||
},
|
||||
"auth": {
|
||||
"subtitle": "一站式安全双重认证管理",
|
||||
@@ -436,12 +438,91 @@
|
||||
"oauth_config_incomplete": "OAuth 配置不完整",
|
||||
"oauth_api_error": "OAuth API 错误",
|
||||
"oauth_token_exchange_failed": "OAuth token 交换失败",
|
||||
"telegram_missing_bot_name": "尚未配置 Telegram Bot 昵称(Bot Name)",
|
||||
"telegram_missing_bot_name": "尚未配置 Telegram Bot Name",
|
||||
"telegram_missing_query": "Telegram 提供商需要完整的查询参数,不仅是 code",
|
||||
"telegram_missing_bot_token": "尚未配置 Telegram Bot Token",
|
||||
"telegram_missing_hash": "缺少哈希签名",
|
||||
"telegram_signature_failed": "Telegram 签名验证失败",
|
||||
"telegram_login_expired": "Telegram 登录数据已过期",
|
||||
"unsupported_provider": "不支持的提供商"
|
||||
"unsupported_provider": "不支持的提供商",
|
||||
"health_check_failed": "环境配置漏洞警告,系统已被锁定!"
|
||||
},
|
||||
"healthCheck": {
|
||||
"title": "配置安全未达标",
|
||||
"subtitle": "我们检测到当前环境配置存在高危风险或未完成初始化,为了保护您的数据安全,已禁止系统访问。",
|
||||
"passed_title": "体检通过",
|
||||
"passed_subtitle": "太棒了!您的 2FAuth Worker 安全防御体系处于最佳状态。",
|
||||
"go_to_login": "前往登录",
|
||||
"how_to_fix": "如何修复?",
|
||||
"generate_new": "重新生成",
|
||||
"view_docs": "查看相关配置文档",
|
||||
"missing_fields": "缺失以下环境变量:",
|
||||
"passed_checks_title": "检测通过项",
|
||||
"passed_checks": {
|
||||
"encryption_key_passed": "ENCRYPTION_KEY 长度达标",
|
||||
"jwt_secret_passed": "JWT_SECRET 长度达标",
|
||||
"oauth_allow_all_passed": "全局放行已被禁止 (防止任意人员登入)",
|
||||
"allowed_users_passed": "系统已配置有效的管理员邮箱白名单",
|
||||
"oauth_provider_configured": "系统已正确配置至少一套 OAuth 第三方登录"
|
||||
},
|
||||
"issues": {
|
||||
"encryption_key_too_short": {
|
||||
"title": "核心加密密钥合规性错误 (ENCRYPTION_KEY)",
|
||||
"desc": "您当前的环境变量 ENCRYPTION_KEY 为空或长度不足 32 位!这把钥匙用于加密数据库内的所有验证器种子,增加复杂度避免被暴力破解。"
|
||||
},
|
||||
"jwt_secret_too_short": {
|
||||
"title": "会话签名密钥脆弱 (JWT_SECRET)",
|
||||
"desc": "您当前的环境变量 JWT_SECRET 长度不足 32 位,增加复杂度防止黑客伪造身份凭证直接登入系统。"
|
||||
},
|
||||
"oauth_allow_all_enabled": {
|
||||
"title": "致命警告:开启了全局放行访问",
|
||||
"desc": "您当前配置了 OAUTH_ALLOW_ALL=true。这不是演习,此时任何人进入主页都能直接操控您的验证器数据!"
|
||||
},
|
||||
"allowed_users_empty": {
|
||||
"title": "登录白名单为空",
|
||||
"desc": "系统处于锁定保护中,但您没有配置 OAUTH_ALLOWED_USERS。此时没有任何人能够登录本系统。"
|
||||
},
|
||||
"github_config_incomplete": {
|
||||
"title": "GitHub 登录配置不完整",
|
||||
"desc": "GitHub OAuth 配置参数缺失。"
|
||||
},
|
||||
"telegram_config_incomplete": {
|
||||
"title": "Telegram 登录配置不完整",
|
||||
"desc": "Telegram OAuth 配置参数缺失。"
|
||||
},
|
||||
"google_config_incomplete": {
|
||||
"title": "Google 登录配置不完整",
|
||||
"desc": "Google OAuth 配置参数缺失。"
|
||||
},
|
||||
"nodeloc_config_incomplete": {
|
||||
"title": "NodeLoc 登录配置不完整",
|
||||
"desc": "NodeLoc OAuth 配置参数缺失。"
|
||||
},
|
||||
"gitee_config_incomplete": {
|
||||
"title": "Gitee 登录配置不完整",
|
||||
"desc": "Gitee OAuth 配置参数缺失。"
|
||||
},
|
||||
"cloudflare_config_incomplete": {
|
||||
"title": "Cloudflare Access 配置不完整",
|
||||
"desc": "Cloudflare Access OAuth 配置参数缺失。"
|
||||
},
|
||||
"no_provider_configured": {
|
||||
"title": "未配置任何登录方式",
|
||||
"desc": "您必须至少配置一种合法的 OAuth2 登录方式(如 GitHub、Telegram)才能使用系统。"
|
||||
}
|
||||
},
|
||||
"suggestions": {
|
||||
"encryption_key_suggestion": "请复制下方为您预生成的高强度随机密钥。随后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将其填入 ENCRYPTION_KEY。",
|
||||
"jwt_secret_suggestion": "请复制下方为您预生成的高强度随机密钥。前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密, 将其填入 JWT_SECRET",
|
||||
"oauth_allow_all_suggestion": "请立刻前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_ALLOW_ALL 删除,或将其值修改为 false。",
|
||||
"allowed_users_suggestion": "请在 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密中添加 OAUTH_ALLOWED_USERS,并填入您在 GitHub(或其它平台)上对应的注册邮箱地址(多个以英文逗号分隔)。",
|
||||
"github_config_suggestion": "请核对获取的 GitHub OAuth Apps 密钥信息,然后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_GITHUB_CLIENT_ID、OAUTH_GITHUB_CLIENT_SECRET、OAUTH_GITHUB_REDIRECT_URI 填入",
|
||||
"telegram_config_suggestion": "请在 BotFather 检查机器人配置,确保信息填全,然后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_TELEGRAM_BOT_NAME、OAUTH_TELEGRAM_BOT_TOKEN、OAUTH_TELEGRAM_REDIRECT_URI 填入",
|
||||
"google_config_suggestion": "请前往 Google Cloud Console 检查凭据配置,然后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_GOOGLE_CLIENT_ID、OAUTH_GOOGLE_CLIENT_SECRET、OAUTH_GOOGLE_REDIRECT_URI 填入",
|
||||
"nodeloc_config_suggestion": "请前往 NodeLoc 面板检查您的开发者凭据,然后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_NODELOC_CLIENT_ID、OAUTH_NODELOC_CLIENT_SECRET、OAUTH_NODELOC_REDIRECT_URI 填入",
|
||||
"gitee_config_suggestion": "请核对 Gitee 开发者凭据,然后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_GITEE_CLIENT_ID、OAUTH_GITEE_CLIENT_SECRET、OAUTH_GITEE_REDIRECT_URI 填入",
|
||||
"cloudflare_config_suggestion": "需要四位一体的参数:Client ID, Secret, Org Domain, Redirect URI,然后前往 Cloudflare 后台 -> Workers 和 Pages -> 2fauth-worker -> 设置 -> 变量和机密,将环境变量 OAUTH_CLOUDFLARE_CLIENT_ID、OAUTH_CLOUDFLARE_CLIENT_SECRET、OAUTH_CLOUDFLARE_REDIRECT_URI 填入",
|
||||
"no_provider_suggestion": "阅读2Fauth Worker 的 README,挑选最适合您且配置最简单的一种登录方式,按照其说明获取 Client ID 与 Secret 并在云端配置。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<el-button v-if="layoutStore.isMobile && !!authUserStore.userInfo?.username" @click="layoutStore.showMobileMenu = true" style="margin-right: 10px; padding: 5px 8px; border: 1px solid var(--el-border-color); height: auto;">
|
||||
<el-button v-if="layoutStore.isMobile && !['/login', '/health'].includes(route.path)" @click="layoutStore.showMobileMenu = true" style="margin-right: 10px; padding: 5px 8px; border: 1px solid var(--el-border-color); height: auto;">
|
||||
<el-icon :size="14" class="menu-icon" style="color: var(--el-text-color-primary);">
|
||||
<svg viewBox="0 0 16 16" version="1.1" width="100%" height="100%" aria-hidden="true" fill="currentColor">
|
||||
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75Zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75ZM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Z"></path>
|
||||
@@ -13,7 +13,7 @@
|
||||
<h2>2FAuth Worker</h2>
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-actions" v-if="route.path !== '/login'">
|
||||
<div class="user-actions" v-if="!['/login', '/health'].includes(route.path)">
|
||||
<div class="user-profile" v-if="!layoutStore.isMobile">
|
||||
<el-avatar :size="32" :src="authUserStore.userInfo?.avatar">
|
||||
{{ authUserStore.userInfo?.username?.charAt(0)?.toUpperCase() }}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function getCookie(name) {
|
||||
}
|
||||
|
||||
let isNavigatingToLogin = false; // 增加防重入锁,避免并发 401 触发无限弹窗和跳转
|
||||
let isNavigatingToSecurity = false; // 防重入锁,避免 403 触发无限跳转
|
||||
|
||||
export async function request(url, options = {}) {
|
||||
const headers = {
|
||||
@@ -43,6 +44,23 @@ export async function request(url, options = {}) {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// --- 安全拦截大网 (Security Shield Interceptor) ---
|
||||
// 如果后端检测到环节变量配置危险,抛出全局 403,立刻拦截死并跳转
|
||||
if (response.status === 403 && data.message === 'health_check_failed') {
|
||||
if (!isNavigatingToSecurity && router && router.currentRoute.value.path !== '/health') {
|
||||
isNavigatingToSecurity = true;
|
||||
router.replace('/health').then(() => {
|
||||
setTimeout(() => { isNavigatingToSecurity = false }, 1000)
|
||||
}).catch(() => {
|
||||
setTimeout(() => { isNavigatingToSecurity = false }, 1000)
|
||||
})
|
||||
}
|
||||
// 包装特定的错误抛出给 UI (方便 UI 的 catch 处理)
|
||||
const secErr = new Error('Security Check Failed');
|
||||
secErr.data = data.data; // issues 数组
|
||||
throw secErr;
|
||||
}
|
||||
|
||||
// 处理 401 Unauthorized (后端统一抛出的 AppError 会被 index.ts 捕获并返回 { code: 401, success: false, message: ...})
|
||||
if (response.status === 401 || data.code === 401) {
|
||||
if (options.silent) {
|
||||
|
||||
Reference in new issue
Block a user