From face59cdd7bce4155b2cd976ea5cb8028a33b4f2 Mon Sep 17 00:00:00 2001 From: nap0o Date: Sat, 28 Feb 2026 16:33:31 +0800 Subject: [PATCH] =?UTF-8?q?faet:=20=E4=BF=AE=E6=94=B9README.md,=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=AE=89=E5=85=A8=E7=BA=A7=E5=88=AB=E6=84=8F=E8=AF=86?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +++++++++++++++++----- backend/src/app/config.ts | 1 + backend/src/features/auth/authRoutes.ts | 26 +++++++++++++++++++- backend/src/features/auth/authService.ts | 18 ++++++++++---- backend/src/features/vault/vaultService.ts | 7 +++++- 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6d69a70..4af1cfd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [**Demo演示**](https://2fa.nezha.pp.ua) https://2fa.nezha.pp.ua -- 演示站默认允许任何用户登录,区别于正常部署只有白名单用户才允许登录 +- 演示站配置了 `OAUTH_ALLOW_ALL=true`变量, 默认允许任何用户登录,正常部署切记 **不要配置OAUTH_ALLOW_ALL参数** | **不要配置OAUTH_ALLOW_ALL参数** | **不要配置OAUTH_ALLOW_ALL参数** - 演示站数据可以任意修改、删除 --- @@ -107,18 +107,23 @@ curl "https://api.telegram.org/bot<你的BotToken>/setWebhook?url=https://<你 | `CLOUDFLARE_D1_DATABASE_ID` | D1 数据库 ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | #### 🔐 必填:系统安全参数 + +> ⚠️ **高危警告:关于 ENCRYPTION_KEY 的修改风险** +> `ENCRYPTION_KEY` 是用于加密数据库中 2FA 核心令牌种子的底层主密钥。**一旦系统投入生产使用并存入了数据,请绝对不要修改此环境变量的值!** 修改后将导致之前存入的所有数据瞬间无法解密,彻底变成死数据。 + | Secret Name | 说明 | 示例值 | | :--- | :--- | :--- | -| `OAUTH_ALLOWED_USERS` | 允许登录的账户白名单!防止陌生人登录。 | `me@example.com,github_user` (逗号分隔) | -| `JWT_SECRET` | 签发登录状态的系统密钥 | `random_string_here`,越长越好,建议32位 | -| `ENCRYPTION_KEY` | 对数据库中关键数据底层加密密钥 | 建议使用 `openssl rand -hex 16` 生成 | +| `OAUTH_ALLOWED_USERS` | 允许登录的账户白名单 | `me@example.com,github_user` (逗号分隔) | +| `OAUTH_ALLOW_ALL` | 无视白名单放行所有人(**仅限测试**) | `true` 或 `1` (生产环境切勿配置) | +| `JWT_SECRET` | 签发会话状态的系统密钥 | 越长越好, 建议 32 位随机字符 | +| `ENCRYPTION_KEY` | 对数据库持久化2FA核心密码集的唯一底层加密密钥 | **必填**。建议使用 `openssl rand -hex 32` 生成| #### 🔑 必填:Cloudflare D1数据库参数 | Secret Name | 说明 | 示例值 | | :--- | :--- | :--- | | `CLOUDFLARE_D1_DATABASE_ID` | Cloudflare D1 数据库 ID | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | -| `ENCRYPTION_KEY` | Cloudflare D1 数据库名称 | 默认为 `2fauth-db` | +| `CLOUDFLARE_D1_DATABASE_NAME` | Cloudflare D1 数据库名称 | 默认为 `2fauth-db` | #### 🔑 必填:OAuth 身份信息 (按需选择对应的组合) > 如果你用 GitHub 登录,就填下面三个;如果你用 Google,就填 Google 的那三个。 @@ -169,5 +174,16 @@ cd backend && npm run dev --- +## 🛡️ 安全性声明 (Security Policy) + +本项目涉及高度敏感的 2FA 认证种子数据的云端存储,在架构安全方面我们做出了如下强制约束: + +1. **密钥隔离设计**:`JWT_SECRET` (用于签发 Session/Device Token) 与 `ENCRYPTION_KEY` (用于静态加密数据库的核心密码) 必须从 Cloudflare 环境变量层面强制分离,不要设置相同变量值。任何 `ENCRYPTION_KEY` 缺失导致的系统空缺都会被高危拦截拒绝启动,从而遏制降级攻击。 +2. **零容忍 XSS (跨站脚本) 防御策略**:本项目的前端严格禁止任何未经深度转义的动态 HTML(`v-html` 或等效方法)直接上树。任何第三方代码引入必须经过人工审计。 + +遇到任何安全性相关的 Issue,请在提交时标记为 [Security] 以获得最高优的处理优先级。 + +--- + ## 📄 License -本项目基于 [MIT License](LICENSE) 协议完全开源。你可以自由地二次开发、分发或用于商业场景,只要包含原作者和版权声明即可。 \ No newline at end of file +本项目基于 [MIT License](LICENSE) 协议完全开源。你可以自由地二次开发、分发或商业使用,只要包含原作者和版权声明即可。 \ No newline at end of file diff --git a/backend/src/app/config.ts b/backend/src/app/config.ts index 7d23f6b..fd1efdc 100644 --- a/backend/src/app/config.ts +++ b/backend/src/app/config.ts @@ -71,6 +71,7 @@ export type EnvBindings = { OAUTH_TELEGRAM_BOT_NAME?: string; OAUTH_TELEGRAM_BOT_TOKEN?: string; OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填) + OAUTH_ALLOW_ALL?: string; // 是否允许所有用户登录 (仅推荐用于测试演示环境) JWT_SECRET: string; ENCRYPTION_KEY: string; }; diff --git a/backend/src/features/auth/authRoutes.ts b/backend/src/features/auth/authRoutes.ts index 189ba1d..f7af343 100644 --- a/backend/src/features/auth/authRoutes.ts +++ b/backend/src/features/auth/authRoutes.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { setCookie, deleteCookie } from 'hono/cookie'; +import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; import { EnvBindings, AppError } from '@/app/config'; import { authMiddleware } from '@/shared/middleware/auth'; import { getAvailableProviders } from '@/features/auth/providers/index'; @@ -33,6 +33,16 @@ auth.get('/authorize/:provider', async (c) => { // 生成包含唯一 state 防御 CSRF 的跳转地址 const authData = await service.generateAuthorizeUrl(providerName); + // 将 state 存入服务端的 HttpOnly Cookie,有效期 10 分钟 + const stateCookieName = `oauth_state_${providerName}`; + setCookie(c, stateCookieName, authData.state, { + httpOnly: true, + secure: true, + sameSite: 'Lax', // 允许从第三方回调跳回时携带 + maxAge: 10 * 60, // 10分钟有效期 + path: '/', + }); + return c.json({ success: true, authUrl: authData.url, @@ -45,6 +55,20 @@ auth.get('/authorize/:provider', async (c) => { auth.post('/callback/:provider', async (c) => { const providerName = c.req.param('provider'); const body = await c.req.json(); + + // --- State 闭环校验开始 --- + const stateCookieName = `oauth_state_${providerName}`; + const serverState = getCookie(c, stateCookieName); + const clientState = body.state; // 前端传回的回调 state 参数 + + if (!serverState || !clientState || serverState !== clientState) { + throw new AppError('Invalid or missing OAuth state. Security validation failed.', 403); + } + + // 校验通过,清理一次性 State Cookie + deleteCookie(c, stateCookieName, { path: '/', secure: true, sameSite: 'Lax' }); + // --- State 闭环校验结束 --- + const service = getService(c); // 调用 Service 层处理登录 diff --git a/backend/src/features/auth/authService.ts b/backend/src/features/auth/authService.ts index 0a9f7c0..e6fa1aa 100644 --- a/backend/src/features/auth/authService.ts +++ b/backend/src/features/auth/authService.ts @@ -80,14 +80,19 @@ 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') { + return; + } + const allowedUsersStr = this.env.OAUTH_ALLOWED_USERS || ''; const allowedIdentities = allowedUsersStr.split(',').map((e: string) => e.trim().toLowerCase()).filter(Boolean); - const userEmail = (userInfo.email || '').toLowerCase(); - const userName = (userInfo.username || '').toLowerCase(); - - // 仅在配置了白名单且 OAUTH_ALLOWED_USERS 开发用的特定值不同时校验 - if (allowedIdentities.length > 0 && this.env.OAUTH_ALLOWED_USERS !== this.env.JWT_SECRET) { + // 如果配置了白名单,严格执行 + if (allowedIdentities.length > 0) { + const userEmail = (userInfo.email || '').toLowerCase(); + const userName = (userInfo.username || '').toLowerCase(); let isAllowed = false; if (whitelistFields.includes('email') && userEmail && allowedIdentities.includes(userEmail)) { @@ -101,6 +106,9 @@ export class AuthService { if (!isAllowed) { throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${userName}`, 403); } + } else { + // 默认安全策略:如果未配置白名单,未开启 ALLOW_ALL,则拒绝所有人(或者也可以记录警告,但为了安全通常应拒绝) + throw new AppError(`No authorization whitelist configured. Login denied.`, 403); } } diff --git a/backend/src/features/vault/vaultService.ts b/backend/src/features/vault/vaultService.ts index 6fa6bf6..3620161 100644 --- a/backend/src/features/vault/vaultService.ts +++ b/backend/src/features/vault/vaultService.ts @@ -12,7 +12,12 @@ export class VaultService { constructor(env: EnvBindings, repository: VaultRepository) { this.env = env; this.repository = repository; - this.encryptionKey = env.ENCRYPTION_KEY || env.JWT_SECRET; + + if (!env.ENCRYPTION_KEY) { + throw new AppError('FATAL ERROR: ENCRYPTION_KEY is not configured.', 500); + } + + this.encryptionKey = env.ENCRYPTION_KEY; } /**