This commit is contained in:
Baobhan Sith
2025-04-27 02:04:54 +08:00
parent a9d43a2232
commit fbabfc91bf
25 changed files with 65 additions and 1353 deletions
+2 -129
View File
@@ -507,12 +507,6 @@
"vue": "^3.0.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@intlify/core-base": {
"version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.4.tgz",
@@ -584,12 +578,6 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -745,64 +733,6 @@
"node": ">=18.12.0"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.3.16",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.16.tgz",
"integrity": "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"asn1js": "^3.0.5",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz",
"integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"@peculiar/asn1-x509": "^2.3.15",
"asn1js": "^3.0.5",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz",
"integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"@peculiar/asn1-x509": "^2.3.15",
"asn1js": "^3.0.5",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz",
"integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==",
"license": "MIT",
"dependencies": {
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz",
"integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
@@ -1063,30 +993,6 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz",
"integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.1.1.tgz",
"integrity": "sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
@@ -2123,20 +2029,6 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
"integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -5592,24 +5484,6 @@
"once": "^1.3.1"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -6816,7 +6690,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
@@ -7537,7 +7412,6 @@
"name": "@nexus-terminal/backend",
"version": "0.1.0",
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"@types/multer": "^1.4.12",
"@types/session-file-store": "^1.2.5",
"@types/uuid": "^10.0.0",
@@ -7586,7 +7460,6 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.1.4",
"@xterm/addon-search": "^0.15.0",
"axios": "^1.8.4",
-1
View File
@@ -9,7 +9,6 @@
"dev": "cross-env NODE_ENV=development npx ts-node-dev --respawn --transpile-only src/index.ts"
},
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"@types/multer": "^1.4.12",
"@types/session-file-store": "^1.2.5",
"@types/uuid": "^10.0.0",
+2 -292
View File
@@ -4,8 +4,6 @@ import bcrypt from 'bcrypt';
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';
import { PasskeyService } from '../services/passkey.service';
import type { RegistrationResponseJSON } from '@simplewebauthn/server'; // 添加类型导入
import { NotificationService } from '../services/notification.service';
import { AuditLogService } from '../services/audit.service';
import { ipBlacklistService } from '../services/ip-blacklist.service';
@@ -13,8 +11,7 @@ import { captchaService } from '../services/captcha.service';
import { settingsService } from '../services/settings.service';
const passkeyService = new PasskeyService();
const notificationService = new NotificationService();
const notificationService = new NotificationService();
const auditLogService = new AuditLogService();
export interface User { // Add export keyword
@@ -30,7 +27,7 @@ declare module 'express-session' {
username?: string;
tempTwoFactorSecret?: string;
requiresTwoFactor?: boolean;
currentChallenge?: string;
// currentChallenge?: string; // Removed Passkey challenge storage
rememberMe?: boolean;
}
}
@@ -410,294 +407,7 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
};
// --- Passkey 相关方法 ---
/**
* 生成 Passkey 注册选项 (POST /api/v1/auth/passkey/register-options)
*/
export const generatePasskeyRegistrationOptions = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const username = req.session.username; // Passkey 需要用户名
if (!userId || !username || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
return;
}
try {
// 从请求中获取 hostname
const hostname = req.hostname;
// 注意: 确保 Express 配置了 'trust proxy' 如果应用在反向代理后面,
// 否则 req.hostname 可能返回不正确的值 (例如 'localhost')。
// 可以在 Express 初始化时设置 app.set('trust proxy', true);
const options = await passkeyService.generateRegistrationOptions(hostname, username);
// 将 challenge 存储在 session 中,用于后续验证
req.session.currentChallenge = options.challenge;
res.json(options);
} catch (error: any) {
console.error(`用户 ${userId} 生成 Passkey 注册选项时出错:`, error);
res.status(500).json({ message: '生成 Passkey 注册选项失败。', error: error.message });
}
};
/**
* 验证 Passkey 注册响应 (POST /api/v1/auth/passkey/verify-registration)
*/
export const verifyPasskeyRegistration = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const expectedChallenge = req.session.currentChallenge;
// 将 name 提取出来,其余部分作为 registrationData 对象
const { name, ...registrationData } = req.body;
console.log(`[AuthController VerifyReg] Received request body: name=${name}, registrationData=${JSON.stringify(registrationData)}`); // Log received data
if (!userId || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
return;
}
if (!expectedChallenge) {
res.status(400).json({ message: '未找到预期的挑战,请重新生成注册选项。' });
return;
}
// 检查 registrationData 是否存在且不为空对象
if (!registrationData || Object.keys(registrationData).length === 0) {
res.status(400).json({ message: '缺少注册响应数据。' });
return;
}
// 清除 session 中的 challenge,无论成功与否
delete req.session.currentChallenge;
try {
// 从请求中获取 hostname 和 origin
const hostname = req.hostname;
// 尝试从 Origin header 获取,如果不存在,则根据协议和主机名构造
const originHeader = req.get('origin');
const origin = originHeader || `${req.protocol}://${req.get('host')}`; // req.get('host') 包含端口
// 再次提醒: 确保 Express 配置了 'trust proxy'
// 从 session 获取 userId
const userId = req.session.userId;
if (!userId) {
// 这个检查理论上在函数开头已经做过,但为了类型安全和明确性再次检查
throw new Error('无法获取用户 ID,无法验证 Passkey。');
}
console.log(`[AuthController VerifyReg] Calling passkeyService.verifyRegistration with: userId=${userId}, expectedChallenge=${expectedChallenge}, hostname=${hostname}, origin=${origin}, name=${name}`); // Log parameters before calling service
const verification = await passkeyService.verifyRegistration(
userId,
registrationData as RegistrationResponseJSON, // 将收集到的字段重新构造成符合类型的对象
expectedChallenge,
hostname,
origin,
name
);
console.log(`[AuthController VerifyReg] Received verification result from service: verified=${verification.verified}`); // Log service result
if (verification.verified && verification.registrationInfo) {
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP)
const regInfo: any = verification.registrationInfo;
auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp });
notificationService.sendNotification('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp }); // 添加通知调用
res.status(201).json({ message: 'Passkey 注册成功!', verified: true });
} else {
console.error(`用户 ${userId} Passkey 注册验证失败:`, verification);
res.status(400).json({ message: 'Passkey 注册验证失败。', verified: false });
}
} catch (error: any) {
console.error(`用户 ${userId} 验证 Passkey 注册时出错:`, error);
res.status(500).json({ message: '验证 Passkey 注册失败。', error: error.message });
}
};
/**
* 获取当前用户已注册的所有 Passkey (GET /api/v1/auth/passkeys)
*/
export const listUserPasskeys = async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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)
+2 -24
View File
@@ -7,12 +7,7 @@ import {
verifyAndActivate2FA,
disable2FA,
getAuthStatus,
generatePasskeyRegistrationOptions,
verifyPasskeyRegistration,
generatePasskeyAuthenticationOptions, // <-- 添加导入
verifyPasskeyAuthentication, // <-- 添加导入
listUserPasskeys,
deleteUserPasskey,
// Removed Passkey imports
needsSetup,
setupAdmin,
logout,
@@ -57,24 +52,7 @@ router.delete('/2fa', isAuthenticated, disable2FA);
// GET /api/v1/auth/status - 获取当前认证状态 (需要认证)
router.get('/status', isAuthenticated, getAuthStatus);
// --- Passkey 管理接口 (都需要认证) ---
// POST /api/v1/auth/passkey/register-options - 生成 Passkey 注册选项
router.post('/passkey/register-options', isAuthenticated, generatePasskeyRegistrationOptions);
// 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);
// --- Passkey routes removed ---
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
router.post('/logout', logout);
+1 -14
View File
@@ -28,20 +28,7 @@ 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')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- 新增:外键约束
);
`;
// Removed Passkeys table definition (lines 31-44 from original)
export const createNotificationSettingsTableSQL = `
CREATE TABLE IF NOT EXISTS notification_settings (
-2
View File
@@ -20,8 +20,6 @@
"PASSWORD_CHANGED": "Password Changed",
"2FA_ENABLED": "2FA Enabled",
"2FA_DISABLED": "2FA Disabled",
"PASSKEY_REGISTERED": "Passkey Registered",
"PASSKEY_DELETED": "Passkey Deleted",
"CONNECTION_CREATED": "Connection Created",
"CONNECTION_UPDATED": "Connection Updated",
"CONNECTION_DELETED": "Connection Deleted",
-2
View File
@@ -20,8 +20,6 @@
"PASSWORD_CHANGED": "パスワード変更",
"2FA_ENABLED": "2段階認証有効",
"2FA_DISABLED": "2段階認証無効",
"PASSKEY_REGISTERED": "パスキー登録",
"PASSKEY_DELETED": "パスキー削除",
"CONNECTION_CREATED": "接続を作成しました",
"CONNECTION_UPDATED": "接続を更新しました",
"CONNECTION_DELETED": "接続を削除しました",
-2
View File
@@ -6,8 +6,6 @@
"PASSWORD_CHANGED": "密码已更改",
"2FA_ENABLED": "两步验证已启用",
"2FA_DISABLED": "两步验证已禁用",
"PASSKEY_REGISTERED": "通行密钥已注册",
"PASSKEY_DELETED": "通行密钥已删除",
"CONNECTION_CREATED": "连接已创建",
"CONNECTION_UPDATED": "连接已更新",
"CONNECTION_DELETED": "连接已删除",
@@ -1,186 +1 @@
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// 定义 Passkey 数据库记录的接口
export interface PasskeyRecord {
id: number;
user_id: number; // 新增:关联的用户 ID
credential_id: string; // Base64URL encoded
public_key: string; // Base64URL encoded
counter: number;
transports: string | null;
name: string | null;
created_at: number;
updated_at: number;
}
type DbPasskeyRow = PasskeyRecord; // 类型别名保持不变,因为接口已更新
export class PasskeyRepository {
/**
* 保存新的 Passkey 凭证
* @param userId 关联的用户 ID
* @returns Promise<number> 新插入记录的 ID
*/
async savePasskey(
userId: number, // 新增 userId 参数
credentialId: string,
publicKey: string,
counter: number,
transports: string | null,
name?: string
): Promise<number> {
const sql = `
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
`;
// 新增 userId 到参数列表
const params = [userId, credentialId, publicKey, counter, transports, name ?? null];
try {
const db = await getDbInstance();
const result = await runDb(db, sql, params);
// Ensure lastID is valid before returning
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('保存 Passkey 后未能获取有效的 lastID');
}
return result.lastID;
} catch (err: any) {
console.error('保存 Passkey 时出错:', err.message);
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 记录 (包含 user_id)
* @returns Promise<PasskeyRecord | null> 找到的记录或 null
*/
async getPasskeyByCredentialId(credentialId: string): Promise<PasskeyRecord | null> {
// 确保查询包含 user_id
const sql = `SELECT * FROM passkeys WHERE credential_id = ?`;
try {
const db = await getDbInstance();
// 使用更新后的 DbPasskeyRow 类型
const row = await getDbRow<DbPasskeyRow>(db, sql, [credentialId]);
return row || null;
} catch (err: any) {
console.error('按 Credential ID 获取 Passkey 时出错:', err.message);
throw new Error(`按 Credential ID 获取 Passkey 时出错: ${err.message}`);
}
}
/**
* 获取指定用户或所有已注册的 Passkey 记录 (仅选择必要字段)
* @param userId 可选,如果提供,则只获取该用户的 Passkey
* @returns Promise<Partial<PasskeyRecord>[]> 记录的部分信息的数组
*/
async getAllPasskeys(userId?: number): Promise<Array<Pick<PasskeyRecord, 'id' | 'user_id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>> {
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();
// 更新返回类型以包含 user_id
const rows = await allDb<Pick<PasskeyRecord, 'id' | 'user_id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>(db, sql, params);
return rows;
} catch (err: any) {
console.error('获取 Passkey 列表时出错:', err.message);
throw new Error(`获取 Passkey 列表时出错: ${err.message}`);
}
}
/**
* 更新 Passkey 的签名计数器
* @returns Promise<void>
*/
async updatePasskeyCounter(credentialId: string, newCounter: number): Promise<void> {
const sql = `UPDATE passkeys SET counter = ?, updated_at = strftime('%s', 'now') WHERE credential_id = ?`;
try {
const db = await getDbInstance();
const result = await runDb(db, sql, [newCounter, credentialId]);
if (result.changes === 0) {
// Consider if this should be an error or just a warning/no-op
console.warn(`未找到 Credential ID 为 ${credentialId} 的 Passkey 进行计数器更新`);
// throw new Error(`未找到 Credential ID 为 ${credentialId} 的 Passkey 进行更新`);
}
} catch (err: any) {
console.error('更新 Passkey 计数器时出错:', err.message);
throw new Error(`更新 Passkey 计数器时出错: ${err.message}`);
}
}
/**
* 根据 ID 删除 Passkey
* @returns Promise<boolean> 是否成功删除
*/
async deletePasskeyById(id: number): Promise<boolean> {
const sql = `DELETE FROM passkeys WHERE id = ?`;
try {
const db = await getDbInstance();
const result = await runDb(db, sql, [id]);
if (result.changes > 0) {
console.log(`ID 为 ${id} 的 Passkey 已删除。`);
return true;
} else {
console.warn(`尝试删除不存在的 Passkey ID: ${id}`);
return false;
}
} catch (err: any) {
console.error('按 ID 删除 Passkey 时出错:', err.message);
throw new Error(`按 ID 删除 Passkey 时出错: ${err.message}`);
}
}
/**
* 根据 Credential ID 删除 Passkey
* @returns Promise<boolean> 是否成功删除
*/
async deletePasskeyByCredentialId(credentialId: string): Promise<boolean> {
const sql = `DELETE FROM passkeys WHERE credential_id = ?`;
try {
const db = await getDbInstance();
const result = await runDb(db, sql, [credentialId]);
if (result.changes > 0) {
console.log(`Credential ID 为 ${credentialId} 的 Passkey 已删除。`);
return true;
} else {
console.warn(`尝试删除不存在的 Credential ID: ${credentialId}`);
return false;
}
} catch (err: any) {
console.error('按 Credential ID 删除 Passkey 时出错:', err.message);
throw new Error(`按 Credential ID 删除 Passkey 时出错: ${err.message}`);
}
}
/**
* 根据 credential_id 或 name 前缀模糊查找 Passkey 记录(自动补全)
* @returns Promise<PasskeyRecord[]> 匹配的记录数组
*/
// Adjust return type based on selected columns if not selecting all (*)
async searchPasskeyByPrefix(prefix: string): Promise<DbPasskeyRow[]> {
const sql = `SELECT * FROM passkeys WHERE credential_id LIKE ? OR name LIKE ? ORDER BY created_at DESC`;
const likePrefix = `${prefix}%`;
try {
const db = await getDbInstance();
const rows = await allDb<DbPasskeyRow>(db, sql, [likePrefix, likePrefix]);
return rows;
} catch (err: any) {
console.error('模糊查找 Passkey 时出错:', err.message);
throw new Error(`模糊查找 Passkey 时出错: ${err.message}`);
}
}
}
// This file is intentionally left empty as Passkey functionality has been removed.
@@ -10,8 +10,6 @@ export enum AppEventType {
PasswordChanged = 'PASSWORD_CHANGED',
TwoFactorEnabled = '2FA_ENABLED',
TwoFactorDisabled = '2FA_DISABLED',
PasskeyRegistered = 'PASSKEY_REGISTERED',
PasskeyDeleted = 'PASSKEY_DELETED',
ConnectionCreated = 'CONNECTION_CREATED',
ConnectionUpdated = 'CONNECTION_UPDATED',
ConnectionDeleted = 'CONNECTION_DELETED',
@@ -1,318 +0,0 @@
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
VerifiedRegistrationResponse,
// VerifiedAuthenticationResponse, // Remove original import
} from '@simplewebauthn/server';
import type { VerifiedAuthenticationResponse as SimpleVerifiedAuthenticationResponse } from '@simplewebauthn/server'; // Import with alias
import type {
GenerateRegistrationOptionsOpts,
GenerateAuthenticationOptionsOpts,
VerifyRegistrationResponseOpts,
VerifyAuthenticationResponseOpts,
RegistrationResponseJSON,
AuthenticationResponseJSON,
} 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 将从请求动态获取,不再在此处硬编码
// const rpID = process.env.NODE_ENV === 'development' ? 'localhost' : 'YOUR_PRODUCTION_DOMAIN';
// const expectedOrigin = process.env.FRONTEND_URL || 'http://localhost:5173';
export class PasskeyService {
private passkeyRepository: PasskeyRepository;
constructor() {
this.passkeyRepository = new PasskeyRepository();
}
/**
* 生成 Passkey 注册选项 (挑战)
* @param hostname 请求的主机名 (例如 'myapp.example.com' 或 'localhost')
* @param userName WebAuthn 需要的用户名
*/
async generateRegistrationOptions(hostname: string, userName: string = 'nexus-user') {
// 暂时不获取已存在的凭证,允许同一用户注册多个设备
const rpID = hostname; // 使用请求的主机名作为 RP ID
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: Buffer.from(userName), // userID should be a Buffer/Uint8Array
userName: userName,
authenticatorSelection: {
userVerification: 'preferred', // 倾向于需要用户验证 (PIN, 生物识别)
residentKey: 'preferred', // 倾向于创建可发现凭证 (存储在认证器上)
},
// 可选:增加超时时间
timeout: 60000, // 60 秒
};
const registrationOptions = await generateRegistrationOptions(options);
return registrationOptions;
}
/**
* 验证 Passkey 注册响应
* @param userId 当前登录用户的 ID
* @param registrationResponse 来自客户端的注册响应
* @param expectedChallenge 之前生成的、临时存储的挑战
* @param hostname 请求的主机名
* @param origin 请求的源 (例如 'https://myapp.example.com' 或 'http://localhost:5173')
* @param passkeyName 用户为这个 Passkey 起的名字 (可选)
*/
async verifyRegistration(
userId: number, // 新增 userId 参数
registrationResponse: RegistrationResponseJSON,
expectedChallenge: string,
hostname: string,
origin: string,
passkeyName?: string
): Promise<VerifiedRegistrationResponse> {
console.log(`[PasskeyService VerifyReg] Received parameters: userId=${userId}, expectedChallenge=${expectedChallenge}, hostname=${hostname}, origin=${origin}, name=${passkeyName}`); // Log received parameters
console.log(`[PasskeyService VerifyReg] Received registrationResponse: ${JSON.stringify(registrationResponse)}`); // Log the raw registrationResponse
const expectedRPID = hostname;
const expectedOrigin = origin;
const verificationOptions: VerifyRegistrationResponseOpts = {
response: registrationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: expectedOrigin,
expectedRPID: expectedRPID,
requireUserVerification: true, // 强制要求用户验证, simplewebauthn defaults this to true now
};
console.log(`[PasskeyService VerifyReg] Constructed verificationOptions: ${JSON.stringify(verificationOptions)}`); // Log options before verification
let verification: VerifiedRegistrationResponse;
try {
console.log('[PasskeyService VerifyReg] Calling @simplewebauthn/server verifyRegistrationResponse...');
verification = await verifyRegistrationResponse(verificationOptions);
console.log(`[PasskeyService VerifyReg] verifyRegistrationResponse returned: verified=${verification.verified}, registrationInfo exists=${!!verification.registrationInfo}`); // Log verification result
} catch (error: any) {
console.error('Passkey 注册验证时发生异常:', error);
// Provide more context in the error
const err = error as Error;
throw new Error(`Passkey registration verification failed: ${err.message || err}`);
}
// --- 移除日志记录 ---
// console.log('[PasskeyService] Verification result:', JSON.stringify(verification, null, 2));
// --- 结束日志记录 ---
if (verification.verified && verification.registrationInfo) {
const registrationInfo = verification.registrationInfo as any; // Keep type assertion for now
console.log(`[PasskeyService VerifyReg] Verification successful. Extracted registrationInfo: ${JSON.stringify(registrationInfo)}`); // Log extracted info
// Log the critical fields BEFORE using them
// 从嵌套的 credential 对象中获取 id 和 publicKey
// 从嵌套的 credential 对象中获取 id, publicKey 和 counter
const credentialId = registrationInfo.credential?.id;
const credentialPublicKey = registrationInfo.credential?.publicKey;
const counter = registrationInfo.credential?.counter; // counter 也在 credential 内部
console.log(`[PasskeyService VerifyReg] BEFORE Buffer.from(credential.id): Type=${typeof credentialId}, Value=${credentialId}`);
console.log(`[PasskeyService VerifyReg] BEFORE Buffer.from(credential.publicKey): Type=${typeof credentialPublicKey}, Value=${credentialPublicKey}`);
console.log(`[PasskeyService VerifyReg] Extracted counter: Type=${typeof counter}, Value=${counter}`); // Log counter
// 检查所有必要字段
if (!credentialId || !credentialPublicKey || counter === undefined || counter === null) {
console.error('[PasskeyService VerifyReg] Error: credential.id, credential.publicKey, or counter is missing or invalid in registrationInfo.');
throw new Error('Verification successful, but credential ID, Public Key, or Counter is missing or invalid in registration info.');
}
// --- credentialId is already a Base64URL string, use directly ---
// --- publicKey needs conversion from ArrayBuffer/object ---
const credentialIdBase64Url = credentialId; // Use the string directly
const credentialPublicKeyUint8Array = new Uint8Array(credentialPublicKey); // Convert public key
const publicKeyBase64Url = Buffer.from(credentialPublicKeyUint8Array).toString('base64url');
console.log(`[PasskeyService VerifyReg] Using credentialId (already Base64URL): ${credentialIdBase64Url}`); // Log the ID being used
console.log(`[PasskeyService VerifyReg] Converted publicKey to Base64URL: ${publicKeyBase64Url}`); // Log the converted public key
// 获取 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(`用户 ${userId} Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`);
} else {
console.error('Passkey 注册验证失败:', verification);
}
return verification;
}
/**
* 生成 Passkey 认证选项 (挑战)
* @param hostname 请求的主机名
*/
async generateAuthenticationOptions(hostname: string): Promise<ReturnType<typeof generateAuthenticationOptions>> {
const rpID = hostname;
const options: GenerateAuthenticationOptionsOpts = {
rpID,
userVerification: 'preferred', // 倾向于需要用户验证
timeout: 60000, // 60 秒
};
const authenticationOptions = await generateAuthenticationOptions(options);
// TODO: 需要将生成的 challenge 临时存储起来,以便后续验证
// 这里暂时返回 challenge,让 Controller 处理存储
return authenticationOptions;
}
/**
* 验证 Passkey 认证响应
* @param authenticationResponse 来自客户端的认证响应
* @param expectedChallenge 之前生成的、临时存储的挑战
* @param hostname 请求的主机名
* @param origin 请求的源
*/
async verifyAuthentication(
authenticationResponse: AuthenticationResponseJSON,
expectedChallenge: string,
hostname: string,
origin: string
): Promise<VerifiedAuthenticationResponse> { // Return our extended type
const credentialIdBase64Url = authenticationResponse.id; // 客户端传回的 ID 已经是 Base64URL
console.log(`[PasskeyService VerifyAuth] Received credentialId from client: ${credentialIdBase64Url}`); // Log received ID
console.log(`[PasskeyService VerifyAuth] Calling passkeyRepository.getPasskeyByCredentialId with: ${credentialIdBase64Url}`);
const authenticator = await this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url);
console.log(`[PasskeyService VerifyAuth] Result from getPasskeyByCredentialId: ${authenticator ? 'Found' : 'Not Found'}`); // Log lookup result
// Log the raw authenticator object fetched from DB
console.log(`[PasskeyService VerifyAuth] Authenticator data from DB: ${JSON.stringify(authenticator)}`);
if (!authenticator) {
throw new Error(`未找到 Credential ID 为 ${credentialIdBase64Url} 的认证器`);
}
const expectedRPID = hostname;
const expectedOrigin = origin;
const verificationOptions: VerifyAuthenticationResponseOpts = {
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: expectedOrigin,
expectedRPID: expectedRPID,
authenticator: {
credentialID: Buffer.from(authenticator.credential_id, 'base64url'),
credentialPublicKey: Buffer.from(authenticator.public_key, 'base64url'),
counter: authenticator.counter,
// Temporarily remove transports to test if it causes issues
// transports: authenticator.transports ? JSON.parse(authenticator.transports) : undefined,
},
requireUserVerification: true, // Keep user verification requirement
} as any;
// Log the constructed verificationOptions, especially the authenticator part
console.log(`[PasskeyService VerifyAuth] Full authenticationResponse from client: ${JSON.stringify(authenticationResponse, null, 2)}`); // Added log
console.log(`[PasskeyService VerifyAuth] Authenticator Data (Base64URL): ${authenticationResponse.response.authenticatorData}`); // Added log
console.log(`[PasskeyService VerifyAuth] Client Data JSON (Base64URL): ${authenticationResponse.response.clientDataJSON}`); // Added log
console.log(`[PasskeyService VerifyAuth] Constructed verificationOptions for library: ${JSON.stringify(verificationOptions, null, 2)}`);
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse(verificationOptions);
} catch (error: any) {
console.error('Passkey 认证验证时发生异常:', error);
const err = error as Error;
if (!err.message.includes(credentialIdBase64Url)) {
throw new Error(`Passkey authentication verification failed: ${err.message || err}`);
}
throw error;
}
if (verification.verified && verification.authenticationInfo) {
const { newCounter } = verification.authenticationInfo;
// 更新数据库中的计数器
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<User>(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);
}
return verification;
}
/**
* 获取所有已注册 Passkey 的简要信息 (用于管理)
*/
async listPasskeys(): Promise<Partial<PasskeyRecord>[]> {
// 只返回 ID, Name, Transports, CreatedAt 以减少暴露敏感信息
const keys = await this.passkeyRepository.getAllPasskeys();
return keys.map(k => ({
id: k.id,
name: k.name,
transports: k.transports,
created_at: k.created_at
}));
}
/**
* 根据 ID 删除 Passkey
* @param id Passkey 记录的 ID
*/
async deletePasskey(id: number): Promise<void> {
await this.passkeyRepository.deletePasskeyById(id);
}
/**
* 根据 Credential ID 获取 Passkey 记录 (供认证验证使用)
* @param credentialIdBase64Url Base64URL 编码的 Credential ID
*/
async getPasskeyByCredentialId(credentialIdBase64Url: string): Promise<PasskeyRecord | null> {
// 注意:PasskeyRepository 需要有 getPasskeyByCredentialId 方法
// 并且 PasskeyRecord 需要包含 user_id 以便后续查找用户
return this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url);
}
}
+1 -2
View File
@@ -7,8 +7,7 @@ export type AuditLogActionType =
| 'PASSWORD_CHANGED'
| '2FA_ENABLED'
| '2FA_DISABLED'
| 'PASSKEY_REGISTERED'
| 'PASSKEY_DELETED'
// Removed Passkey events
// Connections
| 'CONNECTION_CREATED'
@@ -3,7 +3,7 @@ export type NotificationChannelType = 'webhook' | 'email' | 'telegram';
// Align NotificationEvent with AuditLogActionType as requested
export type NotificationEvent =
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
| '2FA_ENABLED' | '2FA_DISABLED' | 'PASSKEY_REGISTERED' | 'PASSKEY_DELETED'
| '2FA_ENABLED' | '2FA_DISABLED' // Removed Passkey events
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED' | 'CONNECTION_TESTED'
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
+33 -72
View File
@@ -1,80 +1,41 @@
import crypto from 'crypto';
import bcrypt from 'bcrypt';
const algorithm = 'aes-256-gcm';
const ivLength = 16; // GCM 推荐的 IV 长度为 12 或 16 字节
const tagLength = 16; // GCM 认证标签长度
// Function to hash a password
export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10; // Adjust salt rounds as needed for security/performance balance
return bcrypt.hash(password, saltRounds);
}
// Function to compare a password with a hash
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// Function to generate a secure random string (e.g., for session secrets, tokens)
export function generateSecureRandomString(length: number = 32): string {
return crypto.randomBytes(length).toString('hex');
}
// --- Added for WebAuthn ---
/**
* Internal helper to get and validate the encryption key buffer on demand.
* Converts an ArrayBuffer or Buffer to a Base64URL-encoded string.
* @param buffer The ArrayBuffer or Buffer to convert.
* @returns The Base64URL-encoded string.
*/
const getEncryptionKeyBuffer = (): Buffer => {
const keyEnv = process.env.ENCRYPTION_KEY;
if (!keyEnv) {
// This should ideally not happen due to initializeEnvironment in index.ts
console.error('错误:ENCRYPTION_KEY 环境变量未设置!');
throw new Error('ENCRYPTION_KEY is not set.');
}
try {
const keyBuffer = Buffer.from(keyEnv, 'hex');
if (keyBuffer.length !== 32) {
console.error(`错误:加密密钥长度必须是 32 字节,当前长度为 ${keyBuffer.length}`);
throw new Error('Invalid ENCRYPTION_KEY length.');
}
return keyBuffer;
} catch (error) {
console.error('错误:无法将 ENCRYPTION_KEY 从 hex 解码为 Buffer:', error);
throw new Error('Failed to decode ENCRYPTION_KEY.');
}
};
export function bufferToBase64url(buffer: ArrayBuffer | Buffer): string {
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
return buf.toString('base64url');
}
/**
* 加密文本 (例如连接密码)
* @param text - 需要加密的明文
* @returns Base64 编码的字符串,格式为 "iv:encrypted:tag"
* Converts a Base64URL-encoded string back to a Buffer.
* @param base64urlString The Base64URL-encoded string.
* @returns The corresponding Buffer.
*/
export const encrypt = (text: string): string => {
try {
const encryptionKey = getEncryptionKeyBuffer(); // Get key on demand
const iv = crypto.randomBytes(ivLength);
const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// 将 iv、密文和认证标签组合并编码
return Buffer.concat([iv, encrypted, tag]).toString('base64');
} catch (error) {
console.error('加密失败:', error);
throw new Error('加密过程中发生错误');
}
};
/**
* 解密文本
* @param encryptedText - Base64 编码的加密字符串 ("iv:encrypted:tag")
* @returns 解密后的明文
*/
export const decrypt = (encryptedText: string): string => {
try {
const encryptionKey = getEncryptionKeyBuffer(); // Get key on demand
const data = Buffer.from(encryptedText, 'base64');
if (data.length < ivLength + tagLength) {
throw new Error('无效的加密数据格式');
}
// 从组合数据中提取 iv、密文和认证标签
const iv = data.slice(0, ivLength);
const encrypted = data.slice(ivLength, data.length - tagLength);
const tag = data.slice(data.length - tagLength);
const decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv);
decipher.setAuthTag(tag); // 设置认证标签以供验证
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
} catch (error) {
console.error('解密失败:', error);
// 在实际应用中,解密失败通常意味着数据被篡改或密钥错误
// 不应向客户端泄露具体错误细节
throw new Error('解密过程中发生错误或数据无效');
}
};
export function base64urlToBuffer(base64urlString: string): Buffer {
// Pad the string if necessary, as base64url might omit padding
// Node.js Buffer.from handles base64url directly
return Buffer.from(base64urlString, 'base64url');
}
-1
View File
@@ -11,7 +11,6 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.1.4",
"@xterm/addon-search": "^0.15.0",
"axios": "^1.8.4",
@@ -269,7 +269,7 @@ const canTestUnsaved = computed(() => {
// Define all possible events (aligned with AuditLogView's allActionTypes)
const allNotificationEvents: NotificationEvent[] = [
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED', // Added LOGOUT, PASSWORD_CHANGED
'2FA_ENABLED', '2FA_DISABLED', 'PASSKEY_REGISTERED', 'PASSKEY_DELETED', // Added 2FA, changed PASSKEY_ADDED
'2FA_ENABLED', '2FA_DISABLED', // Added 2FA,
'CONNECTION_CREATED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED', 'CONNECTION_TESTED', // Changed _ADDED, added _TESTED
'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED', // Changed _ADDED
'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED', // Changed _ADDED
-4
View File
@@ -497,8 +497,6 @@
"PASSWORD_CHANGED": "Password Changed",
"2FA_ENABLED": "2FA Enabled",
"2FA_DISABLED": "2FA Disabled",
"PASSKEY_REGISTERED": "Passkey Registered",
"PASSKEY_DELETED": "Passkey Deleted",
"CONNECTION_CREATED": "Connection Created",
"CONNECTION_UPDATED": "Connection Updated",
"CONNECTION_DELETED": "Connection Deleted",
@@ -717,8 +715,6 @@
"PASSWORD_CHANGED": "Password Changed",
"2FA_ENABLED": "2FA Enabled",
"2FA_DISABLED": "2FA Disabled",
"PASSKEY_REGISTERED": "Passkey Registered",
"PASSKEY_DELETED": "Passkey Deleted",
"CONNECTION_CREATED": "Connection Created",
"CONNECTION_UPDATED": "Connection Updated",
"CONNECTION_DELETED": "Connection Deleted",
-4
View File
@@ -497,8 +497,6 @@
"PASSWORD_CHANGED": "パスワード変更",
"2FA_ENABLED": "2段階認証有効",
"2FA_DISABLED": "2段階認証無効",
"PASSKEY_REGISTERED": "Passkey 登録",
"PASSKEY_DELETED": "Passkey 削除",
"CONNECTION_CREATED": "接続作成",
"CONNECTION_UPDATED": "接続更新",
"CONNECTION_DELETED": "接続削除",
@@ -720,8 +718,6 @@
"PASSWORD_CHANGED": "パスワード変更",
"2FA_ENABLED": "2段階認証有効",
"2FA_DISABLED": "2段階認証無効",
"PASSKEY_REGISTERED": "Passkey 登録",
"PASSKEY_DELETED": "Passkey 削除",
"CONNECTION_CREATED": "接続作成",
"CONNECTION_UPDATED": "接続更新",
"CONNECTION_DELETED": "接続削除",
-4
View File
@@ -497,8 +497,6 @@
"PASSWORD_CHANGED": "密码已修改",
"2FA_ENABLED": "两步验证已启用",
"2FA_DISABLED": "两步验证已禁用",
"PASSKEY_REGISTERED": "Passkey 已注册",
"PASSKEY_DELETED": "Passkey 已删除",
"CONNECTION_CREATED": "连接已创建",
"CONNECTION_UPDATED": "连接已更新",
"CONNECTION_DELETED": "连接已删除",
@@ -720,8 +718,6 @@
"PASSWORD_CHANGED": "密码已修改",
"2FA_ENABLED": "两步验证已启用",
"2FA_DISABLED": "两步验证已禁用",
"PASSKEY_REGISTERED": "Passkey 已注册",
"PASSKEY_DELETED": "Passkey 已删除",
"CONNECTION_CREATED": "连接已创建",
"CONNECTION_UPDATED": "连接已更新",
"CONNECTION_DELETED": "连接已删除",
+7 -121
View File
@@ -36,13 +36,7 @@ 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
}
// Removed PasskeyInfo interface
// Auth Store State 接口
@@ -59,9 +53,7 @@ interface AuthState {
};
needsSetup: boolean; // 新增:是否需要初始设置
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
passkeys: PasskeyInfo[]; // 新增:存储 Passkey 列表
passkeysLoading: boolean; // 新增:Passkey 列表加载状态
passkeysError: string | null; // 新增:Passkey 列表错误状态
// Removed Passkey state properties
}
export const useAuthStore = defineStore('auth', {
@@ -74,9 +66,7 @@ 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,
// Removed Passkey state initialization
}),
getters: {
// 可以添加一些 getter,例如获取用户名
@@ -180,7 +170,7 @@ export const useAuthStore = defineStore('auth', {
// 清除本地状态
this.isAuthenticated = false;
this.user = null;
this.passkeys = []; // 登出时清空 Passkey 列表
// Removed passkeys clear on logout
console.log('已登出');
// 登出后重定向到登录页
await router.push({ name: 'Login' });
@@ -210,7 +200,7 @@ export const useAuthStore = defineStore('auth', {
this.isAuthenticated = false;
this.user = null;
this.loginRequires2FA = false;
this.passkeys = []; // 未认证时清空 Passkey 列表
// Removed passkeys clear on unauthenticated
}
} catch (error: any) {
// 如果获取状态失败 (例如 session 过期),则认为未认证
@@ -218,7 +208,7 @@ export const useAuthStore = defineStore('auth', {
this.isAuthenticated = false;
this.user = null;
this.loginRequires2FA = false;
this.passkeys = []; // 失败时也清空 Passkey 列表
// Removed passkeys clear on error
// 可选:如果不是 401 错误,可以记录更详细的日志
} finally {
this.isLoading = false;
@@ -353,111 +343,7 @@ 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<PasskeyInfo[]>('/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;
}
},
// --- Passkey Actions Removed ---
},
persist: true, // Revert to simple persistence to fix TS error for now
});
@@ -9,8 +9,6 @@ export type AuditLogActionType =
| 'PASSWORD_CHANGED'
| '2FA_ENABLED'
| '2FA_DISABLED'
| 'PASSKEY_REGISTERED'
| 'PASSKEY_DELETED'
// Connections
| 'CONNECTION_CREATED'
| 'CONNECTION_UPDATED'
+2 -2
View File
@@ -21,7 +21,7 @@ export type NotificationChannelType = 'webhook' | 'email' | 'telegram';
// Align NotificationEvent with AuditLogActionType as requested
export type NotificationEvent =
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
| '2FA_ENABLED' | '2FA_DISABLED' | 'PASSKEY_REGISTERED' | 'PASSKEY_DELETED'
| '2FA_ENABLED' | '2FA_DISABLED'
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED' | 'CONNECTION_TESTED'
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
@@ -74,7 +74,7 @@ export type NotificationSettingData = Omit<NotificationSetting, 'id' | 'created_
// Keep action types aligned with backend for potential filtering
export type AuditLogActionType =
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
| '2FA_ENABLED' | '2FA_DISABLED' | 'PASSKEY_REGISTERED' | 'PASSKEY_DELETED'
| '2FA_ENABLED' | '2FA_DISABLED'
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED' | 'CONNECTION_TESTED'
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
+1 -1
View File
@@ -118,7 +118,7 @@ const selectedActionType = ref<AuditLogActionType | ''>(''); // Allow empty stri
// Define all possible action types for the dropdown
const allActionTypes: AuditLogActionType[] = [
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED',
'2FA_ENABLED', '2FA_DISABLED', 'PASSKEY_REGISTERED', 'PASSKEY_DELETED',
'2FA_ENABLED', '2FA_DISABLED',
'CONNECTION_CREATED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED', 'CONNECTION_TESTED',
'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED',
'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED',
+4 -50
View File
@@ -2,7 +2,7 @@
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { startAuthentication } from '@simplewebauthn/browser'; // <-- 导入 Passkey 函数
// Removed Passkey import: import { startAuthentication } from '@simplewebauthn/browser';
import { useAuthStore } from '../stores/auth.store';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
@@ -98,46 +98,7 @@ onMounted(() => {
authStore.fetchCaptchaConfig();
});
// --- 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 ---
// --- Passkey Login Handler Removed ---
</script>
<template>
@@ -237,15 +198,8 @@ const handlePasskeyLogin = async () => {
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
</button>
<!-- Passkey Login Button -->
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
class="w-full mt-3 py-3 px-4 bg-secondary text-secondary-foreground border border-border/50 rounded-lg text-base font-semibold cursor-pointer shadow-sm transition-colors duration-200 ease-in-out hover:bg-secondary/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-300 disabled:cursor-not-allowed disabled:opacity-70">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline-block mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 11c.828 0 1.5.672 1.5 1.5S12.828 14 12 14s-1.5-.672-1.5-1.5S11.172 11 12 11zm0-9a7 7 0 00-7 7c0 1.886.738 3.627 1.946 4.946.06.061.117.122.17.185l-.018.018-.002.002A9.5 9.5 0 0012 21.5a9.5 9.5 0 007.89-4.352l-.002-.002-.018-.018a6.965 6.965 0 001.946-4.946 7 7 0 00-7-7zm0 1.5a5.5 5.5 0 110 11 5.5 5.5 0 010-11z" />
</svg>
{{ t('login.passkeyLoginButton', '使用 Passkey 登录') }}
</button>
<!-- Passkey Login Button Removed -->
</form>
</div>
</div>
+7 -116
View File
@@ -50,52 +50,7 @@
</form>
</div>
<hr class="border-border/50">
<!-- Passkey -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.passkey.title') }}</h3>
<p class="text-sm text-text-secondary mb-4">{{ $t('settings.passkey.description') }}</p>
<div class="mb-4">
<label for="passkey-name" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.passkey.nameLabel') }}:</label>
<input type="text" id="passkey-name" v-model="passkeyName" :placeholder="$t('settings.passkey.namePlaceholder')" required
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
</div>
<div class="flex items-center justify-between">
<button @click="handleRegisterPasskey"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium">
{{ $t('settings.passkey.registerButton') }}
</button>
<p v-if="passkeyMessage" class="text-sm text-success">{{ passkeyMessage }}</p>
<p v-if="passkeyError" class="text-sm text-error">{{ passkeyError }}</p>
</div>
<!-- Passkey List -->
<div class="mt-6 pt-4 border-t border-border/50">
<h4 class="text-sm font-semibold text-foreground mb-3">{{ $t('settings.passkey.registeredKeysTitle', '已注册的 Passkey') }}</h4>
<!-- Loading State -->
<div v-if="passkeysLoading" class="text-sm text-text-secondary italic">{{ $t('common.loading') }}</div>
<!-- Error State -->
<div v-else-if="passkeysError" class="text-sm text-error">{{ passkeysError }}</div>
<!-- Empty State -->
<div v-else-if="passkeys.length === 0" class="text-sm text-text-secondary italic">{{ $t('settings.passkey.noKeysRegistered', '尚未注册任何 Passkey') }}</div>
<!-- List -->
<ul v-else class="space-y-3">
<li v-for="key in passkeys" :key="key.id" class="flex items-center justify-between p-3 border border-border rounded-md bg-header/30 text-sm">
<div>
<span class="font-medium text-foreground mr-2">{{ key.name || $t('settings.passkey.unnamedKey', '未命名密钥') }}</span>
<span class="text-xs text-text-secondary">({{ $t('settings.passkey.registeredOn', '注册于') }}: {{ formatDate(key.created_at) }})</span>
</div>
<button
@click="handleDeletePasskey(key.id)"
:disabled="deletingPasskeyId === key.id"
class="px-2 py-1 bg-error text-error-text rounded text-xs font-medium hover:bg-error/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
>
{{ deletingPasskeyId === key.id ? $t('common.deleting') : $t('common.delete') }}
</button>
</li>
</ul>
<p v-if="deletePasskeyError" class="mt-2 text-sm text-error">{{ deletePasskeyError }}</p>
</div>
</div>
<hr class="border-border/50">
<!-- Passkey Section Removed -->
<!-- 2FA -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.twoFactor.title') }}</h3>
@@ -564,7 +519,7 @@ import { storeToRefs } from 'pinia';
import { availableLocales } from '../i18n'; // 导入可用语言列表
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import { isAxiosError } from 'axios'; // 单独导入 isAxiosError
import { startRegistration } from '@simplewebauthn/browser';
// Removed Passkey import: import { startRegistration } from '@simplewebauthn/browser';
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
@@ -588,8 +543,7 @@ const {
commandInputSyncTarget, // NEW: Import command input sync target getter
} = storeToRefs(settingsStore);
// 从 authStore 获取 Passkey 相关状态
const { passkeys, passkeysLoading, passkeysError } = storeToRefs(authStore);
// Removed Passkey state import from authStore
// --- Local state for forms ---
@@ -663,9 +617,7 @@ const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the
const captchaLoading = ref(false);
const captchaMessage = ref('');
const captchaSuccess = ref(false);
// Passkey Deletion State
const deletingPasskeyId = ref<number | null>(null);
const deletePasskeyError = ref<string | null>(null);
// Removed Passkey Deletion State
// 提供一些常用的时区供选择
@@ -893,70 +845,9 @@ const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true);
};
// --- Passkey state & methods ---
const passkeyName = ref('');
const passkeyMessage = ref<string | null>(null);
const passkeyError = ref<string | null>(null);
const handleRegisterPasskey = async () => {
passkeyMessage.value = null;
passkeyError.value = null;
if (!passkeyName.value) {
passkeyError.value = t('settings.passkey.error.nameRequired');
return;
}
try {
console.log('[Passkey Register] 开始获取注册选项...');
const optionsResponse = await apiClient.post('/auth/passkey/register-options'); // 使用 apiClient
const options = optionsResponse.data;
console.log('[Passkey Register] 获取到的注册选项:', JSON.stringify(options, null, 2)); // 记录选项
// --- Passkey state & methods Removed ---
console.log('[Passkey Register] 调用 startRegistration...');
let registrationResponse = await startRegistration(options);
console.log('[Passkey Register] startRegistration 返回结果:', JSON.stringify(registrationResponse, null, 2)); // 记录响应
const verificationPayload = { ...registrationResponse, name: passkeyName.value };
console.log('[Passkey Register] 调用验证接口,发送数据:', JSON.stringify(verificationPayload, null, 2)); // 记录发送的数据
// 将 startRegistration 返回的对象字段展开,与 name 一起作为请求体发送
await apiClient.post('/auth/passkey/verify-registration', verificationPayload); // 使用 apiClient
console.log('[Passkey Register] 验证接口调用成功。');
passkeyMessage.value = t('settings.passkey.success.registered');
passkeyName.value = '';
await authStore.fetchPasskeys(); // 注册成功后刷新列表
} catch (error: any) {
// 在 catch 块中记录更详细的错误信息
console.error('[Passkey Register] 注册流程出错:', error);
console.error('[Passkey Register] 错误详情:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2)); // 尝试记录错误对象的更多属性
if (error.name === 'NotAllowedError') {
passkeyError.value = t('settings.passkey.error.cancelled');
} else if (isAxiosError(error) && error.response) { // 使用导入的 isAxiosError
passkeyError.value = t('settings.passkey.error.verificationFailed', { message: error.response.data.message || 'Server error' });
} else {
passkeyError.value = t('settings.passkey.error.genericRegistration', { message: error.message || t('settings.passkey.error.unknown') });
}
}
};
// 新增:处理 Passkey 删除
const handleDeletePasskey = async (id: number) => {
deletingPasskeyId.value = id;
deletePasskeyError.value = null;
if (confirm(t('settings.passkey.confirmDelete', '确定要删除这个 Passkey 吗?'))) { // 需要添加翻译
try {
await authStore.deletePasskey(id);
// 列表会自动更新,因为 store action 修改了 state
} catch (error: any) {
console.error('删除 Passkey 失败:', error);
deletePasskeyError.value = error.message || t('settings.passkey.error.deleteFailed', '删除 Passkey 失败'); // 需要添加翻译
} finally {
deletingPasskeyId.value = null;
}
} else {
deletingPasskeyId.value = null;
}
};
// 新增:格式化日期函数
// --- Formatting function (kept in case other parts need it, can be removed if unused) ---
const formatDate = (timestamp: number | undefined) => {
if (!timestamp) return t('statusMonitor.notAvailable');
try {
@@ -1208,7 +1099,7 @@ onMounted(async () => {
await checkTwoFactorStatus(); // Check 2FA status
await fetchIpBlacklist(); // Fetch current blacklist entries
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
await authStore.fetchPasskeys(); // <-- 获取 Passkey 列表
// Removed fetchPasskeys call: await authStore.fetchPasskeys();
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
});