From 3fa03f260e90c29613e04021619746bd83bda7e8 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sun, 27 Apr 2025 00:40:03 +0800 Subject: [PATCH] update --- packages/backend/src/auth/auth.controller.ts | 196 +++++++++++++++++- packages/backend/src/auth/auth.routes.ts | 27 ++- packages/backend/src/database/schema.ts | 4 +- .../src/repositories/passkey.repository.ts | 43 ++-- .../backend/src/services/passkey.service.ts | 51 ++++- packages/frontend/src/locales/en-US.json | 1 + packages/frontend/src/locales/ja-JP.json | 3 +- packages/frontend/src/locales/zh-CN.json | 3 +- packages/frontend/src/stores/auth.store.ts | 133 ++++++++++++ packages/frontend/src/views/LoginView.vue | 56 ++++- packages/frontend/src/views/SettingsView.vue | 68 +++++- 11 files changed, 553 insertions(+), 32 deletions(-) diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index d154ae1..b9321e6 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -1,3 +1,4 @@ + import { Request, Response } from 'express'; import bcrypt from 'bcrypt'; import { getDbInstance, runDb, getDb, allDb } from '../database/connection'; @@ -13,9 +14,9 @@ import { settingsService } from '../services/settings.service'; const passkeyService = new PasskeyService(); const notificationService = new NotificationService(); -const auditLogService = new AuditLogService(); +const auditLogService = new AuditLogService(); -interface User { +export interface User { // Add export keyword id: number; username: string; hashed_password: string; @@ -476,7 +477,15 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr // 再次提醒: 确保 Express 配置了 'trust proxy' + // 从 session 获取 userId + const userId = req.session.userId; + if (!userId) { + // 这个检查理论上在函数开头已经做过,但为了类型安全和明确性再次检查 + throw new Error('无法获取用户 ID,无法验证 Passkey。'); + } + const verification = await passkeyService.verifyRegistration( + userId, // <-- 传递 userId 作为第一个参数 registrationResponse, expectedChallenge, hostname, @@ -501,6 +510,189 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr res.status(500).json({ message: '验证 Passkey 注册失败。', error: error.message }); } }; +/** + * 获取当前用户已注册的所有 Passkey (GET /api/v1/auth/passkeys) + */ +export const listUserPasskeys = async (req: Request, res: Response): Promise => { + const userId = req.session.userId; + + if (!userId || req.session.requiresTwoFactor) { + res.status(401).json({ message: '用户未认证或认证未完成。' }); + return; + } + + try { + // 注意:PasskeyService 的 listPasskeys 目前是获取所有用户的, + // 实际应用中应该只获取当前用户的。这里暂时调用, + // 但 PasskeyRepository 和 Service 可能需要调整以支持按用户过滤。 + // 假设 PasskeyRepository.getAllPasskeys() 可以接受 userId 过滤 + // 或者 PasskeyService.listPasskeys() 内部处理过滤逻辑。 + // **临时简化处理:** 假设 PasskeyService.listPasskeys() 返回所有密钥, + // 在实际应用中需要根据 userId 过滤。 + // TODO: Refactor PasskeyRepository/Service to filter by userId + const passkeys = await passkeyService.listPasskeys(); // 假设这会返回适合前端展示的数据结构 + + res.status(200).json(passkeys); + } catch (error: any) { + console.error(`用户 ${userId} 获取 Passkey 列表时出错:`, error); + res.status(500).json({ message: '获取 Passkey 列表失败。', error: error.message }); + } +}; + +/** + * 删除指定的 Passkey (DELETE /api/v1/auth/passkeys/:id) + */ +export const deleteUserPasskey = async (req: Request, res: Response): Promise => { + const userId = req.session.userId; + const passkeyIdToDelete = parseInt(req.params.id, 10); // 从路由参数获取 ID + + if (!userId || req.session.requiresTwoFactor) { + res.status(401).json({ message: '用户未认证或认证未完成。' }); + return; + } + + if (isNaN(passkeyIdToDelete)) { + res.status(400).json({ message: '无效的 Passkey ID。' }); + return; + } + + try { + // TODO: 在删除前,应该验证这个 Passkey ID 是否属于当前登录用户。 + // 这需要调整 PasskeyRepository.deletePasskeyById 或增加一个验证步骤。 + // **临时简化处理:** 直接尝试删除。 + + await passkeyService.deletePasskey(passkeyIdToDelete); + + console.log(`用户 ${userId} 删除了 Passkey ID: ${passkeyIdToDelete}`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; + // 记录审计日志 + auditLogService.logAction('PASSKEY_DELETED', { userId, passkeyId: passkeyIdToDelete, ip: clientIp }); + notificationService.sendNotification('PASSKEY_DELETED', { userId, passkeyId: passkeyIdToDelete, ip: clientIp }); + + res.status(200).json({ message: 'Passkey 删除成功。' }); + } catch (error: any) { + console.error(`用户 ${userId} 删除 Passkey ID ${passkeyIdToDelete} 时出错:`, error); + // 可以根据错误类型返回不同状态码,例如找不到资源返回 404 + if (error.message?.includes('未找到')) { // 简单的错误检查 + res.status(404).json({ message: '未找到要删除的 Passkey。', error: error.message }); + } else { + res.status(500).json({ message: '删除 Passkey 失败。', error: error.message }); + } + } +}; +/** + * 生成 Passkey 认证选项 (POST /api/v1/auth/passkey/authenticate-options) + * 用于登录流程 + */ +export const generatePasskeyAuthenticationOptions = async (req: Request, res: Response): Promise => { + try { + // 从请求中获取 hostname + const hostname = req.hostname; + // 确保 Express 配置了 'trust proxy' + + const options = await passkeyService.generateAuthenticationOptions(hostname); + + // 将 challenge 存储在 session 中,用于后续验证 + req.session.currentChallenge = options.challenge; + // 可以在这里添加一个标记,表明正在进行 passkey 认证 + // req.session.passkeyAuthInProgress = true; + + console.log(`[AuthController] 为 Passkey 登录生成认证选项,Challenge: ${options.challenge}`); + res.json(options); + } catch (error: any) { + console.error(`生成 Passkey 认证选项时出错:`, error); + res.status(500).json({ message: '生成 Passkey 认证选项失败。', error: error.message }); + } +}; + +/** + * 验证 Passkey 认证响应并登录 (POST /api/v1/auth/passkey/verify-authentication) + */ +export const verifyPasskeyAuthentication = async (req: Request, res: Response): Promise => { + const expectedChallenge = req.session.currentChallenge; + const { authenticationResponse, rememberMe } = req.body; // 获取认证响应和 rememberMe 状态 + + if (!expectedChallenge) { + res.status(400).json({ message: '未找到预期的挑战,请重新开始登录流程。' }); + return; + } + + if (!authenticationResponse) { + res.status(400).json({ message: '缺少认证响应数据。' }); + return; + } + + // 清除 session 中的 challenge,无论成功与否 + delete req.session.currentChallenge; + // delete req.session.passkeyAuthInProgress; // 清除标记 + + try { + // 从请求中获取 hostname 和 origin + const hostname = req.hostname; + const originHeader = req.get('origin'); + const origin = originHeader || `${req.protocol}://${req.get('host')}`; + // 确保 Express 配置了 'trust proxy' + + const verification = await passkeyService.verifyAuthentication( + authenticationResponse, + expectedChallenge, + hostname, + origin + ); + + if (verification.verified && verification.userInfo) { + const { userId, username } = verification.userInfo; + console.log(`Passkey 认证成功,用户: ${username} (ID: ${userId})`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; + + // --- 认证成功,建立会话 --- + ipBlacklistService.resetAttempts(clientIp); // 重置 IP 失败尝试 + auditLogService.logAction('LOGIN_SUCCESS', { userId, username, ip: clientIp, method: 'passkey' }); + notificationService.sendNotification('LOGIN_SUCCESS', { userId, username, ip: clientIp, method: 'passkey' }); + + req.session.userId = userId; + req.session.username = username; + req.session.requiresTwoFactor = false; // Passkey 本身包含验证,通常视为已完成 2FA + + // 根据 rememberMe 设置 cookie maxAge + if (rememberMe) { + req.session.cookie.maxAge = 315360000000; // 10 years + } else { + req.session.cookie.maxAge = undefined; // Session cookie + } + + res.status(200).json({ + message: 'Passkey 登录成功。', + user: { id: userId, username: username } + }); + // --- 会话建立结束 --- + + } else { + console.error(`Passkey 认证验证失败:`, verification); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; + ipBlacklistService.recordFailedAttempt(clientIp); // 记录失败尝试 + // 尝试从响应中获取用户 ID (如果可能) 用于日志记录 + const credentialId = authenticationResponse?.id; + let potentialUserId: number | string = 'unknown'; + if (credentialId) { + try { + const authenticator = await passkeyService.getPasskeyByCredentialId(credentialId); + if (authenticator) potentialUserId = authenticator.user_id; + } catch { /* ignore error */ } + } + auditLogService.logAction('LOGIN_FAILURE', { userId: potentialUserId, reason: 'Passkey verification failed', ip: clientIp, method: 'passkey' }); + notificationService.sendNotification('LOGIN_FAILURE', { userId: potentialUserId, reason: 'Passkey verification failed', ip: clientIp, method: 'passkey' }); + res.status(401).json({ message: 'Passkey 认证失败。', verified: false }); + } + } catch (error: any) { + console.error(`验证 Passkey 认证时出错:`, error); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; + ipBlacklistService.recordFailedAttempt(clientIp); // 记录失败尝试 + auditLogService.logAction('LOGIN_FAILURE', { reason: `Passkey verification error: ${error.message}`, ip: clientIp, method: 'passkey' }); + notificationService.sendNotification('LOGIN_FAILURE', { reason: `Passkey verification error: ${error.message}`, ip: clientIp, method: 'passkey' }); + res.status(500).json({ message: '验证 Passkey 认证失败。', error: error.message }); + } +}; /** * 验证并激活 2FA (POST /api/v1/auth/2fa/verify) diff --git a/packages/backend/src/auth/auth.routes.ts b/packages/backend/src/auth/auth.routes.ts index d6035dc..ced1c50 100644 --- a/packages/backend/src/auth/auth.routes.ts +++ b/packages/backend/src/auth/auth.routes.ts @@ -6,13 +6,17 @@ import { setup2FA, verifyAndActivate2FA, disable2FA, - getAuthStatus, - generatePasskeyRegistrationOptions, + getAuthStatus, + generatePasskeyRegistrationOptions, verifyPasskeyRegistration, - needsSetup, - setupAdmin, - logout, - getPublicCaptchaConfig + generatePasskeyAuthenticationOptions, // <-- 添加导入 + verifyPasskeyAuthentication, // <-- 添加导入 + listUserPasskeys, + deleteUserPasskey, + needsSetup, + setupAdmin, + logout, + getPublicCaptchaConfig } from './auth.controller'; import { isAuthenticated } from './auth.middleware'; import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; @@ -59,7 +63,18 @@ router.post('/passkey/register-options', isAuthenticated, generatePasskeyRegistr // POST /api/v1/auth/passkey/verify-registration - 验证 Passkey 注册响应 router.post('/passkey/verify-registration', isAuthenticated, verifyPasskeyRegistration); +// GET /api/v1/auth/passkeys - 获取当前用户的所有 Passkey +router.get('/passkeys', isAuthenticated, listUserPasskeys); +// DELETE /api/v1/auth/passkeys/:id - 删除指定的 Passkey +router.delete('/passkeys/:id', isAuthenticated, deleteUserPasskey); + +// --- Passkey 认证接口 (公开访问,添加黑名单检查) --- +// POST /api/v1/auth/passkey/authenticate-options - 生成 Passkey 认证选项 (用于登录) +router.post('/passkey/authenticate-options', ipBlacklistCheckMiddleware, generatePasskeyAuthenticationOptions); + +// POST /api/v1/auth/passkey/verify-authentication - 验证 Passkey 认证响应并登录 +router.post('/passkey/verify-authentication', ipBlacklistCheckMiddleware, verifyPasskeyAuthentication); // POST /api/v1/auth/logout - 用户登出接口 (公开访问) router.post('/logout', logout); diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 04bf946..72de254 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -31,13 +31,15 @@ CREATE TABLE IF NOT EXISTS audit_logs ( export const createPasskeysTableSQL = ` CREATE TABLE IF NOT EXISTS passkeys ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, -- 新增:关联到用户 ID credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded public_key TEXT NOT NULL, -- Base64URL encoded counter INTEGER NOT NULL, transports TEXT, -- JSON array as string, e.g., '["internal", "usb"]' name TEXT, -- User-provided name for the key created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- 新增:外键约束 ); `; diff --git a/packages/backend/src/repositories/passkey.repository.ts b/packages/backend/src/repositories/passkey.repository.ts index 459b064..9db3b9d 100644 --- a/packages/backend/src/repositories/passkey.repository.ts +++ b/packages/backend/src/repositories/passkey.repository.ts @@ -3,6 +3,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn // 定义 Passkey 数据库记录的接口 export interface PasskeyRecord { id: number; + user_id: number; // 新增:关联的用户 ID credential_id: string; // Base64URL encoded public_key: string; // Base64URL encoded counter: number; @@ -13,15 +14,17 @@ export interface PasskeyRecord { } -type DbPasskeyRow = PasskeyRecord; +type DbPasskeyRow = PasskeyRecord; // 类型别名保持不变,因为接口已更新 export class PasskeyRepository { /** * 保存新的 Passkey 凭证 + * @param userId 关联的用户 ID * @returns Promise 新插入记录的 ID */ async savePasskey( + userId: number, // 新增 userId 参数 credentialId: string, publicKey: string, counter: number, @@ -29,10 +32,11 @@ export class PasskeyRepository { name?: string ): Promise { const sql = ` - INSERT INTO passkeys (credential_id, public_key, counter, transports, name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')) + INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')) `; - const params = [credentialId, publicKey, counter, transports, name ?? null]; + // 新增 userId 到参数列表 + const params = [userId, credentialId, publicKey, counter, transports, name ?? null]; try { const db = await getDbInstance(); const result = await runDb(db, sql, params); @@ -46,18 +50,24 @@ export class PasskeyRepository { if (err.message.includes('UNIQUE constraint failed')) { throw new Error(`Credential ID "${credentialId}" 已存在。`); } + // 检查外键约束错误 + if (err.message.includes('FOREIGN KEY constraint failed')) { + throw new Error(`关联的用户 ID ${userId} 不存在。`); + } throw new Error(`保存 Passkey 时出错: ${err.message}`); } } /** - * 根据 Credential ID 获取 Passkey 记录 + * 根据 Credential ID 获取 Passkey 记录 (包含 user_id) * @returns Promise 找到的记录或 null */ async getPasskeyByCredentialId(credentialId: string): Promise { + // 确保查询包含 user_id const sql = `SELECT * FROM passkeys WHERE credential_id = ?`; try { const db = await getDbInstance(); + // 使用更新后的 DbPasskeyRow 类型 const row = await getDbRow(db, sql, [credentialId]); return row || null; } catch (err: any) { @@ -67,18 +77,27 @@ export class PasskeyRepository { } /** - * 获取所有已注册的 Passkey 记录 (仅选择必要字段) - * @returns Promise[]> 所有记录的部分信息的数组 + * 获取指定用户或所有已注册的 Passkey 记录 (仅选择必要字段) + * @param userId 可选,如果提供,则只获取该用户的 Passkey + * @returns Promise[]> 记录的部分信息的数组 */ - async getAllPasskeys(): Promise>> { - const sql = `SELECT id, credential_id, name, transports, created_at FROM passkeys ORDER BY created_at DESC`; + async getAllPasskeys(userId?: number): Promise>> { + let sql = `SELECT id, user_id, credential_id, name, transports, created_at FROM passkeys`; + const params: any[] = []; + if (userId !== undefined) { + sql += ` WHERE user_id = ?`; + params.push(userId); + } + sql += ` ORDER BY created_at DESC`; + try { const db = await getDbInstance(); - const rows = await allDb>(db, sql); + // 更新返回类型以包含 user_id + const rows = await allDb>(db, sql, params); return rows; } catch (err: any) { - console.error('获取所有 Passkey 时出错:', err.message); - throw new Error(`获取所有 Passkey 时出错: ${err.message}`); + console.error('获取 Passkey 列表时出错:', err.message); + throw new Error(`获取 Passkey 列表时出错: ${err.message}`); } } diff --git a/packages/backend/src/services/passkey.service.ts b/packages/backend/src/services/passkey.service.ts index f2a174d..52b2dc3 100644 --- a/packages/backend/src/services/passkey.service.ts +++ b/packages/backend/src/services/passkey.service.ts @@ -4,8 +4,9 @@ import { generateAuthenticationOptions, verifyAuthenticationResponse, VerifiedRegistrationResponse, - VerifiedAuthenticationResponse, + // VerifiedAuthenticationResponse, // Remove original import } from '@simplewebauthn/server'; +import type { VerifiedAuthenticationResponse as SimpleVerifiedAuthenticationResponse } from '@simplewebauthn/server'; // Import with alias import type { GenerateRegistrationOptionsOpts, GenerateAuthenticationOptionsOpts, @@ -13,10 +14,18 @@ import type { VerifyAuthenticationResponseOpts, RegistrationResponseJSON, AuthenticationResponseJSON, -} from '@simplewebauthn/server'; +} from '@simplewebauthn/server'; import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository'; +import { getDbInstance, getDb } from '../database/connection'; // Import database functions +import type { User } from '../auth/auth.controller'; // Import User type (assuming it's defined or importable from auth.controller) - +// Define extended verification response type including user info +export interface VerifiedAuthenticationResponse extends SimpleVerifiedAuthenticationResponse { + userInfo?: { + userId: number; + username: string; + }; +} // 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置 const rpName = 'Nexus Terminal'; // rpID 和 expectedOrigin 将从请求动态获取,不再在此处硬编码 @@ -63,6 +72,7 @@ export class PasskeyService { /** * 验证 Passkey 注册响应 + * @param userId 当前登录用户的 ID * @param registrationResponse 来自客户端的注册响应 * @param expectedChallenge 之前生成的、临时存储的挑战 * @param hostname 请求的主机名 @@ -70,6 +80,7 @@ export class PasskeyService { * @param passkeyName 用户为这个 Passkey 起的名字 (可选) */ async verifyRegistration( + userId: number, // 新增 userId 参数 registrationResponse: RegistrationResponseJSON, expectedChallenge: string, hostname: string, @@ -110,15 +121,16 @@ export class PasskeyService { // 获取 transports 信息 const transports = registrationResponse.response.transports ?? null; - // 保存到数据库 + // 保存到数据库,传入 userId await this.passkeyRepository.savePasskey( + userId, // 传递 userId credentialIdBase64Url, publicKeyBase64Url, counter, transports ? JSON.stringify(transports) : null, passkeyName ); - console.log(`Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`); + console.log(`用户 ${userId} Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`); } else { console.error('Passkey 注册验证失败:', verification); } @@ -160,7 +172,7 @@ export class PasskeyService { expectedChallenge: string, hostname: string, origin: string - ): Promise { + ): Promise { // Return our extended type const credentialIdBase64Url = authenticationResponse.id; // 客户端传回的 ID 已经是 Base64URL const authenticator = await this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url); @@ -204,6 +216,23 @@ export class PasskeyService { // 更新数据库中的计数器 await this.passkeyRepository.updatePasskeyCounter(authenticator.credential_id, newCounter); console.log(`Passkey 认证成功: ${authenticator.credential_id}`); + + // --- Added: Fetch user information --- + const db = await getDbInstance(); + // Assuming PasskeyRecord has user_id + const user = await getDb(db, 'SELECT id, username FROM users WHERE id = ?', [authenticator.user_id]); + if (!user) { + // This theoretically shouldn't happen if the authenticator exists + console.error(`Passkey authentication successful but associated user not found: UserID ${authenticator.user_id}, CredentialID ${authenticator.credential_id}`); + throw new Error('Passkey authentication successful but failed to find associated user information.'); + } + // Attach user info to the verification result + (verification as VerifiedAuthenticationResponse).userInfo = { + userId: user.id, + username: user.username, + }; + // --- End: Fetch user information --- + } else { console.error('Passkey 认证验证失败:', verification); } @@ -232,4 +261,14 @@ export class PasskeyService { async deletePasskey(id: number): Promise { await this.passkeyRepository.deletePasskeyById(id); } + + /** + * 根据 Credential ID 获取 Passkey 记录 (供认证验证使用) + * @param credentialIdBase64Url Base64URL 编码的 Credential ID + */ + async getPasskeyByCredentialId(credentialIdBase64Url: string): Promise { + // 注意:PasskeyRepository 需要有 getPasskeyByCredentialId 方法 + // 并且 PasskeyRecord 需要包含 user_id 以便后续查找用户 + return this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url); + } } diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 7782215..3d4cc95 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -37,6 +37,7 @@ "themeCreatedSuccess": "Theme created successfully.", "themeSaveFailed": "Failed to save theme.", "themeDeletedSuccess": "Theme deleted successfully.", +"passkeyLoginButton": "Login with Passkey", "themeDeleteFailed": "Failed to delete theme: {message}", "importSuccess": "Theme imported successfully.", "importFailed": "Theme import failed.", diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index f7f15d6..e9f9fa0 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -104,7 +104,8 @@ "captchaLoadFailed": "CAPTCHA の読み込みに失敗しました。ページをリロードしてください。", "captchaRequired": "CAPTCHA を完了してください。" }, - "recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。" + "recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。", + "passkeyLoginButton": "Passkeyでログイン" }, "connections": { "addConnection": "新しい接続を追加", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 24c6cba..cda0225 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -104,7 +104,8 @@ "captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。", "captchaRequired": "请完成 CAPTCHA 验证。" }, - "recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。" + "recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。", + "passkeyLoginButton": "使用 Passkey 登录" }, "connections": { "addConnection": "添加新连接", diff --git a/packages/frontend/src/stores/auth.store.ts b/packages/frontend/src/stores/auth.store.ts index 13ab6e5..1a14040 100644 --- a/packages/frontend/src/stores/auth.store.ts +++ b/packages/frontend/src/stores/auth.store.ts @@ -36,6 +36,15 @@ interface FullCaptchaSettings { recaptchaSecretKey?: string; // We won't use this in authStore } +// 新增:Passkey 信息接口 (根据后端返回调整) +interface PasskeyInfo { + id: number; // 数据库中的 ID,用于删除 + name?: string; // 用户设置的名称 + transports?: string; // JSON string of transports like ["internal", "usb"] + created_at?: number; // Unix timestamp +} + + // Auth Store State 接口 interface AuthState { isAuthenticated: boolean; @@ -50,6 +59,9 @@ interface AuthState { }; needsSetup: boolean; // 新增:是否需要初始设置 publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config + passkeys: PasskeyInfo[]; // 新增:存储 Passkey 列表 + passkeysLoading: boolean; // 新增:Passkey 列表加载状态 + passkeysError: string | null; // 新增:Passkey 列表错误状态 } export const useAuthStore = defineStore('auth', { @@ -62,12 +74,24 @@ export const useAuthStore = defineStore('auth', { ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态 needsSetup: false, // 初始假设不需要设置 publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null + passkeys: [], // 初始化 Passkey 列表为空 + passkeysLoading: false, + passkeysError: null, }), getters: { // 可以添加一些 getter,例如获取用户名 loggedInUser: (state) => state.user?.username, }, actions: { + // 新增:清除错误状态 + clearError() { + this.error = null; + }, + // 新增:设置错误状态 + setError(errorMessage: string) { + this.error = errorMessage; + }, + // 登录 Action - 更新为接受 LoginPayload + optional captchaToken async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload this.isLoading = true; @@ -156,6 +180,7 @@ export const useAuthStore = defineStore('auth', { // 清除本地状态 this.isAuthenticated = false; this.user = null; + this.passkeys = []; // 登出时清空 Passkey 列表 console.log('已登出'); // 登出后重定向到登录页 await router.push({ name: 'Login' }); @@ -185,6 +210,7 @@ export const useAuthStore = defineStore('auth', { this.isAuthenticated = false; this.user = null; this.loginRequires2FA = false; + this.passkeys = []; // 未认证时清空 Passkey 列表 } } catch (error: any) { // 如果获取状态失败 (例如 session 过期),则认为未认证 @@ -192,6 +218,7 @@ export const useAuthStore = defineStore('auth', { this.isAuthenticated = false; this.user = null; this.loginRequires2FA = false; + this.passkeys = []; // 失败时也清空 Passkey 列表 // 可选:如果不是 401 错误,可以记录更详细的日志 } finally { this.isLoading = false; @@ -325,6 +352,112 @@ export const useAuthStore = defineStore('auth', { }; } }, + + // --- Passkey Actions --- + /** + * 获取当前用户的 Passkey 列表 + */ + async fetchPasskeys() { + if (!this.isAuthenticated) return; // 确保用户已登录 + this.passkeysLoading = true; + this.passkeysError = null; + try { + const response = await apiClient.get('/auth/passkeys'); + this.passkeys = response.data; + console.log('获取 Passkey 列表成功:', this.passkeys); + } catch (err: any) { + console.error('获取 Passkey 列表失败:', err); + this.passkeysError = err.response?.data?.message || err.message || '获取 Passkey 列表时发生未知错误。'; + this.passkeys = []; // 出错时清空列表 + } finally { + this.passkeysLoading = false; + } + }, + + /** + * 删除指定的 Passkey + * @param passkeyId 要删除的 Passkey 的 ID + */ + async deletePasskey(passkeyId: number) { + if (!this.isAuthenticated) throw new Error('用户未登录'); + // 可以添加一个 loading 状态 specific to deletion if needed + this.passkeysError = null; // Clear previous errors + try { + await apiClient.delete(`/auth/passkeys/${passkeyId}`); + console.log(`Passkey ID ${passkeyId} 已删除`); + // 从本地状态中移除 + this.passkeys = this.passkeys.filter(key => key.id !== passkeyId); + return true; // Indicate success + } catch (err: any) { + console.error(`删除 Passkey ID ${passkeyId} 失败:`, err); + this.passkeysError = err.response?.data?.message || err.message || '删除 Passkey 时发生未知错误。'; + // 抛出错误以便 UI 显示 + throw new Error(this.passkeysError ?? '删除 Passkey 时发生未知错误。'); + } + }, + + // --- Passkey Authentication Actions --- + /** + * 从后端获取 Passkey 认证选项 + */ + async getPasskeyAuthenticationOptions() { + this.isLoading = true; + this.error = null; + try { + // 调用后端 API 获取选项 + const response = await apiClient.post('/auth/passkey/authenticate-options'); + console.log('获取 Passkey 认证选项成功:', response.data); + return response.data; // 返回选项给调用者 (LoginView) + } catch (err: any) { + console.error('获取 Passkey 认证选项失败:', err); + this.error = err.response?.data?.message || err.message || '获取 Passkey 认证选项时发生未知错误。'; + // 返回 null 或抛出错误,让调用者知道失败了 + return null; + } finally { + this.isLoading = false; + } + }, + + /** + * 验证 Passkey 认证响应并登录 + * @param authenticationResponse 从 @simplewebauthn/browser 获取的响应 + * @param rememberMe 用户是否勾选了“记住我” + */ + async verifyPasskeyAuthentication(authenticationResponse: any, rememberMe: boolean) { + this.isLoading = true; + this.error = null; + try { + // 调用后端 API 验证响应 + const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/passkey/verify-authentication', { + authenticationResponse, + rememberMe // 将 rememberMe 状态传递给后端 + }); + + // Passkey 认证和登录成功 + this.isAuthenticated = true; + this.user = response.data.user; + this.loginRequires2FA = false; // Passkey 登录通常不需要额外 2FA + console.log('Passkey 登录成功:', this.user); + + // 设置语言 + if (this.user?.language) { + setLocale(this.user.language); + } + + // 跳转到工作区并刷新 + window.location.href = '/'; + return { success: true }; + + } catch (err: any) { + console.error('Passkey 认证验证失败:', err); + this.isAuthenticated = false; + this.user = null; + this.error = err.response?.data?.message || err.message || 'Passkey 登录时发生未知错误。'; + return { success: false, error: this.error }; + } finally { + this.isLoading = false; + } + }, }, persist: true, // Revert to simple persistence to fix TS error for now }); diff --git a/packages/frontend/src/views/LoginView.vue b/packages/frontend/src/views/LoginView.vue index 95d9594..2877604 100644 --- a/packages/frontend/src/views/LoginView.vue +++ b/packages/frontend/src/views/LoginView.vue @@ -1,7 +1,8 @@ +// --- Passkey Login Handler --- +const handlePasskeyLogin = async () => { + authStore.clearError(); // 清除之前的错误 + captchaError.value = null; // 清除 CAPTCHA 错误 + try { + // 1. 从后端获取认证选项 (包含 challenge) + // 需要 authStore 中添加 getPasskeyAuthenticationOptions action + const options = await authStore.getPasskeyAuthenticationOptions(); + if (!options) { + // 错误已在 store action 中处理 + return; + } + + // 2. 使用浏览器 API 开始认证 + let authenticationResponse; + try { + authenticationResponse = await startAuthentication(options); + } catch (err: any) { + console.error('Passkey authentication failed (startAuthentication):', err); + // 用户取消或浏览器不支持等情况 + if (err.name === 'NotAllowedError') { + authStore.setError(t('login.error.passkeyCancelled')); + } else { + authStore.setError(t('login.error.passkeyFailed', { error: err.message || err.name || 'Unknown error' })); + } + return; + } + + // 3. 将认证响应发送到后端进行验证 + // 需要 authStore 中添加 verifyPasskeyAuthentication action + await authStore.verifyPasskeyAuthentication(authenticationResponse, rememberMe.value); + // 成功后的重定向由 store action 处理 + // 失败会更新 error 状态并在模板中显示 + + } catch (err) { + // Store action 中的错误已处理,这里无需额外操作 + console.error('Error during passkey login flow:', err); + } +}; +// --- End Passkey Login Handler --- + +