sa_token_core/sso.rs
1//! # SSO 单点登录模块 | SSO Single Sign-On Module
2//!
3//! 提供完整的单点登录功能实现,支持票据认证和统一登出。
4//! Provides complete Single Sign-On functionality with ticket-based authentication and unified logout.
5//!
6//! ## 代码流程逻辑 | Code Flow Logic
7//!
8//! ### 1. 核心组件 | Core Components
9//!
10//! ```text
11//! SsoServer(SSO 服务端)
12//! ├── 票据管理 | Ticket Management
13//! │ ├── 生成票据 create_ticket()
14//! │ ├── 验证票据 validate_ticket()
15//! │ └── 清理过期票据 cleanup_expired_tickets()
16//! ├── 会话管理 | Session Management
17//! │ ├── 创建会话 login()
18//! │ ├── 获取会话 get_session()
19//! │ └── 删除会话 logout()
20//! └── 客户端追踪 | Client Tracking
21//! └── 获取活跃客户端 get_active_clients()
22//!
23//! SsoClient(SSO 客户端)
24//! ├── URL 生成 | URL Generation
25//! │ ├── 登录 URL get_login_url()
26//! │ └── 登出 URL get_logout_url()
27//! ├── 本地会话 | Local Session
28//! │ ├── 检查登录 check_local_login()
29//! │ └── 票据登录 login_by_ticket()
30//! └── 登出处理 | Logout Handling
31//! └── 处理登出 handle_logout()
32//! ```
33//!
34//! ### 2. 登录流程 | Login Flow
35//!
36//! ```text
37//! 步骤 1: 用户访问应用 → 重定向到 SSO Server
38//! Step 1: User accesses app → Redirect to SSO Server
39//!
40//! 步骤 2: SSO Server 验证凭证
41//! Step 2: SSO Server validates credentials
42//! └─> login(login_id, service)
43//! ├─> 创建 Token
44//! ├─> 创建或更新 SsoSession
45//! └─> 生成 SsoTicket
46//!
47//! 步骤 3: 客户端应用验证票据
48//! Step 3: Client app validates ticket
49//! └─> validate_ticket(ticket_id, service)
50//! ├─> 检查票据存在
51//! ├─> 验证票据有效性(未过期、未使用)
52//! ├─> 验证服务 URL 匹配
53//! ├─> 标记票据为已使用
54//! └─> 返回 login_id
55//!
56//! 步骤 4: 创建本地会话
57//! Step 4: Create local session
58//! └─> client.login_by_ticket(login_id)
59//! └─> manager.login(login_id) → 创建本地 Token
60//! ```
61//!
62//! ### 3. SSO 无缝登录流程 | SSO Seamless Login Flow
63//!
64//! ```text
65//! 用户已在应用1登录,访问应用2:
66//! User logged in App1, accessing App2:
67//!
68//! 应用2 → SSO Server: 请求认证
69//! App2 → SSO Server: Request authentication
70//! └─> is_logged_in(login_id) → true
71//! └─> create_ticket(login_id, app2_url)
72//! └─> 直接返回票据(无需再次登录)
73//! Return ticket (no re-login required)
74//!
75//! 应用2 → 验证票据 → 创建本地会话 → 访问授权
76//! App2 → Validate ticket → Create local session → Access granted
77//! ```
78//!
79//! ### 4. 统一登出流程 | Unified Logout Flow
80//!
81//! ```text
82//! 用户从任一应用登出:
83//! User logs out from any app:
84//!
85//! logout(login_id)
86//! ├─> 获取 SsoSession
87//! ├─> 获取所有已登录客户端列表
88//! ├─> 删除 SsoSession
89//! ├─> 删除用户的所有 Token
90//! └─> 返回客户端列表
91//!
92//! 通知所有客户端:
93//! Notify all clients:
94//! └─> for each client_url
95//! └─> client.handle_logout(login_id)
96//! └─> 清除本地会话 | Clear local session
97//! ```
98//!
99//! ### 5. 票据生命周期 | Ticket Lifecycle
100//!
101//! ```text
102//! 创建 | Create: ticket.create_time = now
103//! └─> 设置过期时间 | Set expiration: expire_time = now + timeout
104//! └─> 状态 | Status: used = false
105//!
106//! 验证 | Validate:
107//! ├─> 检查过期 | Check expiration: now > expire_time?
108//! ├─> 检查使用状态 | Check usage: used == true?
109//! └─> 验证服务 | Verify service: service == expected?
110//!
111//! 使用 | Use: 验证成功后 | After validation
112//! └─> ticket.used = true(一次性使用 | One-time use)
113//!
114//! 清理 | Cleanup: cleanup_expired_tickets()
115//! └─> 删除所有过期或已使用的票据
116//! Remove all expired or used tickets
117//! ```
118//!
119//! ### 6. 安全机制 | Security Mechanisms
120//!
121//! ```text
122//! 1. 票据一次性使用 | One-time ticket usage
123//! └─> validate_ticket() 后立即设置 used = true
124//!
125//! 2. 服务 URL 匹配 | Service URL matching
126//! └─> ticket.service 必须与请求的 service 完全匹配
127//!
128//! 3. 票据过期 | Ticket expiration
129//! └─> 默认 5 分钟过期,可配置
130//!
131//! 4. 跨域保护 | Cross-domain protection
132//! └─> SsoConfig.allowed_origins 白名单机制
133//!
134//! 5. UUID 票据 ID | UUID ticket ID
135//! └─> 使用 UUID 防止票据 ID 被猜测
136//! ```
137
138use std::sync::Arc;
139use std::collections::HashMap;
140use chrono::{DateTime, Utc, Duration as ChronoDuration};
141use serde::{Serialize, Deserialize};
142use tokio::sync::RwLock;
143use crate::{SaTokenError, SaTokenResult, SaTokenManager};
144
145/// SSO 票据结构 | SSO Ticket Structure
146///
147/// 票据是一个短期、一次性使用的认证令牌
148/// A ticket is a short-lived, one-time use authentication token
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SsoTicket {
151 /// 票据唯一标识符(UUID)| Unique ticket identifier (UUID)
152 pub ticket_id: String,
153 /// 目标服务 URL | Target service URL
154 pub service: String,
155 /// 用户登录 ID | User login ID
156 pub login_id: String,
157 /// 票据创建时间 | Ticket creation time
158 pub create_time: DateTime<Utc>,
159 /// 票据过期时间 | Ticket expiration time
160 pub expire_time: DateTime<Utc>,
161 /// 是否已使用(一次性使用)| Whether used (one-time use)
162 pub used: bool,
163}
164
165impl SsoTicket {
166 /// 创建新票据 | Create a new ticket
167 ///
168 /// # 参数 | Parameters
169 /// * `login_id` - 用户登录 ID | User login ID
170 /// * `service` - 目标服务 URL | Target service URL
171 /// * `timeout_seconds` - 票据有效期(秒)| Ticket validity period (seconds)
172 pub fn new(login_id: String, service: String, timeout_seconds: i64) -> Self {
173 let now = Utc::now();
174 Self {
175 ticket_id: uuid::Uuid::new_v4().to_string(),
176 service,
177 login_id,
178 create_time: now,
179 expire_time: now + ChronoDuration::seconds(timeout_seconds),
180 used: false,
181 }
182 }
183
184 /// 检查票据是否过期 | Check if ticket is expired
185 pub fn is_expired(&self) -> bool {
186 Utc::now() > self.expire_time
187 }
188
189 /// 检查票据是否有效(未使用且未过期)| Check if ticket is valid (not used and not expired)
190 pub fn is_valid(&self) -> bool {
191 !self.used && !self.is_expired()
192 }
193}
194
195/// SSO 全局会话 | SSO Global Session
196///
197/// 跟踪用户在所有应用中的登录状态
198/// Tracks user's login status across all applications
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct SsoSession {
201 /// 用户登录 ID | User login ID
202 pub login_id: String,
203 /// 已登录的客户端列表 | List of logged-in clients
204 pub clients: Vec<String>,
205 /// 会话创建时间 | Session creation time
206 pub create_time: DateTime<Utc>,
207 /// 最后活动时间 | Last activity time
208 pub last_active_time: DateTime<Utc>,
209}
210
211impl SsoSession {
212 /// 创建新会话 | Create a new session
213 pub fn new(login_id: String) -> Self {
214 let now = Utc::now();
215 Self {
216 login_id,
217 clients: Vec::new(),
218 create_time: now,
219 last_active_time: now,
220 }
221 }
222
223 /// 添加客户端到会话 | Add client to session
224 ///
225 /// 如果客户端不在列表中,则添加
226 /// Adds client if not already in the list
227 pub fn add_client(&mut self, service: String) {
228 if !self.clients.contains(&service) {
229 self.clients.push(service);
230 }
231 self.last_active_time = Utc::now();
232 }
233
234 /// 从会话中移除客户端 | Remove client from session
235 pub fn remove_client(&mut self, service: &str) {
236 self.clients.retain(|c| c != service);
237 self.last_active_time = Utc::now();
238 }
239}
240
241/// SSO 服务端 | SSO Server
242///
243/// 中央认证服务,负责票据生成、验证和会话管理
244/// Central authentication service responsible for ticket generation, validation, and session management
245pub struct SsoServer {
246 manager: Arc<SaTokenManager>,
247 tickets: Arc<RwLock<HashMap<String, SsoTicket>>>,
248 sessions: Arc<RwLock<HashMap<String, SsoSession>>>,
249 ticket_timeout: i64,
250}
251
252impl SsoServer {
253 /// 创建新的 SSO 服务端 | Create a new SSO Server
254 ///
255 /// # 参数 | Parameters
256 /// * `manager` - SaTokenManager 实例 | SaTokenManager instance
257 pub fn new(manager: Arc<SaTokenManager>) -> Self {
258 Self {
259 manager,
260 tickets: Arc::new(RwLock::new(HashMap::new())),
261 sessions: Arc::new(RwLock::new(HashMap::new())),
262 ticket_timeout: 300, // 默认 5 分钟 | Default 5 minutes
263 }
264 }
265
266 /// 设置票据超时时间 | Set ticket timeout
267 ///
268 /// # 参数 | Parameters
269 /// * `timeout` - 超时时间(秒)| Timeout in seconds
270 pub fn with_ticket_timeout(mut self, timeout: i64) -> Self {
271 self.ticket_timeout = timeout;
272 self
273 }
274
275 /// 检查用户是否已登录 | Check if user is logged in
276 ///
277 /// 通过检查 SSO 会话是否存在来判断
278 /// Determined by checking if SSO session exists
279 pub async fn is_logged_in(&self, login_id: &str) -> bool {
280 let sessions = self.sessions.read().await;
281 sessions.contains_key(login_id)
282 }
283
284 /// 创建票据 | Create ticket
285 ///
286 /// 为已登录用户创建访问特定服务的票据
287 /// Creates a ticket for logged-in user to access specific service
288 ///
289 /// # 参数 | Parameters
290 /// * `login_id` - 用户登录 ID | User login ID
291 /// * `service` - 目标服务 URL | Target service URL
292 ///
293 /// # 返回 | Returns
294 /// 新创建的票据 | Newly created ticket
295 pub async fn create_ticket(&self, login_id: String, service: String) -> SaTokenResult<SsoTicket> {
296 // 生成票据 | Generate ticket
297 let ticket = SsoTicket::new(login_id.clone(), service.clone(), self.ticket_timeout);
298
299 // 存储票据 | Store ticket
300 let mut tickets = self.tickets.write().await;
301 tickets.insert(ticket.ticket_id.clone(), ticket.clone());
302
303 // 更新会话,添加客户端 | Update session, add client
304 let mut sessions = self.sessions.write().await;
305 sessions.entry(login_id.clone())
306 .or_insert_with(|| SsoSession::new(login_id))
307 .add_client(service);
308
309 Ok(ticket)
310 }
311
312 /// 验证票据 | Validate ticket
313 ///
314 /// 验证票据的有效性并将其标记为已使用(一次性使用)
315 /// Validates ticket and marks it as used (one-time use)
316 ///
317 /// # 参数 | Parameters
318 /// * `ticket_id` - 票据 ID | Ticket ID
319 /// * `service` - 请求的服务 URL | Requested service URL
320 ///
321 /// # 返回 | Returns
322 /// 用户登录 ID | User login ID
323 ///
324 /// # 错误 | Errors
325 /// * `InvalidTicket` - 票据不存在 | Ticket not found
326 /// * `TicketExpired` - 票据已过期或已使用 | Ticket expired or used
327 /// * `ServiceMismatch` - 服务 URL 不匹配 | Service URL mismatch
328 pub async fn validate_ticket(&self, ticket_id: &str, service: &str) -> SaTokenResult<String> {
329 let mut tickets = self.tickets.write().await;
330
331 // 1. 检查票据是否存在 | Check if ticket exists
332 let ticket = tickets.get_mut(ticket_id)
333 .ok_or(SaTokenError::InvalidTicket)?;
334
335 // 2. 验证票据有效性(未过期、未使用)| Validate ticket (not expired, not used)
336 if !ticket.is_valid() {
337 return Err(SaTokenError::TicketExpired);
338 }
339
340 // 3. 验证服务 URL 匹配 | Verify service URL matches
341 if ticket.service != service {
342 return Err(SaTokenError::ServiceMismatch);
343 }
344
345 // 4. 标记票据为已使用(一次性使用)| Mark ticket as used (one-time use)
346 ticket.used = true;
347 let login_id = ticket.login_id.clone();
348
349 Ok(login_id)
350 }
351
352 /// 用户登录 | User login
353 ///
354 /// 完整的登录流程:创建 Token、会话和票据
355 /// Complete login flow: create Token, session, and ticket
356 ///
357 /// # 参数 | Parameters
358 /// * `login_id` - 用户登录 ID | User login ID
359 /// * `service` - 目标服务 URL | Target service URL
360 ///
361 /// # 返回 | Returns
362 /// 生成的票据 | Generated ticket
363 pub async fn login(&self, login_id: String, service: String) -> SaTokenResult<SsoTicket> {
364 let _token = self.manager.login(&login_id).await?;
365
366 let mut sessions = self.sessions.write().await;
367 sessions.entry(login_id.clone())
368 .or_insert_with(|| SsoSession::new(login_id.clone()))
369 .add_client(service.clone());
370
371 drop(sessions);
372
373 self.create_ticket(login_id, service).await
374 }
375
376 /// 统一登出 | Unified logout
377 ///
378 /// 从 SSO 服务端登出,并返回需要通知的客户端列表
379 /// Logout from SSO Server and return list of clients to notify
380 ///
381 /// # 参数 | Parameters
382 /// * `login_id` - 用户登录 ID | User login ID
383 ///
384 /// # 返回 | Returns
385 /// 需要清除会话的客户端 URL 列表 | List of client URLs to clear sessions
386 pub async fn logout(&self, login_id: &str) -> SaTokenResult<Vec<String>> {
387 // 1. 获取并删除 SSO 会话 | Get and remove SSO session
388 let mut sessions = self.sessions.write().await;
389 let session = sessions.remove(login_id);
390
391 // 2. 提取客户端列表 | Extract client list
392 let clients = session.map(|s| s.clients).unwrap_or_default();
393
394 drop(sessions);
395
396 // 3. 从 Token 管理器中登出 | Logout from Token manager
397 self.manager.logout_by_login_id(login_id).await?;
398
399 // 4. 返回客户端列表供通知 | Return client list for notification
400 Ok(clients)
401 }
402
403 pub async fn get_session(&self, login_id: &str) -> Option<SsoSession> {
404 let sessions = self.sessions.read().await;
405 sessions.get(login_id).cloned()
406 }
407
408 pub async fn check_session(&self, login_id: &str) -> bool {
409 let sessions = self.sessions.read().await;
410 sessions.contains_key(login_id)
411 }
412
413 pub async fn cleanup_expired_tickets(&self) {
414 let mut tickets = self.tickets.write().await;
415 tickets.retain(|_, ticket| ticket.is_valid());
416 }
417
418 pub async fn get_active_clients(&self, login_id: &str) -> Vec<String> {
419 let sessions = self.sessions.read().await;
420 sessions.get(login_id)
421 .map(|s| s.clients.clone())
422 .unwrap_or_default()
423 }
424}
425
426/// SSO 客户端 | SSO Client
427///
428/// 每个应用作为 SSO 客户端,处理本地会话和票据验证
429/// Each application acts as SSO Client, handling local sessions and ticket validation
430pub struct SsoClient {
431 /// Token 管理器 | Token manager
432 manager: Arc<SaTokenManager>,
433 /// SSO 服务端 URL | SSO Server URL
434 server_url: String,
435 /// 当前服务 URL | Current service URL
436 service_url: String,
437 /// 登出回调函数 | Logout callback function
438 logout_callback: Option<Arc<dyn Fn(&str) -> bool + Send + Sync>>,
439}
440
441impl SsoClient {
442 /// 创建新的 SSO 客户端 | Create a new SSO Client
443 ///
444 /// # 参数 | Parameters
445 /// * `manager` - SaTokenManager 实例 | SaTokenManager instance
446 /// * `server_url` - SSO 服务端 URL | SSO Server URL
447 /// * `service_url` - 当前服务 URL | Current service URL
448 pub fn new(
449 manager: Arc<SaTokenManager>,
450 server_url: String,
451 service_url: String,
452 ) -> Self {
453 Self {
454 manager,
455 server_url,
456 service_url,
457 logout_callback: None,
458 }
459 }
460
461 pub fn with_logout_callback<F>(mut self, callback: F) -> Self
462 where
463 F: Fn(&str) -> bool + Send + Sync + 'static,
464 {
465 self.logout_callback = Some(Arc::new(callback));
466 self
467 }
468
469 pub fn get_login_url(&self) -> String {
470 format!("{}?service={}", self.server_url, urlencoding::encode(&self.service_url))
471 }
472
473 pub fn get_logout_url(&self) -> String {
474 format!("{}/logout?service={}", self.server_url, urlencoding::encode(&self.service_url))
475 }
476
477 pub async fn check_local_login(&self, login_id: &str) -> bool {
478 let key = format!("sa:login:token:{}", login_id);
479 match self.manager.storage.get(&key).await {
480 Ok(Some(_)) => true,
481 _ => false,
482 }
483 }
484
485 pub async fn process_ticket(&self, ticket: &str, service: &str) -> SaTokenResult<String> {
486 if service != self.service_url {
487 return Err(SaTokenError::ServiceMismatch);
488 }
489
490 Ok(ticket.to_string())
491 }
492
493 pub async fn login_by_ticket(&self, login_id: String) -> SaTokenResult<String> {
494 let token = self.manager.login(&login_id).await?;
495 Ok(token.to_string())
496 }
497
498 pub async fn handle_logout(&self, login_id: &str) -> SaTokenResult<()> {
499 if let Some(callback) = &self.logout_callback {
500 callback(login_id);
501 }
502
503 self.manager.logout_by_login_id(login_id).await?;
504 Ok(())
505 }
506
507 pub fn server_url(&self) -> &str {
508 &self.server_url
509 }
510
511 pub fn service_url(&self) -> &str {
512 &self.service_url
513 }
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct SsoConfig {
518 pub server_url: String,
519 pub ticket_timeout: i64,
520 pub allow_cross_domain: bool,
521 pub allowed_origins: Vec<String>,
522}
523
524impl Default for SsoConfig {
525 fn default() -> Self {
526 Self {
527 server_url: "http://localhost:8080/sso".to_string(),
528 ticket_timeout: 300,
529 allow_cross_domain: true,
530 allowed_origins: vec!["*".to_string()],
531 }
532 }
533}
534
535impl SsoConfig {
536 pub fn builder() -> SsoConfigBuilder {
537 SsoConfigBuilder::default()
538 }
539}
540
541#[derive(Default)]
542pub struct SsoConfigBuilder {
543 config: SsoConfig,
544}
545
546impl SsoConfigBuilder {
547 pub fn server_url(mut self, url: impl Into<String>) -> Self {
548 self.config.server_url = url.into();
549 self
550 }
551
552 pub fn ticket_timeout(mut self, timeout: i64) -> Self {
553 self.config.ticket_timeout = timeout;
554 self
555 }
556
557 pub fn allow_cross_domain(mut self, allow: bool) -> Self {
558 self.config.allow_cross_domain = allow;
559 self
560 }
561
562 pub fn allowed_origins(mut self, origins: Vec<String>) -> Self {
563 self.config.allowed_origins = origins;
564 self
565 }
566
567 pub fn add_allowed_origin(mut self, origin: String) -> Self {
568 if self.config.allowed_origins == vec!["*".to_string()] {
569 self.config.allowed_origins = vec![origin];
570 } else {
571 self.config.allowed_origins.push(origin);
572 }
573 self
574 }
575
576 pub fn build(self) -> SsoConfig {
577 self.config
578 }
579}
580
581pub struct SsoManager {
582 server: Option<Arc<SsoServer>>,
583 client: Option<Arc<SsoClient>>,
584 config: SsoConfig,
585}
586
587impl SsoManager {
588 pub fn new(config: SsoConfig) -> Self {
589 Self {
590 server: None,
591 client: None,
592 config,
593 }
594 }
595
596 pub fn with_server(mut self, server: Arc<SsoServer>) -> Self {
597 self.server = Some(server);
598 self
599 }
600
601 pub fn with_client(mut self, client: Arc<SsoClient>) -> Self {
602 self.client = Some(client);
603 self
604 }
605
606 pub fn server(&self) -> Option<&Arc<SsoServer>> {
607 self.server.as_ref()
608 }
609
610 pub fn client(&self) -> Option<&Arc<SsoClient>> {
611 self.client.as_ref()
612 }
613
614 pub fn config(&self) -> &SsoConfig {
615 &self.config
616 }
617
618 pub fn is_allowed_origin(&self, origin: &str) -> bool {
619 if !self.config.allow_cross_domain {
620 return false;
621 }
622
623 self.config.allowed_origins.contains(&"*".to_string()) ||
624 self.config.allowed_origins.contains(&origin.to_string())
625 }
626}
627