diff --git a/package-lock.json b/package-lock.json index 67c1b7b..3f328de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==", diff --git a/packages/backend/package.json b/packages/backend/package.json index 047608a..480f2d0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index fc84f92..690f8d2 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + // 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 => { + 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 => { + 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 => { + 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 => { 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 => { 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 => { 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 => { // 检查是否启用了 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 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 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 */ export const verifyLogin2FA = async (req: Request, res: Response): Promise => { 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 try { const db = await getDbInstance(); - // 获取用户的 2FA 密钥 using promisified getDb const user = await getDb(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 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 }); } 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 */ export const changePassword = async (req: Request, res: Response): Promise => { 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 } 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 */ export const setup2FA = async (req: Request, res: Response): Promise => { 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 => { 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 => { 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 => { export const verifyAndActivate2FA = async (req: Request, res: Response): Promise => { 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 => { 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 => try { const db = await getDbInstance(); - // 验证当前密码 const user = await getDb(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 => 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 => 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 => */ export const needsSetup = async (req: Request, res: Response): Promise => { 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 => } 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 => export const setupAdmin = async (req: Request, res: Response): Promise => { 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 => 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 => 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 => 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 => { + 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 配置时出错。' }); + } +}; diff --git a/packages/backend/src/auth/auth.routes.ts b/packages/backend/src/auth/auth.routes.ts index 4ae057b..eb0d9b3 100644 --- a/packages/backend/src/auth/auth.routes.ts +++ b/packages/backend/src/auth/auth.routes.ts @@ -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); diff --git a/packages/backend/src/config/app.config.ts b/packages/backend/src/config/app.config.ts new file mode 100644 index 0000000..43b8018 --- /dev/null +++ b/packages/backend/src/config/app.config.ts @@ -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(key: K): AppConfig[K] { + return config[key]; +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 029c453..fe3b98f 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -228,6 +228,30 @@ const definedMigrations: Migration[] = [ ` }, // --- 未来可以添加更多迁移 --- + { + id: 6, + name: 'Create passkeys table for WebAuthn credentials', + check: async (db: Database): Promise => { + 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 + ); + ` + } ]; /** diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index aa153ab..aeeda7b 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -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 ( diff --git a/packages/backend/src/locales/en-US.json b/packages/backend/src/locales/en-US.json index 27ece61..f89cf62 100644 --- a/packages/backend/src/locales/en-US.json +++ b/packages/backend/src/locales/en-US.json @@ -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" } } \ No newline at end of file diff --git a/packages/backend/src/locales/ja-JP.json b/packages/backend/src/locales/ja-JP.json index 06082a8..ab8a382 100644 --- a/packages/backend/src/locales/ja-JP.json +++ b/packages/backend/src/locales/ja-JP.json @@ -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": "パスキー名更新権限なし" } } \ No newline at end of file diff --git a/packages/backend/src/repositories/passkey.repository.ts b/packages/backend/src/repositories/passkey.repository.ts index 6972061..7fe80ed 100644 --- a/packages/backend/src/repositories/passkey.repository.ts +++ b/packages/backend/src/repositories/passkey.repository.ts @@ -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 { + 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 { + const db = await getDbInstance(); + const sql = 'SELECT * FROM passkeys WHERE id = ?'; + const result = await getDb(db, sql, [id]); + return mapPasskeyResult(result); + } + + async getPasskeyByCredentialId(credentialId: string): Promise { + const db = await getDbInstance(); + const sql = 'SELECT * FROM passkeys WHERE credential_id = ?'; + const result = await getDb(db, sql, [credentialId]); + return mapPasskeyResult(result); + } + + async getPasskeysByUserId(userId: number): Promise { + const db = await getDbInstance(); + const sql = 'SELECT * FROM passkeys WHERE user_id = ? ORDER BY created_at DESC'; + const results = await allDb(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const db = await getDbInstance(); + const sql = 'SELECT * FROM passkeys LIMIT 1'; + const result = await getDb(db, sql); + return mapPasskeyResult(result); + } +} + +export const passkeyRepository = new PasskeyRepository(); diff --git a/packages/backend/src/repositories/user.repository.ts b/packages/backend/src/repositories/user.repository.ts new file mode 100644 index 0000000..dfc32e9 --- /dev/null +++ b/packages/backend/src/repositories/user.repository.ts @@ -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 { + 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(db, sql, [id]); + return user ?? null; + } + + async findUserByUsername(username: string): Promise { + 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(db, sql, [username]); + return user ?? null; + } + + // Add other user-related methods if needed, e.g., createUser, updateUserPassword, etc. +} + +export const userRepository = new UserRepository(); \ No newline at end of file diff --git a/packages/backend/src/services/passkey.service.ts b/packages/backend/src/services/passkey.service.ts new file mode 100644 index 0000000..7b5d2dd --- /dev/null +++ b/packages/backend/src/services/passkey.service.ts @@ -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 { + 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 { + + // 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[]> { + 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 { + 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 { + 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 { + 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); \ No newline at end of file diff --git a/packages/backend/src/types/audit.types.ts b/packages/backend/src/types/audit.types.ts index 9c8f93d..116c821 100644 --- a/packages/backend/src/types/audit.types.ts +++ b/packages/backend/src/types/audit.types.ts @@ -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' diff --git a/packages/backend/src/types/notification.types.ts b/packages/backend/src/types/notification.types.ts index e6f5eb3..90a71f4 100644 --- a/packages/backend/src/types/notification.types.ts +++ b/packages/backend/src/types/notification.types.ts @@ -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' diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a59b30b..3def40b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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", diff --git a/packages/frontend/src/components/NotificationSettingForm.vue b/packages/frontend/src/components/NotificationSettingForm.vue index 2503bcf..c80711c 100644 --- a/packages/frontend/src/components/NotificationSettingForm.vue +++ b/packages/frontend/src/components/NotificationSettingForm.vue @@ -126,7 +126,7 @@ - {{ $t('settings.notifications.form.telegramTokenHelp') }} +
@@ -137,7 +137,6 @@ - {{ $t('settings.notifications.form.templateHelp') }}
@@ -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 diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 2490461..624c8ae 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -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": { diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index acc66f8..2892dc3 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -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": "ファイルを開くときにポップアップエディターを表示する", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 591ec5b..80d5e4a 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -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": { diff --git a/packages/frontend/src/stores/auth.store.ts b/packages/frontend/src/stores/auth.store.ts index 665829e..1d69dc5 100644 --- a/packages/frontend/src/stores/auth.store.ts +++ b/packages/frontend/src/stores/auth.store.ts @@ -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('/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 }); diff --git a/packages/frontend/src/types/server.types.ts b/packages/frontend/src/types/server.types.ts index d664712..b153207 100644 --- a/packages/frontend/src/types/server.types.ts +++ b/packages/frontend/src/types/server.types.ts @@ -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; diff --git a/packages/frontend/src/utils/apiClient.ts b/packages/frontend/src/utils/apiClient.ts index ff34c7b..e20bc96 100644 --- a/packages/frontend/src/utils/apiClient.ts +++ b/packages/frontend/src/utils/apiClient.ts @@ -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; \ No newline at end of file diff --git a/packages/frontend/src/views/LoginView.vue b/packages/frontend/src/views/LoginView.vue index 00cf2be..99afd67 100644 --- a/packages/frontend/src/views/LoginView.vue +++ b/packages/frontend/src/views/LoginView.vue @@ -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; + } +};