From f33cc0948eb8a367bf3dc2657b25ed1705dc8ca9 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 2 Jul 2026 09:06:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E6=94=AF=E6=8C=81=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E7=AB=AF=E4=B8=89=E5=8D=81=E5=A4=A9=E5=85=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/auth.py | 65 +++++++-- backend/app/core/config.py | 1 + backend/app/core/security.py | 15 +- backend/tests/test_registration.py | 70 +++++++++- ...desktop-release-stabilization-checklist.md | 2 + docs/desktop-phase-2-design.md | 4 +- docs/desktop-project-plan.md | 1 + .../src/runtime/secureSessionStorage.test.ts | 129 ++++++++++++++++++ frontend/src/runtime/secureSessionStorage.ts | 66 ++++++++- 9 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 frontend/src/runtime/secureSessionStorage.test.ts diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 7dd9450c..9cd0be45 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4a565b7d..8781e6c6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/core/security.py b/backend/app/core/security.py index aa9ec667..f0692ee1 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 4801b30a..32f96064 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -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 diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 5a4448d2..f5a495eb 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -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 或业务敏感数据。 diff --git a/docs/desktop-phase-2-design.md b/docs/desktop-phase-2-design.md index 02b318d5..7a3f167c 100644 --- a/docs/desktop-phase-2-design.md +++ b/docs/desktop-phase-2-design.md @@ -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 对应的系统凭据。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 6b1c5e8a..7c45a7bf 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -59,6 +59,7 @@ - `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。 - `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 - `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 +- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。 - `files`:隔离浏览器上传下载与原生文件能力。 - `notifications`:隔离 Web 通知与桌面系统通知。 - `updates`:隔离桌面自动更新检查与安装入口。 diff --git a/frontend/src/runtime/secureSessionStorage.test.ts b/frontend/src/runtime/secureSessionStorage.test.ts new file mode 100644 index 00000000..1cf45d64 --- /dev/null +++ b/frontend/src/runtime/secureSessionStorage.test.ts @@ -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(); + 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), + }); + }); +}); diff --git a/frontend/src/runtime/secureSessionStorage.ts b/frontend/src/runtime/secureSessionStorage.ts index dfb66a60..9f93b4ab 100644 --- a/frontend/src/runtime/secureSessionStorage.ts +++ b/frontend/src/runtime/secureSessionStorage.ts @@ -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 => { + const stored = await invokeCredential("credential_get", { serverOrigin }); + const parsed = parseStoredSessionToken(stored); + if (parsed?.needsRewrite) { + await invokeCredential("credential_set", { + serverOrigin, + token: serializeSessionToken(parsed.token), + }); + } + if (!parsed && stored) { + await invokeCredential("credential_delete", { serverOrigin }); + } + return parsed?.token ?? null; +}; + export const initializeSecureSessionStorage = async (): Promise => { if (initialized) return; initialized = true; @@ -52,13 +110,11 @@ export const initializeSecureSessionStorage = async (): Promise => { if (legacyToken && isUsableJwt(legacyToken)) { await invokeCredential("credential_set", { serverOrigin: activeServerOrigin, - token: legacyToken, + token: serializeSessionToken(legacyToken), }); cachedToken = legacyToken; } else { - cachedToken = await invokeCredential("credential_get", { - serverOrigin: activeServerOrigin, - }); + cachedToken = await readCredentialToken(activeServerOrigin); } secureStorageAvailable = true; } catch (error) { @@ -87,7 +143,7 @@ export const setSessionToken = async (token: string): Promise => { const serverOrigin = getDesktopServerUrl(); if (!serverOrigin) throw new Error("尚未配置桌面服务器地址"); - await invokeCredential("credential_set", { serverOrigin, token }); + await invokeCredential("credential_set", { serverOrigin, token: serializeSessionToken(token) }); activeServerOrigin = serverOrigin; cachedToken = token; initialized = true;