feat(desktop): add persistent Device ID with encryption
- Generate UUID-based Device ID on first launch - Store deviceId in auth.json (persists across logins/logouts) - Add double SHA-256 encryption (consistent with Web) - Expose getDeviceId and getDeviceIdHeader IPC methods - Fix callback path to only accept /callback (prevent duplicate toasts) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ae3fee93da
commit
57cb99dbba
2 changed files with 168 additions and 19 deletions
|
|
@ -9,12 +9,12 @@
|
|||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { ipcMain, shell, BrowserWindow } from "electron";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
|
@ -30,27 +30,144 @@ export type { AuthUser };
|
|||
export interface AuthData {
|
||||
sid: string;
|
||||
user: AuthUser;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
// Internal type for the full file structure (deviceId is always present)
|
||||
interface AuthFileData {
|
||||
sid?: string;
|
||||
user?: AuthUser;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device ID - 设备唯一标识
|
||||
// ============================================================================
|
||||
|
||||
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.
|
||||
*/
|
||||
function sha256(text: string): string {
|
||||
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function generateDeviceIdHeader(deviceId: string): string {
|
||||
if (!deviceId || typeof deviceId !== "string") {
|
||||
throw new Error("[Auth] Invalid deviceId for header generation");
|
||||
}
|
||||
|
||||
const hash1 = sha256(deviceId);
|
||||
const hashedDeviceId = hash1.slice(0, 32);
|
||||
|
||||
const hash2 = sha256(hashedDeviceId);
|
||||
return hash2.slice(0, 8) + hashedDeviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw auth file data, handling all edge cases.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
function readAuthFile(): Partial<AuthFileData> | null {
|
||||
try {
|
||||
if (!existsSync(AUTH_FILE_PATH)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = readFileSync(AUTH_FILE_PATH, "utf8").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
if (typeof data !== "object" || data === null) {
|
||||
console.warn("[Auth] Invalid auth file format, ignoring");
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Partial<AuthFileData>;
|
||||
} catch (error) {
|
||||
// JSON parse error or file read error
|
||||
console.error("[Auth] Failed to read auth file:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write auth file data to disk.
|
||||
*/
|
||||
function writeAuthFile(data: Partial<AuthFileData>): boolean {
|
||||
try {
|
||||
mkdirSync(dirname(AUTH_FILE_PATH), { recursive: true });
|
||||
writeFileSync(AUTH_FILE_PATH, JSON.stringify(data, null, 2), "utf8");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to write auth file:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent Device ID.
|
||||
* Device ID persists across logins/logouts - it represents the device, not the user.
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const existing = readAuthFile();
|
||||
|
||||
// If we have a valid deviceId, return it
|
||||
if (existing?.deviceId && typeof existing.deviceId === "string") {
|
||||
return existing.deviceId;
|
||||
}
|
||||
|
||||
// Generate new deviceId and persist it
|
||||
const newDeviceId = generateUUID();
|
||||
console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "...");
|
||||
|
||||
// Preserve any existing auth data while adding deviceId
|
||||
const dataToSave: Partial<AuthFileData> = existing
|
||||
? { ...existing, deviceId: newDeviceId }
|
||||
: { deviceId: newDeviceId };
|
||||
|
||||
if (!writeAuthFile(dataToSave)) {
|
||||
// Write failed, but we can still return the generated ID for this session
|
||||
console.error("[Auth] Failed to persist new Device ID");
|
||||
}
|
||||
|
||||
return newDeviceId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage - 认证数据持久化
|
||||
// ============================================================================
|
||||
|
||||
const AUTH_FILE_PATH = join(DATA_DIR, "auth.json");
|
||||
|
||||
function loadAuthData(): AuthData | null {
|
||||
try {
|
||||
if (!existsSync(AUTH_FILE_PATH)) {
|
||||
return null;
|
||||
}
|
||||
const raw = readFileSync(AUTH_FILE_PATH, "utf8");
|
||||
const data = JSON.parse(raw) as AuthData;
|
||||
const data = readAuthFile();
|
||||
|
||||
if (!data.sid || !data.user?.uid) {
|
||||
if (!data?.sid || !data?.user?.uid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
sid: data.sid,
|
||||
user: data.user,
|
||||
deviceId: data.deviceId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to load auth data:", error);
|
||||
return null;
|
||||
|
|
@ -59,10 +176,14 @@ function loadAuthData(): AuthData | null {
|
|||
|
||||
function saveAuthData(sid: string, user: AuthUser): boolean {
|
||||
try {
|
||||
mkdirSync(dirname(AUTH_FILE_PATH), { recursive: true });
|
||||
// Ensure we have a deviceId (get existing or create new)
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
const data: AuthData = { sid, user };
|
||||
writeFileSync(AUTH_FILE_PATH, JSON.stringify(data, null, 2), "utf8");
|
||||
const data: AuthFileData = { sid, user, deviceId };
|
||||
|
||||
if (!writeAuthFile(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[Auth] Auth data saved successfully");
|
||||
return true;
|
||||
|
|
@ -72,12 +193,25 @@ function saveAuthData(sid: string, user: AuthUser): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear auth data (logout) while preserving Device ID.
|
||||
* Device ID persists across logins - it represents the device, not the user.
|
||||
*/
|
||||
function clearAuthData(): boolean {
|
||||
try {
|
||||
if (existsSync(AUTH_FILE_PATH)) {
|
||||
unlinkSync(AUTH_FILE_PATH);
|
||||
console.log("[Auth] Auth data cleared");
|
||||
// Read existing data to preserve deviceId
|
||||
const existing = readAuthFile();
|
||||
const deviceId = existing?.deviceId || getOrCreateDeviceId();
|
||||
|
||||
// Write back only the deviceId
|
||||
const preserved: Partial<AuthFileData> = { deviceId };
|
||||
|
||||
if (!writeAuthFile(preserved)) {
|
||||
console.error("[Auth] Failed to preserve Device ID during logout");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[Auth] Auth data cleared (Device ID preserved)");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to clear auth data:", error);
|
||||
|
|
@ -167,8 +301,8 @@ async function createLocalServerSession(): Promise<number> {
|
|||
try {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
|
||||
// 处理回调请求
|
||||
if (url.pathname === "/callback" || url.pathname === "/") {
|
||||
// 处理回调请求(只接受 /callback 路径)
|
||||
if (url.pathname === "/callback") {
|
||||
const sid = url.searchParams.get("sid");
|
||||
const userJson = url.searchParams.get("user");
|
||||
|
||||
|
|
@ -352,4 +486,15 @@ export function registerAuthHandlers(): void {
|
|||
ipcMain.handle("auth:startLogin", () => {
|
||||
return startLogin();
|
||||
});
|
||||
|
||||
// 获取 Device ID(原始值)
|
||||
ipcMain.handle("auth:getDeviceId", () => {
|
||||
return getOrCreateDeviceId();
|
||||
});
|
||||
|
||||
// 获取加密后的 Device-Id header 值
|
||||
ipcMain.handle("auth:getDeviceIdHeader", () => {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
return generateDeviceIdHeader(deviceId);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ const electronAPI = {
|
|||
// Auth management
|
||||
auth: {
|
||||
/** Load auth data from local file */
|
||||
load: (): Promise<{ sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number } } | null> =>
|
||||
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> =>
|
||||
|
|
@ -150,6 +150,10 @@ const electronAPI = {
|
|||
clear: (): Promise<boolean> => ipcRenderer.invoke('auth:clear'),
|
||||
/** Start login flow (opens browser) */
|
||||
startLogin: (): Promise<void> => ipcRenderer.invoke('auth:startLogin'),
|
||||
/** Get Device ID (raw UUID) */
|
||||
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) => {
|
||||
ipcRenderer.on('auth:callback', (_event, data) => callback(data))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue