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