feat: 添加 passkey 登录功能

This commit is contained in:
Baobhan Sith
2025-05-08 14:13:32 +08:00
parent 56dcbc33e0
commit bc4ae93d7d
20 changed files with 1347 additions and 159 deletions
+1
View File
@@ -9,6 +9,7 @@
"dev": "cross-env NODE_ENV=development npx ts-node-dev --respawn --transpile-only src/index.ts"
},
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"@types/archiver": "^6.0.3",
"@types/multer": "^1.4.12",
"@types/session-file-store": "^1.2.5",
+292 -98
View File
@@ -1,4 +1,3 @@
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
@@ -8,13 +7,15 @@ import { NotificationService } from '../services/notification.service';
import { AuditLogService } from '../services/audit.service';
import { ipBlacklistService } from '../services/ip-blacklist.service';
import { captchaService } from '../services/captcha.service';
import { settingsService } from '../services/settings.service';
import { settingsService } from '../services/settings.service';
import { passkeyService } from '../services/passkey.service'; // +++ Passkey Service
import { passkeyRepository } from '../repositories/passkey.repository'; // +++ Passkey Repository
import { userRepository } from '../repositories/user.repository'; // For passkey auth success
const notificationService = new NotificationService();
const auditLogService = new AuditLogService();
export interface User { // Add export keyword
export interface User {
id: number;
username: string;
hashed_password: string;
@@ -27,11 +28,260 @@ declare module 'express-session' {
username?: string;
tempTwoFactorSecret?: string;
requiresTwoFactor?: boolean;
// currentChallenge?: string; // Removed Passkey challenge storage
currentChallenge?: string; // +++ For Passkey challenge storage
passkeyUserHandle?: string; // +++ For Passkey user handle (user ID as string)
rememberMe?: boolean;
}
}
// --- Passkey Controller Methods ---
/**
* 生成 Passkey 注册选项 (POST /api/v1/auth/passkey/registration-options)
*/
export const generatePasskeyRegistrationOptionsHandler = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const username = req.session.username;
if (!userId || !username) {
res.status(401).json({ message: '用户未认证。' });
return;
}
try {
// PasskeyService's generateRegistrationOptions expects userId as number
const options = await passkeyService.generateRegistrationOptions(username, userId);
req.session.currentChallenge = options.challenge;
// The user.id from options is a Uint8Array. We need to store the original string userId for userHandle.
req.session.passkeyUserHandle = userId.toString();
console.log(`[AuthController] Generated Passkey registration options for user ${username}, challenge: ${options.challenge.substring(0,10)}...`);
res.json(options);
} catch (error: any) {
console.error(`[AuthController] 生成 Passkey 注册选项时出错 (用户: ${username}):`, error.message, error.stack);
res.status(500).json({ message: '生成 Passkey 注册选项失败。', error: error.message });
}
};
/**
* 验证并保存新的 Passkey (POST /api/v1/auth/passkey/register)
*/
export const verifyPasskeyRegistrationHandler = async (req: Request, res: Response): Promise<void> => {
const registrationResponse = req.body; // The whole body is the response from @simplewebauthn/browser
const expectedChallenge = req.session.currentChallenge;
const userHandle = req.session.passkeyUserHandle;
if (!registrationResponse) {
res.status(400).json({ message: '注册响应不能为空。' });
return;
}
if (!expectedChallenge) {
res.status(400).json({ message: '会话中未找到质询信息,请重试注册流程。' });
return;
}
if (!userHandle) {
res.status(400).json({ message: '会话中未找到用户句柄,请重试注册流程。' });
return;
}
try {
const verification = await passkeyService.verifyRegistration(
registrationResponse,
expectedChallenge,
userHandle // userHandle is userId as string
);
if (verification.verified && verification.newPasskeyToSave) {
await passkeyRepository.createPasskey(verification.newPasskeyToSave);
const userIdNum = parseInt(userHandle, 10);
console.log(`[AuthController] 用户 ${userHandle} 的 Passkey 注册成功并已保存。 CredentialID: ${verification.newPasskeyToSave.credential_id}`);
auditLogService.logAction('PASSKEY_REGISTERED', { userId: userIdNum, credentialId: verification.newPasskeyToSave.credential_id });
notificationService.sendNotification('PASSKEY_REGISTERED', { userId: userIdNum, username: req.session.username, credentialId: verification.newPasskeyToSave.credential_id });
delete req.session.currentChallenge;
delete req.session.passkeyUserHandle;
res.status(201).json({ verified: true, message: 'Passkey 注册成功。' });
} else {
console.warn(`[AuthController] Passkey 注册验证失败 (用户: ${userHandle}):`, verification);
res.status(400).json({ verified: false, message: 'Passkey 注册验证失败。', error: (verification as any).error?.message || 'Unknown verification error' });
}
} catch (error: any) {
console.error(`[AuthController] 验证 Passkey 注册时出错 (用户: ${userHandle}):`, error.message, error.stack);
res.status(500).json({ verified: false, message: '验证 Passkey 注册失败。', error: error.message });
}
};
/**
* 生成 Passkey 认证选项 (POST /api/v1/auth/passkey/authentication-options)
*/
export const generatePasskeyAuthenticationOptionsHandler = async (req: Request, res: Response): Promise<void> => {
const { username } = req.body; // Can be initiated by username (if not logged in) or for currently logged-in user
try {
// PasskeyService's generateAuthenticationOptions can optionally take a username
const options = await passkeyService.generateAuthenticationOptions(username);
req.session.currentChallenge = options.challenge;
// For authentication, userHandle is not strictly needed in session beforehand if RP ID is specific enough
// or if allowCredentials is used. We'll clear any old one just in case.
delete req.session.passkeyUserHandle;
console.log(`[AuthController] Generated Passkey authentication options (username: ${username || 'any'}), challenge: ${options.challenge.substring(0,10)}...`);
res.json(options);
} catch (error: any) {
console.error(`[AuthController] 生成 Passkey 认证选项时出错 (username: ${username || 'any'}):`, error.message, error.stack);
res.status(500).json({ message: '生成 Passkey 认证选项失败。', error: error.message });
}
};
/**
* 验证 Passkey 凭据并登录用户 (POST /api/v1/auth/passkey/authenticate)
*/
export const verifyPasskeyAuthenticationHandler = async (req: Request, res: Response): Promise<void> => {
const authenticationResponse = req.body; // The whole body is the response from @simplewebauthn/browser
const expectedChallenge = req.session.currentChallenge;
const { rememberMe } = req.body; // Optional rememberMe flag
if (!authenticationResponse) {
res.status(400).json({ message: '认证响应不能为空。' });
return;
}
if (!expectedChallenge) {
res.status(400).json({ message: '会话中未找到质询信息,请重试认证流程。' });
return;
}
try {
const verification = await passkeyService.verifyAuthentication(
authenticationResponse,
expectedChallenge
);
if (verification.verified && verification.userId && verification.passkey) {
const user = await userRepository.findUserById(verification.userId);
if (!user) {
// This should ideally not happen if passkey verification was successful
console.error(`[AuthController] Passkey 认证成功但未找到用户 ID: ${verification.userId}`);
auditLogService.logAction('PASSKEY_AUTH_FAILURE', { credentialId: verification.passkey.credential_id, reason: 'User not found after verification' });
res.status(401).json({ verified: false, message: 'Passkey 认证失败:用户数据错误。' });
return;
}
console.log(`[AuthController] 用户 ${user.username} (ID: ${user.id}) 通过 Passkey (ID: ${verification.passkey.id}) 认证成功。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
auditLogService.logAction('PASSKEY_AUTH_SUCCESS', { userId: user.id, username: user.username, credentialId: verification.passkey.credential_id, ip: clientIp });
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, method: 'Passkey' });
// Setup session similar to password login
req.session.userId = user.id;
req.session.username = user.username;
req.session.requiresTwoFactor = false; // Passkey implies 2FA characteristics
if (rememberMe) {
req.session.cookie.maxAge = 315360000000; // 10 years
} else {
req.session.cookie.maxAge = undefined; // Session cookie
}
delete req.session.currentChallenge;
delete req.session.passkeyUserHandle;
res.status(200).json({
verified: true,
message: 'Passkey 认证成功。',
user: { id: user.id, username: user.username }
});
} else {
console.warn(`[AuthController] Passkey 认证验证失败:`, verification);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
auditLogService.logAction('PASSKEY_AUTH_FAILURE', {
credentialId: authenticationResponse.id,
reason: 'Verification failed',
ip: clientIp
});
res.status(401).json({ verified: false, message: 'Passkey 认证失败。' });
}
} catch (error: any) {
console.error(`[AuthController] 验证 Passkey 认证时出错:`, error.message, error.stack);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
auditLogService.logAction('PASSKEY_AUTH_FAILURE', {
credentialId: authenticationResponse?.id || 'unknown',
reason: error.message,
ip: clientIp
});
res.status(500).json({ verified: false, message: '验证 Passkey 认证失败。', error: error.message });
}
};
/**
* 获取当前认证用户的所有 Passkey (GET /api/v1/user/passkeys)
*/
export const listUserPasskeysHandler = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const username = req.session.username;
if (!userId || !username) {
res.status(401).json({ message: '用户未认证。' });
return;
}
try {
const passkeys = await passkeyService.listPasskeysByUserId(userId);
console.log(`[AuthController] 用户 ${username} (ID: ${userId}) 获取了 Passkey 列表,数量: ${passkeys.length}`);
res.status(200).json(passkeys);
} catch (error: any) {
console.error(`[AuthController] 用户 ${username} (ID: ${userId}) 获取 Passkey 列表时出错:`, error.message, error.stack);
res.status(500).json({ message: '获取 Passkey 列表失败。', error: error.message });
}
};
/**
* 删除当前认证用户指定的 Passkey (DELETE /api/v1/user/passkeys/:credentialID)
*/
export const deleteUserPasskeyHandler = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const username = req.session.username;
const { credentialID } = req.params;
if (!userId || !username) {
res.status(401).json({ message: '用户未认证。' });
return;
}
if (!credentialID) {
res.status(400).json({ message: '必须提供 Passkey 的 CredentialID。' });
return;
}
try {
const wasDeleted = await passkeyService.deletePasskey(userId, credentialID);
if (wasDeleted) {
console.log(`[AuthController] 用户 ${username} (ID: ${userId}) 成功删除了 Passkey (CredentialID: ${credentialID})。`);
auditLogService.logAction('PASSKEY_DELETED', { userId, username, credentialId: credentialID });
notificationService.sendNotification('PASSKEY_DELETED', { userId, username, credentialId: credentialID });
res.status(200).json({ message: 'Passkey 删除成功。' });
} else {
// 这通常不应该发生,因为 service 层会在找不到或权限不足时抛出错误
console.warn(`[AuthController] 用户 ${username} (ID: ${userId}) 删除 Passkey (CredentialID: ${credentialID}) 失败,但未抛出错误。`);
res.status(404).json({ message: 'Passkey 未找到或无法删除。' });
}
} catch (error: any) {
console.error(`[AuthController] 用户 ${username} (ID: ${userId}) 删除 Passkey (CredentialID: ${credentialID}) 时出错:`, error.message, error.stack);
if (error.message === 'Passkey not found.') {
res.status(404).json({ message: '指定的 Passkey 未找到。' });
} else if (error.message === 'Unauthorized to delete this passkey.') {
auditLogService.logAction('PASSKEY_DELETE_UNAUTHORIZED', { userId, username, credentialIdAttempted: credentialID });
res.status(403).json({ message: '无权删除此 Passkey。' });
} else {
res.status(500).json({ message: '删除 Passkey 失败。', error: error.message });
}
}
};
/**
* 处理用户登录请求 (POST /api/v1/auth/login)
@@ -61,7 +311,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.recordFailedAttempt(clientIp);
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); // 取消注释
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
res.status(401).json({ message: 'CAPTCHA 验证失败。' });
return;
}
@@ -84,12 +334,9 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (!user) {
console.log(`登录尝试失败: 用户未找到 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
// 发送登录失败通知
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp }); // 取消注释
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
res.status(401).json({ message: '无效的凭据。' });
return;
}
@@ -98,13 +345,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (!isMatch) {
console.log(`登录尝试失败: 密码错误 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
// 发送登录失败通知
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp }); // 取消注释
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
res.status(401).json({ message: '无效的凭据。' });
return;
}
@@ -112,30 +356,23 @@ export const login = async (req: Request, res: Response): Promise<void> => {
// 检查是否启用了 2FA
if (user.two_factor_secret) {
console.log(`用户 ${username} 已启用 2FA,需要进行二次验证。`);
// 不设置完整 session,只标记需要 2FA
req.session.userId = user.id; // 临时存储 userId 以便 2FA 验证
req.session.userId = user.id;
req.session.requiresTwoFactor = true;
req.session.rememberMe = rememberMe; // 临时存储 rememberMe 状态
req.session.rememberMe = rememberMe;
res.status(200).json({ message: '需要进行两步验证。', requiresTwoFactor: true });
} else {
// --- 认证成功 (未启用 2FA) ---
console.log(`登录成功 (无 2FA): ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 重置失败尝试次数
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.resetAttempts(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp });
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp }); // 添加通知调用
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp });
req.session.userId = user.id;
req.session.username = user.username;
req.session.requiresTwoFactor = false; // 明确标记不需要 2FA
req.session.requiresTwoFactor = false;
// 根据 rememberMe 设置 cookie maxAge
if (rememberMe) {
// 如果勾选了“记住我”,设置 cookie 有效期为 10 年 (毫秒)
req.session.cookie.maxAge = 315360000000;
} else {
// 如果未勾选,则不设置 maxAge,使其成为会话 cookie
req.session.cookie.maxAge = undefined;
}
@@ -159,17 +396,14 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
const username = req.session.username;
if (!userId || !username || req.session.requiresTwoFactor) {
// 如果 session 无效或 2FA 未完成,视为未认证
res.status(401).json({ isAuthenticated: false });
return;
}
try {
const db = await getDbInstance(); // Get DB instance
// 查询用户的 2FA 状态 using promisified getDb
const db = await getDbInstance();
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]);
// 如果找不到用户,也视为未认证
if (!user) {
res.status(401).json({ isAuthenticated: false });
return;
@@ -180,7 +414,7 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
user: {
id: userId,
username: username,
isTwoFactorEnabled: !!user.two_factor_secret // 返回 2FA 是否启用
isTwoFactorEnabled: !!user.two_factor_secret
}
});
@@ -194,9 +428,8 @@ export const getAuthStatus = async (req: Request, res: Response): Promise<void>
*/
export const verifyLogin2FA = async (req: Request, res: Response): Promise<void> => {
const { token } = req.body;
const userId = req.session.userId; // 获取之前临时存储的 userId
const userId = req.session.userId;
// 检查 session 状态
if (!userId || !req.session.requiresTwoFactor) {
res.status(400).json({ message: '无效的请求或会话状态。' });
return;
@@ -209,7 +442,6 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
try {
const db = await getDbInstance();
// 获取用户的 2FA 密钥 using promisified getDb
const user = await getDb<User>(db, 'SELECT id, username, two_factor_secret FROM users WHERE id = ?', [userId]);
@@ -220,35 +452,27 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
return;
}
// 验证 TOTP 令牌
const verified = speakeasy.totp.verify({
secret: user.two_factor_secret,
encoding: 'base32',
token: token,
window: 1 // 允许前后一个时间窗口 (30秒) 的容错
window: 1
});
if (verified) {
console.log(`用户 ${user.username} 2FA 验证成功。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 重置失败尝试次数
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.resetAttempts(clientIp);
// 记录审计日志 (2FA 成功也算登录成功) (添加 IP)
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true });
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true }); // 添加通知调用
// 验证成功,建立完整会话
notificationService.sendNotification('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true });
req.session.username = user.username;
req.session.requiresTwoFactor = false; // 标记 2FA 已完成
req.session.requiresTwoFactor = false;
// 根据之前存储在 session 中的 rememberMe 设置 cookie maxAge
if (req.session.rememberMe) {
// 如果勾选了“记住我”,设置 cookie 有效期为 1 年 (毫秒)
req.session.cookie.maxAge = 315360000000; // 10 years (Effectively permanent)
req.session.cookie.maxAge = 315360000000;
} else {
// 如果未勾选,则不设置 maxAge,使其成为会话 cookie
req.session.cookie.maxAge = undefined; // 或者 null
req.session.cookie.maxAge = undefined;
}
// 清除临时的 rememberMe 状态
delete req.session.rememberMe;
res.status(200).json({
@@ -257,12 +481,10 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
});
} else {
console.log(`用户 ${user.username} 2FA 验证失败: 验证码错误。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp });
notificationService.sendNotification('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp }); // 添加通知调用
notificationService.sendNotification('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp });
res.status(401).json({ message: '验证码无效。' });
}
@@ -278,15 +500,13 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
*/
export const changePassword = async (req: Request, res: Response): Promise<void> => {
const { currentPassword, newPassword } = req.body;
const userId = req.session.userId; // 从会话中获取用户 ID
const userId = req.session.userId;
// 检查用户是否登录且 2FA 已完成 (如果需要)
if (!userId || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成,请先登录。' });
return;
}
// 基础输入验证
if (!currentPassword || !newPassword) {
res.status(400).json({ message: '当前密码和新密码不能为空。' });
return;
@@ -335,10 +555,9 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
}
console.log(`用户 ${userId} 密码已成功修改。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
auditLogService.logAction('PASSWORD_CHANGED', { userId, ip: clientIp });
notificationService.sendNotification('PASSWORD_CHANGED', { userId, ip: clientIp }); // 添加通知调用
notificationService.sendNotification('PASSWORD_CHANGED', { userId, ip: clientIp });
res.status(200).json({ message: '密码已成功修改。' });
@@ -354,7 +573,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
*/
export const setup2FA = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const username = req.session.username; // 获取用户名用于 OTP URL
const username = req.session.username;
if (!userId || !username || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
@@ -363,7 +582,6 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
try {
const db = await getDbInstance();
// 检查用户是否已启用 2FA using promisified getDb
const user = await getDb<{ two_factor_secret: string | null }>(db, 'SELECT two_factor_secret FROM users WHERE id = ?', [userId]);
const existingSecret = user ? user.two_factor_secret : null;
@@ -373,30 +591,25 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
return;
}
// 生成新的 2FA 密钥
const secret = speakeasy.generateSecret({
length: 20,
name: `NexusTerminal (${username})` // 应用名称和用户名,显示在 Authenticator 应用中
name: `NexusTerminal (${username})`
});
// 将临时密钥存储在 session 中,等待验证
req.session.tempTwoFactorSecret = secret.base32;
// 生成 OTP Auth URL (用于生成二维码)
if (!secret.otpauth_url) {
throw new Error('无法生成 OTP Auth URL');
}
// 生成二维码 Data URL
qrcode.toDataURL(secret.otpauth_url, (err, data_url) => {
if (err) {
console.error('生成二维码时出错:', err);
throw new Error('生成二维码失败');
}
// 返回密钥 (base32) 和二维码数据 URL 给前端
res.json({
secret: secret.base32, // 供用户手动输入
qrCodeUrl: data_url // 用于显示二维码图片
secret: secret.base32,
qrCodeUrl: data_url
});
});
@@ -415,7 +628,7 @@ export const setup2FA = async (req: Request, res: Response): Promise<void> => {
export const verifyAndActivate2FA = async (req: Request, res: Response): Promise<void> => {
const { token } = req.body;
const userId = req.session.userId;
const tempSecret = req.session.tempTwoFactorSecret; // 获取存储在 session 中的临时密钥
const tempSecret = req.session.tempTwoFactorSecret;
if (!userId || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
@@ -434,16 +647,14 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
try {
const db = await getDbInstance();
// 使用临时密钥验证用户提交的令牌
const verified = speakeasy.totp.verify({
secret: tempSecret,
encoding: 'base32',
token: token,
window: 1 // 允许一定的时钟漂移
window: 1
});
if (verified) {
// 验证成功,将密钥永久存储到数据库 using promisified runDb
const now = Math.floor(Date.now() / 1000);
const result = await runDb(db,
'UPDATE users SET two_factor_secret = ?, updated_at = ? WHERE id = ?',
@@ -457,16 +668,13 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
console.log(`用户 ${userId} 已成功激活两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp });
notificationService.sendNotification('2FA_ENABLED', { userId, ip: clientIp }); // 添加通知调用
notificationService.sendNotification('2FA_ENABLED', { userId, ip: clientIp });
// 清除 session 中的临时密钥
delete req.session.tempTwoFactorSecret;
res.status(200).json({ message: '两步验证已成功激活!' });
} else {
// 验证失败
console.log(`用户 ${userId} 2FA 激活失败: 验证码错误。`);
res.status(400).json({ message: '验证码无效。' });
}
@@ -482,7 +690,7 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
*/
export const disable2FA = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const { password } = req.body; // 需要验证当前密码以禁用
const { password } = req.body;
if (!userId || req.session.requiresTwoFactor) {
res.status(401).json({ message: '用户未认证或认证未完成。' });
@@ -496,7 +704,6 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
try {
const db = await getDbInstance();
// 验证当前密码
const user = await getDb<User>(db, 'SELECT id, hashed_password FROM users WHERE id = ?', [userId]);
if (!user) {
@@ -507,7 +714,6 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
res.status(400).json({ message: '当前密码不正确。' }); return;
}
// 清除数据库中的 2FA 密钥
const now = Math.floor(Date.now() / 1000);
const result = await runDb(db,
'UPDATE users SET two_factor_secret = NULL, updated_at = ? WHERE id = ?',
@@ -521,9 +727,8 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
console.log(`用户 ${userId} 已成功禁用两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp });
notificationService.sendNotification('2FA_DISABLED', { userId, ip: clientIp }); // 添加通知调用
notificationService.sendNotification('2FA_DISABLED', { userId, ip: clientIp });
res.status(200).json({ message: '两步验证已成功禁用。' });
@@ -539,8 +744,7 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
*/
export const needsSetup = async (req: Request, res: Response): Promise<void> => {
try {
const db = await getDbInstance(); // Get DB instance
// Use promisified getDb
const db = await getDbInstance();
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
const userCount = row ? row.count : 0;
@@ -548,7 +752,6 @@ export const needsSetup = async (req: Request, res: Response): Promise<void> =>
} catch (error) {
console.error('检查设置状态时发生内部错误:', error);
// 如果检查失败,保守起见返回 false,避免用户卡在设置页面
res.status(500).json({ message: '检查设置状态时发生错误。', needsSetup: false });
}
};
@@ -559,7 +762,6 @@ export const needsSetup = async (req: Request, res: Response): Promise<void> =>
export const setupAdmin = async (req: Request, res: Response): Promise<void> => {
const { username, password, confirmPassword } = req.body;
// 基本输入验证
if (!username || !password || !confirmPassword) {
res.status(400).json({ message: '用户名、密码和确认密码不能为空。' });
return;
@@ -576,7 +778,6 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
try {
const db = await getDbInstance();
// 检查数据库中是否已存在用户
const row = await getDb<{ count: number }>(db, 'SELECT COUNT(*) as count FROM users');
const userCount = row ? row.count : 0;
@@ -586,12 +787,10 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
return;
}
// 哈希密码
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
const now = Math.floor(Date.now() / 1000);
// 插入新用户
const result = await runDb(db,
`INSERT INTO users (username, hashed_password, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
@@ -607,9 +806,8 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
console.log(`初始管理员账号 '${username}' (ID: ${newUser.id}) 已成功创建。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
// 记录审计日志 (添加 IP)
auditLogService.logAction('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp });
notificationService.sendNotification('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp }); // 添加通知调用
notificationService.sendNotification('ADMIN_SETUP_COMPLETE', { userId: newUser.id, username, ip: clientIp });
res.status(201).json({ message: '初始管理员账号创建成功!' });
@@ -629,17 +827,14 @@ export const logout = (req: Request, res: Response): void => {
req.session.destroy((err) => {
if (err) {
console.error(`销毁用户 ${userId} (${username}) 的会话时出错:`, err);
// 即使销毁失败,也尝试让前端认为已登出
res.status(500).json({ message: '登出时发生服务器内部错误。' });
} else {
console.log(`用户 ${userId} (${username}) 已成功登出。`);
// 清除客户端的 session cookie (通常 connect-sqlite3 会处理,但显式设置更保险)
res.clearCookie('connect.sid'); // 'connect.sid' 是 express-session 的默认 cookie 名称
// 记录审计日志
if (userId) { // 仅在能获取到 userId 时记录
res.clearCookie('connect.sid');
if (userId) {
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
auditLogService.logAction('LOGOUT', { userId, username, ip: clientIp });
notificationService.sendNotification('LOGOUT', { userId, username, ip: clientIp }); // 添加通知调用
notificationService.sendNotification('LOGOUT', { userId, username, ip: clientIp });
}
res.status(200).json({ message: '已成功登出。' });
}
@@ -666,7 +861,6 @@ export const getPublicCaptchaConfig = async (req: Request, res: Response): Promi
res.status(200).json(publicConfig);
} catch (error: any) {
console.error('[AuthController] 获取公共 CAPTCHA 配置时出错:', error);
// 即使出错,也返回一个“禁用”状态,避免前端出错
res.status(500).json({
enabled: false,
provider: 'none',
+29 -3
View File
@@ -7,11 +7,18 @@ import {
verifyAndActivate2FA,
disable2FA,
getAuthStatus,
// Removed Passkey imports
needsSetup,
setupAdmin,
logout,
getPublicCaptchaConfig
getPublicCaptchaConfig,
// Passkey handlers
generatePasskeyRegistrationOptionsHandler,
verifyPasskeyRegistrationHandler,
generatePasskeyAuthenticationOptionsHandler,
verifyPasskeyAuthenticationHandler,
// 新的 Passkey 管理处理器
listUserPasskeysHandler,
deleteUserPasskeyHandler
} from './auth.controller';
import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
@@ -52,7 +59,26 @@ router.delete('/2fa', isAuthenticated, disable2FA);
// GET /api/v1/auth/status - 获取当前认证状态 (需要认证)
router.get('/status', isAuthenticated, getAuthStatus);
// --- Passkey routes removed ---
// --- Passkey Routes ---
// POST /api/v1/auth/passkey/registration-options - 生成 Passkey 注册选项 (需要认证)
router.post('/passkey/registration-options', isAuthenticated, generatePasskeyRegistrationOptionsHandler);
// POST /api/v1/auth/passkey/register - 验证并保存新的 Passkey (需要认证,因为通常在已登录会话中添加新凭据)
router.post('/passkey/register', isAuthenticated, verifyPasskeyRegistrationHandler);
// POST /api/v1/auth/passkey/authentication-options - 生成 Passkey 认证选项 (公开或半公开,取决于是否提供了用户名)
router.post('/passkey/authentication-options', generatePasskeyAuthenticationOptionsHandler);
// POST /api/v1/auth/passkey/authenticate - 验证 Passkey 并登录用户 (公开)
router.post('/passkey/authenticate', ipBlacklistCheckMiddleware, verifyPasskeyAuthenticationHandler);
// --- User's Passkey Management Routes (New) ---
// GET /api/v1/auth/user/passkeys - 获取当前用户的所有 Passkey (需要认证)
router.get('/user/passkeys', isAuthenticated, listUserPasskeysHandler);
// DELETE /api/v1/auth/user/passkeys/:credentialID - 删除当前用户指定的 Passkey (需要认证)
router.delete('/user/passkeys/:credentialID', isAuthenticated, deleteUserPasskeyHandler);
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
router.post('/logout', logout);
+22
View File
@@ -0,0 +1,22 @@
// Basic application configuration
// In a real application, consider using a more robust config library like 'dotenv' or 'convict'
interface AppConfig {
appName: string;
rpId: string; // Relying Party ID for WebAuthn
rpOrigin: string; // Relying Party Origin for WebAuthn
port: number;
// Add other application-wide configurations here
}
export const config: AppConfig = {
appName: process.env.APP_NAME || 'Nexus Terminal',
rpId: process.env.RP_ID || 'localhost', // IMPORTANT: This MUST match your domain in production
rpOrigin: process.env.RP_ORIGIN || 'http://localhost:5173', // IMPORTANT: This MUST match your frontend origin in production
port: parseInt(process.env.PORT || '3000', 10),
};
// Function to get a config value, though direct access is also possible
export function getConfigValue<K extends keyof AppConfig>(key: K): AppConfig[K] {
return config[key];
}
@@ -228,6 +228,30 @@ const definedMigrations: Migration[] = [
`
},
// --- 未来可以添加更多迁移 ---
{
id: 6,
name: 'Create passkeys table for WebAuthn credentials',
check: async (db: Database): Promise<boolean> => {
const passkeysTableAlreadyExists = await tableExists(db, 'passkeys');
return !passkeysTableAlreadyExists; // Only run if the table does NOT exist
},
sql: `
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
public_key TEXT NOT NULL, -- COSE public key, stored as Base64URL or HEX
counter INTEGER NOT NULL,
transports TEXT, -- JSON array of transports e.g. ["usb", "nfc", "ble", "internal"]
name TEXT NULL, -- User-friendly name for the passkey
backed_up BOOLEAN NOT NULL DEFAULT FALSE, -- Stored as 0 or 1
last_used_at INTEGER NULL,
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
);
`
}
];
/**
+17 -1
View File
@@ -28,7 +28,23 @@ CREATE TABLE IF NOT EXISTS audit_logs (
// );
// `;
// Removed Passkeys table definition (lines 31-44 from original)
// Passkeys table definition
export const createPasskeysTableSQL = `
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
public_key TEXT NOT NULL, -- COSE public key, stored as Base64URL or HEX
counter INTEGER NOT NULL,
transports TEXT, -- JSON array of transports e.g. ["usb", "nfc", "ble", "internal"]
name TEXT NULL, -- User-friendly name for the passkey
backed_up BOOLEAN NOT NULL DEFAULT FALSE,
last_used_at INTEGER NULL,
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
);
`;
export const createNotificationSettingsTableSQL = `
CREATE TABLE IF NOT EXISTS notification_settings (
@@ -1 +1,132 @@
// This file is intentionally left empty as Passkey functionality has been removed.
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
export interface Passkey {
id: number;
user_id: number;
credential_id: string;
public_key: string;
counter: number;
transports: string | null; // JSON string
name: string | null;
backed_up: boolean; // SQLite stores booleans as 0 or 1
last_used_at: number | null;
created_at: number;
updated_at: number;
}
export interface NewPasskey {
user_id: number;
credential_id: string;
public_key: string;
counter: number;
transports?: string | null; // JSON string
name?: string | null;
backed_up?: boolean;
}
// Helper to convert DB result (0/1) to boolean for backed_up field
function mapPasskeyResult(dbResult: any): Passkey | null {
if (!dbResult) return null;
return {
...dbResult,
backed_up: !!dbResult.backed_up, // Ensure boolean
transports: dbResult.transports, // Already string or null
};
}
function mapPasskeyResults(dbResults: any[]): Passkey[] {
return dbResults.map(row => ({
...row,
backed_up: !!row.backed_up,
transports: row.transports,
}));
}
export class PasskeyRepository {
async createPasskey(passkeyData: NewPasskey): Promise<Passkey> {
const db = await getDbInstance();
const sql = `
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, backed_up, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
RETURNING *
`;
// Note: RETURNING * might not work as expected with the 'sqlite3' package's run method.
// We'll do a SELECT after INSERT if needed, or rely on lastID and then select.
// For simplicity with 'sqlite3', we'll insert then select.
const insertSql = `
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, backed_up, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
`;
const params = [
passkeyData.user_id,
passkeyData.credential_id,
passkeyData.public_key,
passkeyData.counter,
passkeyData.transports ?? null,
passkeyData.name ?? null,
passkeyData.backed_up ? 1 : 0, // Store boolean as 0 or 1
];
const { lastID } = await runDb(db, insertSql, params);
// Fetch the inserted row
const newPasskey = await this.getPasskeyById(lastID);
if (!newPasskey) {
throw new Error('Failed to create or retrieve passkey after insert.');
}
return newPasskey;
}
async getPasskeyById(id: number): Promise<Passkey | null> {
const db = await getDbInstance();
const sql = 'SELECT * FROM passkeys WHERE id = ?';
const result = await getDb<any>(db, sql, [id]);
return mapPasskeyResult(result);
}
async getPasskeyByCredentialId(credentialId: string): Promise<Passkey | null> {
const db = await getDbInstance();
const sql = 'SELECT * FROM passkeys WHERE credential_id = ?';
const result = await getDb<any>(db, sql, [credentialId]);
return mapPasskeyResult(result);
}
async getPasskeysByUserId(userId: number): Promise<Passkey[]> {
const db = await getDbInstance();
const sql = 'SELECT * FROM passkeys WHERE user_id = ? ORDER BY created_at DESC';
const results = await allDb<any>(db, sql, [userId]);
return mapPasskeyResults(results);
}
async updatePasskeyCounter(credentialId: string, newCounter: number): Promise<boolean> {
const db = await getDbInstance();
const sql = "UPDATE passkeys SET counter = ?, updated_at = strftime('%s', 'now') WHERE credential_id = ?";
const { changes } = await runDb(db, sql, [newCounter, credentialId]);
return changes > 0;
}
async updatePasskeyLastUsedAt(credentialId: string): Promise<boolean> {
const db = await getDbInstance();
const sql = "UPDATE passkeys SET last_used_at = strftime('%s', 'now'), updated_at = strftime('%s', 'now') WHERE credential_id = ?";
const { changes } = await runDb(db, sql, [credentialId]);
return changes > 0;
}
async deletePasskey(credentialId: string): Promise<boolean> {
const db = await getDbInstance();
const sql = 'DELETE FROM passkeys WHERE credential_id = ?';
const { changes } = await runDb(db, sql, [credentialId]);
return changes > 0;
}
async deletePasskeysByUserId(userId: number): Promise<boolean> {
const db = await getDbInstance();
const sql = 'DELETE FROM passkeys WHERE user_id = ?';
const { changes } = await runDb(db, sql, [userId]);
return changes > 0;
}
}
export const passkeyRepository = new PasskeyRepository();
@@ -0,0 +1,30 @@
import { getDbInstance, getDb, allDb } from '../database/connection';
export interface User {
id: number;
username: string;
hashed_password?: string; // Optional, as not always needed by consumers
two_factor_secret?: string | null;
created_at: number;
updated_at: number;
}
export class UserRepository {
async findUserById(id: number): Promise<User | null> {
const db = await getDbInstance();
const sql = 'SELECT id, username, hashed_password, two_factor_secret, created_at, updated_at FROM users WHERE id = ?';
const user = await getDb<User>(db, sql, [id]);
return user ?? null;
}
async findUserByUsername(username: string): Promise<User | null> {
const db = await getDbInstance();
const sql = 'SELECT id, username, hashed_password, two_factor_secret, created_at, updated_at FROM users WHERE username = ?';
const user = await getDb<User>(db, sql, [username]);
return user ?? null;
}
// Add other user-related methods if needed, e.g., createUser, updateUserPassword, etc.
}
export const userRepository = new UserRepository();
@@ -0,0 +1,237 @@
import {
generateRegistrationOptions as generateRegOptions,
verifyRegistrationResponse,
generateAuthenticationOptions as generateAuthOptions,
verifyAuthenticationResponse,
VerifiedRegistrationResponse,
VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
GenerateRegistrationOptionsOpts,
GenerateAuthenticationOptionsOpts,
VerifyRegistrationResponseOpts,
VerifyAuthenticationResponseOpts,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
AuthenticationResponseJSON,
// The actual type for verification.registrationInfo is RegistrationInfo within @simplewebauthn/server
// and for verification.authenticationInfo is AuthenticationInfo.
// We will rely on TypeScript's inference from the VerifiedRegistrationResponse/VerifiedAuthenticationResponse types.
} from '@simplewebauthn/server';
import { passkeyRepository, Passkey, NewPasskey } from '../repositories/passkey.repository';
import { userRepository, User } from '../repositories/user.repository';
import { config } from '../config/app.config';
const RP_ID = config.rpId;
const RP_ORIGIN = config.rpOrigin;
const RP_NAME = config.appName;
const textEncoder = new TextEncoder();
function base64UrlToUint8Array(base64urlString: string): Uint8Array {
const base64 = base64urlString.replace(/-/g, '+').replace(/_/g, '/');
const padLength = (4 - (base64.length % 4)) % 4;
const paddedBase64 = base64 + '='.repeat(padLength);
try {
const binaryString = atob(paddedBase64);
const uint8Array = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
uint8Array[i] = binaryString.charCodeAt(i);
}
return uint8Array;
} catch (e) {
console.error("Failed to decode base64url string:", base64urlString, e);
throw new Error("Invalid base64url string");
}
}
export class PasskeyService {
constructor(
private passkeyRepo: typeof passkeyRepository,
private userRepo: typeof userRepository
) {}
async generateRegistrationOptions(username: string, userId: number) {
const user = await this.userRepo.findUserById(userId);
if (!user || user.username !== username) {
throw new Error('User not found or username mismatch');
}
const existingPasskeys = await this.passkeyRepo.getPasskeysByUserId(userId);
const excludeCredentials: {id: string, type: 'public-key', transports?: AuthenticatorTransportFuture[]}[] = existingPasskeys.map(pk => ({
id: pk.credential_id,
type: 'public-key',
transports: pk.transports ? JSON.parse(pk.transports) as AuthenticatorTransportFuture[] : undefined,
}));
const options: GenerateRegistrationOptionsOpts = {
rpName: RP_NAME,
rpID: RP_ID,
userID: textEncoder.encode(userId.toString()),
userName: username,
userDisplayName: username,
timeout: 60000,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
supportedAlgorithmIDs: [-7, -257],
};
const generatedOptions = await generateRegOptions(options);
return generatedOptions;
}
async verifyRegistration(
registrationResponseJSON: RegistrationResponseJSON,
expectedChallenge: string,
userHandleFromClient: string
): Promise<VerifiedRegistrationResponse & { newPasskeyToSave?: NewPasskey }> {
const userId = parseInt(userHandleFromClient, 10);
if (isNaN(userId)) {
throw new Error('Invalid user handle provided.');
}
const user = await this.userRepo.findUserById(userId);
if (!user) {
throw new Error('User not found for the provided handle.');
}
const verifyOpts: VerifyRegistrationResponseOpts = {
response: registrationResponseJSON,
expectedChallenge,
expectedOrigin: RP_ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: true,
};
const verification = await verifyRegistrationResponse(verifyOpts);
if (verification.verified && verification.registrationInfo) {
const regInfo = verification.registrationInfo;
// Assuming regInfo has these properties based on standard WebAuthn structures.
// If these are incorrect for @simplewebauthn/server@13.1.1, this needs adjustment.
const credentialPublicKey = (regInfo as any).credentialPublicKey;
const credentialID = (regInfo as any).credentialID;
const counter = (regInfo as any).counter;
const transports = (regInfo as any).transports;
const credentialBackedUp = (regInfo as any).credentialBackedUp;
if (!credentialPublicKey || typeof credentialID !== 'string' || typeof counter !== 'number') {
console.error('Verification successful, but registrationInfo structure is unexpected:', regInfo);
throw new Error('Failed to process registration info due to unexpected structure.');
}
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64');
const newPasskeyEntry: NewPasskey = {
user_id: user.id,
credential_id: credentialID,
public_key: publicKeyBase64,
counter: counter,
transports: transports ? JSON.stringify(transports) : null,
backed_up: !!credentialBackedUp,
};
return { ...verification, newPasskeyToSave: newPasskeyEntry };
}
return verification;
}
async generateAuthenticationOptions(username?: string) {
let allowCredentials: {id: string, type: 'public-key', transports?: AuthenticatorTransportFuture[]}[] | undefined = undefined;
if (username) {
const user = await this.userRepo.findUserByUsername(username);
if (user) {
const userPasskeys = await this.passkeyRepo.getPasskeysByUserId(user.id);
allowCredentials = userPasskeys.map(pk => ({
id: pk.credential_id,
type: 'public-key',
transports: pk.transports ? JSON.parse(pk.transports) as AuthenticatorTransportFuture[] : undefined,
}));
}
}
const options: GenerateAuthenticationOptionsOpts = {
rpID: RP_ID,
timeout: 60000,
allowCredentials,
userVerification: 'preferred',
};
const generatedOptions = await generateAuthOptions(options);
return generatedOptions;
}
async verifyAuthentication(
authenticationResponseJSON: AuthenticationResponseJSON,
expectedChallenge: string
): Promise<VerifiedAuthenticationResponse & { passkey?: Passkey, userId?: number }> {
const credentialIdFromResponse = authenticationResponseJSON.id;
if (!credentialIdFromResponse) {
throw new Error('Credential ID missing from authentication response.');
}
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialIdFromResponse);
if (!passkey) {
throw new Error('Authentication failed. Passkey not found.');
}
// TODO: Re-evaluate the structure of VerifyAuthenticationResponseOpts for @simplewebauthn/server@13.1.1
// The 'authenticator' field seems to be causing type errors.
const verifyOpts: any = { // Using 'any' temporarily to bypass the authenticator structure error
response: authenticationResponseJSON,
expectedChallenge,
expectedOrigin: RP_ORIGIN,
expectedRPID: RP_ID,
authenticator: {
credentialID: base64UrlToUint8Array(passkey.credential_id),
credentialPublicKey: Buffer.from(passkey.public_key, 'base64'),
counter: passkey.counter,
transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransportFuture[] : undefined,
},
requireUserVerification: true,
};
const verification = await verifyAuthenticationResponse(verifyOpts as VerifyAuthenticationResponseOpts);
if (verification.verified && verification.authenticationInfo) {
const authInfo = verification.authenticationInfo;
await this.passkeyRepo.updatePasskeyCounter(passkey.credential_id, authInfo.newCounter);
await this.passkeyRepo.updatePasskeyLastUsedAt(passkey.credential_id);
return { ...verification, passkey, userId: passkey.user_id };
}
throw new Error('Authentication failed.');
}
async listPasskeysByUserId(userId: number): Promise<Partial<Passkey>[]> {
const passkeys = await this.passkeyRepo.getPasskeysByUserId(userId);
// 只返回部分信息以避免泄露敏感数据
return passkeys.map(pk => ({
credential_id: pk.credential_id,
created_at: pk.created_at,
last_used_at: pk.last_used_at,
transports: pk.transports ? JSON.parse(pk.transports) : undefined,
// 可以考虑添加一个用户可定义的名称字段
}));
}
async deletePasskey(userId: number, credentialID: string): Promise<boolean> {
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialID);
if (!passkey) {
throw new Error('Passkey not found.');
}
if (passkey.user_id !== userId) {
// 安全措施:用户只能删除自己的 Passkey
throw new Error('Unauthorized to delete this passkey.');
}
const wasDeleted = await this.passkeyRepo.deletePasskey(credentialID);
return wasDeleted;
}
}
export const passkeyService = new PasskeyService(passkeyRepository, userRepository);
+6 -1
View File
@@ -7,7 +7,12 @@ export type AuditLogActionType =
| 'PASSWORD_CHANGED'
| '2FA_ENABLED'
| '2FA_DISABLED'
// Removed Passkey events
// Passkey Events
| 'PASSKEY_REGISTERED'
| 'PASSKEY_AUTH_SUCCESS'
| 'PASSKEY_AUTH_FAILURE'
| 'PASSKEY_DELETED'
| 'PASSKEY_DELETE_UNAUTHORIZED'
// Connections
| 'CONNECTION_CREATED'
@@ -3,7 +3,12 @@ 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' // Removed Passkey events
| '2FA_ENABLED' | '2FA_DISABLED'
// Passkey Events
| 'PASSKEY_REGISTERED'
| 'PASSKEY_AUTH_SUCCESS' // Could also use LOGIN_SUCCESS with a 'method: passkey' detail
| 'PASSKEY_AUTH_FAILURE'
| 'PASSKEY_DELETED'
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED'
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
+2 -1
View File
@@ -33,7 +33,8 @@
"vue3-recaptcha2": "^1.8.0",
"vuedraggable": "^4.1.0",
"xterm": "^5.3.0",
"xterm-addon-web-links": "^0.9.0"
"xterm-addon-web-links": "^0.9.0",
"@simplewebauthn/browser": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20",
+20 -5
View File
@@ -100,10 +100,14 @@
"twoFactorPrompt": "Enter your two-factor authentication code:",
"verifyButton": "Verify",
"rememberMe": "Remember Me",
"loginWithPasskey": "Login with Passkey",
"captchaPrompt": "Please complete the verification below:",
"error": {
"captchaLoadFailed": "Failed to load CAPTCHA. Please try refreshing.",
"captchaRequired": "Please complete the CAPTCHA verification."
"captchaRequired": "Please complete the CAPTCHA verification.",
"usernameRequiredForPasskey": "Username is required to use a passkey.",
"passkeyAuthOptionsFailed": "Failed to get passkey authentication options from the server.",
"passkeyAuthFailed": "Passkey authentication failed. Please try again or use your password."
},
"recaptchaV3Notice": "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply."
},
@@ -527,19 +531,30 @@
}
},
"passkey": {
"title": "Passkey Settings",
"title": "Passkey Management",
"description": "Use Passkeys (biometrics or security keys) for passwordless authentication to enhance security and convenience.",
"nameLabel": "Passkey Name",
"namePlaceholder": "e.g., My Laptop",
"registerButton": "Register New Passkey",
"registerNewButton": "Register New Passkey",
"registeredKeysTitle": "Registered Passkeys",
"unnamedKey": "Unnamed Passkey",
"createdDate": "Created",
"lastUsedDate": "Last Used",
"noKeysRegistered": "No Passkeys registered yet.",
"confirmDelete": "Are you sure you want to delete this Passkey? This action cannot be undone.",
"error": {
"nameRequired": "Please enter a Passkey name.",
"cancelled": "Passkey registration was cancelled by the user.",
"genericRegistration": "Could not register Passkey: {message}",
"verificationFailed": "Registration failed: {message}"
"verificationFailed": "Registration failed: {message}",
"userNotLoggedIn": "User not logged in or username unavailable.",
"registrationCancelled": "Passkey registration was cancelled.",
"registrationFailed": "Passkey registration failed.",
"deleteFailedGeneral": "Failed to delete Passkey. Please try again."
},
"success": {
"registered": "Passkey registered successfully!"
"registered": "New Passkey registered successfully!",
"deleted": "Passkey deleted successfully."
}
},
"notifications": {
+26 -12
View File
@@ -458,11 +458,14 @@
"captchaPrompt": "以下の認証を完了してください:",
"error": {
"captchaLoadFailed": "CAPTCHA の読み込みに失敗しました。ページをリロードしてください。",
"captchaRequired": "CAPTCHA を完了してください。"
"captchaRequired": "CAPTCHA を完了してください。",
"usernameRequiredForPasskey": "Passkey を使用するにはユーザー名が必要です。",
"passkeyAuthOptionsFailed": "サーバーから Passkey 認証オプションを取得できませんでした。",
"passkeyAuthFailed": "Passkey 認証に失敗しました。もう一度試すか、パスワードを使用してください。"
},
"loggingIn": "ログイン中...",
"loginButton": "ログイン",
"passkeyLoginButton": "Passkeyでログイン",
"loginWithPasskey": "Passkeyでログイン",
"password": "パスワード",
"recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。",
"rememberMe": "ログイン状態を保持",
@@ -818,20 +821,31 @@
}
},
"passkey": {
"title": "Passkey 管理",
"description": "Passkey (生体認証またはセキュリティキー) を使用してパスワードなし認証を行い、アカウントのセキュリティとログインの利便性を向上させます。",
"error": {
"cancelled": "Passkey の登録がキャンセルされました。",
"genericRegistration": "Passkey を登録できません: {message}",
"nameRequired": "Passkey 名を入力してください。",
"verificationFailed": "登録に失敗しました: {message}"
},
"nameLabel": "Passkey 名",
"namePlaceholder": "例: マイノートパソコン",
"registerButton": "新しい Passkey を登録",
"success": {
"registered": "Passkey の登録に成功しました!"
"registerNewButton": "新しい Passkey を登録",
"registeredKeysTitle": "登録済みの Passkey",
"unnamedKey": "名前のない Passkey",
"createdDate": "作成日",
"lastUsedDate": "最終使用日",
"noKeysRegistered": "Passkey はまだ登録されていません。",
"confirmDelete": "この Passkey を削除しますか?この操作は元に戻せません。",
"error": {
"nameRequired": "Passkey 名を入力してください。",
"cancelled": "Passkey の登録がキャンセルされました。",
"genericRegistration": "Passkey を登録できません: {message}",
"verificationFailed": "登録に失敗しました: {message}",
"userNotLoggedIn": "ユーザーがログインしていないか、ユーザー名が利用できません。",
"registrationCancelled": "Passkey の登録がキャンセルされました。",
"registrationFailed": "Passkey の登録に失敗しました。",
"deleteFailedGeneral": "Passkey の削除に失敗しました。もう一度お試しください。"
},
"title": "Passkey 設定"
"success": {
"registered": "新しい Passkey が正常に登録されました!",
"deleted": "Passkey が正常に削除されました。"
}
},
"popupEditor": {
"enableLabel": "ファイルを開くときにポップアップエディターを表示する",
+21 -7
View File
@@ -100,12 +100,15 @@
"verifyButton": "验证",
"rememberMe": "记住我",
"captchaPrompt": "请完成下方的验证:",
"loginWithPasskey": "使用 Passkey 登录",
"error": {
"captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。",
"captchaRequired": "请完成 CAPTCHA 验证。"
"captchaRequired": "请完成 CAPTCHA 验证。",
"usernameRequiredForPasskey": "使用 Passkey 需要输入用户名。",
"passkeyAuthOptionsFailed": "从服务器获取 Passkey 认证选项失败。",
"passkeyAuthFailed": "Passkey 认证失败。请重试或使用密码登录。"
},
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。",
"passkeyLoginButton": "使用 Passkey 登录"
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。"
},
"connections": {
"addConnection": "添加新连接",
@@ -526,19 +529,30 @@
}
},
"passkey": {
"title": "Passkey 设置",
"title": "Passkey 管理",
"description": "使用 Passkey(生物识别或安全密钥)进行无密码认证,提升账户安全性和登录便捷性。",
"nameLabel": "Passkey 名称",
"namePlaceholder": "例如:我的笔记本电脑",
"registerButton": "注册新 Passkey",
"registerNewButton": "注册新 Passkey",
"registeredKeysTitle": "已注册的 Passkey",
"unnamedKey": "未命名 Passkey",
"createdDate": "创建于",
"lastUsedDate": "上次使用",
"noKeysRegistered": "尚未注册任何 Passkey。",
"confirmDelete": "确定要删除此 Passkey 吗?此操作无法撤销。",
"error": {
"nameRequired": "请输入 Passkey 名称。",
"cancelled": "Passkey 注册已被用户取消。",
"genericRegistration": "无法注册 Passkey: {message}",
"verificationFailed": "注册失败: {message}"
"verificationFailed": "注册失败: {message}",
"userNotLoggedIn": "用户未登录或用户名不可用。",
"registrationCancelled": "Passkey 注册已取消。",
"registrationFailed": "Passkey 注册失败。",
"deleteFailedGeneral": "删除 Passkey 失败。请重试。"
},
"success": {
"registered": "Passkey 注册成功!"
"registered": "新的 Passkey 已成功注册!",
"deleted": "Passkey 已成功删除。"
}
},
"notifications": {
+125 -5
View File
@@ -11,6 +11,18 @@ interface UserInfo {
language?: 'en' | 'zh'; // 新增:用户偏好语言
}
// Passkey Information Interface
interface PasskeyInfo {
credentialID: string;
publicKey: string; // Or a more specific type if available
counter: number;
transports?: AuthenticatorTransport[]; // e.g., "usb", "nfc", "ble", "internal"
creationDate: string; // ISO date string
lastUsedDate: string; // ISO date string
name?: string; // User-friendly name for the passkey
// Add other relevant fields from your backend response
}
// 新增:登录请求的载荷接口
interface LoginPayload {
username: string;
@@ -36,8 +48,6 @@ interface FullCaptchaSettings {
recaptchaSecretKey?: string; // We won't use this in authStore
}
// Removed PasskeyInfo interface
// Auth Store State 接口
interface AuthState {
@@ -53,7 +63,8 @@ interface AuthState {
};
needsSetup: boolean; // 新增:是否需要初始设置
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
// Removed Passkey state properties
passkeys: PasskeyInfo[] | null; // NEW: Store for user's passkeys
passkeysLoading: boolean; // NEW: Loading state for passkeys
}
export const useAuthStore = defineStore('auth', {
@@ -66,7 +77,8 @@ export const useAuthStore = defineStore('auth', {
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
needsSetup: false, // 初始假设不需要设置
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
// Removed Passkey state initialization
passkeys: null, // Initialize passkeys as null
passkeysLoading: false, // Initialize passkeysLoading as false
}),
getters: {
// 可以添加一些 getter,例如获取用户名
@@ -343,7 +355,115 @@ export const useAuthStore = defineStore('auth', {
}
},
// --- Passkey Actions Removed ---
// --- Passkey Actions ---
async loginWithPasskey(username: string, assertionResponse: any) {
this.isLoading = true;
this.error = null;
this.loginRequires2FA = false; // Passkey login bypasses traditional 2FA
try {
const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/passkey/authenticate', {
username,
assertionResponse,
});
this.isAuthenticated = true;
this.user = response.data.user;
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;
}
},
async getPasskeyRegistrationOptions(username: string) {
this.isLoading = true;
this.error = null;
try {
const response = await apiClient.post('/auth/passkey/registration-options', { username });
return response.data; // Returns FIDO2 creation options
} catch (err: any) {
console.error('获取 Passkey 注册选项失败:', err);
this.error = err.response?.data?.message || err.message || '获取 Passkey 注册选项失败。';
throw new Error(this.error ?? '获取 Passkey 注册选项失败。');
} finally {
this.isLoading = false;
}
},
async registerPasskey(username: string, registrationResponse: any) {
this.isLoading = true;
this.error = null;
try {
await apiClient.post('/auth/passkey/register', {
username,
registrationResponse,
});
console.log('Passkey 注册成功');
// Optionally, refresh user data or passkeys list if applicable
return { success: true };
} catch (err: any) {
console.error('Passkey 注册失败:', err);
this.error = err.response?.data?.message || err.message || 'Passkey 注册失败。';
throw new Error(this.error ?? 'Passkey 注册失败。');
} finally {
this.isLoading = false;
}
},
// Action to fetch user's passkeys
async fetchPasskeys() {
if (!this.isAuthenticated) {
console.warn('User not authenticated. Cannot fetch passkeys.');
this.passkeys = null;
return;
}
this.passkeysLoading = true;
this.error = null; // Clear previous errors
try {
const response = await apiClient.get<PasskeyInfo[]>('/auth/user/passkeys');
this.passkeys = response.data;
console.log('Passkeys fetched successfully:', this.passkeys);
} catch (err: any) {
console.error('Failed to fetch passkeys:', err);
this.error = err.response?.data?.message || err.message || 'Failed to load passkeys.';
this.passkeys = null; // Clear passkeys on error
} finally {
this.passkeysLoading = false;
}
},
// Action to delete a passkey
async deletePasskey(credentialID: string) {
if (!this.isAuthenticated) {
throw new Error('User not authenticated. Cannot delete passkey.');
}
this.isLoading = true; // Use general isLoading or a specific one for this action
this.error = null;
try {
await apiClient.delete(`/auth/user/passkeys/${credentialID}`);
console.log(`Passkey ${credentialID} deleted successfully.`);
// Refresh the passkey list
await this.fetchPasskeys();
return { success: true };
} catch (err: any) {
console.error(`Failed to delete passkey ${credentialID}:`, err);
this.error = err.response?.data?.message || err.message || 'Failed to delete passkey.';
throw new Error(this.error ?? 'Failed to delete passkey.');
} finally {
this.isLoading = false;
}
},
},
persist: true, // Revert to simple persistence to fix TS error for now
});
+8
View File
@@ -85,4 +85,12 @@ apiClient.interceptors.response.use(
}
);
// Passkey Management
export const fetchPasskeys = () => {
return apiClient.get('/auth/user/passkeys');
};
export const deletePasskey = (credentialID: string) => {
return apiClient.delete(`/auth/user/passkeys/${credentialID}`);
};
export default apiClient;
+50 -4
View File
@@ -2,7 +2,7 @@
import { reactive, ref, onMounted } from 'vue'; // computed 使
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
// Removed Passkey import: import { startAuthentication } from '@simplewebauthn/browser';
import { startAuthentication } from '@simplewebauthn/browser';
import { useAuthStore } from '../stores/auth.store';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import VueRecaptcha from 'vue3-recaptcha2'; // 使
@@ -98,7 +98,48 @@ onMounted(() => {
authStore.fetchCaptchaConfig();
});
// --- Passkey Login Handler Removed ---
// --- Passkey Login Handler ---
const handlePasskeyLogin = async () => {
// TODO: Implement Passkey login logic
// 1. Get username (assume it's available in credentials.username for now)
if (!credentials.username) {
// TODO: Handle missing username, maybe show an error
alert(t('login.error.usernameRequiredForPasskey'));
return;
}
try {
isLoading.value = true;
error.value = null; // Clear previous errors
// Step 1: Get authentication options from the server
const optionsResponse = await fetch('/api/v1/auth/passkey/authentication-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: credentials.username }),
});
if (!optionsResponse.ok) {
const errData = await optionsResponse.json();
throw new Error(errData.message || t('login.error.passkeyAuthOptionsFailed'));
}
const authOptions = await optionsResponse.json();
// Step 2: Use WebAuthn API to authenticate
const authenticationResult = await startAuthentication(authOptions);
// Step 3: Send authentication result to the server
await authStore.loginWithPasskey(credentials.username, authenticationResult);
} catch (err: any) {
console.error('Passkey login error:', err);
error.value = err.message || t('login.error.passkeyAuthFailed');
// Potentially reset CAPTCHA if it was involved, though typically not for passkey flows directly
// if (publicCaptchaConfig.value?.enabled) {
// resetCaptchaWidget();
// }
} finally {
isLoading.value = false;
}
};
</script>
<template>
@@ -198,8 +239,13 @@ onMounted(() => {
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
</button>
<!-- Passkey Login Button Removed -->
<!-- Passkey Login Button -->
<div class="mt-4 text-center">
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
class="w-full py-3 px-4 bg-secondary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
{{ isLoading ? t('login.loggingIn') : t('login.loginWithPasskey') }}
</button>
</div>
</form>
</div>
</div>
+132 -10
View File
@@ -50,7 +50,49 @@
</form>
</div>
<hr class="border-border/50">
<!-- Passkey Section Removed -->
<!-- Passkey Management -->
<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>
<button @click="handleRegisterNewPasskey" :disabled="passkeyLoading"
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">
{{ passkeyLoading ? $t('common.loading') : $t('settings.passkey.registerNewButton') }}
</button>
<p v-if="passkeyMessage" :class="['mt-3 text-sm', passkeySuccess ? 'text-success' : 'text-error']">{{ passkeyMessage }}</p>
<!-- Display list of registered passkeys -->
<div class="mt-6">
<h4 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.passkey.registeredKeysTitle') }}</h4>
<div v-if="authStore.passkeysLoading" class="p-4 text-center text-text-secondary italic">
{{ $t('common.loading') }}
</div>
<div v-else-if="authStore.passkeys && authStore.passkeys.length > 0">
<ul class="space-y-3">
<li v-for="key in authStore.passkeys" :key="key.credentialID" class="flex flex-col sm:flex-row justify-between items-start sm:items-center p-3 border border-border rounded-md bg-header/20 hover:bg-header/40 transition-colors duration-150">
<div class="flex-grow mb-2 sm:mb-0">
<span class="block font-medium text-foreground text-sm">
{{ key.name || $t('settings.passkey.unnamedKey') }}
<span class="text-xs text-text-tertiary ml-1">(ID: ...{{ key.credentialID.slice(-8) }})</span>
</span>
<div class="text-xs text-text-secondary mt-1 space-x-2">
<span>{{ $t('settings.passkey.createdDate') }}: {{ formatDate(key.creationDate) }}</span>
<span v-if="key.lastUsedDate">{{ $t('settings.passkey.lastUsedDate') }}: {{ formatDate(key.lastUsedDate) }}</span>
<span v-if="key.transports && key.transports.length > 0" class="capitalize">({{ key.transports.join(', ') }})</span>
</div>
</div>
<button @click="handleDeletePasskey(key.credentialID)"
:disabled="passkeyDeleteLoadingStates[key.credentialID]"
class="px-3 py-1.5 bg-error text-error-text rounded-md 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 self-start sm:self-center">
{{ passkeyDeleteLoadingStates[key.credentialID] ? $t('common.loading') : $t('common.delete') }}
</button>
</li>
</ul>
</div>
<p v-else class="text-sm text-text-secondary italic">{{ $t('settings.passkey.noKeysRegistered') }}</p>
<p v-if="passkeyDeleteError" class="mt-3 text-sm text-error">{{ passkeyDeleteError }}</p>
</div>
</div>
<hr class="border-border/50">
<!-- 2FA -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.twoFactor.title') }}</h3>
@@ -670,7 +712,7 @@ import { storeToRefs } from 'pinia';
import { availableLocales } from '../i18n'; //
import apiClient from '../utils/apiClient'; // 使 apiClient
import { isAxiosError } from 'axios'; // isAxiosError
// Removed Passkey import: import { startRegistration } from '@simplewebauthn/browser';
import { startRegistration } from '@simplewebauthn/browser';
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
@@ -708,8 +750,8 @@ const {
terminalScrollbackLimitNumber, // NEW: Import terminal scrollback limit getter
fileManagerShowDeleteConfirmationBoolean, // NEW: Import file manager delete confirmation getter
} = storeToRefs(settingsStore);
// Removed Passkey state import from authStore
const { passkeys, passkeysLoading } = storeToRefs(authStore); // Import passkey state
// --- Local state for forms ---
@@ -803,8 +845,13 @@ const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the
const captchaLoading = ref(false);
const captchaMessage = ref('');
const captchaSuccess = ref(false);
// Removed Passkey Deletion State
// --- Passkey State ---
const passkeyLoading = ref(false);
const passkeyMessage = ref('');
const passkeySuccess = ref(false);
const passkeyDeleteLoadingStates = reactive<Record<string, boolean>>({});
const passkeyDeleteError = ref<string | null>(null);
//
const commonTimezones = ref([
@@ -1128,13 +1175,86 @@ const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true);
};
// --- Passkey state & methods Removed ---
// --- Passkey Methods ---
const handleRegisterNewPasskey = async () => {
passkeyLoading.value = true;
passkeyMessage.value = '';
passkeySuccess.value = false;
const username = authStore.user?.username;
if (!username) {
passkeyMessage.value = t('settings.passkey.error.userNotLoggedIn');
passkeyLoading.value = false;
return;
}
try {
// 1. Get registration options from the server
const registrationOptions = await authStore.getPasskeyRegistrationOptions(username);
// 2. Start WebAuthn registration ceremony
const registrationResult = await startRegistration(registrationOptions);
// 3. Send registration result to the server
await authStore.registerPasskey(username, registrationResult);
passkeyMessage.value = t('settings.passkey.success.registered');
passkeySuccess.value = true;
await authStore.fetchPasskeys(); // Refresh passkey list
} catch (error: any) {
console.error('Passkey 注册失败:', error);
// Check if the error is from startRegistration (e.g., user cancellation)
if (error.name === 'InvalidStateError' || error.message.includes('cancelled')) {
passkeyMessage.value = t('settings.passkey.error.registrationCancelled');
} else {
passkeyMessage.value = error.response?.data?.message || error.message || t('settings.passkey.error.registrationFailed');
}
passkeySuccess.value = false;
} finally {
passkeyLoading.value = false;
}
};
const handleDeletePasskey = async (credentialID: string) => {
if (!confirm(t('settings.passkey.confirmDelete'))) return;
passkeyDeleteLoadingStates[credentialID] = true;
passkeyDeleteError.value = null;
passkeyMessage.value = ''; // Clear previous general passkey messages
try {
await authStore.deletePasskey(credentialID);
// The authStore.deletePasskey action should internally call fetchPasskeys to refresh the list.
// So, no need to call it explicitly here if the store handles it.
// If not, uncomment the line below:
// await authStore.fetchPasskeys();
passkeyMessage.value = t('settings.passkey.success.deleted');
passkeySuccess.value = true; // Use general success for feedback
} catch (error: any) {
console.error(`删除 Passkey ${credentialID} 失败:`, error);
passkeyDeleteError.value = error.message || t('settings.passkey.error.deleteFailedGeneral');
passkeySuccess.value = false;
} finally {
passkeyDeleteLoadingStates[credentialID] = false;
}
};
// --- 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');
const formatDate = (dateInput: string | number | Date | undefined): string => {
if (!dateInput) return t('statusMonitor.notAvailable');
try {
return new Date(timestamp * 1000).toLocaleString();
const date = new Date(dateInput);
// Check if date is valid
if (isNaN(date.getTime())) {
// Try parsing as seconds if it's a number (common for Unix timestamps)
if (typeof dateInput === 'number') {
const dateFromSeconds = new Date(dateInput * 1000);
if (!isNaN(dateFromSeconds.getTime())) {
return dateFromSeconds.toLocaleString();
}
}
return t('statusMonitor.notAvailable');
}
return date.toLocaleString();
} catch (e) {
console.error("Error formatting date:", e);
return t('statusMonitor.notAvailable');
@@ -1439,7 +1559,9 @@ onMounted(async () => {
await fetchIpBlacklist(); // Fetch current blacklist entries
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
await checkLatestVersion(); // <-- Check for latest version on mount
// Removed fetchPasskeys call: await authStore.fetchPasskeys();
if (authStore.isAuthenticated) {
await authStore.fetchPasskeys();
}
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
});