feat(desktop): 支持桌面端三十天免登录
This commit is contained in:
+54
-11
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from dataclasses import dataclass
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -70,12 +71,50 @@ AVATAR_ALLOWED_CONTENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def issue_user_token(db_user) -> Token:
|
||||
@dataclass(frozen=True)
|
||||
class SessionPolicy:
|
||||
access_minutes: int
|
||||
absolute_max_seconds: int
|
||||
|
||||
|
||||
def normalize_session_client_type(value: str | None) -> str:
|
||||
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
|
||||
|
||||
|
||||
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
|
||||
if client_type == "desktop":
|
||||
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
|
||||
return SessionPolicy(
|
||||
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
|
||||
absolute_max_seconds=max_seconds,
|
||||
)
|
||||
return SessionPolicy(
|
||||
access_minutes=settings.JWT_EXPIRE_MINUTES,
|
||||
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
|
||||
)
|
||||
|
||||
|
||||
def get_request_session_client_type(request: Request) -> str:
|
||||
return normalize_session_client_type(request.headers.get("x-ctms-client-type"))
|
||||
|
||||
|
||||
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
|
||||
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
|
||||
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
|
||||
return min(access_expires_at, session_expires_at)
|
||||
|
||||
|
||||
def issue_user_token(db_user, request: Request) -> Token:
|
||||
session_start = datetime.now(timezone.utc)
|
||||
client_type = get_request_session_client_type(request)
|
||||
policy = get_session_policy_for_client_type(client_type)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
expires_minutes=None,
|
||||
expires_minutes=policy.access_minutes,
|
||||
session_start=session_start,
|
||||
max_age_seconds=policy.absolute_max_seconds,
|
||||
issued_at=session_start,
|
||||
client_type=client_type,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
@@ -240,23 +279,23 @@ async def get_login_key() -> LoginKeyResponse:
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
db_user = await authenticate_encrypted_password(payload, db)
|
||||
ensure_user_active(db_user)
|
||||
|
||||
return issue_user_token(db_user)
|
||||
return issue_user_token(db_user, request)
|
||||
|
||||
|
||||
@router.post("/dev-login", response_model=Token)
|
||||
async def dev_login_for_access_token(
|
||||
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
payload: DevLoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
if settings.ENV != "development":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
db_user = await authenticate_plain_password(payload, db)
|
||||
ensure_user_active(db_user)
|
||||
return issue_user_token(db_user)
|
||||
return issue_user_token(db_user, request)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
@@ -285,19 +324,23 @@ async def extend_access_token(
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
||||
session_start_ts = payload.get("orig_iat") or payload.get("iat")
|
||||
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
|
||||
if session_start_ts:
|
||||
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
|
||||
if now_ts - int(session_start_ts) > max_seconds:
|
||||
if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
|
||||
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
|
||||
else:
|
||||
session_start = datetime.now(timezone.utc)
|
||||
issued_at = datetime.now(timezone.utc)
|
||||
new_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
expires_minutes=None,
|
||||
expires_minutes=policy.access_minutes,
|
||||
session_start=session_start,
|
||||
max_age_seconds=policy.absolute_max_seconds,
|
||||
issued_at=issued_at,
|
||||
client_type=normalize_session_client_type(payload.get("client_type")),
|
||||
)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
expires_at = policy_expires_at(issued_at, session_start, policy)
|
||||
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
||||
JWT_EXPIRE_MINUTES: int = 60
|
||||
JWT_EXTEND_GRACE_SECONDS: int = 120
|
||||
ABSOLUTE_SESSION_MAX_HOURS: int = 8
|
||||
DESKTOP_SESSION_MAX_DAYS: int = 30
|
||||
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
|
||||
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
|
||||
LOGIN_RSA_KEY_ID: str = "default"
|
||||
|
||||
@@ -19,16 +19,29 @@ def create_access_token(
|
||||
user_id: str,
|
||||
expires_minutes: Optional[int] = None,
|
||||
session_start: Optional[datetime] = None,
|
||||
max_age_seconds: Optional[int] = None,
|
||||
issued_at: Optional[datetime] = None,
|
||||
client_type: Optional[str] = None,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
now = issued_at or datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
|
||||
session_start_time = session_start or now
|
||||
if session_start_time.tzinfo is None:
|
||||
session_start_time = session_start_time.replace(tzinfo=timezone.utc)
|
||||
if max_age_seconds is not None:
|
||||
session_expire = session_start_time + timedelta(seconds=max_age_seconds)
|
||||
if expire > session_expire:
|
||||
expire = session_expire
|
||||
to_encode: Dict[str, Any] = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"iat": int(now.timestamp()),
|
||||
"orig_iat": int(session_start_time.timestamp()),
|
||||
}
|
||||
if client_type:
|
||||
to_encode["client_type"] = client_type
|
||||
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import os
|
||||
from app.main import create_app
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_db_session
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.core.security import create_access_token, decode_token_allow_expired, hash_password, verify_password
|
||||
from app.crud import user as user_crud
|
||||
from app.db.base_class import Base
|
||||
from app.models.audit_log import AuditLog
|
||||
@@ -422,6 +422,74 @@ async def test_registered_user_can_login_after_email_verification(client_and_db)
|
||||
assert resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_desktop_login_uses_30_day_token_without_changing_web_login(client_and_db):
|
||||
client, _ = client_and_db
|
||||
|
||||
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
|
||||
desktop_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json=await encrypted_auth_payload(client, "admin@test.com", "admin123"),
|
||||
headers={"X-CTMS-Client-Type": "desktop"},
|
||||
)
|
||||
|
||||
assert web_resp.status_code == 200
|
||||
assert desktop_resp.status_code == 200
|
||||
web_payload = decode_token_allow_expired(web_resp.json()["access_token"])
|
||||
desktop_payload = decode_token_allow_expired(desktop_resp.json()["access_token"])
|
||||
assert web_payload["client_type"] == "web"
|
||||
assert desktop_payload["client_type"] == "desktop"
|
||||
assert web_payload["exp"] - web_payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
|
||||
assert desktop_payload["exp"] - desktop_payload["iat"] == settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_token_extension_cannot_be_upgraded_with_desktop_header(client_and_db):
|
||||
client, _ = client_and_db
|
||||
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
|
||||
token = web_resp.json()["access_token"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/extend",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-CTMS-Client-Type": "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
payload = decode_token_allow_expired(resp.json()["accessToken"])
|
||||
assert payload["client_type"] == "web"
|
||||
assert payload["exp"] - payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_desktop_token_extension_rejects_sessions_after_30_days(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
async with SessionLocal() as session:
|
||||
admin = await user_crud.get_by_email(session, "admin@test.com")
|
||||
|
||||
session_start = datetime.now(timezone.utc) - timedelta(days=settings.DESKTOP_SESSION_MAX_DAYS, seconds=1)
|
||||
token = create_access_token(
|
||||
user_id=str(admin.id),
|
||||
expires_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
|
||||
session_start=session_start,
|
||||
max_age_seconds=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600,
|
||||
client_type="desktop",
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/extend",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-CTMS-Client-Type": "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "会话已到期" in resp.json().get("detail", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_created_user_is_active_by_default(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
|
||||
@@ -63,6 +63,7 @@ npm run desktop:build:app
|
||||
| 场景 | Web | macOS Desktop | 预期 |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
|
||||
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
||||
@@ -80,6 +81,7 @@ npm run desktop:build:app
|
||||
## 4. 桌面体验验收
|
||||
|
||||
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
|
||||
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。
|
||||
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
|
||||
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
|
||||
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
|
||||
|
||||
@@ -34,13 +34,15 @@
|
||||
- macOS:Keychain。
|
||||
- Windows:Credential Manager。
|
||||
|
||||
桌面端登录使用后端签发的在线会话 token,可在系统凭据库中保存最长 30 天,以支持重启 App 后免输入密码。启动恢复后仍必须使用后端 token 校验和 `/auth/me` 用户状态校验;服务端不可达或会话被后端拒绝时不得进入离线模式。
|
||||
|
||||
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
|
||||
|
||||
应用挂载前异步初始化 token:
|
||||
|
||||
1. Web 端继续读取 `localStorage.ctms_token`。
|
||||
2. 桌面端先删除 legacy `localStorage.ctms_token`。
|
||||
3. 若 legacy token 仍有效,则迁移到系统凭据库。
|
||||
3. 若 legacy token 仍有效,则迁移为带 30 天本机到期时间的系统凭据库会话记录。
|
||||
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
|
||||
|
||||
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
||||
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
||||
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
||||
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
|
||||
- `files`:隔离浏览器上传下载与原生文件能力。
|
||||
- `notifications`:隔离 Web 通知与桌面系统通知。
|
||||
- `updates`:隔离桌面自动更新检查与安装入口。
|
||||
|
||||
@@ -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