feat(desktop): 支持桌面端三十天免登录
This commit is contained in:
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user