From ed91e1943a65deccae72665abc7f54293985f398 Mon Sep 17 00:00:00 2001 From: nap0o Date: Tue, 10 Mar 2026 16:38:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E9=80=9A=E8=A1=8C=E5=AF=86=E9=92=A5=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/{security.js => security.cjs} | 0 .github/workflows/security.yml | 12 +- backend/package.json | 3 +- backend/schema.sql | 13 ++ backend/src/features/auth/authRoutes.ts | 91 ++++++++ backend/src/features/auth/webAuthnService.ts | 204 ++++++++++++++++++ backend/src/shared/db/schema.ts | 11 + frontend/package.json | 1 + frontend/src/app/styles/main.css | 3 +- .../app/styles/modules/components/auth.css | 31 +++ .../styles/modules/components/security.css | 21 ++ .../features/auth/service/webAuthnService.js | 70 ++++++ frontend/src/features/auth/views/login.vue | 59 ++++- .../features/auth/views/securitySettings.vue | 177 +++++++++++++++ .../features/vault/components/appSidebar.vue | 8 + frontend/src/features/vault/views/home.vue | 8 +- frontend/src/locales/en-US.json | 24 +++ frontend/src/locales/zh-CN.json | 24 +++ .../components/icons/iconFingerprint.vue | 8 + 19 files changed, 759 insertions(+), 9 deletions(-) rename .github/scripts/{security.js => security.cjs} (100%) create mode 100644 backend/src/features/auth/webAuthnService.ts create mode 100644 frontend/src/app/styles/modules/components/security.css create mode 100644 frontend/src/features/auth/service/webAuthnService.js create mode 100644 frontend/src/features/auth/views/securitySettings.vue create mode 100644 frontend/src/shared/components/icons/iconFingerprint.vue diff --git a/.github/scripts/security.js b/.github/scripts/security.cjs similarity index 100% rename from .github/scripts/security.js rename to .github/scripts/security.cjs diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3c66b79..ecfbe1e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -52,13 +52,14 @@ jobs: - name: Install Project Dependencies if: env.SECURITY_SNYK_TOKEN != '' + continue-on-error: true env: - SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }} + SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || secrets.SECURITY_PACKAGE || env.SECURITY_PACKAGE || '' }} run: | echo "Preparing dependency lock files for security scanning..." if [ -z "$SECURITY_PACKAGE" ]; then echo "SECURITY_PACKAGE is empty, installing in root..." - npm install --package-lock-only + npm install --package-lock-only --legacy-peer-deps else echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE" # Split by comma and install @@ -66,7 +67,7 @@ jobs: for pkg in "${PACKAGES[@]}"; do if [ -d "$pkg" ]; then echo "Installing in "$pkg"..." - npm install --prefix "$pkg" --package-lock-only + npm install --prefix "$pkg" --package-lock-only --legacy-peer-deps else echo "Warning: Directory $pkg not found, skipping." fi @@ -103,12 +104,13 @@ jobs: trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true - name: Generate Security Report + if: always() env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Gitleaks typically produces results.sarif if configured or by default in some versions # We'll ensure it exists for our reporter - node .github/scripts/security.js + node .github/scripts/security.cjs # Also append to step summary for immediate visibility in GHA UI cat README.md >> $GITHUB_STEP_SUMMARY @@ -123,7 +125,7 @@ jobs: category: gitleaks - name: Push to Audit Branch - if: github.event_name != 'pull_request' + if: always() && github.event_name != 'pull_request' run: | mkdir audit_temp cp README.md audit_temp/ diff --git a/backend/package.json b/backend/package.json index a64f5db..4d544f3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@hono/node-server": "^1.19.11", + "@simplewebauthn/server": "^13.3.0", "aws4fetch": "^1.0.20", "better-sqlite3": "^12.6.2", "drizzle-orm": "^0.30.10", @@ -27,4 +28,4 @@ "overrides": { "esbuild": ">=0.25.0" } -} \ No newline at end of file +} diff --git a/backend/schema.sql b/backend/schema.sql index 3fdaf06..4e04019 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -41,12 +41,25 @@ CREATE TABLE IF NOT EXISTS backup_telegram_history ( created_at INTEGER NOT NULL ); +-- Passkey 凭证表 +CREATE TABLE IF NOT EXISTS auth_passkeys ( + credential_id TEXT PRIMARY KEY, -- 凭证唯一标识 + user_id TEXT NOT NULL, -- 关联用户标识 (Email) + public_key BLOB NOT NULL, -- 导出的公钥 + counter INTEGER DEFAULT 0, -- 签名计数器 + name TEXT, -- 硬件名称/别名 + transports TEXT, -- 允许的传输方式 (JSON array) + created_at INTEGER NOT NULL, + last_used_at INTEGER +); + -- 索引 DROP INDEX IF EXISTS idx_vault_service; CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC); CREATE INDEX IF NOT EXISTS idx_backup_providers_type ON backup_providers(type); CREATE INDEX IF NOT EXISTS idx_vault_service_created_at ON vault(service, created_at DESC); CREATE INDEX IF NOT EXISTS idx_backup_telegram_history_provider_id ON backup_telegram_history(provider_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON auth_passkeys(user_id); -- Remove any existing duplicates before enforcing unique constraint; keep the earliest DELETE FROM vault diff --git a/backend/src/features/auth/authRoutes.ts b/backend/src/features/auth/authRoutes.ts index 8eac1c1..3235041 100644 --- a/backend/src/features/auth/authRoutes.ts +++ b/backend/src/features/auth/authRoutes.ts @@ -4,10 +4,12 @@ import { EnvBindings, AppError } from '@/app/config'; import { authMiddleware } from '@/shared/middleware/auth'; import { getAvailableProviders } from '@/features/auth/providers/index'; import { AuthService } from '@/features/auth/authService'; +import { WebAuthnService } from '@/features/auth/webAuthnService'; const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); const getService = (c: any) => new AuthService(c.env); +const getWebAuthnService = (c: any) => new WebAuthnService(c.env, c.req.url); // 获取可用登录方式列表 auth.get('/providers', (c) => { @@ -126,4 +128,93 @@ auth.get('/me', authMiddleware, (c) => { }); }); +// --- WebAuthn (Passkey) 核心接口 --- + +// 1. 获取注册选项 (需已登录) +auth.get('/webauthn/register/options', authMiddleware, async (c) => { + const user = c.get('user'); + const service = getWebAuthnService(c); + const options = await service.generateRegistrationOptions(user.id, user.email); + + // 存储 challenge 到 Cookie + setCookie(c, 'webauthn_registration_challenge', options.challenge, { + httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 120, path: '/' + }); + + return c.json(options); +}); + +// 2. 验证注册响应 (需已登录) +auth.post('/webauthn/register/verify', authMiddleware, async (c) => { + const user = c.get('user'); + const body = await c.req.json(); + const expectedChallenge = getCookie(c, 'webauthn_registration_challenge'); + + if (!expectedChallenge) throw new AppError('webauthn_challenge_missing', 400); + + const service = getWebAuthnService(c); + const { name } = body; // 前端传回的自定义别名 + const result = await service.verifyRegistrationResponse(user.email, body.response, expectedChallenge, name); + + deleteCookie(c, 'webauthn_registration_challenge', { path: '/' }); + return c.json(result); +}); + +// 3. 获取登录选项 (公开) +auth.get('/webauthn/login/options', async (c) => { + const service = getWebAuthnService(c); + const options = await service.generateAuthenticationOptions(); + + setCookie(c, 'webauthn_login_challenge', options.challenge, { + httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 120, path: '/' + }); + + return c.json(options); +}); + +// 4. 验证登录响应 (公开) +auth.post('/webauthn/login/verify', async (c) => { + const body = await c.req.json(); + const expectedChallenge = getCookie(c, 'webauthn_login_challenge'); + + if (!expectedChallenge) throw new AppError('webauthn_challenge_missing', 400); + + const service = getWebAuthnService(c); + const result = await service.verifyAuthenticationResponse(body, expectedChallenge); + + // 登录成功,设置会话 Cookie (逻辑同 OAuth callback) + setCookie(c, 'auth_token', result.token, { + httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60, path: '/', + }); + + const csrfToken = crypto.randomUUID(); + setCookie(c, 'csrf_token', csrfToken, { + httpOnly: false, secure: true, sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60, path: '/', + }); + + deleteCookie(c, 'webauthn_login_challenge', { path: '/' }); + + return c.json({ + success: true, + userInfo: result.userInfo + }); +}); + +// 5. 获取凭证列表 (需已登录) +auth.get('/webauthn/credentials', authMiddleware, async (c) => { + const user = c.get('user'); + const service = getWebAuthnService(c); + const credentials = await service.listCredentials(user.email); + return c.json({ success: true, credentials }); +}); + +// 6. 删除凭证 (需已登录) +auth.delete('/webauthn/credentials/:id', authMiddleware, async (c) => { + const user = c.get('user'); + const credentialId = c.req.param('id'); + const service = getWebAuthnService(c); + const result = await service.deleteCredential(user.email, credentialId); + return c.json(result); +}); + export default auth; \ No newline at end of file diff --git a/backend/src/features/auth/webAuthnService.ts b/backend/src/features/auth/webAuthnService.ts new file mode 100644 index 0000000..4d41611 --- /dev/null +++ b/backend/src/features/auth/webAuthnService.ts @@ -0,0 +1,204 @@ +import { EnvBindings, AppError } from '@/app/config'; +import { generateSecureJWT } from '@/shared/utils/crypto'; +import * as schema from '@/shared/db/schema'; +import { eq, desc, and } from 'drizzle-orm'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server'; + +export class WebAuthnService { + private env: EnvBindings; + private rpName = '2FAuth Worker'; + private rpID: string; + private origin: string; + + constructor(env: EnvBindings, url: string) { + this.env = env; + const parsedUrl = new URL(url); + this.rpID = parsedUrl.hostname; + this.origin = `${parsedUrl.protocol}//${parsedUrl.host}`; + } + + /** + * 生成注册选项 + */ + async generateRegistrationOptions(userId: string, userEmail: string) { + // 获取已存在的凭证 + const results = await this.env.DB.select({ credential_id: schema.authPasskeys.credentialId }) + .from(schema.authPasskeys) + .where(eq(schema.authPasskeys.userId, userEmail)); + + const options = await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userID: new TextEncoder().encode(userId), + userName: userEmail, + attestationType: 'none', + excludeCredentials: results.map((row: any) => ({ + id: row.credential_id, + type: 'public-key', + })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + + return options; + } + + /** + * 验证注册响应 + */ + async verifyRegistrationResponse(userEmail: string, body: any, expectedChallenge: string, credentialName?: string) { + const verification = await verifyRegistrationResponse({ + response: body, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + }); + + if (verification.verified && verification.registrationInfo) { + const { credential } = verification.registrationInfo; + + // 存储到 DB + await this.env.DB.insert(schema.authPasskeys).values({ + credentialId: credential.id, + userId: userEmail, + publicKey: credential.publicKey as any, + counter: verification.registrationInfo.credential.counter, + name: credentialName || `Passkey ${new Date().toLocaleDateString()}`, + createdAt: Date.now() + }); + + return { success: true }; + } + + throw new AppError('webauthn_registration_failed', 400); + } + + /** + * 生成登录选项 + */ + async generateAuthenticationOptions() { + const options = await generateAuthenticationOptions({ + rpID: this.rpID, + userVerification: 'preferred', + }); + + return options; + } + + /** + * 验证登录响应 + */ + async verifyAuthenticationResponse(body: any, expectedChallenge: string) { + const credentialID = body.id; + + // 从 DB 查找凭证 + const [credential] = await this.env.DB.select() + .from(schema.authPasskeys) + .where(eq(schema.authPasskeys.credentialId, credentialID)) + .limit(1); + + if (!credential) { + throw new AppError('passkey_not_found', 404); + } + + const verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + credential: { + id: credential.credentialId, + publicKey: new Uint8Array(Object.values(credential.publicKey)) as any, + counter: credential.counter, + transports: [] + }, + }); + + if (verification.verified && verification.authenticationInfo) { + // 更新计数器 + await this.env.DB.update(schema.authPasskeys) + .set({ + counter: verification.authenticationInfo.newCounter, + lastUsedAt: Date.now() + }) + .where(eq(schema.authPasskeys.credentialId, credentialID)); + + // 验证白名单 (这里我们需要模拟一个 OAuthUserInfo 结构) + const userEmail = credential.userId; // 在这里 user_id 存储的是 Email + + // 签发 Token + const token = await this.generateSystemToken({ + id: userEmail, + username: userEmail.split('@')[0], + email: userEmail, + provider: 'passkey' + }); + + return { + success: true, + token, + userInfo: { + id: userEmail, + username: userEmail.split('@')[0], + email: userEmail, + provider: 'passkey' + } + }; + } + + throw new AppError('webauthn_login_failed', 400); + } + + private async generateSystemToken(userInfo: any): Promise { + const payload = { + userInfo: { + id: userInfo.id, + username: userInfo.username, + email: userInfo.email, + avatar: userInfo.avatar, + provider: userInfo.provider + } + }; + + if (!this.env.JWT_SECRET) { + throw new AppError('missing_jwt_secret', 500); + } + + return await generateSecureJWT(payload, this.env.JWT_SECRET); + } + + /** + * 获取用户的凭证列表 + */ + async listCredentials(userEmail: string) { + const results = await this.env.DB.select({ + id: schema.authPasskeys.credentialId, + name: schema.authPasskeys.name, + created_at: schema.authPasskeys.createdAt, + last_used_at: schema.authPasskeys.lastUsedAt + }) + .from(schema.authPasskeys) + .where(eq(schema.authPasskeys.userId, userEmail)) + .orderBy(desc(schema.authPasskeys.createdAt)); + return results; + } + + /** + * 删除凭证 + */ + async deleteCredential(userEmail: string, credentialId: string) { + await this.env.DB.delete(schema.authPasskeys) + .where(and( + eq(schema.authPasskeys.userId, userEmail), + eq(schema.authPasskeys.credentialId, credentialId) + )); + return { success: true }; + } +} diff --git a/backend/src/shared/db/schema.ts b/backend/src/shared/db/schema.ts index 6ac3e31..e9715da 100644 --- a/backend/src/shared/db/schema.ts +++ b/backend/src/shared/db/schema.ts @@ -45,6 +45,17 @@ export const backupTelegramHistory = sqliteTable('backup_telegram_history', { createdAt: integer('created_at').notNull(), }); +// 4. 通行密钥表 (Passkeys) +export const authPasskeys = sqliteTable('auth_passkeys', { + credentialId: text('credential_id').primaryKey(), // 唯一凭据 ID + userId: text('user_id').notNull(), // 在本应用中绑定的是邮箱 + name: text('name'), // 别名 + publicKey: text('public_key').notNull(), // Uint8Array 序列化后的数组 + counter: integer('counter').default(0), // 认证流计算器 + lastUsedAt: integer('last_used_at'), // 最后一次使用的时间戳 + createdAt: integer('created_at').notNull(), +}); + // 导出类型定义 export type VaultItem = typeof vault.$inferSelect; export type NewVaultItem = typeof vault.$inferInsert; diff --git a/frontend/package.json b/frontend/package.json index e29cd5d..e248b37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@simplewebauthn/browser": "^13.3.0", "@tanstack/vue-query": "^5.66.0", "argon2-browser": "^1.18.0", "bcryptjs": "^3.0.3", diff --git a/frontend/src/app/styles/main.css b/frontend/src/app/styles/main.css index 384ad29..ba488cb 100644 --- a/frontend/src/app/styles/main.css +++ b/frontend/src/app/styles/main.css @@ -27,4 +27,5 @@ @import './modules/components/backup.css'; @import './modules/components/migration.css'; @import './modules/components/tools.css'; -@import './modules/components/health.css'; \ No newline at end of file +@import './modules/components/health.css'; +@import './modules/components/security.css'; \ No newline at end of file diff --git a/frontend/src/app/styles/modules/components/auth.css b/frontend/src/app/styles/modules/components/auth.css index ac37138..eebb2e3 100644 --- a/frontend/src/app/styles/modules/components/auth.css +++ b/frontend/src/app/styles/modules/components/auth.css @@ -60,6 +60,37 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } +.passkey-btn { + margin-bottom: 5px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; + border: none !important; + color: white !important; +} + +.login-divider { + display: flex; + align-items: center; + text-align: center; + color: var(--el-text-color-secondary); + font-size: 12px; + margin: 10px 0; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + border-bottom: 1px dashed var(--el-border-color-lighter); +} + +.login-divider:not(:empty)::before { + margin-right: .5em; +} + +.login-divider:not(:empty)::after { + margin-left: .5em; +} + .footer-tips { margin-top: 10px; } diff --git a/frontend/src/app/styles/modules/components/security.css b/frontend/src/app/styles/modules/components/security.css new file mode 100644 index 0000000..0e1d6bc --- /dev/null +++ b/frontend/src/app/styles/modules/components/security.css @@ -0,0 +1,21 @@ +.security-wrapper .empty-state { + padding: 60px 0; +} + +.security-container { + max-width: 1200px; + margin: 0 auto; +} + +.security-header-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.security-header-actions h3 { + margin: 0; + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/frontend/src/features/auth/service/webAuthnService.js b/frontend/src/features/auth/service/webAuthnService.js new file mode 100644 index 0000000..c6b3bf5 --- /dev/null +++ b/frontend/src/features/auth/service/webAuthnService.js @@ -0,0 +1,70 @@ +import { + startRegistration, + startAuthentication, + browserSupportsWebAuthn +} from '@simplewebauthn/browser'; +import { request } from '@/shared/utils/request'; + +export const webAuthnService = { + /** + * 判断当前浏览器是否支持 WebAuthn + */ + isSupported() { + return browserSupportsWebAuthn(); + }, + + /** + * 注册新的 Passkey + */ + async register(name) { + // 1. 获取注册选项 + const options = await request('/api/oauth/webauthn/register/options'); + + // 2. 调用浏览器 API 进行挑战响应 + const attestation = await startRegistration(options); + + // 3. 发回后端验证并存储 + const result = await request('/api/oauth/webauthn/register/verify', { + method: 'POST', + body: JSON.stringify({ + name, + response: attestation + }) + }); + + return result; + }, + + /** + * 使用 Passkey 登录 + */ + async login() { + // 1. 获取登录选项 + const options = await request('/api/oauth/webauthn/login/options'); + + // 2. 调用浏览器 API 进行签名 + const assertion = await startAuthentication(options); + + // 3. 发回后端验证并签发 JWT + const result = await request('/api/oauth/webauthn/login/verify', { + method: 'POST', + body: JSON.stringify(assertion) + }); + + return result; + }, + + /** + * 获取凭证列表 + */ + async listCredentials() { + return await request('/api/oauth/webauthn/credentials'); + }, + + /** + * 删除凭证 + */ + async deleteCredential(id) { + return await request(`/api/oauth/webauthn/credentials/${id}`, { method: 'DELETE' }); + } +}; diff --git a/frontend/src/features/auth/views/login.vue b/frontend/src/features/auth/views/login.vue index 9889ef1..57ac93a 100644 --- a/frontend/src/features/auth/views/login.vue +++ b/frontend/src/features/auth/views/login.vue @@ -20,7 +20,7 @@ class="oauth-btn" :style="{ backgroundColor: provider.color, borderColor: provider.color }" :loading="loadingProvider === provider.id" - :disabled="!!loadingProvider && loadingProvider !== provider.id" + :disabled="(!!loadingProvider && loadingProvider !== provider.id) || loadingPasskey" @click="handleLogin(provider.id)" > + + + + + + + {{ $t('auth.passkey_login') }} +