项目地址:项目地址:https://github.com/nap0o/2fauth-worker
先说结论,再拆细节。
一句话结论
- 这个项目整体安全设计是有意识且比较现代的,没有明显“一眼就 NO”的硬伤。
- 但它本质是「自建 2FA 管理器」,安全基线=密码管理器级别,风险集中在:
- 配置是否严格(密钥、白名单);
- Cloudflare 账号是否安全;
- 前端有无 XSS(一旦有,所有 TOTP secret 都能被拖走)。
- 如果你愿意把 2FA 秘钥托管在自己的 Cloudflare 账号上,并且能严格按要求管好 ENCRYPTION_KEY / JWT_SECRET / 白名单,可以考虑使用,但要当成高敏感资产来运维。
下面是我基于 backend 代码的具体审计(只看了后端 TypeScript / Hono / D1 部分,前端没完全展开)。
架构 & 安全特性概览
后端入口:backend/src/app/index.ts
- 基于 Hono + Cloudflare Workers + D1 (SQLite)。
- 全局中间件:
logger():记录请求日志。cors():Origin 动态反射,允许携带 Cookie,开放X-CSRF-Token头。secureHeaders():设置 CSP / X-Frame-Options / 等安全头:default-src 'self'script-src,img-src,connect-src,frame-src都从CSP_POLICY配置。
- 路由:
/api/oauth→ OAuth 登录相关。/api/vault→ 2FA 金库(TOTP 账户管理)。/api/backups→ WebDAV/S3 备份。/api/telegram→ Telegram Webhook。/api/tools→ 工具接口。
- 静态资源全部通过
env.ASSETS.fetch(),再由 Hono 包一层加 CSP 头。
这套设计:前后端合仓 + 严格 CSP + API/静态统一走一层 Worker,对 XSS / 混乱源有积极作用。
核心敏感点:TOTP Secret 存储 & 加密
相关文件:
src/shared/db/schema.tssrc/shared/db/db.tssrc/shared/utils/crypto.tssrc/features/vault/vaultService.ts
数据库结构(D1)
export const vault = sqliteTable('vault', {
id: text('id').primaryKey(),
service: text('service').notNull(),
account: text('account').notNull(),
category: text('category'),
secret: text('secret').notNull(), // 加密后的密文
digits: integer('digits').default(6),
period: integer('period').default(30),
algorithm: text('algorithm').default('SHA-1'),
createdAt: integer('created_at').notNull(),
createdBy: text('created_by'),
updatedAt: integer('updated_at'),
updatedBy: text('updated_by'),
});
TOTP 的原始 secret 存在 secret 字段里,不是明文,而是 JSON 化的加密结构。
加密实现
src/shared/utils/crypto.ts 里有两个层次:
- 备份文件的“重型版”:
// encryptBackupFile: PBKDF2 + AES-GCM + salt + iv // ITERATIONS = 100000, SALT_LEN = 16, IV_LEN = 12这是用于“导出备份文件 + 用户密码”场景的,比较重,但合理。 - 数据库存储用的“极速模式”:
async function getFastKey(secret: string): Promise<CryptoKey> { const encoder = new TextEncoder(); const keyBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(secret)); return crypto.subtle.importKey( 'raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); } export async function encryptData(data: any, masterKey: string) { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await getFastKey(masterKey); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encoder.encode(JSON.stringify(data)) ); return { encrypted: Array.from(new Uint8Array(encrypted)), iv: Array.from(iv) }; } export async function decryptData(encryptedData, masterKey: string) { // 如果有 salt,用 PBKDF2,否则走 getFastKey }
DB 层封装:
export async function encryptField(data: any, key: string) {
const encrypted = await encryptData(data, key);
return JSON.stringify(encrypted);
}
export async function decryptField(encryptedStr: string, key: string) {
const encryptedObj = JSON.parse(encryptedStr);
return await decryptData(encryptedObj, key);
}
Vault 中真正存的是:
- 业务字段(service/account/…)明文;
secret字段是 JSON string:{ encrypted: number[], iv: number[] };- 密钥 = SHA-256(ENCRYPTION_KEY 或 JWT_SECRET)。
VaultService 里怎么用
src/features/vault/vaultService.ts:
constructor(env: EnvBindings, repository: VaultRepository) {
this.env = env;
this.repository = repository;
this.encryptionKey = env.ENCRYPTION_KEY || env.JWT_SECRET;
}
- 如果
ENCRYPTION_KEY设置了,就用它; - 否则退回
JWT_SECRET。
创建账号:
const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
const encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey);
读取时:
secret: await decryptField(item.secret, this.encryptionKey) || ''
评价:
- 正向肯定:
- 数据库里的 TOTP secret 至少不是明文,落地是 AES-GCM 密文 + 随机 IV;
- 解密需要掌握 ENCRYPTION_KEY(或 JWT_SECRET),即 CF 环境密钥。
- 风险/建议:
- 密钥来源复用:ENCRYPTION_KEY 和 JWT_SECRET 共用
- 如果 ENCRYPTION_KEY 没配,就用 JWT_SECRET。
- 这会把两个安全域绑死:
- JWT_SECRET 泄露 ⇒ 不仅会话可伪造,还能解开所有 TOTP secret;
- ENCRYPTION_KEY 泄露 ⇒ 所有 TOTP secret 暴露,同时 JWT 也在同一个环境(虽然算法不同,但“高价值钥匙全在一处”)。
- 建议:
- 强制要求配置独立的 ENCRYPTION_KEY(高熵随机);
- 不推荐回退到 JWT_SECRET 做加密密钥,至少生产模式禁止。
- “fast mode” 没有 per-row salt
- 备份文件是 PBKDF2+salt,DB 加密则是
AES-GCM(key=SHA256(masterKey), iv=random)。 - 如果 masterKey 足够长随机,这个问题不算致命 —— 攻击者即使拿到 DB dump,也得先拿到 Cloudflare 环境变量才能解。
- 但从纯密码设计角度,还是更建议:
- 强制 ENCRYPTION_KEY 高熵;
- 或者在 encryptData 里加一个 per-record salt(当前 decryptData 代码已经兼容带 salt 的老格式)。
- 备份文件是 PBKDF2+salt,DB 加密则是
- 密钥来源复用:ENCRYPTION_KEY 和 JWT_SECRET 共用
身份认证 & 会话安全
相关文件:
src/features/auth/authService.tssrc/shared/utils/crypto.ts(JWT)src/shared/middleware/auth.ts- 各 provider:
src/features/auth/providers/*.ts
JWT 实现
generateSecureJWT / verifySecureJWT:
- 自己实现了 HS256 JWT(header+payload base64url,HMAC-SHA256 签名)的逻辑;
payload里加了iat / exp / jti;- 验证时:
- 重新 HMAC 验签;
- 解析 payload;
- 检查
exp过期。
优点:
- 不依赖第三方 JWT 库,减少依赖风险;
- 用的是 WebCrypto HMAC-SHA256,签名验证本身 OK;
- 不信任 header 里的算法,直接按 HS256 verify,不存在典型
alg=none/RS256->HS256混淆。
注意点:
- 它是“自用”token(只在服务端生成和验证),不接受外部 JWT,因此攻击面还好;
- 但前提是你绝对不要在别的系统里用同一个 JWT_SECRET 去生成其它用途的 token。
会话存储 & CSRF
src/shared/middleware/auth.ts:
const token = getCookie(c, 'auth_token');
const csrfCookie = getCookie(c, 'csrf_token');
const csrfHeader = c.req.header('X-CSRF-Token');
if (!token) 401;
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) 403;
const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
if (!payload || !payload.userInfo) 401;
c.set('user', payload.userInfo);
- 会话 token 放在
auth_tokenCookie; - CSRF: Double Submit Cookie 模式:
- Cookie 里存一个
csrf_token; - 前端必须在请求头带
X-CSRF-Token,两者一致才过;
- Cookie 里存一个
- 只要所有敏感 API 都挂了
authMiddleware,CSRF 防护是到位的。
我看了 Vault 路由:
src/features/vault/vaultRoutes.ts:
vault.use('/*', authMiddleware);
说明:
- 所有
/api/vault/*都经过 JWT + CSRF 检查; - 相当于你的 2FA 数据所有读写、导入导出,都需要合法 session + 正确 CSRF token,基础 OK。
TODO / 风险点:
- 没有检查其它模块(backup / tools / telegram)的路由是否都加了 authMiddleware,这里没完全展开,但 Vault 这块是 OK 的;
- 如果有新路由忘记加 authMiddleware,就可能变成“未授权导出/导入”等问题 —— 这需要作者在代码上持续保持 discipline。
OAuth 登录 & 白名单控制
src/features/auth/authService.ts:
async handleOAuthCallback(providerName, body) {
const provider = getOAuthProvider(providerName, this.env);
// Telegram 特别处理,其它 provider 用 code
...
const userInfo = await provider.handleCallback(params, body.codeVerifier);
// 白名单检查
this.verifyWhitelist(userInfo, provider.whitelistFields);
// 生成系统 token
const token = await this.generateSystemToken(userInfo);
const deviceKey = await this.generateDeviceKey(userInfo.id);
return { token, userInfo, deviceKey };
}
白名单逻辑:
private verifyWhitelist(userInfo, whitelistFields) {
const allowedUsersStr = this.env.OAUTH_ALLOWED_USERS || '';
const allowedIdentities = allowedUsersStr
.split(',')
.map(e => e.trim().toLowerCase())
.filter(Boolean);
...
if (allowedIdentities.length > 0 && this.env.OAUTH_ALLOWED_USERS !== this.env.JWT_SECRET) {
...
if (!isAllowed) throw new AppError('Unauthorized user...', 403);
}
}
这里有个有点“hacky”的设计:
- 只有在:
- OAUTH_ALLOWED_USERS 非空,且
OAUTH_ALLOWED_USERS !== JWT_SECRET
的时候才真正启用白名单;
- 这明显是为了某种“开发模式”方便(比如把 OAUTH_ALLOWED_USERS 配成一个特殊值等于 JWT_SECRET 时,禁用白名单)。
安全性评价:
- 导致两个风险:
- 配置失误时,白名单悄悄失效:
- 如果你不小心把
OAUTH_ALLOWED_USERS填成了和JWT_SECRET一样的值(或者脚本复制错了),白名单逻辑直接 bypass,任何通过 OAuth 的用户都可以登录 2FA 管理器。
- 如果你不小心把
- 模糊了配置语义:
- 正常使用者不会想到“把白名单 env 值填成和 JWT_SECRET 一样就等于关掉白名单”这种逻辑。
- 配置失误时,白名单悄悄失效:
建议:
- 生产环境强烈建议:
OAUTH_ALLOWED_USERS明确填 email/username 列表;- 不要使用 “等于 JWT_SECRET 即关闭白名单” 这种隐式开关。如果要 dev 模式,应该显式用
DEV_ALLOW_ALL=true之类 env; - 部署前最好自己写个小脚本或 checklist 确认:
OAUTH_ALLOWED_USERS非空;- 且与
JWT_SECRET不相等。
TOTP 逻辑 & 输入校验
src/shared/utils/totp.ts:
- Base32 校验:
validateBase32Secret: /^[A-Z2-7]+=*$/.test(cleaned) && cleaned.length >= 16; - TOTP 生成:自己实现 HMAC-SHA1 和动态偏移,逻辑符合 RFC 6238。
- otpauth:// 解析:
// parseOTPAuthURI: - 有长度上限 1000,避免超长 payload - protocol 必须 otpauth: - type 必须 totp 或 hotp - secret 必须通过 Base32 校验 - digits 有范围,period 有范围
在 vaultRoutes 里还有一个“宽松版 add-from-uri”,专门给扫码用,解析更宽松,但创建账号时仍然会经过 createAccount 的 Base32 校验,这层是 OK 的。
备份 & 外部存储(WebDAV / S3)
这里只部分看了 DB 和 crypto 侧:
- 备份提供方表
backupProviders里,存的是 加密后的 config JSON 和自动备份密码等; - 说明外链存储用的敏感配置(WebDAV/S3 密码)也会用和 vault 相同的加密机制保护;
- 风险和 vault 一样:ENCRYPTION_KEY 管不好 = 所有云备份配置 + TOTP secret 一起暴露。
潜在风险 & 需要你自己权衡的点
- Cloudflare 账号是“单点失败”
- Workers 代码、环境变量、D1 数据库都在 CF 账号下;
- 一旦 Cloudflare 账号被接管,攻击者可以:
- 查看/篡改 ENCRYPTION_KEY / JWT_SECRET;
- 改 Worker 代码植入后门;
- dump D1 数据库;
- 对等于你的 2FA 密钥全部暴露。
- 所以要当成“密码管理器 + Cloudflare 运营商”的组合风险来看:
- CF 账号必须开强 2FA;
- 尽量用单独的 CF 账号,只跑这一个 Worker,减少 blast radius。
- 前端 XSS = 直接 Game Over
- 后端会把解密后的
secret返回给前端(否则你没法生成 OTP),这点没法避免; - 只要前端存在 XSS(包括第三方脚本引入、富文本渲染等),攻击者可以:
- 在已经登录的浏览器里直接读取所有 secret;
- 或者窃取 JWT + CSRF token,远程用 API dump 整个 vault。
- 后端通过 CSP / secureHeaders 已经做了一些防御,但真正的 XSS 风险还得看前端代码有没有
v-html/ 动态注入脚本之类,这里没有完全审。
- 后端会把解密后的
- 配置失误导致的“白名单失效 / 密钥复用”
- ENCRYPTION_KEY 缺失时用 JWT_SECRET;
- OAUTH_ALLOWED_USERS==JWT_SECRET 时白名单不生效;
- 这些设计对“懂的人”来说是 feature,对搞运维的人来说是坑:
- 若你用 GitHub Actions 自动注入 env,写错一次,整个系统可能就变成“任何人能登录 + 所有 2FA 密钥都只靠一个弱 JWT_SECRET 保护”。
给你的实际建议(如果你真考虑用)
如果你打算评估“可不可以自己用 / 推荐给别人用”,建议按下面 checklist 看:
- 只考虑自用 / 小范围使用,不当成公共 SaaS
- 面向互联网开放但只白名单你自己的 GitHub/Google/Telegram,用它保护你自己的其他服务 2FA,是一个合理 use case;
- 不建议拿这套去做公开的“2FA 托管服务”,那是完全不同的威胁模型。
- 部署前的强制配置:
JWT_SECRET:- 至少 32 字节随机(
openssl rand -hex 32); - 不和任何别的系统共享;
- 至少 32 字节随机(
ENCRYPTION_KEY:- 单独生成,同样高熵;
- 和 JWT_SECRET 不同;
OAUTH_ALLOWED_USERS:- 填你的 email / username 列表;
- 确认它和 JWT_SECRET 不相等;
- Cloudflare:
- 开 2FA;
- API Token 只给 D1 + Workers 最小权限。
- 部署后做两件事:
- 代码级验证(你可以自己 grep 一下):
- 确认所有
/api/vault、/api/backups、/api/tools等敏感路由都use(authMiddleware);
- 确认所有
- 手工 smoke 测试:
- 不登录时访问
/api/vault,应该 401; - 用浏览器跨站表单 POST 尝试写入,看没有
X-CSRF-Token的请求会被 403 拒绝。
- 不登录时访问
- 代码级验证(你可以自己 grep 一下):
- 把它当成“半成品密码管理器”:
- 一旦前端有 XSS,或 Cloudflare 账号被拿下,你的 2FA 秘钥都算泄露;
- 所以你自己的安全标准要拉高:
- 定期备份导出(加密导出);
- 定期 rotate ENCRYPTION_KEY(要迁移数据,这个项目目前没现成迁移逻辑,
/migrate-crypto已经弃用); - 重要账号的 2FA,可以考虑仍然保留一份硬件 token / 另一家 2FA app 做备份。