feat(desktop): 支持桌面端三十天免登录
This commit is contained in:
+54
-11
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
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 fastapi import File, UploadFile
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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)
|
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(
|
access_token = create_access_token(
|
||||||
user_id=str(db_user.id),
|
user_id=str(db_user.id),
|
||||||
expires_minutes=None,
|
expires_minutes=policy.access_minutes,
|
||||||
session_start=session_start,
|
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")
|
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)
|
@router.post("/login", response_model=Token)
|
||||||
async def login_for_access_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:
|
) -> Token:
|
||||||
db_user = await authenticate_encrypted_password(payload, db)
|
db_user = await authenticate_encrypted_password(payload, db)
|
||||||
ensure_user_active(db_user)
|
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)
|
@router.post("/dev-login", response_model=Token)
|
||||||
async def dev_login_for_access_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:
|
) -> Token:
|
||||||
if settings.ENV != "development":
|
if settings.ENV != "development":
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||||
db_user = await authenticate_plain_password(payload, db)
|
db_user = await authenticate_plain_password(payload, db)
|
||||||
ensure_user_active(db_user)
|
ensure_user_active(db_user)
|
||||||
return issue_user_token(db_user)
|
return issue_user_token(db_user, request)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserRead)
|
@router.get("/me", response_model=UserRead)
|
||||||
@@ -285,19 +324,23 @@ async def extend_access_token(
|
|||||||
if db_user.status != UserStatus.ACTIVE:
|
if db_user.status != UserStatus.ACTIVE:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
||||||
session_start_ts = payload.get("orig_iat") or payload.get("iat")
|
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:
|
if session_start_ts:
|
||||||
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
|
if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
|
||||||
if now_ts - int(session_start_ts) > max_seconds:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
|
||||||
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
|
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
|
||||||
else:
|
else:
|
||||||
session_start = datetime.now(timezone.utc)
|
session_start = datetime.now(timezone.utc)
|
||||||
|
issued_at = datetime.now(timezone.utc)
|
||||||
new_token = create_access_token(
|
new_token = create_access_token(
|
||||||
user_id=str(db_user.id),
|
user_id=str(db_user.id),
|
||||||
expires_minutes=None,
|
expires_minutes=policy.access_minutes,
|
||||||
session_start=session_start,
|
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)
|
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
|||||||
JWT_EXPIRE_MINUTES: int = 60
|
JWT_EXPIRE_MINUTES: int = 60
|
||||||
JWT_EXTEND_GRACE_SECONDS: int = 120
|
JWT_EXTEND_GRACE_SECONDS: int = 120
|
||||||
ABSOLUTE_SESSION_MAX_HOURS: int = 8
|
ABSOLUTE_SESSION_MAX_HOURS: int = 8
|
||||||
|
DESKTOP_SESSION_MAX_DAYS: int = 30
|
||||||
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
|
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
|
||||||
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
|
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
|
||||||
LOGIN_RSA_KEY_ID: str = "default"
|
LOGIN_RSA_KEY_ID: str = "default"
|
||||||
|
|||||||
@@ -19,16 +19,29 @@ def create_access_token(
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
expires_minutes: Optional[int] = None,
|
expires_minutes: Optional[int] = None,
|
||||||
session_start: Optional[datetime] = None,
|
session_start: Optional[datetime] = None,
|
||||||
|
max_age_seconds: Optional[int] = None,
|
||||||
|
issued_at: Optional[datetime] = None,
|
||||||
|
client_type: Optional[str] = None,
|
||||||
) -> str:
|
) -> 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)
|
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
|
||||||
session_start_time = session_start or now
|
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] = {
|
to_encode: Dict[str, Any] = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"iat": int(now.timestamp()),
|
"iat": int(now.timestamp()),
|
||||||
"orig_iat": int(session_start_time.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)
|
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.main import create_app
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.deps import get_db_session
|
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.crud import user as user_crud
|
||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
from app.models.audit_log import AuditLog
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_admin_created_user_is_active_by_default(client_and_db):
|
async def test_admin_created_user_is_active_by_default(client_and_db):
|
||||||
client, SessionLocal = client_and_db
|
client, SessionLocal = client_and_db
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ npm run desktop:build:app
|
|||||||
| 场景 | Web | macOS Desktop | 预期 |
|
| 场景 | Web | macOS Desktop | 预期 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||||
|
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
|
||||||
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||||
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||||
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
||||||
@@ -80,6 +81,7 @@ npm run desktop:build:app
|
|||||||
## 4. 桌面体验验收
|
## 4. 桌面体验验收
|
||||||
|
|
||||||
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
|
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
|
||||||
|
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。
|
||||||
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
|
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
|
||||||
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
|
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
|
||||||
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
|
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
|
||||||
|
|||||||
@@ -34,13 +34,15 @@
|
|||||||
- macOS:Keychain。
|
- macOS:Keychain。
|
||||||
- Windows:Credential Manager。
|
- Windows:Credential Manager。
|
||||||
|
|
||||||
|
桌面端登录使用后端签发的在线会话 token,可在系统凭据库中保存最长 30 天,以支持重启 App 后免输入密码。启动恢复后仍必须使用后端 token 校验和 `/auth/me` 用户状态校验;服务端不可达或会话被后端拒绝时不得进入离线模式。
|
||||||
|
|
||||||
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
|
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
|
||||||
|
|
||||||
应用挂载前异步初始化 token:
|
应用挂载前异步初始化 token:
|
||||||
|
|
||||||
1. Web 端继续读取 `localStorage.ctms_token`。
|
1. Web 端继续读取 `localStorage.ctms_token`。
|
||||||
2. 桌面端先删除 legacy `localStorage.ctms_token`。
|
2. 桌面端先删除 legacy `localStorage.ctms_token`。
|
||||||
3. 若 legacy token 仍有效,则迁移到系统凭据库。
|
3. 若 legacy token 仍有效,则迁移为带 30 天本机到期时间的系统凭据库会话记录。
|
||||||
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
|
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
|
||||||
|
|
||||||
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
|
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
||||||
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
||||||
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
||||||
|
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
|
||||||
- `files`:隔离浏览器上传下载与原生文件能力。
|
- `files`:隔离浏览器上传下载与原生文件能力。
|
||||||
- `notifications`:隔离 Web 通知与桌面系统通知。
|
- `notifications`:隔离 Web 通知与桌面系统通知。
|
||||||
- `updates`:隔离桌面自动更新检查与安装入口。
|
- `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";
|
import { isTauriRuntime } from "./platform";
|
||||||
|
|
||||||
const LEGACY_TOKEN_KEY = "ctms_token";
|
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 cachedToken: string | null = null;
|
||||||
let initialized = false;
|
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> => {
|
export const initializeSecureSessionStorage = async (): Promise<void> => {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
@@ -52,13 +110,11 @@ export const initializeSecureSessionStorage = async (): Promise<void> => {
|
|||||||
if (legacyToken && isUsableJwt(legacyToken)) {
|
if (legacyToken && isUsableJwt(legacyToken)) {
|
||||||
await invokeCredential<void>("credential_set", {
|
await invokeCredential<void>("credential_set", {
|
||||||
serverOrigin: activeServerOrigin,
|
serverOrigin: activeServerOrigin,
|
||||||
token: legacyToken,
|
token: serializeSessionToken(legacyToken),
|
||||||
});
|
});
|
||||||
cachedToken = legacyToken;
|
cachedToken = legacyToken;
|
||||||
} else {
|
} else {
|
||||||
cachedToken = await invokeCredential<string | null>("credential_get", {
|
cachedToken = await readCredentialToken(activeServerOrigin);
|
||||||
serverOrigin: activeServerOrigin,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
secureStorageAvailable = true;
|
secureStorageAvailable = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,7 +143,7 @@ export const setSessionToken = async (token: string): Promise<void> => {
|
|||||||
|
|
||||||
const serverOrigin = getDesktopServerUrl();
|
const serverOrigin = getDesktopServerUrl();
|
||||||
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
|
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
|
||||||
await invokeCredential<void>("credential_set", { serverOrigin, token });
|
await invokeCredential<void>("credential_set", { serverOrigin, token: serializeSessionToken(token) });
|
||||||
activeServerOrigin = serverOrigin;
|
activeServerOrigin = serverOrigin;
|
||||||
cachedToken = token;
|
cachedToken = token;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user