diff --git a/apps/desktop/src/main/ipc/auth.ts b/apps/desktop/src/main/ipc/auth.ts index 54681c12..200e6edd 100644 --- a/apps/desktop/src/main/ipc/auth.ts +++ b/apps/desktop/src/main/ipc/auth.ts @@ -46,13 +46,6 @@ interface AuthFileData { const AUTH_FILE_PATH = join(DATA_DIR, "auth.json"); -/** - * Generate a UUID v4 for device identification. - */ -function generateUUID(): string { - return crypto.randomUUID(); -} - /** * SHA-256 hash function. */ @@ -61,22 +54,27 @@ function sha256(text: string): string { } /** - * Generate encrypted Device-Id header value. - * Algorithm (consistent with Web): - * 1. SHA-256 hash of deviceId, take first 32 chars - * 2. SHA-256 hash of step 1 result, take first 8 chars - * 3. Return: step2[0:8] + step1[0:32] = 40 chars + * Generate encrypted Device ID. + * Algorithm (consistent with devv-sdk and Web): + * 1. Generate UUID + * 2. SHA-256 hash of UUID, take first 32 chars + * 3. SHA-256 hash of step 2 result, take first 8 chars + * 4. Return: step3[0:8] + step2[0:32] = 40 chars + * + * This encrypted format is stored directly (not the raw UUID). */ -export function generateDeviceIdHeader(deviceId: string): string { - if (!deviceId || typeof deviceId !== "string") { - throw new Error("[Auth] Invalid deviceId for header generation"); - } +function generateEncryptedDeviceId(): string { + const uuid = crypto.randomUUID(); + const firstHash = sha256(uuid).slice(0, 32); + const finalId = sha256(firstHash).slice(0, 8) + firstHash; + return finalId; +} - const hash1 = sha256(deviceId); - const hashedDeviceId = hash1.slice(0, 32); - - const hash2 = sha256(hashedDeviceId); - return hash2.slice(0, 8) + hashedDeviceId; +/** + * Validate device ID format (40 hex characters). + */ +function isValidDeviceId(deviceId: string): boolean { + return typeof deviceId === "string" && /^[a-f0-9]{40}$/i.test(deviceId); } /** @@ -125,20 +123,26 @@ function writeAuthFile(data: Partial): boolean { /** * Get or create a persistent Device ID. * Device ID persists across logins/logouts - it represents the device, not the user. + * The stored value is already encrypted (40 hex chars), not the raw UUID. */ export function getOrCreateDeviceId(): string { const existing = readAuthFile(); - // If we have a valid deviceId, return it - if (existing?.deviceId && typeof existing.deviceId === "string") { + // If we have a valid encrypted deviceId (40 hex chars), return it + if (existing?.deviceId && isValidDeviceId(existing.deviceId)) { return existing.deviceId; } - // Generate new deviceId and persist it - const newDeviceId = generateUUID(); + // Generate new encrypted deviceId + const newDeviceId = generateEncryptedDeviceId(); console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "..."); - // Preserve any existing auth data while adding deviceId + // If there was an old-format deviceId (UUID), we'll replace it + if (existing?.deviceId && !isValidDeviceId(existing.deviceId)) { + console.log("[Auth] Migrating old-format Device ID to encrypted format"); + } + + // Preserve any existing auth data while adding/updating deviceId const dataToSave: Partial = existing ? { ...existing, deviceId: newDeviceId } : { deviceId: newDeviceId }; @@ -174,10 +178,26 @@ function loadAuthData(): AuthData | null { } } -function saveAuthData(sid: string, user: AuthUser): boolean { +/** + * Save auth data to disk. + * @param sid Session ID + * @param user User info + * @param passedDeviceId Optional Device ID from Web browser (encrypted 40-char format). + * If provided and valid, use it; otherwise fall back to local Device ID. + */ +function saveAuthData(sid: string, user: AuthUser, passedDeviceId?: string): boolean { try { - // Ensure we have a deviceId (get existing or create new) - const deviceId = getOrCreateDeviceId(); + // Use passed deviceId from Web if valid, otherwise use local one + let deviceId: string; + if (passedDeviceId && isValidDeviceId(passedDeviceId)) { + deviceId = passedDeviceId; + console.log("[Auth] Using Device ID from Web browser:", deviceId.slice(0, 8) + "..."); + } else { + deviceId = getOrCreateDeviceId(); + if (passedDeviceId) { + console.warn("[Auth] Invalid Device ID from Web, using local:", passedDeviceId); + } + } const data: AuthFileData = { sid, user, deviceId }; @@ -305,6 +325,7 @@ async function createLocalServerSession(): Promise { if (url.pathname === "/callback") { const sid = url.searchParams.get("sid"); const userJson = url.searchParams.get("user"); + const deviceId = url.searchParams.get("deviceId"); // 返回成功页面 res.writeHead(200, { @@ -313,7 +334,7 @@ async function createLocalServerSession(): Promise { }); res.end(callbackHtml); - console.log("[Auth] Parsed params:", { sid, userJson }); + console.log("[Auth] Parsed params:", { sid, userJson, deviceId: deviceId?.slice(0, 8) + "..." }); if (sid && userJson) { try { @@ -322,17 +343,15 @@ async function createLocalServerSession(): Promise { console.log("[Auth] Received auth callback:", { sid: sid.substring(0, 8) + "...", user: user.name, + deviceId: deviceId ? deviceId.slice(0, 8) + "..." : "not provided", }); - // 保存认证数据 - saveAuthData(sid, user); + // 保存认证数据(使用 Web 传递的 deviceId) + saveAuthData(sid, user, deviceId || undefined); // 通知渲染进程 - console.log("[Auth] mainWindowRef:", mainWindowRef ? "exists" : "null"); if (mainWindowRef && !mainWindowRef.isDestroyed()) { - console.log("[Auth] Sending auth:callback to renderer..."); - mainWindowRef.webContents.send("auth:callback", { sid, user }); - console.log("[Auth] auth:callback sent!"); + mainWindowRef.webContents.send("auth:callback", { sid, user, deviceId }); // 聚焦窗口 if (mainWindowRef.isMinimized()) mainWindowRef.restore(); mainWindowRef.focus(); @@ -430,7 +449,7 @@ export function handleAuthDeepLink(url: string): void { return; } - // multica://auth?sid=xxx&user=xxx + // multica://auth?sid=xxx&user=xxx&deviceId=xxx if ( parsedUrl.host === "auth" || parsedUrl.pathname === "//auth" || @@ -438,20 +457,22 @@ export function handleAuthDeepLink(url: string): void { ) { const sid = parsedUrl.searchParams.get("sid"); const userJson = parsedUrl.searchParams.get("user"); + const deviceId = parsedUrl.searchParams.get("deviceId"); if (sid && userJson) { const user = JSON.parse(decodeURIComponent(userJson)) as AuthUser; console.log("[Auth] Deep link auth received:", { sid: sid.substring(0, 8) + "...", user: user.name, + deviceId: deviceId ? deviceId.slice(0, 8) + "..." : "not provided", }); - // 保存认证数据 - saveAuthData(sid, user); + // 保存认证数据(使用 Web 传递的 deviceId) + saveAuthData(sid, user, deviceId || undefined); // 通知渲染进程 if (mainWindowRef && !mainWindowRef.isDestroyed()) { - mainWindowRef.webContents.send("auth:callback", { sid, user }); + mainWindowRef.webContents.send("auth:callback", { sid, user, deviceId }); if (mainWindowRef.isMinimized()) mainWindowRef.restore(); mainWindowRef.focus(); } @@ -472,9 +493,9 @@ export function registerAuthHandlers(): void { return loadAuthData(); }); - // 保存认证数据 - ipcMain.handle("auth:save", (_, sid: string, user: AuthUser) => { - return saveAuthData(sid, user); + // 保存认证数据(支持传入 deviceId) + ipcMain.handle("auth:save", (_, sid: string, user: AuthUser, deviceId?: string) => { + return saveAuthData(sid, user, deviceId); }); // 清除认证数据(登出) @@ -487,14 +508,13 @@ export function registerAuthHandlers(): void { return startLogin(); }); - // 获取 Device ID(原始值) + // 获取 Device ID(已加密的 40 字符格式) ipcMain.handle("auth:getDeviceId", () => { return getOrCreateDeviceId(); }); - // 获取加密后的 Device-Id header 值 + // 获取 Device-Id header 值(与 getDeviceId 相同,已加密) ipcMain.handle("auth:getDeviceIdHeader", () => { - const deviceId = getOrCreateDeviceId(); - return generateDeviceIdHeader(deviceId); + return getOrCreateDeviceId(); }); } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index af3f7d47..e317dc33 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -143,9 +143,9 @@ const electronAPI = { /** Load auth data from local file */ load: (): Promise<{ sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number }; deviceId?: string } | null> => ipcRenderer.invoke('auth:load'), - /** Save auth data to local file */ - save: (sid: string, user: { uid: string; name: string; email?: string; icon?: string; vip?: number }): Promise => - ipcRenderer.invoke('auth:save', sid, user), + /** Save auth data to local file (with optional deviceId from Web) */ + save: (sid: string, user: { uid: string; name: string; email?: string; icon?: string; vip?: number }, deviceId?: string): Promise => + ipcRenderer.invoke('auth:save', sid, user, deviceId), /** Clear auth data (logout) */ clear: (): Promise => ipcRenderer.invoke('auth:clear'), /** Start login flow (opens browser) */ @@ -154,8 +154,8 @@ const electronAPI = { getDeviceId: (): Promise => ipcRenderer.invoke('auth:getDeviceId'), /** Get encrypted Device-Id header value for API requests */ getDeviceIdHeader: (): Promise => ipcRenderer.invoke('auth:getDeviceIdHeader'), - /** Listen for auth callback */ - onAuthCallback: (callback: (data: { sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number } }) => void) => { + /** Listen for auth callback (includes deviceId from Web browser) */ + onAuthCallback: (callback: (data: { sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number }; deviceId?: string }) => void) => { ipcRenderer.on('auth:callback', (_event, data) => callback(data)) }, /** Remove auth callback listener */ diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 68a23c62..73213be9 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -88,16 +88,15 @@ export default function App() { const setCompleted = useOnboardingStore((s) => s.setCompleted) useEffect(() => { - let cleanupAuth: (() => void) | undefined + // Setup auth callback listener BEFORE async operations + // This ensures cleanup works correctly in React Strict Mode + const cleanupAuth = setupAuthCallbackListener() async function hydrateState() { try { // Load auth state first await useAuthStore.getState().loadAuth() - // Setup auth callback listener - cleanupAuth = setupAuthCallbackListener() - // Load onboarding state const completed = await window.electronAPI.appState.getOnboardingCompleted() setCompleted(completed) @@ -119,7 +118,7 @@ export default function App() { useCronJobsStore.getState().fetch() return () => { - cleanupAuth?.() + cleanupAuth() } }, [setCompleted]) diff --git a/apps/desktop/src/renderer/src/stores/auth.ts b/apps/desktop/src/renderer/src/stores/auth.ts index 2dd54121..129c253e 100644 --- a/apps/desktop/src/renderer/src/stores/auth.ts +++ b/apps/desktop/src/renderer/src/stores/auth.ts @@ -108,14 +108,14 @@ export const useAuthStore = create((set) => ({ */ export function setupAuthCallbackListener(): () => void { window.electronAPI.auth.onAuthCallback(async (data) => { - console.log('[AuthStore] Received auth callback:', data) if (data.sid && data.user) { useAuthStore.setState({ sid: data.sid, user: data.user, isAuthenticated: true, }) - toast.success(`Welcome back, ${data.user.name}`) + // Use a fixed ID to prevent duplicate toasts + toast.success(`Welcome back, ${data.user.name}`, { id: 'auth-welcome' }) } }) diff --git a/apps/web/app/login/login-form.tsx b/apps/web/app/login/login-form.tsx index 8b92830c..e8a016f6 100644 --- a/apps/web/app/login/login-form.tsx +++ b/apps/web/app/login/login-form.tsx @@ -10,6 +10,7 @@ import { MulticaIcon } from '@multica/ui/components/multica-icon' import { LoginAuthType, UserInfo } from '@/lib/interface' import { saveSession, isAuthenticated } from '@/lib/auth' import { userLogin } from '@/service/user' +import { getOrCreateDeviceId, generateDeviceIdHeader } from '@/lib/device' type LoginStep = 'email' | 'code' @@ -103,7 +104,7 @@ export function LoginForm() { }, [countdown]) // Handle login success - const handleLoginSuccess = (sid: string, user: UserInfo) => { + const handleLoginSuccess = async (sid: string, user: UserInfo) => { // Save session to cookie for web app saveSession(sid, user) @@ -114,9 +115,14 @@ export function LoginForm() { const port = nextUrl.searchParams.get('port') const platform = nextUrl.searchParams.get('platform') || 'web' + // Get Device ID and encrypt for Desktop + const rawDeviceId = getOrCreateDeviceId() + const deviceId = await generateDeviceIdHeader(rawDeviceId) + const params = new URLSearchParams({ sid, user: JSON.stringify(user), + deviceId, }) if (platform === 'web' && port) { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index dada2a25..50d9c193 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,10 +1,5 @@ import ChatPage from "@/components/pages/chat-page"; -import { AuthGuard } from "@/components/auth-guard"; export default function Page() { - return ( - - - - ); + return ; } diff --git a/apps/web/lib/device.ts b/apps/web/lib/device.ts index a19ceabc..299179a0 100644 --- a/apps/web/lib/device.ts +++ b/apps/web/lib/device.ts @@ -1,36 +1,42 @@ -const DEVICE_ID = 'MULTICA_DEVICE_ID'; +/** + * Device ID management for Multica Web + * Consistent with copilot-search: stores raw UUID, encrypts when transmitting + */ + +const DEVICE_ID_KEY = 'MULTICA_DEVICE_ID' // SHA-256 hash function (using Web Crypto API) async function sha256(text: string): Promise { - const buffer = new TextEncoder().encode(text); - const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + const buffer = new TextEncoder().encode(text) + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } -// Generate Device-Id header -export async function generateDeviceIdHeader(deviceId: string): Promise { - // First hash, take first 32 chars - const hash1 = await sha256(deviceId); - const hashedDeviceId = hash1.slice(0, 32); - - // Second hash, take first 8 chars - const hash2 = await sha256(hashedDeviceId); - const finalDeviceId = hash2.slice(0, 8) + hashedDeviceId; - - return finalDeviceId; -} - -// Get or create Device ID +/** + * Get or create Device ID (raw UUID format) + * Stored in localStorage, encrypted only when transmitting + */ export function getOrCreateDeviceId(): string { - if (typeof window === 'undefined') return ''; + if (typeof window === 'undefined') return '' - let deviceId = localStorage.getItem(DEVICE_ID); + let deviceId = localStorage.getItem(DEVICE_ID_KEY) if (!deviceId) { - deviceId = crypto.randomUUID(); - localStorage.setItem(DEVICE_ID, deviceId); + deviceId = crypto.randomUUID() + localStorage.setItem(DEVICE_ID_KEY, deviceId) } - return deviceId; + return deviceId +} + +/** + * Generate encrypted Device-Id header value + * Algorithm (consistent with copilot-search): + * 1. sha256(uuid).slice(0, 32) = hashedDeviceId + * 2. sha256(hashedDeviceId).slice(0, 8) + hashedDeviceId = 40 chars + */ +export async function generateDeviceIdHeader(deviceId: string): Promise { + const hashedDeviceId = (await sha256(deviceId)).slice(0, 32) + return (await sha256(hashedDeviceId)).slice(0, 8) + hashedDeviceId } diff --git a/apps/web/service/request.ts b/apps/web/service/request.ts index e4ac3921..9c97b5c1 100644 --- a/apps/web/service/request.ts +++ b/apps/web/service/request.ts @@ -1,10 +1,10 @@ import { API_HOST } from '@/lib/constant'; -import { generateDeviceIdHeader, getOrCreateDeviceId } from '@/lib/device'; +import { getOrCreateDeviceId, generateDeviceIdHeader } from '@/lib/device'; import { getSid } from '@/lib/auth'; // Fetch request wrapper export async function request(url: string, options: RequestInit = {}): Promise { - // Get or generate Device ID + // Get or generate Device ID, encrypt for header let deviceIdHeader = ''; let sid: string | null = null; if (typeof window !== 'undefined') {