From 4e10f2d09b6260e6c165cb823b55978b7f38af87 Mon Sep 17 00:00:00 2001 From: Bai Date: Tue, 3 Nov 2026 16:30:44 +0800 Subject: [PATCH] fix: support login policies in client v4.2.0 --- i18n/locales/en.json | 12 +- i18n/locales/zh.json | 12 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/api/client.rs | 10 +- src-tauri/src/api/session.rs | 19 +- src-tauri/src/commands/api_session.rs | 61 +++++- src-tauri/src/commands/asset_actions.rs | 6 +- src-tauri/src/commands/auth_flow.rs | 23 ++- src-tauri/src/commands/auth_logout.rs | 11 +- src-tauri/src/commands/connect_token.rs | 4 +- src-tauri/src/commands/get_setting.rs | 4 +- src-tauri/src/lib.rs | 25 ++- src-tauri/src/service/client_session.rs | 247 ++++++++++++++++++++++++ src-tauri/src/service/mod.rs | 1 + src-tauri/src/service/oauth.rs | 180 ++++++++++------- src-tauri/src/service/token.rs | 102 +++++++--- src-tauri/tauri.conf.json | 2 +- ui/app.config.ts | 2 +- ui/app.vue | 10 +- ui/composables/useAssetFetcher.ts | 14 +- ui/composables/useClientSession.ts | 128 ++++++++++++ ui/pages/auth/browser.vue | 25 ++- ui/store/modules/userInfo.ts | 24 ++- ui/types/index.ts | 6 + 26 files changed, 784 insertions(+), 150 deletions(-) create mode 100644 src-tauri/src/service/client_session.rs create mode 100644 ui/composables/useClientSession.ts diff --git a/i18n/locales/en.json b/i18n/locales/en.json index f3625557e..831d55f36 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -263,7 +263,17 @@ "InvalidCertificateGeneric": "Login failed: certificate or secure connection issue.", "NetworkError": "Login failed: network error. Please check connectivity.", "VersionIncompatible": "The client version is incompatible with JumpServer versions earlier than v4.10.12-lts. Please download and use the v3.0.7 client.", - "VersionNoMatch": "The client version does not match the JumpServer version. Please download and use the v{version} client, or upgrade JumpServer to the latest version." + "VersionNoMatch": "The client version does not match the JumpServer version. Please download and use the v{version} client, or upgrade JumpServer to the latest version.", + "SessionChecking": "Checking client sign-in…", + "SessionCheckFailed": "Unable to verify sign-in. Check your connection and try again.", + "SessionRetry": "Retry", + "LifetimeUntil": "This sign-in expires at {at} ({duration})", + "LifetimeHours": "{hours}h", + "LifetimeMinutes": "{minutes}m", + "LifetimeUnlimited": "No maximum login lifetime is configured", + "LifetimeSoon": "Sign-in expires soon", + "LifetimeSoonDescription": "{site}: {remaining} remaining. Sign-in expires at {at}; please sign in again then.", + "LifetimeCountdown": "{minutes}m {seconds}s" }, "Asset": { "GetAssetFailed": "Get asset failed, please refresh and try again" diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index d300685b7..b8e86e379 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -258,7 +258,17 @@ "InvalidCertificateGeneric": "登录失败:证书或安全连接异常。", "NetworkError": "登录失败:网络异常,请检查连接", "VersionIncompatible": "客户端与JumpServer 版本(< v4.10.12-lts)不匹配,请下载使用 v3.0.7 的客户端", - "VersionNoMatch": "客户端与 JumpServer 版本不匹配,请下载使用 v{version} 版本的客户端或将 JumpServer 更新至最新" + "VersionNoMatch": "客户端与 JumpServer 版本不匹配,请下载使用 v{version} 版本的客户端或将 JumpServer 更新至最新", + "SessionChecking": "正在验证客户端登录状态…", + "SessionCheckFailed": "暂时无法验证登录状态,请检查网络后重试。", + "SessionRetry": "重试", + "LifetimeUntil": "本次登录有效期至 {at}({duration})", + "LifetimeHours": "{hours} 小时", + "LifetimeMinutes": "{minutes} 分钟", + "LifetimeUnlimited": "未设置最长登录时间", + "LifetimeSoon": "登录即将到期", + "LifetimeSoonDescription": "{site}:剩余 {remaining},将于 {at} 到期,届时需要重新登录。", + "LifetimeCountdown": "{minutes} 分 {seconds} 秒" }, "Loading": { "Loading": "加载中", diff --git a/package.json b/package.json index bdb7a3712..a2926db2c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jumpserver-client", "type": "module", - "version": "4.1.2", + "version": "4.2.0", "private": true, "packageManager": "pnpm@11.4.0", "description": "JumpServer client tool", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7df1e4490..a227d3054 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3154,7 +3154,7 @@ dependencies = [ [[package]] name = "jumpserver-client" -version = "4.1.2" +version = "4.2.0" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cff7c42ff..17090de93 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jumpserver-client" -version = "4.1.2" +version = "4.2.0" description = "JumpServer client tool" authors = [ "JumpServer" ] license = "MIT" diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs index 2980ab869..eeb00d2ac 100644 --- a/src-tauri/src/api/client.rs +++ b/src-tauri/src/api/client.rs @@ -17,21 +17,13 @@ pub(crate) fn api_client_for_origin(origin: &str) -> Result Result { - instance_client_builder() - .redirect(redirect::Policy::none()) - .build() -} - /// 构建绑定指定站点 origin 的 OAuth HTTP 客户端。 /// /// OAuth code/token 交换同样需要对本地回环地址绕过代理。 pub(crate) fn oauth_client_for_origin(origin: &str) -> Result { instance_client_builder_for_origin(origin) .redirect(redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(10)) .build() } diff --git a/src-tauri/src/api/session.rs b/src-tauri/src/api/session.rs index 8ebb69829..5fe0d57d3 100644 --- a/src-tauri/src/api/session.rs +++ b/src-tauri/src/api/session.rs @@ -45,17 +45,32 @@ impl ApiSessionStore { } /// 更新当前会话保存的 bearer token - pub fn update_current_bearer_token(&self, bearer_token: String) -> Result<(), String> { + pub fn update_bearer_token(&self, origin: &str, bearer_token: String) -> Result<(), String> { let mut values = self.values.write().expect("api session lock poisoned"); let session_key = values .get(CURRENT_SESSION_KEY) .cloned() .ok_or_else(|| "missing current api session".to_string())?; - values.insert(Self::field_key(&session_key, "bearer_token"), bearer_token); + if values + .get(&Self::field_key(&session_key, "origin")) + .map(String::as_str) + == Some(origin) + { + values.insert(Self::field_key(&session_key, "bearer_token"), bearer_token); + } Ok(()) } + pub fn clear_site(&self, site: &str) { + let mut values = self.values.write().expect("api session lock poisoned"); + let prefix = format!("session:{}:", site); + values.retain(|key, _| !key.starts_with(&prefix)); + if values.get(CURRENT_SESSION_KEY).map(String::as_str) == Some(site) { + values.remove(CURRENT_SESSION_KEY); + } + } + /// 读取当前会话上下文,缺少任一关键字段时返回 None pub fn current_context(&self) -> Option { let values = self.values.read().expect("api session lock poisoned"); diff --git a/src-tauri/src/commands/api_session.rs b/src-tauri/src/commands/api_session.rs index cdbae2e2a..0219c6e9b 100644 --- a/src-tauri/src/commands/api_session.rs +++ b/src-tauri/src/commands/api_session.rs @@ -1,8 +1,8 @@ use crate::{ api::session::{ApiSessionContext, ApiSessionStore}, - service::oauth::ensure_fresh_token, + service::client_session, }; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; #[tauri::command] pub fn set_api_session( @@ -20,20 +20,61 @@ pub fn set_api_org(state: State<'_, ApiSessionStore>, org_id: String) -> Result< state.set_current_org(org_id) } +async fn checked_token( + app: &AppHandle, + state: &ApiSessionStore, + site: &str, + validate_identity: bool, +) -> Result { + let result = if validate_identity { + client_session::check_session(site).await + } else { + client_session::checked_token(site).await + }; + match result { + Ok(token) => Ok(token), + Err(error) => { + if client_session::is_expired(&error) { + state.clear_site(site); + let _ = app.emit( + "client-session-expired", + serde_json::json!({ "site": site }), + ); + } + Err(error.to_string()) + } + } +} + +#[tauri::command] +pub async fn check_client_session( + app: AppHandle, + state: State<'_, ApiSessionStore>, + site: String, +) -> Result, String> { + checked_token(&app, &state, &site, true).await?; + client_session::session_info(&site) + .await + .map_err(|error| error.to_string()) +} + +pub(crate) fn session_error_status(error: &str) -> u16 { + if error == client_session::EXPIRED { + 401 + } else { + 0 + } +} + pub(crate) async fn fresh_api_context( - _app: &AppHandle, + app: &AppHandle, state: &ApiSessionStore, ) -> Result { let mut context = state .current_context() .ok_or_else(|| "missing current api session".to_string())?; - - let bearer = ensure_fresh_token(&context.origin, Some(&context.bearer_token)) - .await - .map_err(|error| error.to_string())?; - - state.update_current_bearer_token(bearer.clone())?; + let bearer = checked_token(app, state, &context.origin, false).await?; + state.update_bearer_token(&context.origin, bearer.clone())?; context.bearer_token = bearer; - Ok(context) } diff --git a/src-tauri/src/commands/asset_actions.rs b/src-tauri/src/commands/asset_actions.rs index 9313a73ca..9dc3d7626 100644 --- a/src-tauri/src/commands/asset_actions.rs +++ b/src-tauri/src/commands/asset_actions.rs @@ -2,7 +2,7 @@ use crate::api::{ request::ApiRequestClient, session::{ApiSessionContext, ApiSessionStore}, }; -use crate::commands::api_session::fresh_api_context; +use crate::commands::api_session::{fresh_api_context, session_error_status}; use crate::service::asset::{AssetQuery, AssetService}; use log::{error, info}; use serde_json::{json, Value}; @@ -49,7 +49,7 @@ pub async fn get_assets( error!("load asset service failed: {}", error); let _ = app.emit( "get-asset-failure", - json!({ "status": 401, "request_id": request_id }), + json!({ "status": session_error_status(&error), "request_id": request_id }), ); return Ok(()); } @@ -125,7 +125,7 @@ pub async fn get_asset_detail( Err(error) => { let _ = app.emit( "get-asset-detail-failure", - json!({ "status": 401, "error": error }), + json!({ "status": session_error_status(&error), "error": error }), ); return Ok(()); } diff --git a/src-tauri/src/commands/auth_flow.rs b/src-tauri/src/commands/auth_flow.rs index 8d451e998..f56557a95 100644 --- a/src-tauri/src/commands/auth_flow.rs +++ b/src-tauri/src/commands/auth_flow.rs @@ -36,6 +36,8 @@ pub async fn auth_login( } }; + let policy_supported = oauth_config.client_login_policy_version.is_some() + || oauth_config.client_login_policy.is_some(); let client_id = oauth_config.client_id; let fut = async { @@ -63,12 +65,18 @@ pub async fn auth_login( let tokens = exchange_authorization_code(&client, &http_client, callback).await?; // 保存 OAuth token,供后续请求自动刷新使用。 - if let Err(e) = tokens.persist(&site, &client_id).await { - log::warn!("persist tokens failed: {}", e); - } + tokens + .persist( + &site, + &client_id, + oauth_config.client_login_policy, + policy_supported, + ) + .await?; + let access_token = crate::service::client_session::checked_token(&site).await?; // 发起请求 - let user_service = UserService::new(site.clone(), tokens.access_token.clone())?; + let user_service = UserService::new(site.clone(), access_token.clone())?; let (profile, permission_orgs, current_org, xpack_message) = tokio::join!( user_service.get_user_profile(), user_service.get_permission_orgs(), @@ -76,6 +84,10 @@ pub async fn auth_login( user_service.get_xpack_message(), ); + if profile.status != 200 || !profile.success { + anyhow::bail!("Unable to validate the user profile"); + } + let license_valid = if xpack_message.status == 200 && xpack_message.success { serde_json::from_str::(&xpack_message.data) .ok() @@ -93,12 +105,13 @@ pub async fn auth_login( "login-success-detected", serde_json::json!({ "status": "success", - "bearer": tokens.access_token, + "bearer": access_token, "profile": profile, "resolved_site": site, "current_org": current_org, "xpack_license_valid": license_valid, "permission_orgs": permission_orgs, + "client_login_session": crate::service::client_session::session_info(&site).await?, }), ); diff --git a/src-tauri/src/commands/auth_logout.rs b/src-tauri/src/commands/auth_logout.rs index c16ec45e3..fd3a8ff7d 100644 --- a/src-tauri/src/commands/auth_logout.rs +++ b/src-tauri/src/commands/auth_logout.rs @@ -1,9 +1,16 @@ +use crate::api::session::ApiSessionStore; use crate::service::oauth::revoke_and_clear_tokens; use log::warn; -use tauri::AppHandle; +use tauri::{AppHandle, State}; #[tauri::command] -pub async fn logout(_app: AppHandle, _name: String, site: String) -> Result<(), String> { +pub async fn logout( + _app: AppHandle, + state: State<'_, ApiSessionStore>, + _name: String, + site: String, +) -> Result<(), String> { + state.clear_site(&site); if let Err(e) = revoke_and_clear_tokens(&site).await { warn!("revoke token failed: {}", e); } diff --git a/src-tauri/src/commands/connect_token.rs b/src-tauri/src/commands/connect_token.rs index 598a3ed4b..f067e24b0 100644 --- a/src-tauri/src/commands/connect_token.rs +++ b/src-tauri/src/commands/connect_token.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use tauri::{AppHandle, Emitter, State}; use crate::api::{request::ApiRequestClient, session::ApiSessionStore}; -use crate::commands::api_session::fresh_api_context; +use crate::commands::api_session::{fresh_api_context, session_error_status}; use crate::commands::client_launcher::pull_up; use crate::service::connect::{ConnectService, TokenRequestBody}; @@ -20,7 +20,7 @@ pub async fn get_connect_token( Err(e) => { let _ = app.emit( "get-token-failure", - json!({ "status": 401, "data": e.to_string() }), + json!({ "status": session_error_status(&e), "data": e.to_string() }), ); return Ok(()); } diff --git a/src-tauri/src/commands/get_setting.rs b/src-tauri/src/commands/get_setting.rs index 637654e1c..40b971512 100644 --- a/src-tauri/src/commands/get_setting.rs +++ b/src-tauri/src/commands/get_setting.rs @@ -1,5 +1,5 @@ use crate::api::{request::ApiRequestClient, session::ApiSessionStore}; -use crate::commands::api_session::fresh_api_context; +use crate::commands::api_session::{fresh_api_context, session_error_status}; use crate::service::setting::SettingService; use log::{error, info}; use serde_json::json; @@ -15,7 +15,7 @@ pub async fn get_setting( Err(e) => { let _ = app.emit( "get-setting-failure", - json!({ "status": 401, "error": e.to_string() }), + json!({ "status": session_error_status(&e), "error": e.to_string() }), ); return Ok(()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee3d05ed8..59a9f22c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ use crate::setup::menu::{build_menu, handle_menu_event}; use crate::setup::setup_tray; use crate::api::session::ApiSessionStore; -use crate::commands::api_session::{set_api_org, set_api_session}; +use crate::commands::api_session::{check_client_session, set_api_org, set_api_session}; use crate::commands::asset_actions::{ get_asset_detail, get_assets, rename, set_favorite, unfavorite, }; @@ -209,6 +209,7 @@ pub fn run() { update_config_selection, init_http_callback_server, get_connect_methods, + check_client_session, set_api_session, set_api_org, write_video_player_gzip_file, @@ -216,6 +217,24 @@ pub fn run() { delete_video_player_file, transcode_replays, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + if let tauri::RunEvent::ExitRequested { api, code, .. } = event { + static EXITING: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if !EXITING.swap(true, std::sync::atomic::Ordering::SeqCst) { + api.prevent_exit(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(3), + service::client_session::revoke_on_exit(), + ) + .await; + app.exit(code.unwrap_or(0)); + }); + } + } + }); } diff --git a/src-tauri/src/service/client_session.rs b/src-tauri/src/service/client_session.rs new file mode 100644 index 000000000..f7217cc19 --- /dev/null +++ b/src-tauri/src/service/client_session.rs @@ -0,0 +1,247 @@ +use crate::api::client::oauth_client_for_origin; +use crate::service::{ + oauth, + token::{TokenRecord, TokenService}, +}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock, Mutex}, + time::{Instant, SystemTime}, +}; +use tokio::sync::Mutex as AsyncMutex; + +pub const EXPIRED: &str = "client_session_expired"; +static LOCKS: LazyLock>>>> = + LazyLock::new(Default::default); + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ClientLoginPolicy { + pub reauth_on_exit: bool, + pub max_session_time: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClientLoginSession { + expires_at: Option, + server_time: String, +} + +/// UI-safe lifetime information; credentials stay in the native token store. +#[derive(Debug, Serialize)] +pub struct ClientSessionInfo { + pub max_session_time: u64, + pub expires_at: Option, + pub remaining_seconds: Option, +} + +pub async fn session_info(site: &str) -> Result> { + let Some(record) = TokenService::new(site).load().await? else { + return Ok(None); + }; + let Some(policy) = record.policy else { + return Ok(None); + }; + let now = SystemTime::now(); + let remaining = record.deadline.map(|deadline| { + let wall = deadline.duration_since(now).unwrap_or_default(); + record.monotonic_deadline.map_or(wall, |deadline| { + wall.min(deadline.saturating_duration_since(Instant::now())) + }) + }); + Ok(Some(ClientSessionInfo { + max_session_time: policy.max_session_time, + expires_at: remaining.map(|time| DateTime::::from(now + time).to_rfc3339()), + remaining_seconds: remaining.map(|time| time.as_secs()), + })) +} + +impl ClientLoginSession { + pub fn apply_to(&self, record: &mut TokenRecord) -> Result<()> { + let server_time = DateTime::parse_from_rfc3339(&self.server_time)?; + let deadline = self + .expires_at + .as_ref() + .map(|value| DateTime::parse_from_rfc3339(value)) + .transpose()?; + if deadline.is_some_and(|time| time <= server_time) { + anyhow::bail!(EXPIRED); + } + anyhow::ensure!( + deadline.is_some() + || !record + .policy + .as_ref() + .is_some_and(|p| p.max_session_time > 0), + "Missing client login expiry" + ); + if let Some(deadline) = deadline { + let remaining = (deadline - server_time).to_std()?; + let wall = SystemTime::now() + remaining; + let monotonic = Instant::now() + remaining; + record.deadline = Some(record.deadline.map_or(wall, |old| old.min(wall))); + record.monotonic_deadline = Some( + record + .monotonic_deadline + .map_or(monotonic, |old| old.min(monotonic)), + ); + } + Ok(()) + } +} + +pub fn site_lock(site: &str) -> Arc> { + LOCKS + .lock() + .expect("session locks poisoned") + .entry(site.to_string()) + .or_default() + .clone() +} + +pub fn is_expired(error: &anyhow::Error) -> bool { + error.to_string() == EXPIRED +} + +fn expired_locally(record: &TokenRecord) -> bool { + record + .deadline + .is_some_and(|time| SystemTime::now() >= time) + || record + .monotonic_deadline + .is_some_and(|time| Instant::now() >= time) +} + +async fn expire(service: &TokenService) -> Result { + // Tombstone memory before touching the keyring; a failed deletion must not restore a session. + if let Err(error) = service.delete().await { + log::warn!("clear expired token failed: {}", error); + } + anyhow::bail!(EXPIRED) +} + +/// Check the server-controlled absolute session lifetime before using credentials. +pub async fn checked_token(site: &str) -> Result { + check(site, false).await +} + +/// Background/startup identity checks reuse the existing profile API. +pub async fn check_session(site: &str) -> Result { + check(site, true).await +} + +async fn check(site: &str, validate_identity: bool) -> Result { + let lock = site_lock(site); + let _guard = lock.lock().await; + let service = TokenService::new(site); + let Some(mut record) = service.load().await? else { + anyhow::bail!(EXPIRED); + }; + if expired_locally(&record) { + return expire(&service).await; + } + + let http = oauth_client_for_origin(site)?; + if record.policy_supported.is_none() + || (validate_identity && record.policy_supported != Some(true)) + { + // Recheck legacy servers too: an in-place server upgrade may enable the policy. + let config = oauth::fetch_oauth_config(site, &http).await?; + let supported = + config.client_login_policy_version.is_some() || config.client_login_policy.is_some(); + anyhow::ensure!( + !supported + || (config.client_login_policy_version == Some(1) + && config.client_login_policy.is_some()), + "Unsupported client login policy metadata" + ); + anyhow::ensure!( + record.policy.is_none() || supported, + "Client login policy metadata disappeared" + ); + let supported = Some(supported); + let changed = + record.policy_supported != supported || record.policy != config.client_login_policy; + record.policy_supported = supported; + record.policy = config.client_login_policy; + if changed { + service.save(&record).await?; + } + if expired_locally(&record) { + return expire(&service).await; + } + } + + if oauth::should_refresh_token(record.expires_at) { + let Some(refresh) = record.refresh_token.as_deref() else { + return expire(&service).await; + }; + let tokens = match oauth::refresh_access_token( + site, + record.client_id.as_deref().unwrap_or_default(), + refresh, + &http, + ) + .await + { + Ok(tokens) => tokens, + Err(error) if is_expired(&error) => return expire(&service).await, + Err(error) => return Err(error), + }; + if let Err(error) = tokens.apply_to(&mut record) { + if is_expired(&error) { + return expire(&service).await; + } + return Err(error); + } + service.save(&record).await?; + } + + if validate_identity { + // Validate identity, never organization availability. Ordinary APIs validate + // their bearer directly; no additional session endpoint is needed. + let response = http + .get(format!( + "{}{}", + site.trim_end_matches('/'), + crate::api::endpoint::user::PROFILE + )) + .bearer_auth(&record.access_token) + .send() + .await?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return expire(&service).await; + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + // V4's first authentication backend has no challenge header, so DRF + // can report missing/revoked credentials as 403. Permission 403s stay errors. + let body: serde_json::Value = response.json().await?; + if matches!( + body.get("code").and_then(|value| value.as_str()), + Some("not_authenticated" | "authentication_failed" | "client_session_expired") + ) { + return expire(&service).await; + } + anyhow::bail!("Profile access denied"); + } + response.error_for_status()?; + } + if expired_locally(&record) { + return expire(&service).await; + } + Ok(record.access_token) +} + +/// Graceful exit revokes best-effort; memory-only credentials also cover crashes and forced exits. +pub async fn revoke_on_exit() { + let sites = TokenService::exit_sites(); + let mut tasks = tokio::task::JoinSet::new(); + for site in sites { + tasks.spawn(async move { + let _ = oauth::revoke_and_clear_tokens(&site).await; + }); + } + while tasks.join_next().await.is_some() {} +} diff --git a/src-tauri/src/service/mod.rs b/src-tauri/src/service/mod.rs index 2a25950a1..2486d033c 100644 --- a/src-tauri/src/service/mod.rs +++ b/src-tauri/src/service/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod asset; +pub(crate) mod client_session; pub(crate) mod config; pub(crate) mod connect; pub(crate) mod oauth; diff --git a/src-tauri/src/service/oauth.rs b/src-tauri/src/service/oauth.rs index 129b29a78..686706f9c 100644 --- a/src-tauri/src/service/oauth.rs +++ b/src-tauri/src/service/oauth.rs @@ -1,21 +1,45 @@ -use crate::api::client::oauth_client; +use crate::api::client::oauth_client_for_origin; use crate::api::endpoint; -use crate::service::token::TokenService; +use crate::service::client_session::{self, ClientLoginPolicy, ClientLoginSession}; +use crate::service::token::{TokenRecord, TokenService}; use anyhow::Result; use chrono::{Duration, Utc}; use oauth2::{ - basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, EndpointNotSet, - EndpointSet, ErrorResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, - RequestTokenError, RevocationUrl, Scope, StandardRevocableToken, TokenResponse, TokenUrl, + basic::{ + BasicClient, BasicErrorResponse, BasicRevocationErrorResponse, + BasicTokenIntrospectionResponse, BasicTokenType, + }, + AuthUrl, AuthorizationCode, ClientId, CsrfToken, EndpointNotSet, EndpointSet, ErrorResponse, + ExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, + RequestTokenError, RevocationUrl, Scope, StandardRevocableToken, StandardTokenResponse, + TokenResponse, TokenUrl, }; use reqwest::Client; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::Mutex; use tokio::sync::oneshot; use url::Url; -pub type JumpServerOAuthClient = - BasicClient; +type ClientOAuth = oauth2::Client< + BasicErrorResponse, + StandardTokenResponse, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, + HasAuthUrl, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + HasTokenUrl, +>; +pub type JumpServerOAuthClient = ClientOAuth; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ClientTokenFields { + pub client_login_policy: Option, + pub client_login_session: Option, +} +impl ExtraTokenFields for ClientTokenFields {} /// OAuth 授权请求阶段的数据。 /// @@ -44,6 +68,7 @@ pub struct OAuthTokenSet { pub access_token: String, pub refresh_token: Option, pub expires_at: Option, + pub fields: ClientTokenFields, } /// OAuth callback 阶段解析出的参数。 @@ -63,6 +88,10 @@ pub struct CallbackParams { #[derive(Deserialize, Debug)] #[allow(dead_code)] pub struct OAuthConfig { + #[serde(default)] + pub client_login_policy: Option, + #[serde(default)] + pub client_login_policy_version: Option, pub issuer: String, pub client_id: String, pub authorization_endpoint: String, @@ -76,6 +105,7 @@ pub struct OAuthConfig { pub code_challenge_methods_supported: Vec, pub response_modes_supported: Vec, pub token_expires_in: i64, + #[serde(default)] pub refresh_token_expires_in: i64, } @@ -98,17 +128,47 @@ pub struct AuthFlowState { impl OAuthTokenSet { /// 将 OAuth token 写入本地安全存储。 - pub async fn persist(&self, site: &str, client_id: &str) -> Result<()> { - let token_service = TokenService::new(site.to_string()); - - token_service - .persist( - &self.access_token, - self.refresh_token.as_deref(), - self.expires_at, - Some(client_id), - ) - .await + pub async fn persist( + &self, + site: &str, + client_id: &str, + policy: Option, + supported: bool, + ) -> Result<()> { + let lock = client_session::site_lock(site); + let _guard = lock.lock().await; + let mut record = TokenRecord { + access_token: self.access_token.clone(), + refresh_token: self.refresh_token.clone(), + expires_at: self.expires_at, + client_id: Some(client_id.to_string()), + policy, + policy_supported: Some(supported), + deadline: None, + monotonic_deadline: None, + }; + self.apply_to(&mut record)?; + TokenService::new(site).save(&record).await + } + + pub(crate) fn apply_to(&self, record: &mut TokenRecord) -> Result<()> { + if record.policy_supported == Some(true) { + anyhow::ensure!( + self.fields.client_login_policy.is_some() + && self.fields.client_login_session.is_some(), + "Missing client login policy or expiry in token response" + ); + } + record.access_token = self.access_token.clone(); + record.refresh_token = self.refresh_token.clone(); + record.expires_at = self.expires_at; + if let Some(policy) = &self.fields.client_login_policy { + record.policy = Some(policy.clone()); + } + if let Some(session) = &self.fields.client_login_session { + session.apply_to(record)?; + } + Ok(()) } } @@ -181,7 +241,11 @@ impl AuthFlowState { pub async fn fetch_oauth_config(site: &str, client: &Client) -> Result { let config_url = format!("{}{}", site, endpoint::oauth::WELL_KNOWN); - let response = client.get(config_url).send().await?; + let response = client + .get(config_url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await?; let status = response.status(); let text = response.text().await?; @@ -196,7 +260,7 @@ pub async fn fetch_oauth_config(site: &str, client: &Client) -> Result Result { - let client = BasicClient::new(ClientId::new(client_id.to_string())) + let client = ClientOAuth::new(ClientId::new(client_id.to_string())) // 指定授权端点:用户会被重定向到这个 URL 登录授权 .set_auth_uri(AuthUrl::new(format!( "{}{}", @@ -226,6 +290,7 @@ pub fn create_authorization_request(client: &JumpServerOAuthClient) -> OAuthAuth .add_scope(Scope::new("write".to_string())) .add_scope(Scope::new("read".to_string())) .set_pkce_challenge(pkce_challenge) + .add_extra_param("client_login_policy_version", "1") .url(); OAuthAuthorizationRequest { @@ -282,6 +347,7 @@ pub async fn exchange_authorization_code( let token_result = client .exchange_code(callback.code) .set_pkce_verifier(callback.pkce_verifier) + .add_extra_param("client_login_policy_version", "1") .request_async(http_client) .await .inspect_err(log_token_exchange_error) @@ -297,64 +363,34 @@ pub async fn exchange_authorization_code( access_token, refresh_token, expires_at, + fields: token_result.extra_fields().clone(), }) } /// 撤销并删除本地保存的 OAuth token。 pub async fn revoke_and_clear_tokens(site: &str) -> Result<()> { - let token_service = TokenService::new(site.to_string()); - - if let Some(entry) = token_service.load().await? { + let lock = client_session::site_lock(site); + let _guard = lock.lock().await; + let token_service = TokenService::new(site); + let entry = token_service.load().await?; + // Remove local credentials before waiting for the network. + let deleted = token_service.delete().await; + if let Some(entry) = entry { if let Some(refresh_token) = entry.refresh_token { let client_id = entry.client_id.unwrap_or_default(); - let http_client = oauth_client()?; - + let http_client = oauth_client_for_origin(site)?; if let Err(error) = - revoke_refresh_token(&site, &client_id, &refresh_token, &http_client).await + revoke_refresh_token(site, &client_id, &refresh_token, &http_client).await { - log::error!("revocation request failed: {}", error); + log::warn!("revocation request failed: {}", error); } } - - token_service.delete().await? } - - Ok(()) -} - -/// 确保 access_token 可用;如果即将过期,则使用 refresh_token 刷新并写回本地存储。 -pub async fn ensure_fresh_token(site: &str, provided: Option<&str>) -> Result { - let token_service = TokenService::new(site.to_string()); - let entry = token_service.load().await?; - - let stored_access = entry.as_ref().map(|token| token.access_token.clone()); - let stored_refresh = entry.as_ref().and_then(|token| token.refresh_token.clone()); - let expires_at = entry.as_ref().and_then(|token| token.expires_at); - let client_id = entry - .as_ref() - .and_then(|token| token.client_id.clone()) - .unwrap_or_default(); - - let mut access = stored_access.or_else(|| provided.map(str::to_string)); - - if should_refresh_token(expires_at) { - let refresh_token = stored_refresh - .as_ref() - .ok_or_else(|| anyhow::anyhow!("refresh_token missing for site {}", site))?; - - let http_client = oauth_client()?; - let tokens = refresh_access_token(site, &client_id, refresh_token, &http_client).await?; - - tokens.persist(site, &client_id).await?; - - access = Some(tokens.access_token); - } - - access.ok_or_else(|| anyhow::anyhow!("no access token available for site {}", site)) + deleted } /// 判断 token 是否需要提前刷新。 -fn should_refresh_token(expires_at: Option) -> bool { +pub(crate) fn should_refresh_token(expires_at: Option) -> bool { expires_at .map(|timestamp| timestamp <= Utc::now().timestamp() + 60) .unwrap_or(false) @@ -379,20 +415,29 @@ fn oauth_redirect_uri() -> &'static str { } /// 使用 refresh_token 刷新 access_token。 -async fn refresh_access_token( +pub(crate) async fn refresh_access_token( site: &str, client_id: &str, refresh_token: &str, http_client: &Client, ) -> Result { - let client = BasicClient::new(ClientId::new(client_id.to_string())).set_token_uri( + let client = ClientOAuth::new(ClientId::new(client_id.to_string())).set_token_uri( TokenUrl::new(format!("{}{}", site, endpoint::oauth::TOKEN))?, ); let token_result = client .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string())) + .add_extra_param("client_login_policy_version", "1") .request_async(http_client) - .await?; + .await + .map_err(|error| match &error { + RequestTokenError::ServerResponse(response) + if response.error() == &oauth2::basic::BasicErrorResponseType::InvalidGrant => + { + anyhow::anyhow!(client_session::EXPIRED) + } + _ => anyhow::anyhow!(error), + })?; let access_token = token_result.access_token().secret().to_owned(); let refresh_token = token_result @@ -405,6 +450,7 @@ async fn refresh_access_token( access_token, refresh_token: Some(refresh_token), expires_at, + fields: token_result.extra_fields().clone(), }) } diff --git a/src-tauri/src/service/token.rs b/src-tauri/src/service/token.rs index 62153860f..a271bc073 100644 --- a/src-tauri/src/service/token.rs +++ b/src-tauri/src/service/token.rs @@ -1,8 +1,16 @@ +use super::client_session::ClientLoginPolicy; use anyhow::Result; use keyring_core::{Entry, Error as KeyringError}; use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, + time::{Instant, SystemTime}, +}; const SERVICE_NAME: &str = "com.jumpserver.client.auth"; +static TOKENS: LazyLock>>> = + LazyLock::new(Default::default); #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TokenRecord { @@ -10,6 +18,14 @@ pub struct TokenRecord { pub refresh_token: Option, pub expires_at: Option, pub client_id: Option, + #[serde(default)] + pub policy: Option, + #[serde(default)] + pub policy_supported: Option, + #[serde(default)] + pub deadline: Option, + #[serde(skip)] + pub monotonic_deadline: Option, } pub struct TokenService { @@ -26,53 +42,93 @@ impl TokenService { Self { site: site.into() } } - pub async fn persist( - &self, - access: &str, - refresh: Option<&str>, - expires_at: Option, - client_id: Option<&str>, - ) -> Result<()> { - let record = TokenRecord { - access_token: access.to_string(), - refresh_token: refresh.map(|r| r.to_string()), - expires_at, - client_id: client_id.map(|c| c.to_string()), - }; - let payload = serde_json::to_string(&record)?; - let site = self.site.clone(); + pub fn cache(&self, record: TokenRecord) { + TOKENS + .lock() + .expect("token cache poisoned") + .insert(self.site.clone(), Some(record)); + } + pub async fn save(&self, record: &TokenRecord) -> Result<()> { + if record + .policy + .as_ref() + .is_some_and(|policy| policy.reauth_on_exit) + { + // Exit-invalidated credentials live only in memory, including across refreshes. + self.delete().await?; + self.cache(record.clone()); + return Ok(()); + } + let payload = serde_json::to_string(record)?; + let site = self.site.clone(); tokio::task::spawn_blocking(move || -> Result<()> { token_entry(&site)?.set_password(&payload)?; Ok(()) }) .await??; - + self.cache(record.clone()); Ok(()) } pub async fn load(&self) -> Result> { + if let Some(record) = TOKENS.lock().expect("token cache poisoned").get(&self.site) { + return Ok(record.clone()); + } let site = self.site.clone(); - tokio::task::spawn_blocking(move || -> Result> { - let entry = token_entry(&site)?; - match entry.get_password() { + let mut record = tokio::task::spawn_blocking(move || -> Result> { + match token_entry(&site)?.get_password() { Ok(val) => Ok(Some(serde_json::from_str(&val)?)), Err(KeyringError::NoEntry) => Ok(None), Err(e) => Err(e.into()), } }) - .await? + .await??; + if record.as_ref().is_some_and(|record| { + record + .policy + .as_ref() + .is_some_and(|policy| policy.reauth_on_exit) + }) { + self.delete().await?; + return Ok(None); + } + if let Some(record) = &mut record { + // Rediscover once per process so upgrading a legacy server cannot freeze old policy. + record.policy_supported = None; + self.cache(record.clone()); + } + Ok(record) } pub async fn delete(&self) -> Result<()> { + TOKENS + .lock() + .expect("token cache poisoned") + .insert(self.site.clone(), None); let site = self.site.clone(); tokio::task::spawn_blocking(move || -> Result<()> { - if let Ok(entry) = token_entry(&site) { - let _ = entry.delete_credential(); + match token_entry(&site)?.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Err(error) => Err(error.into()), } - Ok(()) }) .await??; Ok(()) } + + pub fn exit_sites() -> Vec { + TOKENS + .lock() + .expect("token cache poisoned") + .iter() + .filter(|(_, record)| { + record + .as_ref() + .and_then(|r| r.policy.as_ref()) + .is_some_and(|p| p.reauth_on_exit) + }) + .map(|(site, _)| site.clone()) + .collect() + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 35a3a5e61..746438992 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -48,7 +48,7 @@ }, "mainBinaryName": "JumpServerClient", "productName": "JumpServerClient", - "version": "4.1.2", + "version": "4.2.0", "identifier": "com.jumpserver.client", "plugins": { "deep-link": { diff --git a/ui/app.config.ts b/ui/app.config.ts index 7c20f527d..be0d52d45 100644 --- a/ui/app.config.ts +++ b/ui/app.config.ts @@ -2,7 +2,7 @@ export default defineAppConfig({ app: { name: "JumpServer Client", author: "JumpServer", - version: "4.0.0", + version: "4.2.0", repo: "https://github.com/jumpserver/clients" }, componentsConfig: { diff --git a/ui/app.vue b/ui/app.vue index 74bddce42..6d3839bd9 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -5,6 +5,7 @@ import type { LangType, LanguagePreference } from "~/types"; import { resolveLanguageFromSystem } from "~/utils"; useApplicationConfig(); +const { sessionReady, sessionError, retrySession } = useClientSession(); let applyLanguageSeq = 0; const LOCALE_PREFIX_RE = /^\/[a-z]{2}(?:-[A-Z]{2})?(?=\/|$)/; @@ -204,7 +205,14 @@ onBeforeUnmount(() => { - + +
+ + {{ $t(sessionError ? "Login.SessionCheckFailed" : "Login.SessionChecking") }} + + {{ $t("Login.SessionRetry") }} + +
diff --git a/ui/composables/useAssetFetcher.ts b/ui/composables/useAssetFetcher.ts index c55f91309..0d99539da 100644 --- a/ui/composables/useAssetFetcher.ts +++ b/ui/composables/useAssetFetcher.ts @@ -31,6 +31,7 @@ export const useAssetFetcher = (assetType: AssetPageType, scrollRef?: Ref([]); const lastDetailAssetId = ref(null); const activeRequestId = ref(null); + const activeRequestSite = ref(""); const subscribeGetAssetsEvent = ref(null); const subscribeGetAssetFailedEvent = ref(null); const subscribeGetFavoriteAssetsEvent = ref(null); @@ -298,6 +299,7 @@ export const useAssetFetcher = (assetType: AssetPageType, scrollRef?: Ref { - deleteUserData(currentSite.value); + deleteUserData(requestSite); }); } @@ -476,6 +479,11 @@ export const useAssetFetcher = (assetType: AssetPageType, scrollRef?: Ref { + activeRequestId.value = null; + isLoading.value = false; + favoriteSet.value = new Set(); + lastDetailAssetId.value = null; + getDetail.value = false; hasMore.value = true; offset.value = 0; diff --git a/ui/composables/useClientSession.ts b/ui/composables/useClientSession.ts new file mode 100644 index 000000000..da63b8d27 --- /dev/null +++ b/ui/composables/useClientSession.ts @@ -0,0 +1,128 @@ +import type { UnlistenFn } from "@tauri-apps/api/event"; +import type { ClientSessionInfo } from "~/types"; +import { h } from "vue"; +import { useUserInfoStore } from "~/store/modules/userInfo"; + +/** Native credentials and server policy are authoritative; persisted UI state is only a cache. */ +export function useClientSession() { + const desktop = useTauriCoreIsTauri(); + const store = useUserInfoStore(); + const ready = ref(!desktop); + const error = ref(false); + const toast = useToast(); + const { t, locale } = useI18n(); + const pending = new Map>(); + const warned = new Map(); + const now = ref(Date.now()); + let timer: ReturnType | undefined; + let countdownTimer: ReturnType | undefined; + let unlisten: UnlistenFn | undefined; + let unlistenLogin: UnlistenFn | undefined; + let started = false; + let stopped = false; + + function clearWarning(site: string) { + const id = warned.get(site); + if (id) toast.remove(id); + warned.delete(site); + } + + function expire(site: string) { + clearWarning(site); + if (!store.userMap[site]) return; + store.deleteUserData(site, false); + toast.add({ + title: t("Login.LoginAuthenticationExpired"), + icon: "i-lucide-log-out", + color: "warning" + }); + } + + async function check(site: string): Promise { + if (!store.userMap[site]) return false; + const existing = pending.get(site); + if (existing) return existing; + const request = useTauriCoreInvoke("check_client_session", { site }) + .then((session) => { + if (store.userMap[site] && session?.expires_at && session.remaining_seconds !== null + && session.remaining_seconds > 0 && session.remaining_seconds < 600 && !warned.has(site)) { + const expiresAt = session.expires_at; + const warning = toast.add({ + title: t("Login.LifetimeSoon"), + description: () => { + const seconds = Math.max(0, Math.ceil((Date.parse(expiresAt) - now.value) / 1000)); + const remaining = t("Login.LifetimeCountdown", { minutes: Math.floor(seconds / 60), seconds: String(seconds % 60).padStart(2, "0") }); + return h("span", { "aria-live": "off" }, t("Login.LifetimeSoonDescription", { site, remaining, at: new Date(expiresAt).toLocaleString(locale.value) })); + }, + icon: "i-lucide-clock", + color: "warning", + duration: 0 + }); + warned.set(site, warning.id); + } + return true; + }) + .catch((cause) => { + if (String(cause) === "client_session_expired") expire(site); + return false; + }) + .finally(() => pending.delete(site)); + pending.set(site, request); + return request; + } + + async function retry() { + const site = store.currentSite; + const valid = !site || await check(site); + if (stopped || site !== store.currentSite) return; + ready.value = valid; + error.value = !valid; + } + + watch(() => store.currentSite, () => { + if (!started) return; + ready.value = false; + error.value = false; + void retry(); + }); + + onMounted(async () => { + if (!desktop || useTauriWindowGetCurrentWindow().label !== "main") { + ready.value = true; + return; + } + unlisten = await useTauriEventListen<{ site: string }>("client-session-expired", ({ payload }) => expire(payload.site)); + unlistenLogin = await useTauriEventListen<{ resolved_site: string }>("login-success-detected", ({ payload }) => { + clearWarning(payload.resolved_site); + }); + if (stopped) { + unlisten(); + unlistenLogin(); + return; + } + started = true; + store.$persist(); + const poll = () => { + void retry(); + for (const site of Object.keys(store.userMap)) { + if (site !== store.currentSite) void check(site); + } + }; + poll(); + // Checks never extend the absolute expiry, including for background sites. + timer = setInterval(poll, 30_000); + countdownTimer = setInterval(() => { + now.value = Date.now(); + }, 1000); + }); + + onBeforeUnmount(() => { + stopped = true; + if (timer) clearInterval(timer); + if (countdownTimer) clearInterval(countdownTimer); + unlisten?.(); + unlistenLogin?.(); + }); + + return { sessionReady: ready, sessionError: error, retrySession: retry }; +} diff --git a/ui/pages/auth/browser.vue b/ui/pages/auth/browser.vue index cbf10b8d6..4c2234b46 100644 --- a/ui/pages/auth/browser.vue +++ b/ui/pages/auth/browser.vue @@ -1,6 +1,6 @@