diff --git a/packages/napcat-webui-backend/package.json b/packages/napcat-webui-backend/package.json index 8ea4f068..dbdc4fa3 100644 --- a/packages/napcat-webui-backend/package.json +++ b/packages/napcat-webui-backend/package.json @@ -16,6 +16,7 @@ } }, "dependencies": { + "@simplewebauthn/server": "^13.2.2", "@sinclair/typebox": "^0.34.38", "ajv": "^8.13.0", "compressing": "^1.10.3", diff --git a/packages/napcat-webui-backend/src/api/Auth.ts b/packages/napcat-webui-backend/src/api/Auth.ts index 9e2d46ae..5955759d 100644 --- a/packages/napcat-webui-backend/src/api/Auth.ts +++ b/packages/napcat-webui-backend/src/api/Auth.ts @@ -1,5 +1,6 @@ import { RequestHandler } from 'express'; import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken'; +import { PasskeyHelper } from '@/napcat-webui-backend/src/helper/PasskeyHelper'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response'; import { isEmpty } from '@/napcat-webui-backend/src/utils/check'; @@ -148,3 +149,115 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => { return sendError(res, `Failed to update token: ${e.message}`); } }; + +// 生成Passkey注册选项 +export const GeneratePasskeyRegistrationOptionsHandler: RequestHandler = async (_req, res) => { + try { + // 使用固定用户ID,因为WebUI只有一个用户 + const userId = 'napcat-user'; + const userName = 'NapCat User'; + + // 从请求头获取host来确定RP_ID + const host = _req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + + const options = await PasskeyHelper.generateRegistrationOptions(userId, userName, rpId); + return sendSuccess(res, options); + } catch (error) { + return sendError(res, `Failed to generate registration options: ${(error as Error).message}`); + } +}; + +// 验证Passkey注册 +export const VerifyPasskeyRegistrationHandler: RequestHandler = async (req, res) => { + try { + const { response } = req.body; + if (!response) { + return sendError(res, 'Response is required'); + } + + const origin = req.get('origin') || req.protocol + '://' + req.get('host'); + const host = req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + const userId = 'napcat-user'; + const verification = await PasskeyHelper.verifyRegistration(userId, response, origin, rpId); + + if (verification.verified) { + return sendSuccess(res, { verified: true }); + } else { + return sendError(res, 'Registration failed'); + } + } catch (error) { + return sendError(res, `Registration verification failed: ${(error as Error).message}`); + } +}; + +// 生成Passkey认证选项 +export const GeneratePasskeyAuthenticationOptionsHandler: RequestHandler = async (_req, res) => { + try { + const userId = 'napcat-user'; + + if (!(await PasskeyHelper.hasPasskeys(userId))) { + return sendError(res, 'No passkeys registered'); + } + + // 从请求头获取host来确定RP_ID + const host = _req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + + const options = await PasskeyHelper.generateAuthenticationOptions(userId, rpId); + return sendSuccess(res, options); + } catch (error) { + return sendError(res, `Failed to generate authentication options: ${(error as Error).message}`); + } +}; + +// 验证Passkey认证 +export const VerifyPasskeyAuthenticationHandler: RequestHandler = async (req, res) => { + try { + const { response } = req.body; + if (!response) { + return sendError(res, 'Response is required'); + } + + // 获取WebUI配置用于限速检查 + const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); + // 获取客户端IP + const clientIP = req.ip || req.socket.remoteAddress || ''; + + // 检查登录频率 + if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) { + return sendError(res, 'login rate limit'); + } + + const origin = req.get('origin') || req.protocol + '://' + req.get('host'); + const host = req.get('host') || 'localhost'; + const hostname = host.split(':')[0] || 'localhost'; // 移除端口 + // 对于本地开发,使用localhost而不是IP地址 + const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname; + const userId = 'napcat-user'; + const verification = await PasskeyHelper.verifyAuthentication(userId, response, origin, rpId); + + if (verification.verified) { + // 使用与普通登录相同的凭证签发 + const initialToken = getInitialWebUiToken(); + if (!initialToken) { + return sendError(res, 'Server token not initialized'); + } + const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(AuthHelper.generatePasswordHash(initialToken)))).toString('base64'); + return sendSuccess(res, { + Credential: signCredential, + }); + } else { + return sendError(res, 'Authentication failed'); + } + } catch (error) { + return sendError(res, `Authentication verification failed: ${(error as Error).message}`); + } +}; diff --git a/packages/napcat-webui-backend/src/helper/PasskeyHelper.ts b/packages/napcat-webui-backend/src/helper/PasskeyHelper.ts new file mode 100644 index 00000000..cdf4660c --- /dev/null +++ b/packages/napcat-webui-backend/src/helper/PasskeyHelper.ts @@ -0,0 +1,206 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import { isoBase64URL } from '@simplewebauthn/server/helpers'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { webUiPathWrapper } from '../../index'; + +interface PasskeyCredential { + id: string; + publicKey: string; + counter: number; + transports?: AuthenticatorTransportFuture[]; +} + +const RP_NAME = 'NapCat WebUI'; + +export class PasskeyHelper { + private static getPasskeyFilePath (): string { + return path.join(webUiPathWrapper.configPath, 'passkey.json'); + } + + // 内存中存储临时挑战数据 + private static challenges: Map = new Map(); + private static async ensurePasskeyFile (): Promise { + try { + // 确保配置文件目录存在 + const passkeyFile = this.getPasskeyFilePath(); + await fs.mkdir(path.dirname(passkeyFile), { recursive: true }); + // 检查文件是否存在,如果不存在创建空文件 + try { + await fs.access(passkeyFile); + } catch { + await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2)); + } + } catch (error) { + // Directory or file already exists or other error + } + } + + private static async getAllPasskeys (): Promise> { + await this.ensurePasskeyFile(); + try { + const passkeyFile = this.getPasskeyFilePath(); + const data = await fs.readFile(passkeyFile, 'utf-8'); + const passkeys = JSON.parse(data); + return typeof passkeys === 'object' && passkeys !== null ? passkeys : {}; + } catch (error) { + return {}; + } + } + + private static async saveAllPasskeys (allPasskeys: Record): Promise { + await this.ensurePasskeyFile(); + const passkeyFile = this.getPasskeyFilePath(); + await fs.writeFile(passkeyFile, JSON.stringify(allPasskeys, null, 2)); + } + + private static async getUserPasskeys (userId: string): Promise { + const allPasskeys = await this.getAllPasskeys(); + return allPasskeys[userId] || []; + } + + // 持久性存储用户的passkey到统一配置文件 + private static async setUserPasskeys (userId: string, passkeys: PasskeyCredential[]): Promise { + const allPasskeys = await this.getAllPasskeys(); + if (passkeys.length > 0) { + allPasskeys[userId] = passkeys; + } else { + delete allPasskeys[userId]; + } + await this.saveAllPasskeys(allPasskeys); + } + + static async generateRegistrationOptions (userId: string, userName: string, rpId: string) { + const userPasskeys = await this.getUserPasskeys(userId); + + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: rpId, + userID: new TextEncoder().encode(userId), + userName: userName, + attestationType: 'none', + excludeCredentials: userPasskeys.map(passkey => ({ + id: passkey.id, + type: 'public-key' as const, + transports: passkey.transports, + })), + // Temporarily simplify authenticatorSelection - remove residentKey to avoid conflicts + authenticatorSelection: { + userVerification: 'preferred', + }, + }); + + // Store challenge temporarily in memory + this.challenges.set(`reg_${userId}`, options.challenge); + // Auto cleanup after 5 minutes + setTimeout(() => { + this.challenges.delete(`reg_${userId}`); + }, 300000); + + return options; + } + + static async verifyRegistration (userId: string, response: any, origin: string, rpId: string) { + const expectedChallenge = this.challenges.get(`reg_${userId}`); + if (!expectedChallenge) { + throw new Error('Challenge not found or expired'); + } + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + }); + + if (verification.verified && verification.registrationInfo) { + const { registrationInfo } = verification; + + const newPasskey: PasskeyCredential = { + id: registrationInfo.credential.id, + publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey), + counter: registrationInfo.credential.counter || 0, + transports: response.response.transports, + }; + + const userPasskeys = await this.getUserPasskeys(userId); + userPasskeys.push(newPasskey); + await this.setUserPasskeys(userId, userPasskeys); + + // Clean up challenge + this.challenges.delete(`reg_${userId}`); + } + + return verification; + } + + static async generateAuthenticationOptions (userId: string, rpId: string) { + const userPasskeys = await this.getUserPasskeys(userId); + + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials: userPasskeys.map(passkey => ({ + id: passkey.id, + type: 'public-key' as const, + transports: passkey.transports, + })), + userVerification: 'preferred', + }); + + // Store challenge temporarily in memory + this.challenges.set(`auth_${userId}`, options.challenge); + // Auto cleanup after 5 minutes + setTimeout(() => { + this.challenges.delete(`auth_${userId}`); + }, 300000); + + return options; + } + + static async verifyAuthentication (userId: string, response: any, origin: string, rpId: string) { + const expectedChallenge = this.challenges.get(`auth_${userId}`); + if (!expectedChallenge) { + throw new Error('Challenge not found or expired'); + } + + const userPasskeys = await this.getUserPasskeys(userId); + const passkey = userPasskeys.find(p => p.id === response.id); + if (!passkey) { + throw new Error('Passkey not found'); + } + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + credential: { + id: passkey.id, + publicKey: isoBase64URL.toBuffer(passkey.publicKey), + counter: passkey.counter, + }, + }); + + if (verification.verified && verification.authenticationInfo) { + // Update counter + passkey.counter = verification.authenticationInfo.newCounter; + await this.setUserPasskeys(userId, userPasskeys); + + // Clean up challenge + this.challenges.delete(`auth_${userId}`); + } + + return verification; + } + + static async hasPasskeys (userId: string): Promise { + const userPasskeys = await this.getUserPasskeys(userId); + return userPasskeys.length > 0; + } +} \ No newline at end of file diff --git a/packages/napcat-webui-backend/src/middleware/auth.ts b/packages/napcat-webui-backend/src/middleware/auth.ts index c9d3c916..a352076b 100644 --- a/packages/napcat-webui-backend/src/middleware/auth.ts +++ b/packages/napcat-webui-backend/src/middleware/auth.ts @@ -12,6 +12,12 @@ export async function auth (req: Request, res: Response, next: NextFunction) { if (req.url === '/auth/login') { return next(); } + if (req.url === '/auth/passkey/generate-authentication-options' || + req.url === '/auth/passkey/verify-authentication') { + return next(); + } + + // 判断是否有Authorization头 if (req.headers?.authorization) { diff --git a/packages/napcat-webui-backend/src/router/auth.ts b/packages/napcat-webui-backend/src/router/auth.ts index 830cf5a8..5f74e5f2 100644 --- a/packages/napcat-webui-backend/src/router/auth.ts +++ b/packages/napcat-webui-backend/src/router/auth.ts @@ -5,6 +5,10 @@ import { LoginHandler, LogoutHandler, UpdateTokenHandler, + GeneratePasskeyRegistrationOptionsHandler, + VerifyPasskeyRegistrationHandler, + GeneratePasskeyAuthenticationOptionsHandler, + VerifyPasskeyAuthenticationHandler, } from '@/napcat-webui-backend/src/api/Auth'; const router = Router(); @@ -16,5 +20,13 @@ router.post('/check', checkHandler); router.post('/logout', LogoutHandler); // router:更新token router.post('/update_token', UpdateTokenHandler); +// router:生成Passkey注册选项 +router.post('/passkey/generate-registration-options', GeneratePasskeyRegistrationOptionsHandler); +// router:验证Passkey注册 +router.post('/passkey/verify-registration', VerifyPasskeyRegistrationHandler); +// router:生成Passkey认证选项 +router.post('/passkey/generate-authentication-options', GeneratePasskeyAuthenticationOptionsHandler); +// router:验证Passkey认证 +router.post('/passkey/verify-authentication', VerifyPasskeyAuthenticationHandler); export { router as AuthRouter }; diff --git a/packages/napcat-webui-frontend/package.json b/packages/napcat-webui-frontend/package.json index 2f9ed746..a6b59dc4 100644 --- a/packages/napcat-webui-frontend/package.json +++ b/packages/napcat-webui-frontend/package.json @@ -48,6 +48,7 @@ "@monaco-editor/react": "4.7.0-rc.0", "@react-aria/visually-hidden": "^3.8.19", "@reduxjs/toolkit": "^2.5.1", + "@simplewebauthn/browser": "^13.2.2", "@uidotdev/usehooks": "^2.4.1", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", diff --git a/packages/napcat-webui-frontend/src/controllers/webui_manager.ts b/packages/napcat-webui-frontend/src/controllers/webui_manager.ts index 5a8100cb..d5f7b223 100644 --- a/packages/napcat-webui-frontend/src/controllers/webui_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/webui_manager.ts @@ -212,4 +212,35 @@ export default class WebUIManager { ); return data.data; } + + // Passkey相关方法 + public static async generatePasskeyRegistrationOptions () { + const { data } = await serverRequest.post>( + '/auth/passkey/generate-registration-options' + ); + return data.data; + } + + public static async verifyPasskeyRegistration (response: any) { + const { data } = await serverRequest.post>( + '/auth/passkey/verify-registration', + { response } + ); + return data.data; + } + + public static async generatePasskeyAuthenticationOptions () { + const { data } = await serverRequest.post>( + '/auth/passkey/generate-authentication-options' + ); + return data.data; + } + + public static async verifyPasskeyAuthentication (response: any) { + const { data } = await serverRequest.post>( + '/auth/passkey/verify-authentication', + { response } + ); + return data.data; + } } diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/config/webui.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/config/webui.tsx index 81d47da8..47f487bf 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/config/webui.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/config/webui.tsx @@ -1,6 +1,7 @@ import { Input } from '@heroui/input'; +import { Button } from '@heroui/button'; import { useLocalStorage } from '@uidotdev/usehooks'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import toast from 'react-hot-toast'; @@ -14,6 +15,24 @@ import useMusic from '@/hooks/use-music'; import { siteConfig } from '@/config/site'; import FileManager from '@/controllers/file_manager'; +import WebUIManager from '@/controllers/webui_manager'; + +// Base64URL to Uint8Array converter +function base64UrlToUint8Array (base64Url: string): Uint8Array { + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +// Uint8Array to Base64URL converter +function uint8ArrayToBase64Url (uint8Array: Uint8Array): string { + const base64 = window.btoa(String.fromCharCode(...uint8Array)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} const WebUIConfigCard = () => { const { @@ -35,6 +54,25 @@ const WebUIConfigCard = () => { {} ); const { setListId, listId } = useMusic(); + const [registrationOptions, setRegistrationOptions] = useState(null); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + + // 预先获取注册选项(可以在任何时候调用) + const preloadRegistrationOptions = async () => { + setIsLoadingOptions(true); + try { + console.log('预先获取注册选项...'); + const options = await WebUIManager.generatePasskeyRegistrationOptions(); + setRegistrationOptions(options); + console.log('✅ 注册选项已获取并存储'); + toast.success('注册选项已准备就绪,请点击注册按钮'); + } catch (error) { + console.error('❌ 获取注册选项失败:', error); + toast.error('获取注册选项失败,请重试'); + } finally { + setIsLoadingOptions(false); + } + }; const reset = () => { setWebuiValue('musicListID', listId); @@ -125,6 +163,122 @@ const WebUIConfigCard = () => { /> ))} +
+
Passkey认证
+
+ 注册Passkey后,您可以更便捷地登录WebUI,无需每次输入token +
+
+ + +
+ {registrationOptions && ( +
+ ✅ 注册选项已准备就绪,可以开始注册 +
+ )} +
(token || ''); const [isLoading, setIsLoading] = useState(false); + const [isPasskeyLoading, setIsPasskeyLoading] = useState(true); // 初始为true,表示正在检查passkey const [, setLocalToken] = useLocalStorage(key.token, ''); + + // Helper function to decode base64url + function base64UrlToUint8Array (base64Url: string): Uint8Array { + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + // Helper function to encode Uint8Array to base64url + function uint8ArrayToBase64Url (uint8Array: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...uint8Array)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + // 自动检查并尝试passkey登录 + const tryPasskeyLogin = async () => { + try { + // 检查是否有passkey + const options = await WebUIManager.generatePasskeyAuthenticationOptions(); + + // 如果有passkey,自动进行认证 + const credential = await navigator.credentials.get({ + publicKey: { + challenge: base64UrlToUint8Array(options.challenge) as BufferSource, + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + id: base64UrlToUint8Array(cred.id) as BufferSource, + type: cred.type, + transports: cred.transports, + })), + userVerification: options.userVerification, + }, + }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Passkey authentication cancelled'); + } + + // 准备响应进行验证 - 转换为base64url字符串格式 + const authResponse = credential.response as AuthenticatorAssertionResponse; + const response = { + id: credential.id, + rawId: uint8ArrayToBase64Url(new Uint8Array(credential.rawId)), + response: { + authenticatorData: uint8ArrayToBase64Url(new Uint8Array(authResponse.authenticatorData)), + clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(authResponse.clientDataJSON)), + signature: uint8ArrayToBase64Url(new Uint8Array(authResponse.signature)), + userHandle: authResponse.userHandle ? uint8ArrayToBase64Url(new Uint8Array(authResponse.userHandle)) : null, + }, + type: credential.type, + }; + + // 验证认证 + const data = await WebUIManager.verifyPasskeyAuthentication(response); + + if (data && data.Credential) { + setLocalToken(data.Credential); + navigate('/qq_login', { replace: true }); + return true; // 登录成功 + } + } catch (error) { + // passkey登录失败,继续显示token登录界面 + console.log('Passkey login failed or not available:', error); + } + return false; // 登录失败 + }; + const onSubmit = async () => { if (!tokenValue) { toast.error('请输入token'); @@ -48,7 +119,7 @@ export default function WebLoginPage () { // 处理全局键盘事件 const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !isLoading) { + if (e.key === 'Enter' && !isLoading && !isPasskeyLoading) { onSubmit(); } }; @@ -60,12 +131,19 @@ export default function WebLoginPage () { return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [tokenValue, isLoading]); // 依赖项包含用于登录的状态 + }, [tokenValue, isLoading, isPasskeyLoading]); // 依赖项包含用于登录的状态 useEffect(() => { + // 如果URL中有token,直接登录 if (token) { onSubmit(); + return; } + + // 否则尝试passkey自动登录 + tryPasskeyLogin().finally(() => { + setIsPasskeyLoading(false); + }); }, []); return ( @@ -92,6 +170,11 @@ export default function WebLoginPage () { + {isPasskeyLoading && ( +
+ 🔐 正在检查Passkey... +
+ )}
{ e.preventDefault(); @@ -135,7 +218,7 @@ export default function WebLoginPage () { '!cursor-text', ], }} - isDisabled={isLoading} + isDisabled={isLoading || isPasskeyLoading} label='Token' placeholder='请输入token' radius='lg'