From b73f23c1ebb6d6580c80fcd177433d368c3dfe0e Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 8 Jul 2026 20:20:41 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E4=BC=98=E5=8C=96=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=92=8C=E5=81=8F=E5=A5=BD=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=A0=B7=E5=BC=8F=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=85=A5=E5=8F=A3=E4=B8=BA=E7=B2=BE=E8=87=B4?= =?UTF-8?q?=E5=88=86=E5=B1=8F=E5=B8=83=E5=B1=80=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=A6=96=E5=AD=97=E5=BE=BD=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...desktop-release-stabilization-checklist.md | 6 +- docs/desktop-project-plan.md | 3 +- frontend/index.html | 62 +- frontend/scripts/verify-desktop-release.mjs | 7 + frontend/src-tauri/src/credentials.rs | 85 +- frontend/src-tauri/src/lib.rs | 3 + frontend/src/api/auth.ts | 9 +- frontend/src/components/DesktopLayout.vue | 148 +-- .../src/components/Layout.desktop.test.ts | 57 +- frontend/src/components/WebLayout.vue | 26 +- frontend/src/main.test.ts | 16 + frontend/src/main.ts | 4 +- frontend/src/router.test.ts | 30 +- frontend/src/router/index.ts | 58 +- frontend/src/runtime/index.ts | 6 + .../src/runtime/savedLoginCredentials.test.ts | 137 ++ frontend/src/runtime/savedLoginCredentials.ts | 160 +++ frontend/src/session/authRecovery.test.ts | 21 + frontend/src/session/authRecovery.ts | 14 + frontend/src/store/auth.ts | 17 +- frontend/src/styles/main.css | 21 +- frontend/src/views/DesktopPreferences.vue | 16 +- .../src/views/DesktopProjectEntry.test.ts | 29 + frontend/src/views/DesktopProjectEntry.vue | 1099 +++++++++++++++++ .../src/views/DesktopSessionRestore.test.ts | 21 + frontend/src/views/DesktopSessionRestore.vue | 234 ++++ frontend/src/views/ForgotPassword.vue | 2 - frontend/src/views/Login.test.ts | 51 +- frontend/src/views/Login.vue | 82 +- frontend/src/views/ProfileSettings.vue | 246 ++-- frontend/src/views/Register.vue | 2 - frontend/src/views/ia/EtmfPlaceholder.test.ts | 2 +- frontend/src/views/ia/ProjectOverview.vue | 28 +- 33 files changed, 2323 insertions(+), 379 deletions(-) create mode 100644 frontend/src/main.test.ts create mode 100644 frontend/src/runtime/savedLoginCredentials.test.ts create mode 100644 frontend/src/runtime/savedLoginCredentials.ts create mode 100644 frontend/src/session/authRecovery.test.ts create mode 100644 frontend/src/session/authRecovery.ts create mode 100644 frontend/src/views/DesktopProjectEntry.test.ts create mode 100644 frontend/src/views/DesktopProjectEntry.vue create mode 100644 frontend/src/views/DesktopSessionRestore.test.ts create mode 100644 frontend/src/views/DesktopSessionRestore.vue diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 87b13877..c8047308 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -50,6 +50,7 @@ npm run desktop:build:app - [ ] Tauri command 白名单仅包含凭据和更新命令。 - [ ] 前端源码不通过 query string 传递 token。 - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 +- [ ] 登录表单密码不写入 `localStorage` 或 `sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。 - [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 - [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default`。 - [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。 @@ -61,6 +62,7 @@ npm run desktop:build:app 人工复审还必须确认: - [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。 +- [ ] 密码不出现在 URL、日志、系统通知正文、诊断信息或明文浏览器存储中。 - [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。 - [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。 - [ ] Web 运行时不直接导入 Tauri API。 @@ -70,6 +72,7 @@ npm run desktop:build:app | 场景 | Web | macOS Desktop | 预期 | | --- | --- | --- | --- | | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | +| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 | | 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 | | 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | @@ -88,7 +91,8 @@ npm run desktop:build:app ## 4. 桌面体验验收 - [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。 -- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。 +- [ ] 30 天免登录仍只保存系统凭据库会话记录,不把 token 写入 URL、日志、通知正文或业务缓存。 +- [ ] 记住密码与 30 天免登录使用独立凭据记录;服务器切换后不复用旧服务器保存的密码。 - [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。 - [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。 - [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index fd5b79dd..561a1747 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -60,6 +60,7 @@ - `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 - `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 - 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。 +- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage`、`sessionStorage`、URL、日志或通知正文,且不等同于离线登录。 - `files`:隔离浏览器上传下载与原生文件能力。 - `notifications`:隔离 Web 通知与桌面系统通知。 - `updates`:隔离桌面自动更新检查与安装入口。 @@ -75,7 +76,7 @@ - 非本地服务连接优先使用 HTTPS。 - 认证与授权决策保留在后端。 - 审计敏感决策保留在后端。 -- 敏感凭据必须继续使用明确批准的安全存储方案。 +- 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库。 - 不向前端暴露宽泛文件系统访问权限。 - Tauri 权限保持最小化,并按功能精确授权。 - 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。 diff --git a/frontend/index.html b/frontend/index.html index fbb2a5bf..066879be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,67 @@ -
+
+ +
+
+
CTMS
+
+

正在启动 CTMS

+

正在加载桌面客户端...

+
+
+
+
diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index 93d2b92d..2c13eab5 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -184,6 +184,9 @@ const verifyRustBoundary = async () => { "credentials::credential_get", "credentials::credential_set", "credentials::credential_delete", + "credentials::login_credential_get", + "credentials::login_credential_set", + "credentials::login_credential_delete", "updates::desktop_update_check", "updates::desktop_update_install", ]; @@ -211,6 +214,10 @@ const verifySourceSafety = async () => { if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") { fail(`${file}: ctms_token may only be handled by secureSessionStorage.`); } + assert( + !/(?:localStorage|sessionStorage)\.setItem\([^)]*password/i.test(source), + `${file}: passwords must not be written to browser storage.`, + ); if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") { fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`); } diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index f103978c..d94c4181 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -1,7 +1,8 @@ use sha2::{Digest, Sha256}; use url::Url; -const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session"; +const SESSION_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session"; +const LOGIN_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.login"; fn credential_account(server_origin: &str) -> Result { let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?; @@ -23,9 +24,9 @@ fn credential_account(server_origin: &str) -> Result { } #[cfg(any(target_os = "macos", windows))] -fn get_entry(server_origin: &str) -> Result { +fn get_entry(service: &str, server_origin: &str) -> Result { let account = credential_account(server_origin)?; - keyring::Entry::new(CREDENTIAL_SERVICE, &account) + keyring::Entry::new(service, &account) .map_err(|error| format!("无法访问系统凭据库:{error}")) } @@ -34,7 +35,7 @@ pub async fn credential_get(server_origin: String) -> Result, Str tauri::async_runtime::spawn_blocking(move || { #[cfg(any(target_os = "macos", windows))] { - let entry = get_entry(&server_origin)?; + let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?; return match entry.get_password() { Ok(token) => Ok(Some(token)), Err(keyring::Error::NoEntry) => Ok(None), @@ -59,7 +60,7 @@ pub async fn credential_set(server_origin: String, token: String) -> Result<(), tauri::async_runtime::spawn_blocking(move || { #[cfg(any(target_os = "macos", windows))] { - return get_entry(&server_origin)? + return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)? .set_password(&token) .map_err(|error| format!("保存系统凭据失败:{error}")); } @@ -78,7 +79,7 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { #[cfg(any(target_os = "macos", windows))] { - let entry = get_entry(&server_origin)?; + let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?; return match entry.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(error) => Err(format!("删除系统凭据失败:{error}")), @@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> { .map_err(|error| format!("删除系统凭据任务失败:{error}"))? } +#[tauri::command] +pub async fn login_credential_get(server_origin: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?; + return match entry.get_password() { + Ok(credential) => Ok(Some(credential)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(format!("读取登录凭据失败:{error}")), + }; + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("读取登录凭据任务失败:{error}"))? +} + +#[tauri::command] +pub async fn login_credential_set(server_origin: String, credential: String) -> Result<(), String> { + if credential.trim().is_empty() { + return Err("拒绝保存空登录凭据".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + return get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)? + .set_password(&credential) + .map_err(|error| format!("保存登录凭据失败:{error}")); + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("保存登录凭据任务失败:{error}"))? +} + +#[tauri::command] +pub async fn login_credential_delete(server_origin: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?; + return match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(format!("删除登录凭据失败:{error}")), + }; + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("删除登录凭据任务失败:{error}"))? +} + #[cfg(test)] mod tests { - use super::credential_account; + use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE}; #[test] fn account_is_stable_for_same_origin() { @@ -124,4 +190,9 @@ mod tests { assert!(!account.contains("ctms.example.com")); assert!(!account.contains("https")); } + + #[test] + fn login_credentials_use_a_separate_keyring_service() { + assert_ne!(SESSION_CREDENTIAL_SERVICE, LOGIN_CREDENTIAL_SERVICE); + } } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index b3708d5f..725be312 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -144,6 +144,9 @@ pub fn run() { credentials::credential_get, credentials::credential_set, credentials::credential_delete, + credentials::login_credential_get, + credentials::login_credential_set, + credentials::login_credential_delete, updates::desktop_update_check, updates::desktop_update_install, ]) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 3ca3af97..c87e20b7 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,5 @@ import type { AxiosResponse } from "axios"; -import api, { apiGet, apiPatch, apiPost } from "./axios"; +import api, { apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios"; import type { UserMeResponse, LoginRequest, @@ -24,10 +24,11 @@ export const devLogin = (payload: DevLoginRequest): Promise> => apiGet("/api/v1/auth/login-key"); -export const fetchMe = (): Promise> => apiGet("/api/v1/auth/me"); +export const fetchMe = (config?: ApiRequestConfig): Promise> => + apiGet("/api/v1/auth/me", config); -export const fetchEmailDomains = (): Promise> => - apiGet("/api/v1/auth/email-domains"); +export const fetchEmailDomains = (config?: ApiRequestConfig): Promise> => + apiGet("/api/v1/auth/email-domains", config); export const register = (payload: RegisterRequest): Promise> => apiPost("/api/v1/auth/register", payload); diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index ba180068..e99bae7f 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -5,25 +5,7 @@ @@ -87,7 +69,7 @@