Generated
+167
-10
@@ -567,6 +567,12 @@
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.14.4",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.4.tgz",
|
||||
@@ -734,6 +740,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
@@ -778,8 +790,8 @@
|
||||
"resolved": "packages/frontend",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nexus-terminal/rdp": {
|
||||
"resolved": "packages/rdp",
|
||||
"node_modules/@nexus-terminal/remote-gateway": {
|
||||
"resolved": "packages/remote-gateway",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
@@ -893,6 +905,64 @@
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.16.tgz",
|
||||
"integrity": "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz",
|
||||
"integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"@peculiar/asn1-x509": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz",
|
||||
"integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"@peculiar/asn1-x509": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz",
|
||||
"integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.5",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz",
|
||||
"integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -1163,6 +1233,39 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz",
|
||||
"integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.1.1.tgz",
|
||||
"integrity": "sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
|
||||
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||
@@ -2485,6 +2588,20 @@
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
|
||||
"integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -6683,6 +6800,24 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
|
||||
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -8034,8 +8169,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
@@ -8844,6 +8978,7 @@
|
||||
"name": "@nexus-terminal/backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
@@ -8892,10 +9027,11 @@
|
||||
},
|
||||
"packages/frontend": {
|
||||
"name": "@nexus-terminal/frontend",
|
||||
"version": "0.2.5",
|
||||
"version": "0.4",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vscode/iconv-lite-umd": "^0.7.0",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
@@ -9508,6 +9644,7 @@
|
||||
"packages/rdp": {
|
||||
"name": "@nexus-terminal/rdp",
|
||||
"version": "1.0.0",
|
||||
"extraneous": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -9525,17 +9662,37 @@
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"packages/rdp/node_modules/@types/node": {
|
||||
"version": "22.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.2.tgz",
|
||||
"integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==",
|
||||
"packages/remote-gateway": {
|
||||
"name": "@nexus-terminal/remote-gateway",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"guacamole-lite": "^0.7.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^22.15.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"nodemon": "^3.1.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"packages/remote-gateway/node_modules/@types/node": {
|
||||
"version": "22.15.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.16.tgz",
|
||||
"integrity": "sha512-3pr+KjwpVujqWqOKT8mNR+rd09FqhBLwg+5L/4t0cNYBzm/yEiYGCxWttjaPBsLtAo+WFNoXzGJfolM1JuRXoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"packages/rdp/node_modules/undici-types": {
|
||||
"packages/remote-gateway/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,12 +28,314 @@ 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> => {
|
||||
// Extract assertionResponse and rememberMe from the request body
|
||||
const { assertionResponse, rememberMe } = req.body;
|
||||
const expectedChallenge = req.session.currentChallenge;
|
||||
|
||||
// Rename assertionResponse to authenticationResponseJSON for clarity within this scope
|
||||
const authenticationResponseJSON = assertionResponse;
|
||||
|
||||
if (!authenticationResponseJSON) {
|
||||
res.status(400).json({ message: '认证响应 (assertionResponse) 不能为空。' });
|
||||
return;
|
||||
}
|
||||
if (!expectedChallenge) {
|
||||
res.status(400).json({ message: '会话中未找到质询信息,请重试认证流程。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass the extracted authenticationResponseJSON to the service
|
||||
const verification = await passkeyService.verifyAuthentication(
|
||||
authenticationResponseJSON,
|
||||
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: authenticationResponseJSON?.id || 'unknown', // Use the extracted object
|
||||
reason: 'Verification failed',
|
||||
ip: clientIp
|
||||
});
|
||||
notificationService.sendNotification('PASSKEY_AUTH_FAILURE', { credentialId: authenticationResponseJSON?.id || 'unknown', 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: authenticationResponseJSON?.id || 'unknown', // Use the extracted object
|
||||
reason: error.message,
|
||||
ip: clientIp
|
||||
});
|
||||
notificationService.sendNotification('PASSKEY_AUTH_FAILURE', { credentialId: authenticationResponseJSON?.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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新当前认证用户指定的 Passkey 名称 (PUT /api/v1/user/passkeys/:credentialID/name)
|
||||
*/
|
||||
export const updateUserPasskeyNameHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username;
|
||||
const { credentialID } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!userId || !username) {
|
||||
res.status(401).json({ message: '用户未认证。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!credentialID) {
|
||||
res.status(400).json({ message: '必须提供 Passkey 的 CredentialID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
res.status(400).json({ message: 'Passkey 名称不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
await passkeyService.updatePasskeyName(userId, credentialID, trimmedName);
|
||||
console.log(`[AuthController] 用户 ${username} (ID: ${userId}) 成功更新了 Passkey (CredentialID: ${credentialID}) 的名称为 "${trimmedName}"。`);
|
||||
auditLogService.logAction('PASSKEY_NAME_UPDATED', { userId, username, credentialId: credentialID, newName: trimmedName });
|
||||
// Optionally send a notification if desired
|
||||
// notificationService.sendNotification('PASSKEY_NAME_UPDATED', { userId, username, credentialId: credentialID, newName: trimmedName });
|
||||
res.status(200).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 update this passkey name.') {
|
||||
auditLogService.logAction('PASSKEY_NAME_UPDATE_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 +364,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 +387,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 +398,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 +409,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 +449,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 +467,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 +481,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 +495,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 +505,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 +534,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 +553,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 +608,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 +626,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 +635,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 +644,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 +681,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 +700,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 +721,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 +743,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 +757,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 +767,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 +780,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 +797,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 +805,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 +815,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 +831,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 +840,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 +859,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 +880,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 +914,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',
|
||||
@@ -676,3 +923,20 @@ export const getPublicCaptchaConfig = async (req: Request, res: Response): Promi
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查系统中是否配置了任何 Passkey (GET /api/v1/auth/passkey/has-configured)
|
||||
* 或者特定用户是否配置了 Passkey (GET /api/v1/auth/passkey/has-configured?username=xxx)
|
||||
* 公开访问,用于登录页面判断是否显示 Passkey 登录按钮。
|
||||
*/
|
||||
export const checkHasPasskeys = async (req: Request, res: Response): Promise<void> => {
|
||||
const username = req.query.username as string | undefined;
|
||||
try {
|
||||
const hasPasskeys = await passkeyService.hasPasskeysConfigured(username);
|
||||
res.status(200).json({ hasPasskeys });
|
||||
} catch (error: any) {
|
||||
console.error(`[AuthController] 检查 Passkey 配置状态时出错 (username: ${username || 'any'}):`, error.message);
|
||||
// 即使出错,也返回 false,避免登录流程中断
|
||||
res.status(200).json({ hasPasskeys: false, error: '检查 Passkey 配置时出错。' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,11 +7,20 @@ import {
|
||||
verifyAndActivate2FA,
|
||||
disable2FA,
|
||||
getAuthStatus,
|
||||
// Removed Passkey imports
|
||||
needsSetup,
|
||||
setupAdmin,
|
||||
logout,
|
||||
getPublicCaptchaConfig
|
||||
getPublicCaptchaConfig,
|
||||
// Passkey handlers
|
||||
generatePasskeyRegistrationOptionsHandler,
|
||||
verifyPasskeyRegistrationHandler,
|
||||
generatePasskeyAuthenticationOptionsHandler,
|
||||
verifyPasskeyAuthenticationHandler,
|
||||
// 新的 Passkey 管理处理器
|
||||
listUserPasskeysHandler,
|
||||
deleteUserPasskeyHandler,
|
||||
updateUserPasskeyNameHandler, // 新增:更新 Passkey 名称的处理器
|
||||
checkHasPasskeys // +++ 新增:检查是否有 Passkey 配置的处理器
|
||||
} from './auth.controller';
|
||||
import { isAuthenticated } from './auth.middleware';
|
||||
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware';
|
||||
@@ -52,8 +61,34 @@ 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);
|
||||
|
||||
// GET /api/v1/auth/passkey/has-configured - 检查是否配置了 Passkey (公开)
|
||||
router.get('/passkey/has-configured', checkHasPasskeys);
|
||||
|
||||
// --- 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);
|
||||
|
||||
// PUT /api/v1/auth/user/passkeys/:credentialID/name - 更新当前用户指定的 Passkey 名称 (需要认证)
|
||||
router.put('/user/passkeys/:credentialID/name', isAuthenticated, updateUserPasskeyNameHandler);
|
||||
|
||||
|
||||
// POST /api/v1/auth/logout - 用户登出接口 (公开访问)
|
||||
router.post('/logout', logout);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
"SSH_CONNECT_FAILURE": "SSH Connection Failed",
|
||||
"SSH_SHELL_FAILURE": "SSH Shell Open Failed",
|
||||
"DATABASE_MIGRATION": "Database Migration",
|
||||
"ADMIN_SETUP_COMPLETE": "Initial Admin Setup Completed"
|
||||
"ADMIN_SETUP_COMPLETE": "Initial Admin Setup Completed",
|
||||
"PASSKEY_REGISTERED": "Passkey Registered",
|
||||
"PASSKEY_AUTH_SUCCESS": "Passkey Authentication Successful",
|
||||
"PASSKEY_AUTH_FAILURE": "Passkey Authentication Failed",
|
||||
"PASSKEY_DELETED": "Passkey Deleted",
|
||||
"PASSKEY_DELETE_UNAUTHORIZED": "Passkey Deletion Unauthorized",
|
||||
"PASSKEY_NAME_UPDATED": "Passkey Name Updated",
|
||||
"PASSKEY_NAME_UPDATE_UNAUTHORIZED": "Passkey Name Update Unauthorized"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,13 @@
|
||||
"SSH_CONNECT_FAILURE": "SSH 接続失敗",
|
||||
"SSH_SHELL_FAILURE": "SSH Shell オープン失敗",
|
||||
"DATABASE_MIGRATION": "データベース移行",
|
||||
"ADMIN_SETUP_COMPLETE": "初期管理者設定完了"
|
||||
"ADMIN_SETUP_COMPLETE": "初期管理者設定完了",
|
||||
"PASSKEY_REGISTERED": "パスキー登録済み",
|
||||
"PASSKEY_AUTH_SUCCESS": "パスキー認証成功",
|
||||
"PASSKEY_AUTH_FAILURE": "パスキー認証失敗",
|
||||
"PASSKEY_DELETED": "パスキー削除済み",
|
||||
"PASSKEY_DELETE_UNAUTHORIZED": "パスキー削除権限なし",
|
||||
"PASSKEY_NAME_UPDATED": "パスキー名更新済み",
|
||||
"PASSKEY_NAME_UPDATE_UNAUTHORIZED": "パスキー名更新権限なし"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,154 @@
|
||||
// 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
|
||||
created_at: typeof dbResult.created_at === 'string' ? parseInt(dbResult.created_at, 10) : dbResult.created_at,
|
||||
last_used_at: dbResult.last_used_at && typeof dbResult.last_used_at === 'string' ? parseInt(dbResult.last_used_at, 10) : dbResult.last_used_at,
|
||||
updated_at: typeof dbResult.updated_at === 'string' ? parseInt(dbResult.updated_at, 10) : dbResult.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPasskeyResults(dbResults: any[]): Passkey[] {
|
||||
return dbResults.map(row => ({
|
||||
...row,
|
||||
backed_up: !!row.backed_up,
|
||||
transports: row.transports,
|
||||
created_at: typeof row.created_at === 'string' ? parseInt(row.created_at, 10) : row.created_at,
|
||||
last_used_at: row.last_used_at && typeof row.last_used_at === 'string' ? parseInt(row.last_used_at, 10) : row.last_used_at,
|
||||
updated_at: typeof row.updated_at === 'string' ? parseInt(row.updated_at, 10) : row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
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]);
|
||||
// Log the raw results from the database before mapping
|
||||
// console.log(`[PasskeyRepository] Raw passkeys for user ${userId}:`, JSON.stringify(results, null, 2));
|
||||
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;
|
||||
}
|
||||
|
||||
async updatePasskeyName(credentialId: string, name: string): Promise<boolean> {
|
||||
const db = await getDbInstance();
|
||||
const sql = "UPDATE passkeys SET name = ?, updated_at = strftime('%s', 'now') WHERE credential_id = ?";
|
||||
const { changes } = await runDb(db, sql, [name, credentialId]);
|
||||
return changes > 0;
|
||||
}
|
||||
|
||||
async getFirstPasskey(): Promise<Passkey | null> {
|
||||
const db = await getDbInstance();
|
||||
const sql = 'SELECT * FROM passkeys LIMIT 1';
|
||||
const result = await getDb<any>(db, sql);
|
||||
return mapPasskeyResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
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,322 @@
|
||||
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, '/');
|
||||
// Buffer.from will handle padding correctly for base64
|
||||
try {
|
||||
return Buffer.from(base64, 'base64');
|
||||
} catch (e) {
|
||||
console.error("Failed to decode base64url string to Buffer:", base64urlString, e);
|
||||
throw new Error("Invalid base64url string for Buffer conversion");
|
||||
}
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
// The actual WebAuthn response is nested within the received object
|
||||
const actualRegistrationResponse = (registrationResponseJSON as any).registrationResponse;
|
||||
|
||||
// Add a check for the presence of credential ID before calling the library
|
||||
if (!actualRegistrationResponse || !actualRegistrationResponse.id) {
|
||||
console.error('Missing credential ID in actualRegistrationResponse from client:', registrationResponseJSON);
|
||||
throw new Error('Registration failed: Missing or malformed credential ID from client.');
|
||||
}
|
||||
|
||||
const verifyOpts: VerifyRegistrationResponseOpts = {
|
||||
response: actualRegistrationResponse, // Use the nested object
|
||||
expectedChallenge,
|
||||
expectedOrigin: RP_ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
requireUserVerification: true,
|
||||
};
|
||||
|
||||
const verification = await verifyRegistrationResponse(verifyOpts);
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const regInfo = verification.registrationInfo;
|
||||
|
||||
// Based on the logs, credentialPublicKey, credentialID, counter, and transports
|
||||
// are nested within regInfo.credential.
|
||||
// credentialBackedUp is at the top level of regInfo.
|
||||
const credentialDetails = (regInfo as any).credential;
|
||||
const credentialBackedUp = (regInfo as any).credentialBackedUp; // This seems to be at the top level
|
||||
|
||||
if (!credentialDetails || typeof credentialDetails.publicKey !== 'object' || typeof credentialDetails.id !== 'string' || typeof credentialDetails.counter !== 'number') {
|
||||
console.error('Verification successful, but registrationInfo.credential structure is unexpected or missing:', regInfo);
|
||||
throw new Error('Failed to process registration info due to unexpected credential structure.');
|
||||
}
|
||||
|
||||
const credentialPublicKey = credentialDetails.publicKey;
|
||||
const credentialID = credentialDetails.id;
|
||||
const counter = credentialDetails.counter;
|
||||
const transports = credentialDetails.transports; // This might be undefined, handle appropriately
|
||||
|
||||
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 }> {
|
||||
|
||||
// Decode and check authenticatorData length
|
||||
if (authenticationResponseJSON.response && authenticationResponseJSON.response.authenticatorData) {
|
||||
try {
|
||||
const authenticatorDataBytes = base64UrlToUint8Array(authenticationResponseJSON.response.authenticatorData);
|
||||
if (authenticatorDataBytes.length < 37) {
|
||||
// console.warn(`[PasskeyService] WARNING: Decoded authenticatorData length (${authenticatorDataBytes.length} bytes) is less than the expected minimum of 37 bytes. This may lead to CBOR parsing errors and subsequent failures (e.g., 'cannot read counter').`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error decoding authenticatorData from client response:', e.message);
|
||||
// Potentially re-throw or handle as a critical error, as this is unexpected.
|
||||
}
|
||||
} else {
|
||||
console.warn('[PasskeyService] authenticatorData is missing in the client response.');
|
||||
}
|
||||
|
||||
const credentialIdFromResponse = authenticationResponseJSON.id;
|
||||
if (!credentialIdFromResponse) {
|
||||
console.error('[PasskeyService] Credential ID missing from authentication response.');
|
||||
throw new Error('Credential ID missing from authentication response.');
|
||||
}
|
||||
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialIdFromResponse);
|
||||
if (!passkey) {
|
||||
console.error('[PasskeyService] Passkey not found for credential ID:', credentialIdFromResponse);
|
||||
throw new Error('Authentication failed. Passkey not found.');
|
||||
}
|
||||
|
||||
let authenticatorCredentialID: Uint8Array;
|
||||
try {
|
||||
authenticatorCredentialID = base64UrlToUint8Array(passkey.credential_id);
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error decoding credential_id to Uint8Array:', passkey.credential_id, e.message);
|
||||
throw new Error('Failed to decode credential_id.');
|
||||
}
|
||||
|
||||
let authenticatorPublicKey: Uint8Array; // Changed type from Buffer to Uint8Array
|
||||
try {
|
||||
const pkBuffer = Buffer.from(passkey.public_key, 'base64');
|
||||
// Ensure it's a plain Uint8Array instance
|
||||
authenticatorPublicKey = new Uint8Array(pkBuffer.buffer, pkBuffer.byteOffset, pkBuffer.byteLength);
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error decoding public_key to Uint8Array:', passkey.public_key, e.message);
|
||||
throw new Error('Failed to decode public_key.');
|
||||
}
|
||||
|
||||
let authenticatorTransports: AuthenticatorTransportFuture[] | undefined;
|
||||
try {
|
||||
authenticatorTransports = passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransportFuture[] : undefined;
|
||||
} catch (e: any) {
|
||||
console.error('[PasskeyService] Error parsing transports JSON:', passkey.transports, e.message);
|
||||
authenticatorTransports = undefined;
|
||||
}
|
||||
|
||||
// This object structure should match what @simplewebauthn/server expects for its `credential` option parameter.
|
||||
// Specifically, it expects `id`, `publicKey`, and `counter`.
|
||||
const credentialObjectForLibrary = {
|
||||
id: authenticatorCredentialID, // Renamed from credentialID
|
||||
publicKey: authenticatorPublicKey, // Renamed from credentialPublicKey
|
||||
counter: passkey.counter,
|
||||
transports: authenticatorTransports,
|
||||
credentialBackedUp: !!passkey.backed_up,
|
||||
credentialDeviceType: (passkey.backed_up ? 'multiDevice' : 'singleDevice') as 'multiDevice' | 'singleDevice',
|
||||
};
|
||||
|
||||
// Reverting to 'any' for verifyOpts due to issues with the library's
|
||||
// type definitions for VerifyAuthenticationResponseOpts not recognizing 'authenticator' key.
|
||||
// This aligns with the original code's approach and TODO comment.
|
||||
const verifyOpts: any = {
|
||||
response: authenticationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: RP_ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
credential: credentialObjectForLibrary, // Renamed from authenticator to credential
|
||||
requireUserVerification: true,
|
||||
};
|
||||
|
||||
// Call without 'as VerifyAuthenticationResponseOpts' since verifyOpts is 'any'
|
||||
const verification = await verifyAuthenticationResponse(verifyOpts);
|
||||
|
||||
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,
|
||||
name: pk.name, // <-- 添加 name 字段
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async updatePasskeyName(userId: number, credentialID: string, newName: string): Promise<void> {
|
||||
const passkey = await this.passkeyRepo.getPasskeyByCredentialId(credentialID);
|
||||
if (!passkey) {
|
||||
throw new Error('Passkey not found.');
|
||||
}
|
||||
if (passkey.user_id !== userId) {
|
||||
// Security measure: User can only update their own passkey names
|
||||
throw new Error('Unauthorized to update this passkey name.');
|
||||
}
|
||||
await this.passkeyRepo.updatePasskeyName(credentialID, newName);
|
||||
}
|
||||
|
||||
async hasPasskeysConfigured(username?: string): Promise<boolean> {
|
||||
if (username) {
|
||||
const user = await this.userRepo.findUserByUsername(username);
|
||||
if (!user) {
|
||||
return false; // 如果提供了用户名但用户不存在,则认为没有配置 passkey
|
||||
}
|
||||
const passkeys = await this.passkeyRepo.getPasskeysByUserId(user.id);
|
||||
return passkeys.length > 0;
|
||||
} else {
|
||||
// 如果没有提供用户名,检查整个系统中是否存在任何 passkey
|
||||
// 这对于“可发现凭证”场景可能有用,或者简单地检查系统是否启用了 passkey 功能
|
||||
const anyPasskey = await this.passkeyRepo.getFirstPasskey();
|
||||
return !!anyPasskey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const passkeyService = new PasskeyService(passkeyRepository, userRepository);
|
||||
@@ -7,8 +7,15 @@ 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'
|
||||
| 'PASSKEY_NAME_UPDATED'
|
||||
| 'PASSKEY_NAME_UPDATE_UNAUTHORIZED'
|
||||
|
||||
// Connections
|
||||
| 'CONNECTION_CREATED'
|
||||
| 'CONNECTION_UPDATED'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<label for="telegram-token" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.notifications.form.telegramToken') }}</label>
|
||||
<input type="password" id="telegram-token" v-model="telegramConfig.botToken" required autocomplete="new-password"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
|
||||
<small class="block mt-1 text-xs text-text-secondary">{{ $t('settings.notifications.form.telegramTokenHelp') }}</small>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<label for="telegram-chatid" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.notifications.form.telegramChatId') }}</label>
|
||||
@@ -137,7 +137,6 @@
|
||||
<label for="telegram-message" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.notifications.form.telegramMessageTemplate') }}</label>
|
||||
<textarea id="telegram-message" v-model="telegramConfig.messageTemplate" rows="3" :placeholder="`${$t('settings.notifications.form.telegramMessagePlaceholder')} {event}, {timestamp}, {details}.`"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary font-mono text-sm"></textarea>
|
||||
<small class="block mt-1 text-xs text-text-secondary">{{ $t('settings.notifications.form.templateHelp') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,16 +267,19 @@ const canTestUnsaved = computed(() => {
|
||||
|
||||
// Define all possible events (aligned with AuditLogView's allActionTypes)
|
||||
const allNotificationEvents: NotificationEvent[] = [
|
||||
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED', // Added LOGOUT, PASSWORD_CHANGED
|
||||
'2FA_ENABLED', '2FA_DISABLED', // Added 2FA,
|
||||
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED',
|
||||
'2FA_ENABLED', '2FA_DISABLED',
|
||||
// Passkey Events
|
||||
// 'PASSKEY_AUTH_SUCCESS',
|
||||
// 'PASSKEY_AUTH_FAILURE',
|
||||
'CONNECTION_CREATED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
|
||||
'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED', // Changed _ADDED
|
||||
'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED', // Changed _ADDED
|
||||
'SETTINGS_UPDATED', 'IP_WHITELIST_UPDATED', // Added IP_WHITELIST_UPDATED
|
||||
'NOTIFICATION_SETTING_CREATED', 'NOTIFICATION_SETTING_UPDATED', 'NOTIFICATION_SETTING_DELETED', // Added NOTIFICATION types
|
||||
'SSH_CONNECT_SUCCESS', 'SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE', // Added SSH types
|
||||
'DATABASE_MIGRATION', 'ADMIN_SETUP_COMPLETE'
|
||||
// Removed IP_BLACKLISTED as it's not in the Audit Log list source
|
||||
'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED',
|
||||
'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED',
|
||||
'SETTINGS_UPDATED', 'IP_WHITELIST_UPDATED', 'IP_BLOCKED', // Added IP_BLOCKED as it's in backend types
|
||||
'NOTIFICATION_SETTING_CREATED', 'NOTIFICATION_SETTING_UPDATED', 'NOTIFICATION_SETTING_DELETED',
|
||||
'SSH_CONNECT_SUCCESS', 'SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE',
|
||||
'DATABASE_MIGRATION', 'ADMIN_SETUP_COMPLETE'
|
||||
// Removed IP_BLACKLISTED as it's not in the Audit Log list source, but IP_BLOCKED is present in backend types
|
||||
];
|
||||
|
||||
// Reactive form data structure
|
||||
|
||||
@@ -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,31 @@
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
"title": "Passkey Settings",
|
||||
"description": "Use Passkeys (biometrics or security keys) for passwordless authentication to enhance security and convenience.",
|
||||
"title": "Passkey Management",
|
||||
"description": "Use Passkeys (biometrics or security keys) for passwordless authentication.",
|
||||
"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.",
|
||||
"nameUpdated": "Passkey name updated."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -595,6 +611,10 @@
|
||||
"PASSWORD_CHANGED": "Password Changed",
|
||||
"2FA_ENABLED": "2FA Enabled",
|
||||
"2FA_DISABLED": "2FA Disabled",
|
||||
"PASSKEY_REGISTERED": "Passkey Registered",
|
||||
"PASSKEY_AUTH_SUCCESS": "Passkey Authentication Successful",
|
||||
"PASSKEY_AUTH_FAILURE": "Passkey Authentication Failed",
|
||||
"PASSKEY_DELETED": "Passkey Deleted",
|
||||
"CONNECTION_CREATED": "Connection Created",
|
||||
"CONNECTION_UPDATED": "Connection Updated",
|
||||
"CONNECTION_DELETED": "Connection Deleted",
|
||||
@@ -606,6 +626,7 @@
|
||||
"TAG_DELETED": "Tag Deleted",
|
||||
"SETTINGS_UPDATED": "Settings Updated",
|
||||
"IP_WHITELIST_UPDATED": "IP Whitelist Updated",
|
||||
"IP_BLOCKED": "IP Blocked",
|
||||
"NOTIFICATION_SETTING_CREATED": "Notification Setting Created",
|
||||
"NOTIFICATION_SETTING_UPDATED": "Notification Setting Updated",
|
||||
"NOTIFICATION_SETTING_DELETED": "Notification Setting Deleted",
|
||||
@@ -883,7 +904,14 @@
|
||||
"ADMIN_SETUP_COMPLETE": "Initial Admin Setup Completed",
|
||||
"REMOTE_DESKTOP_CONNECTING": "Remote Desktop Connecting",
|
||||
"REMOTE_DESKTOP_CONNECTED": "Remote Desktop Connected",
|
||||
"REMOTE_DESKTOP_DISCONNECTED": "Remote Desktop Disconnected"
|
||||
"REMOTE_DESKTOP_DISCONNECTED": "Remote Desktop Disconnected",
|
||||
"PASSKEY_REGISTERED": "Passkey Registered",
|
||||
"PASSKEY_AUTH_SUCCESS": "Passkey Authentication Successful",
|
||||
"PASSKEY_AUTH_FAILURE": "Passkey Authentication Failed",
|
||||
"PASSKEY_DELETED": "Passkey Deleted",
|
||||
"PASSKEY_DELETE_UNAUTHORIZED": "Passkey Deletion Unauthorized",
|
||||
"PASSKEY_NAME_UPDATED": "Passkey Name Updated",
|
||||
"PASSKEY_NAME_UPDATE_UNAUTHORIZED": "Passkey Name Update Unauthorized"
|
||||
}
|
||||
},
|
||||
"workspaceConnectionList": {
|
||||
|
||||
@@ -29,7 +29,14 @@
|
||||
"SSH_SHELL_FAILURE": "SSH Shell オープン失敗",
|
||||
"TAG_CREATED": "タグ作成",
|
||||
"TAG_DELETED": "タグ削除",
|
||||
"TAG_UPDATED": "タグ更新"
|
||||
"TAG_UPDATED": "タグ更新",
|
||||
"PASSKEY_REGISTERED": "パスキー登録済み",
|
||||
"PASSKEY_AUTH_SUCCESS": "パスキー認証成功",
|
||||
"PASSKEY_AUTH_FAILURE": "パスキー認証失敗",
|
||||
"PASSKEY_DELETED": "パスキー削除済み",
|
||||
"PASSKEY_DELETE_UNAUTHORIZED": "パスキー削除権限なし",
|
||||
"PASSKEY_NAME_UPDATED": "パスキー名更新済み",
|
||||
"PASSKEY_NAME_UPDATE_UNAUTHORIZED": "パスキー名更新権限なし"
|
||||
},
|
||||
"noLogs": "監査ログが見つかりませんでした。",
|
||||
"paginationInfo": "{currentPage} ページ / 全 {totalPages} ページ ({totalLogs} 件のログ)",
|
||||
@@ -458,11 +465,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": "ログイン状態を保持",
|
||||
@@ -748,6 +758,10 @@
|
||||
"events": {
|
||||
"2FA_DISABLED": "2段階認証無効",
|
||||
"2FA_ENABLED": "2段階認証有効",
|
||||
"PASSKEY_REGISTERED": "パスキー登録済み",
|
||||
"PASSKEY_AUTH_SUCCESS": "パスキー認証成功",
|
||||
"PASSKEY_AUTH_FAILURE": "パスキー認証失敗",
|
||||
"PASSKEY_DELETED": "パスキー削除済み",
|
||||
"ADMIN_SETUP_COMPLETE": "初期管理者設定完了",
|
||||
"CONNECTIONS_EXPORTED": "接続がエクスポートされました",
|
||||
"CONNECTION_CREATED": "接続作成",
|
||||
@@ -755,6 +769,7 @@
|
||||
"CONNECTION_UPDATED": "接続更新",
|
||||
"DATABASE_MIGRATION": "データベース移行",
|
||||
"IP_WHITELIST_UPDATED": "IP ホワイトリスト更新",
|
||||
"IP_BLOCKED": "IPブロック済み",
|
||||
"LOGIN_FAILURE": "ログイン失敗",
|
||||
"LOGIN_SUCCESS": "ログイン成功",
|
||||
"LOGOUT": "ログアウト",
|
||||
@@ -818,20 +833,32 @@
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
"description": "Passkey (生体認証またはセキュリティキー) を使用してパスワードなし認証を行い、アカウントのセキュリティとログインの利便性を向上させます。",
|
||||
"error": {
|
||||
"cancelled": "Passkey の登録がキャンセルされました。",
|
||||
"genericRegistration": "Passkey を登録できません: {message}",
|
||||
"nameRequired": "Passkey 名を入力してください。",
|
||||
"verificationFailed": "登録に失敗しました: {message}"
|
||||
},
|
||||
"title": "Passkey 管理",
|
||||
"description": "Passkey (生体認証またはセキュリティキー) を使用してパスワードなし認証を行い",
|
||||
"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 が正常に削除されました。",
|
||||
"nameUpdated": "Passkey 名が更新されました。"
|
||||
}
|
||||
},
|
||||
"popupEditor": {
|
||||
"enableLabel": "ファイルを開くときにポップアップエディターを表示する",
|
||||
|
||||
@@ -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,31 @@
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
"title": "Passkey 设置",
|
||||
"description": "使用 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 已成功删除。",
|
||||
"nameUpdated": "Passkey 名称已更新。"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -594,6 +609,10 @@
|
||||
"PASSWORD_CHANGED": "密码已修改",
|
||||
"2FA_ENABLED": "两步验证已启用",
|
||||
"2FA_DISABLED": "两步验证已禁用",
|
||||
"PASSKEY_REGISTERED": "Passkey 已注册",
|
||||
"PASSKEY_AUTH_SUCCESS": "Passkey 认证成功",
|
||||
"PASSKEY_AUTH_FAILURE": "Passkey 认证失败",
|
||||
"PASSKEY_DELETED": "Passkey 已删除",
|
||||
"CONNECTION_CREATED": "连接已创建",
|
||||
"CONNECTION_UPDATED": "连接已更新",
|
||||
"CONNECTION_DELETED": "连接已删除",
|
||||
@@ -606,6 +625,7 @@
|
||||
"TAG_DELETED": "标签已删除",
|
||||
"SETTINGS_UPDATED": "设置已更新",
|
||||
"IP_WHITELIST_UPDATED": "IP 白名单已更新",
|
||||
"IP_BLOCKED": "IP 已封禁",
|
||||
"NOTIFICATION_SETTING_CREATED": "通知设置已创建",
|
||||
"NOTIFICATION_SETTING_UPDATED": "通知设置已更新",
|
||||
"NOTIFICATION_SETTING_DELETED": "通知设置已删除",
|
||||
@@ -886,7 +906,14 @@
|
||||
"ADMIN_SETUP_COMPLETE": "初始管理员设置完成",
|
||||
"REMOTE_DESKTOP_CONNECTING": "远程桌面连接中",
|
||||
"REMOTE_DESKTOP_CONNECTED": "远程桌面已连接",
|
||||
"REMOTE_DESKTOP_DISCONNECTED": "远程桌面已断开"
|
||||
"REMOTE_DESKTOP_DISCONNECTED": "远程桌面已断开",
|
||||
"PASSKEY_REGISTERED": "Passkey 已注册",
|
||||
"PASSKEY_AUTH_SUCCESS": "Passkey 认证成功",
|
||||
"PASSKEY_AUTH_FAILURE": "Passkey 认证失败",
|
||||
"PASSKEY_DELETED": "Passkey 已删除",
|
||||
"PASSKEY_DELETE_UNAUTHORIZED": "Passkey 删除未授权",
|
||||
"PASSKEY_NAME_UPDATED": "Passkey 名称已更新",
|
||||
"PASSKEY_NAME_UPDATE_UNAUTHORIZED": "Passkey 名称更新未授权"
|
||||
}
|
||||
},
|
||||
"workspaceConnectionList": {
|
||||
|
||||
@@ -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,9 @@ 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
|
||||
hasPasskeysAvailable: boolean; // NEW: Indicates if passkeys are available for login
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -66,7 +78,9 @@ 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
|
||||
hasPasskeysAvailable: false, // Initialize as false
|
||||
}),
|
||||
getters: {
|
||||
// 可以添加一些 getter,例如获取用户名
|
||||
@@ -310,12 +324,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
|
||||
// NEW: 获取公共 CAPTCHA 配置 (修改为从 /settings/captcha 获取)
|
||||
async fetchCaptchaConfig() {
|
||||
console.log('[AuthStore] fetchCaptchaConfig called. Current publicCaptchaConfig:', JSON.stringify(this.publicCaptchaConfig)); // 添加日志
|
||||
// Avoid refetching if already loaded
|
||||
if (this.publicCaptchaConfig !== null) {
|
||||
console.log('[AuthStore] publicCaptchaConfig is not null, returning early.'); // 添加日志
|
||||
return;
|
||||
}
|
||||
console.log('[AuthStore] fetchCaptchaConfig called. Forcing refetch.'); // 更新日志,表明强制刷新
|
||||
|
||||
// Don't set isLoading for this, it should be quick background fetch
|
||||
try {
|
||||
@@ -343,7 +352,173 @@ 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 {
|
||||
// Define an interface for the backend response structure
|
||||
interface BackendPasskeyInfo {
|
||||
credential_id: string;
|
||||
public_key: string;
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransport[];
|
||||
created_at: string; // Backend uses snake_case
|
||||
last_used_at: string; // Backend uses snake_case
|
||||
name?: string;
|
||||
}
|
||||
const response = await apiClient.get<BackendPasskeyInfo[]>('/auth/user/passkeys');
|
||||
// Map backend response to frontend PasskeyInfo structure
|
||||
this.passkeys = response.data.map(pk => ({
|
||||
credentialID: pk.credential_id,
|
||||
publicKey: pk.public_key,
|
||||
counter: pk.counter,
|
||||
transports: pk.transports,
|
||||
creationDate: pk.created_at, // Map created_at to creationDate
|
||||
lastUsedDate: pk.last_used_at, // Map last_used_at to lastUsedDate
|
||||
name: pk.name,
|
||||
}));
|
||||
console.log('Passkeys fetched and mapped 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;
|
||||
}
|
||||
},
|
||||
|
||||
// Action to update a passkey's name
|
||||
async updatePasskeyName(credentialID: string, newName: string) {
|
||||
if (!this.isAuthenticated) {
|
||||
throw new Error('User not authenticated. Cannot update passkey name.');
|
||||
}
|
||||
// Consider using a specific loading state for this if needed, e.g., this.passkeyNameUpdateLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await apiClient.put(`/auth/user/passkeys/${credentialID}/name`, { name: newName });
|
||||
console.log(`Passkey ${credentialID} name updated to "${newName}".`);
|
||||
// Refresh the passkey list to show the new name
|
||||
await this.fetchPasskeys();
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to update passkey ${credentialID} name:`, err);
|
||||
this.error = err.response?.data?.message || err.message || 'Failed to update passkey name.';
|
||||
throw new Error(this.error ?? 'Failed to update passkey name.');
|
||||
} finally {
|
||||
// if using specific loading state: this.passkeyNameUpdateLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Action to check if passkeys are configured (for login page)
|
||||
async checkHasPasskeysConfigured(username?: string) {
|
||||
// This action should not set isLoading to true, as it's a quick check
|
||||
// and primarily used to determine UI elements on the login page.
|
||||
try {
|
||||
const params = username ? { username } : {};
|
||||
const response = await apiClient.get<{ hasPasskeys: boolean }>('/auth/passkey/has-configured', { params });
|
||||
this.hasPasskeysAvailable = response.data.hasPasskeys;
|
||||
console.log(`[AuthStore] Passkeys available for ${username || 'any user'}: ${this.hasPasskeysAvailable}`);
|
||||
return this.hasPasskeysAvailable;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to check if passkeys are configured:', error.response?.data?.message || error.message);
|
||||
this.hasPasskeysAvailable = false; // Default to false on error
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true, // Revert to simple persistence to fix TS error for now
|
||||
});
|
||||
|
||||
@@ -22,17 +22,18 @@ export type NotificationChannelType = 'webhook' | 'email' | 'telegram';
|
||||
export type NotificationEvent =
|
||||
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
|
||||
| '2FA_ENABLED' | '2FA_DISABLED'
|
||||
// Passkey Events
|
||||
| 'PASSKEY_REGISTERED'
|
||||
| 'PASSKEY_AUTH_SUCCESS'
|
||||
| 'PASSKEY_AUTH_FAILURE'
|
||||
| 'PASSKEY_DELETED'
|
||||
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED'
|
||||
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
|
||||
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
|
||||
| 'SETTINGS_UPDATED' | 'IP_WHITELIST_UPDATED'
|
||||
| 'SETTINGS_UPDATED' | 'IP_WHITELIST_UPDATED' | 'IP_BLOCKED'
|
||||
| 'NOTIFICATION_SETTING_CREATED' | 'NOTIFICATION_SETTING_UPDATED' | 'NOTIFICATION_SETTING_DELETED'
|
||||
// SSH Actions
|
||||
| 'SSH_CONNECT_SUCCESS' | 'SSH_CONNECT_FAILURE' | 'SSH_SHELL_FAILURE'
|
||||
// System/Error
|
||||
| 'DATABASE_MIGRATION' | 'ADMIN_SETUP_COMPLETE';
|
||||
// Settings (Specific) - Keep aligned with AuditLogActionType
|
||||
// Note: IP_BLACKLISTED was in NotificationEvent but not AuditLogActionType, removed for consistency based on user request
|
||||
|
||||
export interface WebhookConfig {
|
||||
url: string;
|
||||
|
||||
@@ -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;
|
||||
@@ -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'; // 使用默认导入
|
||||
@@ -10,7 +10,7 @@ import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
// 获取 loginRequires2FA 状态
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig } = storeToRefs(authStore); // Get publicCaptchaConfig
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore); // Get publicCaptchaConfig and hasPasskeysAvailable
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
@@ -92,13 +92,60 @@ const handleSubmit = async () => {
|
||||
} // <-- Correctly closing the try block here
|
||||
};
|
||||
|
||||
// Fetch CAPTCHA config on component mount
|
||||
onMounted(() => {
|
||||
console.log('[LoginView] Component mounted, calling fetchCaptchaConfig...'); // 添加日志
|
||||
// Fetch CAPTCHA config and check passkey availability on component mount
|
||||
onMounted(async () => {
|
||||
// console.log('[LoginView] Component mounted, calling fetchCaptchaConfig and checkHasPasskeysConfigured...');
|
||||
authStore.fetchCaptchaConfig();
|
||||
// Check if passkeys are available for login (uses the new public endpoint)
|
||||
// Optionally pass username if needed: await authStore.checkHasPasskeysConfigured(credentials.username);
|
||||
await authStore.checkHasPasskeysConfigured();
|
||||
});
|
||||
|
||||
// --- Passkey Login Handler Removed ---
|
||||
// --- Passkey Login Handler ---
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null; // Clear previous errors
|
||||
|
||||
// Prepare body for authentication options request
|
||||
// If username is provided, include it. Otherwise, send an empty object
|
||||
// to allow the backend to attempt discoverable credential authentication.
|
||||
const authOptionsBody = credentials.username ? { username: credentials.username } : {};
|
||||
|
||||
// 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(authOptionsBody),
|
||||
});
|
||||
|
||||
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
|
||||
// Pass username if it was used to get options, otherwise pass null or rely on backend to extract from assertion
|
||||
// For simplicity, we'll pass the username if available, or an empty string if not.
|
||||
// The store action `loginWithPasskey` expects a string.
|
||||
// The backend should ideally identify the user from the assertion if an empty username is provided.
|
||||
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 +245,14 @@ onMounted(() => {
|
||||
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
|
||||
</button>
|
||||
|
||||
<!-- Passkey Login Button Removed -->
|
||||
|
||||
<!-- Passkey Login Button -->
|
||||
<div v-if="hasPasskeysAvailable" class="mt-4 text-center">
|
||||
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-secondary text-black 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 flex items-center justify-center">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
<span>{{ isLoading ? t('login.loggingIn') : t('login.loginWithPasskey') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,66 @@
|
||||
</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">
|
||||
<div class="flex items-center">
|
||||
<span v-if="!editingPasskeyId || editingPasskeyId !== key.credentialID" class="block font-medium text-foreground text-sm">
|
||||
{{ key.name || $t('settings.passkey.unnamedKey') }}
|
||||
<span class="text-xs text-text-tertiary ml-1">(ID: ...{{ typeof key.credentialID === 'string' && key.credentialID ? key.credentialID.slice(-8) : 'N/A' }})</span>
|
||||
</span>
|
||||
<div v-else class="flex items-center flex-grow">
|
||||
<input type="text" v-model="editingPasskeyName" @keyup.enter="savePasskeyName(key.credentialID)" @keyup.esc="cancelEditPasskeyName" class="w-48 px-2 py-1 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm" :placeholder="$t('settings.passkey.enterNamePlaceholder', '输入 Passkey 名称')" />
|
||||
<button @click="savePasskeyName(key.credentialID)" :disabled="passkeyEditLoadingStates[key.credentialID]" class="ml-2 px-2 py-1 bg-success text-success-text rounded-md text-xs font-medium hover:bg-success/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-success disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
||||
{{ passkeyEditLoadingStates[key.credentialID] ? $t('common.saving') : $t('common.save') }}
|
||||
</button>
|
||||
<button @click="cancelEditPasskeyName" :disabled="passkeyEditLoadingStates[key.credentialID]" class="ml-1 px-2 py-1 bg-transparent text-text-secondary border border-border rounded-md text-xs font-medium hover:bg-border hover:text-foreground 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">
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="!editingPasskeyId || editingPasskeyId !== key.credentialID" @click="startEditPasskeyName(key.credentialID, key.name || '')" class="ml-2 p-1 text-text-secondary hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out" :title="$t('settings.passkey.editNameTooltip', '编辑名称')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" class="bi bi-pencil-square">
|
||||
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
|
||||
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<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] || (editingPasskeyId === 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 +729,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 +767,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 +862,20 @@ 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); // For registering new passkey
|
||||
const passkeyMessage = ref(''); // General messages for passkey operations (register, delete, edit name)
|
||||
const passkeySuccess = ref(false); // Success status for general passkey operations
|
||||
const passkeyDeleteLoadingStates = reactive<Record<string, boolean>>({});
|
||||
const passkeyDeleteError = ref<string | null>(null); // Specific error for delete operation
|
||||
|
||||
// State for editing passkey name
|
||||
const editingPasskeyId = ref<string | null>(null);
|
||||
const editingPasskeyName = ref('');
|
||||
const passkeyEditLoadingStates = reactive<Record<string, boolean>>({});
|
||||
// passkeyMessage and passkeySuccess can be reused for edit name feedback.
|
||||
// const passkeyEditError = ref<string | null>(null); // Or use a specific error ref if needed
|
||||
|
||||
// 提供一些常用的时区供选择
|
||||
const commonTimezones = ref([
|
||||
@@ -1128,13 +1199,133 @@ 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 startEditPasskeyName = (credentialID: string, currentName: string) => {
|
||||
editingPasskeyId.value = credentialID;
|
||||
editingPasskeyName.value = currentName;
|
||||
passkeyMessage.value = ''; // Clear previous messages
|
||||
passkeySuccess.value = false;
|
||||
};
|
||||
|
||||
const cancelEditPasskeyName = () => {
|
||||
editingPasskeyId.value = null;
|
||||
editingPasskeyName.value = '';
|
||||
};
|
||||
|
||||
const savePasskeyName = async (credentialID: string) => {
|
||||
if (!editingPasskeyName.value.trim()) {
|
||||
passkeyMessage.value = t('settings.passkey.error.nameRequired', 'Passkey 名称不能为空。');
|
||||
passkeySuccess.value = false;
|
||||
return;
|
||||
}
|
||||
passkeyEditLoadingStates[credentialID] = true;
|
||||
passkeyMessage.value = '';
|
||||
passkeySuccess.value = false;
|
||||
try {
|
||||
// Предполагается, что в authStore есть метод updatePasskeyName
|
||||
await authStore.updatePasskeyName(credentialID, editingPasskeyName.value.trim());
|
||||
passkeyMessage.value = t('settings.passkey.success.nameUpdated');
|
||||
passkeySuccess.value = true;
|
||||
await authStore.fetchPasskeys(); // Обновить список для отображения нового имени
|
||||
cancelEditPasskeyName(); // Сбросить состояние редактирования
|
||||
} catch (error: any) {
|
||||
console.error(`更新 Passkey ${credentialID} 名称失败:`, error);
|
||||
passkeyMessage.value = error.message || t('settings.passkey.error.nameUpdateFailed', '更新 Passkey 名称失败。');
|
||||
passkeySuccess.value = false;
|
||||
} finally {
|
||||
passkeyEditLoadingStates[credentialID] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (credentialID: string) => {
|
||||
if (editingPasskeyId.value === credentialID) {
|
||||
cancelEditPasskeyName(); // Cancel editing if the key being edited is deleted
|
||||
}
|
||||
if (!credentialID || typeof credentialID !== 'string') {
|
||||
console.error('Attempted to delete a passkey with an invalid or undefined credentialID:', credentialID);
|
||||
passkeyDeleteError.value = t('settings.passkey.error.deleteFailedInvalidId', '删除失败:无效的凭证 ID。'); // Add translation
|
||||
return;
|
||||
}
|
||||
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);
|
||||
passkeyMessage.value = t('settings.passkey.success.deleted');
|
||||
passkeySuccess.value = true; // Use general success for feedback
|
||||
// authStore.fetchPasskeys() will be called by deletePasskey if successful
|
||||
} 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();
|
||||
// If dateInput is a number, assume it's a Unix timestamp in seconds
|
||||
if (typeof dateInput === 'number') {
|
||||
const dateFromSeconds = new Date(dateInput * 1000);
|
||||
if (!isNaN(dateFromSeconds.getTime())) {
|
||||
return dateFromSeconds.toLocaleString();
|
||||
}
|
||||
// If conversion from seconds still results in an invalid date, return N/A
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
|
||||
// If dateInput is a string or Date object, try to parse it directly
|
||||
const date = new Date(dateInput);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// If all parsing fails
|
||||
return t('statusMonitor.notAvailable');
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", e);
|
||||
return t('statusMonitor.notAvailable');
|
||||
@@ -1439,7 +1630,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()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user