Merge pull request #162 from multica-ai/feat/desktop-device-id
feat(auth): Desktop Device ID from Web login
This commit is contained in:
commit
adfff8366f
8 changed files with 118 additions and 92 deletions
|
|
@ -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<AuthFileData>): 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<AuthFileData> = 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<number> {
|
|||
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<number> {
|
|||
});
|
||||
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<number> {
|
|||
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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> =>
|
||||
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<boolean> =>
|
||||
ipcRenderer.invoke('auth:save', sid, user, deviceId),
|
||||
/** Clear auth data (logout) */
|
||||
clear: (): Promise<boolean> => ipcRenderer.invoke('auth:clear'),
|
||||
/** Start login flow (opens browser) */
|
||||
|
|
@ -154,8 +154,8 @@ const electronAPI = {
|
|||
getDeviceId: (): Promise<string> => ipcRenderer.invoke('auth:getDeviceId'),
|
||||
/** Get encrypted Device-Id header value for API requests */
|
||||
getDeviceIdHeader: (): Promise<string> => 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 */
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -108,14 +108,14 @@ export const useAuthStore = create<AuthState>((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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import ChatPage from "@/components/pages/chat-page";
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<ChatPage />
|
||||
</AuthGuard>
|
||||
);
|
||||
return <ChatPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
const hashedDeviceId = (await sha256(deviceId)).slice(0, 32)
|
||||
return (await sha256(hashedDeviceId)).slice(0, 8) + hashedDeviceId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
// Get or generate Device ID
|
||||
// Get or generate Device ID, encrypt for header
|
||||
let deviceIdHeader = '';
|
||||
let sid: string | null = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue