feat(desktop): 支持桌面端三十天免登录

This commit is contained in:
Cheng Zhou
2026-07-02 09:06:24 +08:00
parent 46f488c8ae
commit f33cc0948e
9 changed files with 334 additions and 19 deletions
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
import {
getSessionToken,
initializeSecureSessionStorage,
resetSecureSessionStorageForTests,
setSessionToken,
} from "./secureSessionStorage";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
const SERVER_ORIGIN = "https://ctms.example.com/";
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const encodeJson = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
const createJwt = (expiresAtMs: number): string =>
`${encodeJson({ alg: "none", typ: "JWT" })}.${encodeJson({ exp: Math.floor(expiresAtMs / 1000) })}.signature`;
const createStorage = (): Storage => {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear: () => data.clear(),
getItem: (key) => data.get(key) ?? null,
key: (index) => Array.from(data.keys())[index] ?? null,
removeItem: (key) => data.delete(key),
setItem: (key, value) => {
data.set(key, String(value));
},
};
};
describe("secure session storage", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
resetSecureSessionStorageForTests();
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
it("stores desktop tokens as a 30 day secure session record", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
await setSessionToken(token);
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
serverOrigin: SERVER_ORIGIN,
token: expect.any(String),
});
const stored = JSON.parse(invokeMock.mock.calls[0][1].token);
expect(stored).toMatchObject({ version: 1, token });
expect(stored.expiresAt - stored.storedAt).toBe(DESKTOP_SESSION_MAX_AGE_MS);
});
it("restores a valid desktop secure session record on startup", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now(),
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
});
}
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledWith("credential_get", { serverOrigin: SERVER_ORIGIN });
});
it("deletes an expired desktop secure session record on startup", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000,
expiresAt: Date.now() - 1_000,
});
}
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBeNull();
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("rewrites a legacy raw desktop token into a secure session record", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") return token;
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
serverOrigin: SERVER_ORIGIN,
token: expect.stringContaining(token),
});
});
});
+61 -5
View File
@@ -2,6 +2,13 @@ import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
const LEGACY_TOKEN_KEY = "ctms_token";
const DESKTOP_SESSION_RECORD_VERSION = 1;
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
type StoredSessionToken = {
token: string;
needsRewrite: boolean;
};
let cachedToken: string | null = null;
let initialized = false;
@@ -29,6 +36,57 @@ const isUsableJwt = (token: string): boolean => {
}
};
const serializeSessionToken = (token: string, now: number = Date.now()): string =>
JSON.stringify({
version: DESKTOP_SESSION_RECORD_VERSION,
token,
storedAt: now,
expiresAt: now + DESKTOP_SESSION_MAX_AGE_MS,
});
const parseStoredSessionToken = (stored: string | null, now: number = Date.now()): StoredSessionToken | null => {
if (!stored) return null;
try {
const payload = JSON.parse(stored) as {
version?: unknown;
token?: unknown;
storedAt?: unknown;
expiresAt?: unknown;
};
if (payload.version !== DESKTOP_SESSION_RECORD_VERSION || typeof payload.token !== "string") {
return null;
}
if (
typeof payload.storedAt !== "number" ||
typeof payload.expiresAt !== "number" ||
!Number.isFinite(payload.storedAt) ||
!Number.isFinite(payload.expiresAt)
) {
return null;
}
const localExpiresAt = Math.min(payload.expiresAt, payload.storedAt + DESKTOP_SESSION_MAX_AGE_MS);
if (localExpiresAt <= now || !isUsableJwt(payload.token)) return null;
return { token: payload.token, needsRewrite: false };
} catch {
return isUsableJwt(stored) ? { token: stored, needsRewrite: true } : null;
}
};
const readCredentialToken = async (serverOrigin: string): Promise<string | null> => {
const stored = await invokeCredential<string | null>("credential_get", { serverOrigin });
const parsed = parseStoredSessionToken(stored);
if (parsed?.needsRewrite) {
await invokeCredential<void>("credential_set", {
serverOrigin,
token: serializeSessionToken(parsed.token),
});
}
if (!parsed && stored) {
await invokeCredential<void>("credential_delete", { serverOrigin });
}
return parsed?.token ?? null;
};
export const initializeSecureSessionStorage = async (): Promise<void> => {
if (initialized) return;
initialized = true;
@@ -52,13 +110,11 @@ export const initializeSecureSessionStorage = async (): Promise<void> => {
if (legacyToken && isUsableJwt(legacyToken)) {
await invokeCredential<void>("credential_set", {
serverOrigin: activeServerOrigin,
token: legacyToken,
token: serializeSessionToken(legacyToken),
});
cachedToken = legacyToken;
} else {
cachedToken = await invokeCredential<string | null>("credential_get", {
serverOrigin: activeServerOrigin,
});
cachedToken = await readCredentialToken(activeServerOrigin);
}
secureStorageAvailable = true;
} catch (error) {
@@ -87,7 +143,7 @@ export const setSessionToken = async (token: string): Promise<void> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
await invokeCredential<void>("credential_set", { serverOrigin, token });
await invokeCredential<void>("credential_set", { serverOrigin, token: serializeSessionToken(token) });
activeServerOrigin = serverOrigin;
cachedToken = token;
initialized = true;