feat: 增加配置安全检测页,加固系统安全

This commit is contained in:
nap0o committed 2026-03-06 04:23:47 +08:00
1 parent 790984ad0c
commit 8b5b44363f
19 files changed
+918 -135

No files matched your search

+9 -9
View File
@@ -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 -2
View File
@@ -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"
}
}
}
+30 -3
View File
@@ -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 头。
+1 -1
View File
@@ -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;
+230
View File
@@ -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
};
};
-33
View File
@@ -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 * * *"
]
}
}
+21
View File
@@ -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 * * *"]
-33
View File
@@ -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 * * *"
]
}
}
+21
View File
@@ -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 * * *"]
-45
View File
@@ -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"
}
]
}
}
}
+27
View File
@@ -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"
+6
View File
@@ -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'))
+83 -2
View File
@@ -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."
}
}
}
+85 -4
View File
@@ -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 并在云端配置。"
}
}
}
+2 -2
View File
@@ -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() }}
+18
View File
@@ -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) {