update
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getDbInstance, runDb, getDb, allDb } from '../database/connection';
|
||||
@@ -13,9 +14,9 @@ import { settingsService } from '../services/settings.service';
|
||||
|
||||
const passkeyService = new PasskeyService();
|
||||
const notificationService = new NotificationService();
|
||||
const auditLogService = new AuditLogService();
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
interface User {
|
||||
export interface User { // Add export keyword
|
||||
id: number;
|
||||
username: string;
|
||||
hashed_password: string;
|
||||
@@ -476,7 +477,15 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
|
||||
|
||||
// 再次提醒: 确保 Express 配置了 'trust proxy'
|
||||
|
||||
// 从 session 获取 userId
|
||||
const userId = req.session.userId;
|
||||
if (!userId) {
|
||||
// 这个检查理论上在函数开头已经做过,但为了类型安全和明确性再次检查
|
||||
throw new Error('无法获取用户 ID,无法验证 Passkey。');
|
||||
}
|
||||
|
||||
const verification = await passkeyService.verifyRegistration(
|
||||
userId, // <-- 传递 userId 作为第一个参数
|
||||
registrationResponse,
|
||||
expectedChallenge,
|
||||
hostname,
|
||||
@@ -501,6 +510,189 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
|
||||
res.status(500).json({ message: '验证 Passkey 注册失败。', error: error.message });
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 获取当前用户已注册的所有 Passkey (GET /api/v1/auth/passkeys)
|
||||
*/
|
||||
export const listUserPasskeys = async (req: Request, res: Response): Promise<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)
|
||||
|
||||
@@ -6,13 +6,17 @@ import {
|
||||
setup2FA,
|
||||
verifyAndActivate2FA,
|
||||
disable2FA,
|
||||
getAuthStatus,
|
||||
generatePasskeyRegistrationOptions,
|
||||
getAuthStatus,
|
||||
generatePasskeyRegistrationOptions,
|
||||
verifyPasskeyRegistration,
|
||||
needsSetup,
|
||||
setupAdmin,
|
||||
logout,
|
||||
getPublicCaptchaConfig
|
||||
generatePasskeyAuthenticationOptions, // <-- 添加导入
|
||||
verifyPasskeyAuthentication, // <-- 添加导入
|
||||
listUserPasskeys,
|
||||
deleteUserPasskey,
|
||||
needsSetup,
|
||||
setupAdmin,
|
||||
logout,
|
||||
getPublicCaptchaConfig
|
||||
} from './auth.controller';
|
||||
import { isAuthenticated } from './auth.middleware';
|
||||
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
|
||||
@@ -59,7 +63,18 @@ router.post('/passkey/register-options', isAuthenticated, generatePasskeyRegistr
|
||||
|
||||
// POST /api/v1/auth/passkey/verify-registration - 验证 Passkey 注册响应
|
||||
router.post('/passkey/verify-registration', isAuthenticated, verifyPasskeyRegistration);
|
||||
// GET /api/v1/auth/passkeys - 获取当前用户的所有 Passkey
|
||||
router.get('/passkeys', isAuthenticated, listUserPasskeys);
|
||||
|
||||
// DELETE /api/v1/auth/passkeys/:id - 删除指定的 Passkey
|
||||
router.delete('/passkeys/:id', isAuthenticated, deleteUserPasskey);
|
||||
|
||||
// --- Passkey 认证接口 (公开访问,添加黑名单检查) ---
|
||||
// POST /api/v1/auth/passkey/authenticate-options - 生成 Passkey 认证选项 (用于登录)
|
||||
router.post('/passkey/authenticate-options', ipBlacklistCheckMiddleware, generatePasskeyAuthenticationOptions);
|
||||
|
||||
// POST /api/v1/auth/passkey/verify-authentication - 验证 Passkey 认证响应并登录
|
||||
router.post('/passkey/verify-authentication', ipBlacklistCheckMiddleware, verifyPasskeyAuthentication);
|
||||
|
||||
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
|
||||
router.post('/logout', logout);
|
||||
|
||||
@@ -31,13 +31,15 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
export const createPasskeysTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL, -- 新增:关联到用户 ID
|
||||
credential_id TEXT UNIQUE NOT NULL, -- Base64URL encoded
|
||||
public_key TEXT NOT NULL, -- Base64URL encoded
|
||||
counter INTEGER NOT NULL,
|
||||
transports TEXT, -- JSON array as string, e.g., '["internal", "usb"]'
|
||||
name TEXT, -- User-provided name for the key
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- 新增:外键约束
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
||||
// 定义 Passkey 数据库记录的接口
|
||||
export interface PasskeyRecord {
|
||||
id: number;
|
||||
user_id: number; // 新增:关联的用户 ID
|
||||
credential_id: string; // Base64URL encoded
|
||||
public_key: string; // Base64URL encoded
|
||||
counter: number;
|
||||
@@ -13,15 +14,17 @@ export interface PasskeyRecord {
|
||||
}
|
||||
|
||||
|
||||
type DbPasskeyRow = PasskeyRecord;
|
||||
type DbPasskeyRow = PasskeyRecord; // 类型别名保持不变,因为接口已更新
|
||||
|
||||
export class PasskeyRepository {
|
||||
|
||||
/**
|
||||
* 保存新的 Passkey 凭证
|
||||
* @param userId 关联的用户 ID
|
||||
* @returns Promise<number> 新插入记录的 ID
|
||||
*/
|
||||
async savePasskey(
|
||||
userId: number, // 新增 userId 参数
|
||||
credentialId: string,
|
||||
publicKey: string,
|
||||
counter: number,
|
||||
@@ -29,10 +32,11 @@ export class PasskeyRepository {
|
||||
name?: string
|
||||
): Promise<number> {
|
||||
const sql = `
|
||||
INSERT INTO passkeys (credential_id, public_key, counter, transports, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, transports, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))
|
||||
`;
|
||||
const params = [credentialId, publicKey, counter, transports, name ?? null];
|
||||
// 新增 userId 到参数列表
|
||||
const params = [userId, credentialId, publicKey, counter, transports, name ?? null];
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, params);
|
||||
@@ -46,18 +50,24 @@ export class PasskeyRepository {
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
throw new Error(`Credential ID "${credentialId}" 已存在。`);
|
||||
}
|
||||
// 检查外键约束错误
|
||||
if (err.message.includes('FOREIGN KEY constraint failed')) {
|
||||
throw new Error(`关联的用户 ID ${userId} 不存在。`);
|
||||
}
|
||||
throw new Error(`保存 Passkey 时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Credential ID 获取 Passkey 记录
|
||||
* 根据 Credential ID 获取 Passkey 记录 (包含 user_id)
|
||||
* @returns Promise<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) {
|
||||
@@ -67,18 +77,27 @@ export class PasskeyRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的 Passkey 记录 (仅选择必要字段)
|
||||
* @returns Promise<Partial<PasskeyRecord>[]> 所有记录的部分信息的数组
|
||||
* 获取指定用户或所有已注册的 Passkey 记录 (仅选择必要字段)
|
||||
* @param userId 可选,如果提供,则只获取该用户的 Passkey
|
||||
* @returns Promise<Partial<PasskeyRecord>[]> 记录的部分信息的数组
|
||||
*/
|
||||
async getAllPasskeys(): Promise<Array<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>> {
|
||||
const sql = `SELECT id, credential_id, name, transports, created_at FROM passkeys ORDER BY created_at DESC`;
|
||||
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();
|
||||
const rows = await allDb<Pick<PasskeyRecord, 'id' | 'credential_id' | 'name' | 'transports' | 'created_at'>>(db, sql);
|
||||
// 更新返回类型以包含 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}`);
|
||||
console.error('获取 Passkey 列表时出错:', err.message);
|
||||
throw new Error(`获取 Passkey 列表时出错: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifiedAuthenticationResponse,
|
||||
// VerifiedAuthenticationResponse, // Remove original import
|
||||
} from '@simplewebauthn/server';
|
||||
import type { VerifiedAuthenticationResponse as SimpleVerifiedAuthenticationResponse } from '@simplewebauthn/server'; // Import with alias
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
@@ -13,10 +14,18 @@ import type {
|
||||
VerifyAuthenticationResponseOpts,
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
} from '@simplewebauthn/server';
|
||||
import { PasskeyRepository, PasskeyRecord } from '../repositories/passkey.repository';
|
||||
import { getDbInstance, getDb } from '../database/connection'; // Import database functions
|
||||
import type { User } from '../auth/auth.controller'; // Import User type (assuming it's defined or importable from auth.controller)
|
||||
|
||||
|
||||
// Define extended verification response type including user info
|
||||
export interface VerifiedAuthenticationResponse extends SimpleVerifiedAuthenticationResponse {
|
||||
userInfo?: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
// 定义 Relying Party (RP) 信息 - 这些应该来自配置或设置
|
||||
const rpName = 'Nexus Terminal';
|
||||
// rpID 和 expectedOrigin 将从请求动态获取,不再在此处硬编码
|
||||
@@ -63,6 +72,7 @@ export class PasskeyService {
|
||||
|
||||
/**
|
||||
* 验证 Passkey 注册响应
|
||||
* @param userId 当前登录用户的 ID
|
||||
* @param registrationResponse 来自客户端的注册响应
|
||||
* @param expectedChallenge 之前生成的、临时存储的挑战
|
||||
* @param hostname 请求的主机名
|
||||
@@ -70,6 +80,7 @@ export class PasskeyService {
|
||||
* @param passkeyName 用户为这个 Passkey 起的名字 (可选)
|
||||
*/
|
||||
async verifyRegistration(
|
||||
userId: number, // 新增 userId 参数
|
||||
registrationResponse: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
hostname: string,
|
||||
@@ -110,15 +121,16 @@ export class PasskeyService {
|
||||
// 获取 transports 信息
|
||||
const transports = registrationResponse.response.transports ?? null;
|
||||
|
||||
// 保存到数据库
|
||||
// 保存到数据库,传入 userId
|
||||
await this.passkeyRepository.savePasskey(
|
||||
userId, // 传递 userId
|
||||
credentialIdBase64Url,
|
||||
publicKeyBase64Url,
|
||||
counter,
|
||||
transports ? JSON.stringify(transports) : null,
|
||||
passkeyName
|
||||
);
|
||||
console.log(`Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`);
|
||||
console.log(`用户 ${userId} Passkey 注册成功: ${credentialIdBase64Url}, Name: ${passkeyName ?? 'N/A'}`);
|
||||
} else {
|
||||
console.error('Passkey 注册验证失败:', verification);
|
||||
}
|
||||
@@ -160,7 +172,7 @@ export class PasskeyService {
|
||||
expectedChallenge: string,
|
||||
hostname: string,
|
||||
origin: string
|
||||
): Promise<VerifiedAuthenticationResponse> {
|
||||
): Promise<VerifiedAuthenticationResponse> { // Return our extended type
|
||||
|
||||
const credentialIdBase64Url = authenticationResponse.id; // 客户端传回的 ID 已经是 Base64URL
|
||||
const authenticator = await this.passkeyRepository.getPasskeyByCredentialId(credentialIdBase64Url);
|
||||
@@ -204,6 +216,23 @@ export class PasskeyService {
|
||||
// 更新数据库中的计数器
|
||||
await this.passkeyRepository.updatePasskeyCounter(authenticator.credential_id, newCounter);
|
||||
console.log(`Passkey 认证成功: ${authenticator.credential_id}`);
|
||||
|
||||
// --- Added: Fetch user information ---
|
||||
const db = await getDbInstance();
|
||||
// Assuming PasskeyRecord has user_id
|
||||
const user = await getDb<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);
|
||||
}
|
||||
@@ -232,4 +261,14 @@ export class PasskeyService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"themeCreatedSuccess": "Theme created successfully.",
|
||||
"themeSaveFailed": "Failed to save theme.",
|
||||
"themeDeletedSuccess": "Theme deleted successfully.",
|
||||
"passkeyLoginButton": "Login with Passkey",
|
||||
"themeDeleteFailed": "Failed to delete theme: {message}",
|
||||
"importSuccess": "Theme imported successfully.",
|
||||
"importFailed": "Theme import failed.",
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"captchaLoadFailed": "CAPTCHA の読み込みに失敗しました。ページをリロードしてください。",
|
||||
"captchaRequired": "CAPTCHA を完了してください。"
|
||||
},
|
||||
"recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。"
|
||||
"recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。",
|
||||
"passkeyLoginButton": "Passkeyでログイン"
|
||||
},
|
||||
"connections": {
|
||||
"addConnection": "新しい接続を追加",
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。",
|
||||
"captchaRequired": "请完成 CAPTCHA 验证。"
|
||||
},
|
||||
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。"
|
||||
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。",
|
||||
"passkeyLoginButton": "使用 Passkey 登录"
|
||||
},
|
||||
"connections": {
|
||||
"addConnection": "添加新连接",
|
||||
|
||||
@@ -36,6 +36,15 @@ interface FullCaptchaSettings {
|
||||
recaptchaSecretKey?: string; // We won't use this in authStore
|
||||
}
|
||||
|
||||
// 新增:Passkey 信息接口 (根据后端返回调整)
|
||||
interface PasskeyInfo {
|
||||
id: number; // 数据库中的 ID,用于删除
|
||||
name?: string; // 用户设置的名称
|
||||
transports?: string; // JSON string of transports like ["internal", "usb"]
|
||||
created_at?: number; // Unix timestamp
|
||||
}
|
||||
|
||||
|
||||
// Auth Store State 接口
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
@@ -50,6 +59,9 @@ interface AuthState {
|
||||
};
|
||||
needsSetup: boolean; // 新增:是否需要初始设置
|
||||
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
|
||||
passkeys: PasskeyInfo[]; // 新增:存储 Passkey 列表
|
||||
passkeysLoading: boolean; // 新增:Passkey 列表加载状态
|
||||
passkeysError: string | null; // 新增:Passkey 列表错误状态
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -62,12 +74,24 @@ export const useAuthStore = defineStore('auth', {
|
||||
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
|
||||
needsSetup: false, // 初始假设不需要设置
|
||||
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
|
||||
passkeys: [], // 初始化 Passkey 列表为空
|
||||
passkeysLoading: false,
|
||||
passkeysError: null,
|
||||
}),
|
||||
getters: {
|
||||
// 可以添加一些 getter,例如获取用户名
|
||||
loggedInUser: (state) => state.user?.username,
|
||||
},
|
||||
actions: {
|
||||
// 新增:清除错误状态
|
||||
clearError() {
|
||||
this.error = null;
|
||||
},
|
||||
// 新增:设置错误状态
|
||||
setError(errorMessage: string) {
|
||||
this.error = errorMessage;
|
||||
},
|
||||
|
||||
// 登录 Action - 更新为接受 LoginPayload + optional captchaToken
|
||||
async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload
|
||||
this.isLoading = true;
|
||||
@@ -156,6 +180,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
// 清除本地状态
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.passkeys = []; // 登出时清空 Passkey 列表
|
||||
console.log('已登出');
|
||||
// 登出后重定向到登录页
|
||||
await router.push({ name: 'Login' });
|
||||
@@ -185,6 +210,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.loginRequires2FA = false;
|
||||
this.passkeys = []; // 未认证时清空 Passkey 列表
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 如果获取状态失败 (例如 session 过期),则认为未认证
|
||||
@@ -192,6 +218,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.loginRequires2FA = false;
|
||||
this.passkeys = []; // 失败时也清空 Passkey 列表
|
||||
// 可选:如果不是 401 错误,可以记录更详细的日志
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
@@ -325,6 +352,112 @@ export const useAuthStore = defineStore('auth', {
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// --- Passkey Actions ---
|
||||
/**
|
||||
* 获取当前用户的 Passkey 列表
|
||||
*/
|
||||
async fetchPasskeys() {
|
||||
if (!this.isAuthenticated) return; // 确保用户已登录
|
||||
this.passkeysLoading = true;
|
||||
this.passkeysError = null;
|
||||
try {
|
||||
const response = await apiClient.get<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;
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true, // Revert to simple persistence to fix TS error for now
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, computed } from 'vue'; // computed 已导入
|
||||
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { startAuthentication } from '@simplewebauthn/browser'; // <-- 导入 Passkey 函数
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
|
||||
@@ -96,8 +97,49 @@ onMounted(() => {
|
||||
console.log('[LoginView] Component mounted, calling fetchCaptchaConfig...'); // 添加日志
|
||||
authStore.fetchCaptchaConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<!-- Page Container -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-background p-4">
|
||||
@@ -194,6 +236,16 @@ onMounted(() => {
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
{{ 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>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,33 @@
|
||||
<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">
|
||||
<!-- 2FA -->
|
||||
@@ -561,6 +588,10 @@ const {
|
||||
commandInputSyncTarget, // NEW: Import command input sync target getter
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
// 从 authStore 获取 Passkey 相关状态
|
||||
const { passkeys, passkeysLoading, passkeysError } = storeToRefs(authStore);
|
||||
|
||||
|
||||
// --- Local state for forms ---
|
||||
const ipWhitelistInput = ref('');
|
||||
// 使用 store 的 language getter 初始化 selectedLanguage
|
||||
@@ -632,6 +663,10 @@ 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);
|
||||
|
||||
|
||||
// 提供一些常用的时区供选择
|
||||
const commonTimezones = ref([
|
||||
@@ -858,7 +893,7 @@ const openStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(true);
|
||||
};
|
||||
|
||||
// --- Passkey state & methods --- (Keep as is)
|
||||
// --- Passkey state & methods ---
|
||||
const passkeyName = ref('');
|
||||
const passkeyMessage = ref<string | null>(null);
|
||||
const passkeyError = ref<string | null>(null);
|
||||
@@ -876,6 +911,7 @@ const handleRegisterPasskey = async () => {
|
||||
await apiClient.post('/auth/passkey/verify-registration', { registrationResponse, name: passkeyName.value }); // 使用 apiClient
|
||||
passkeyMessage.value = t('settings.passkey.success.registered');
|
||||
passkeyName.value = '';
|
||||
await authStore.fetchPasskeys(); // 注册成功后刷新列表
|
||||
} catch (error: any) {
|
||||
console.error('Passkey 注册流程出错:', error);
|
||||
if (error.name === 'NotAllowedError') {
|
||||
@@ -887,6 +923,35 @@ const handleRegisterPasskey = async () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
// 新增:处理 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;
|
||||
}
|
||||
};
|
||||
// 新增:格式化日期函数
|
||||
const formatDate = (timestamp: number | undefined) => {
|
||||
if (!timestamp) return t('statusMonitor.notAvailable');
|
||||
try {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", e);
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Change Password state & methods --- (Keep as is)
|
||||
const currentPassword = ref('');
|
||||
@@ -1128,6 +1193,7 @@ onMounted(async () => {
|
||||
await checkTwoFactorStatus(); // Check 2FA status
|
||||
await fetchIpBlacklist(); // Fetch current blacklist entries
|
||||
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
|
||||
await authStore.fetchPasskeys(); // <-- 获取 Passkey 列表
|
||||
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user