1mod checker;
6mod session_store;
7mod sign;
8mod slo;
9mod ticket_store;
10
11pub use checker::{LocalTicketChecker, TicketChecker};
12pub use session_store::SsoSessionStore;
13pub use sign::{RequestSign, map_sign_err_to_sso};
14pub use slo::{NoopSloNotifier, SloNotifier};
15pub use ticket_store::SsoTicketStore;
16
17#[cfg(feature = "sso-http")]
18pub use checker::HttpTicketChecker;
19#[cfg(feature = "sso-http")]
20pub use slo::HttpSloNotifier;
21
22use std::sync::Arc;
23
24use chrono::{DateTime, Duration as ChronoDuration, Utc};
25use serde::{Deserialize, Serialize};
26
27use crate::error::{SaTokenError, SaTokenResult};
28use crate::keys::{LOGIN_TYPE_DEFAULT, LOGIN_TYPE_SSO, LOGIN_TYPE_SSO_CLIENT};
29use crate::manager::SaTokenManager;
30
31type LogoutCallback = Arc<dyn Fn(&str) -> bool + Send + Sync>;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SsoTicket {
37 pub ticket_id: String,
40 pub service: String,
43 pub login_id: String,
46 pub create_time: DateTime<Utc>,
49 pub expire_time: DateTime<Utc>,
52 pub used: bool,
55}
56
57impl SsoTicket {
58 pub fn new(login_id: String, service: String, timeout_seconds: i64) -> Self {
61 let now = Utc::now();
62 Self {
63 ticket_id: uuid::Uuid::new_v4().to_string(),
64 service,
65 login_id,
66 create_time: now,
67 expire_time: now + ChronoDuration::seconds(timeout_seconds),
68 used: false,
69 }
70 }
71
72 pub fn is_expired(&self) -> bool {
75 Utc::now() > self.expire_time
76 }
77
78 pub fn is_valid(&self) -> bool {
81 !self.used && !self.is_expired()
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SsoSession {
89 pub login_id: String,
92 pub clients: Vec<String>,
95 pub create_time: DateTime<Utc>,
98 pub last_active_time: DateTime<Utc>,
101}
102
103impl SsoSession {
104 pub fn new(login_id: String) -> Self {
107 let now = Utc::now();
108 Self {
109 login_id,
110 clients: Vec::new(),
111 create_time: now,
112 last_active_time: now,
113 }
114 }
115
116 pub fn add_client(&mut self, service: String) {
119 if !self.clients.contains(&service) {
120 self.clients.push(service);
121 }
122 self.last_active_time = Utc::now();
123 }
124
125 pub fn remove_client(&mut self, service: &str) {
128 self.clients.retain(|c| c != service);
129 self.last_active_time = Utc::now();
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct CheckTicketResult {
137 pub login_id: String,
140 pub remain_seconds: i64,
143}
144
145pub struct SsoServer {
148 manager: Arc<SaTokenManager>,
149 tickets: SsoTicketStore,
150 sessions: SsoSessionStore,
151 sign: RequestSign,
152 slo_notifier: Arc<dyn SloNotifier>,
153 ticket_timeout: i64,
154 allow_cross_domain: bool,
155 allowed_origins: Vec<String>,
156}
157
158impl std::fmt::Debug for SsoServer {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.write_str("SsoServer { .. }")
161 }
162}
163
164impl SsoServer {
165 pub fn new(manager: Arc<SaTokenManager>) -> Self {
168 let dao = manager.dao().clone();
169 let cfg = SsoConfig::default();
170 Self {
171 tickets: SsoTicketStore::new(dao.clone(), cfg.ticket_timeout),
172 sessions: SsoSessionStore::new(dao.clone()),
173 sign: RequestSign::new(cfg.sign_secret.clone(), cfg.sign_window_secs).with_dao(dao),
174 slo_notifier: Arc::new(NoopSloNotifier),
175 manager,
176 ticket_timeout: cfg.ticket_timeout,
177 allow_cross_domain: cfg.allow_cross_domain,
178 allowed_origins: cfg.allowed_origins,
179 }
180 }
181
182 pub fn with_config(mut self, config: &SsoConfig) -> Self {
185 self.ticket_timeout = config.ticket_timeout;
186 self.allow_cross_domain = config.allow_cross_domain;
187 self.allowed_origins = config.allowed_origins.clone();
188 let dao = self.manager.dao().clone();
189 self.tickets = SsoTicketStore::new(dao.clone(), config.ticket_timeout);
190 self.sign =
191 RequestSign::new(config.sign_secret.clone(), config.sign_window_secs).with_dao(dao);
192 self
193 }
194
195 pub fn with_ticket_timeout(mut self, timeout: i64) -> Self {
198 self.ticket_timeout = timeout;
199 let dao = self.manager.dao().clone();
200 self.tickets = SsoTicketStore::new(dao, timeout);
201 self
202 }
203
204 pub fn with_slo_notifier(mut self, notifier: Arc<dyn SloNotifier>) -> Self {
207 self.slo_notifier = notifier;
208 self
209 }
210
211 pub fn sign(&self) -> &RequestSign {
214 &self.sign
215 }
216
217 pub fn is_allowed_origin(&self, origin: &str) -> bool {
220 if !self.allow_cross_domain {
221 return false;
222 }
223 self.allowed_origins
224 .iter()
225 .any(|allowed| allowed == "*" || allowed == origin)
226 }
227
228 fn validate_service_access(&self, service: &str) -> SaTokenResult<()> {
229 if !self.allow_cross_domain {
232 return Ok(());
233 }
234 if self
235 .allowed_origins
236 .iter()
237 .any(|allowed| allowed == "*" || allowed == service)
238 {
239 return Ok(());
240 }
241 Err(SaTokenError::ServiceMismatch)
242 }
243
244 pub async fn create_ticket(
247 &self,
248 login_id: String,
249 service: String,
250 ) -> SaTokenResult<SsoTicket> {
251 self.validate_service_access(&service)?;
252 let ticket = SsoTicket::new(login_id.clone(), service.clone(), self.ticket_timeout);
253 self.tickets.save(&ticket).await?;
254 self.sessions.upsert_client(&login_id, &service).await?;
255 Ok(ticket)
256 }
257
258 pub async fn validate_ticket(&self, ticket_id: &str, service: &str) -> SaTokenResult<String> {
261 self.validate_service_access(service)?;
262 let (preview, _) = self.tickets.check(ticket_id, service).await?;
264 if !self.check_session(&preview).await {
265 return Err(SaTokenError::SsoSessionNotFound);
266 }
267 self.tickets.consume(ticket_id, service).await
268 }
269
270 pub async fn check_ticket(
273 &self,
274 ticket_id: &str,
275 service: &str,
276 ) -> SaTokenResult<CheckTicketResult> {
277 self.validate_service_access(service)?;
278 let (login_id, remain_seconds) = self.tickets.check(ticket_id, service).await?;
279 Ok(CheckTicketResult {
280 login_id,
281 remain_seconds,
282 })
283 }
284
285 pub fn build_slo_logout_urls(client_urls: &[String]) -> Vec<String> {
288 client_urls
289 .iter()
290 .map(|client| {
291 let base = client.trim_end_matches('/');
292 format!(
293 "{}/sso/logout?slo=1&service={}",
294 base,
295 urlencoding::encode(client)
296 )
297 })
298 .collect()
299 }
300
301 pub async fn logout_with_slo(&self, login_id: &str) -> SaTokenResult<Vec<String>> {
304 let clients = self.logout(login_id).await?;
305 let urls = Self::build_slo_logout_urls(&clients);
306 for url in &urls {
307 if let Err(e) = self.slo_notifier.notify_logout(url, login_id).await {
308 tracing::warn!(url = %url, error = %e, "SLO notify failed");
309 }
310 }
311 Ok(urls)
312 }
313
314 pub async fn login(&self, login_id: String, service: String) -> SaTokenResult<SsoTicket> {
317 let _token = self
318 .manager
319 .login_with_options(
320 &login_id,
321 Some(LOGIN_TYPE_SSO.to_string()),
322 None,
323 Some(serde_json::json!({
324 "sso_mode": true,
325 "service": service.clone()
326 })),
327 None,
328 None,
329 )
330 .await?;
331 self.create_ticket(login_id, service).await
332 }
333
334 pub async fn logout(&self, login_id: &str) -> SaTokenResult<Vec<String>> {
337 let clients = self.sessions.remove(login_id).await?;
338 let _ = self
339 .manager
340 .logout_by_login_id(LOGIN_TYPE_SSO, login_id)
341 .await;
342 let _ = self
343 .manager
344 .logout_by_login_id(LOGIN_TYPE_SSO_CLIENT, login_id)
345 .await;
346 self.manager
347 .logout_by_login_id(LOGIN_TYPE_DEFAULT, login_id)
348 .await?;
349 Ok(clients)
350 }
351
352 pub async fn get_session(&self, login_id: &str) -> Option<SsoSession> {
355 self.sessions.get(login_id).await.ok().flatten()
356 }
357
358 pub async fn check_session(&self, login_id: &str) -> bool {
361 self.get_session(login_id).await.is_some()
362 }
363
364 pub async fn cleanup_expired_tickets(&self) {}
367
368 pub async fn get_active_clients(&self, login_id: &str) -> Vec<String> {
371 self.get_session(login_id)
372 .await
373 .map(|s| s.clients)
374 .unwrap_or_default()
375 }
376
377 pub async fn is_logged_in(&self, login_id: &str) -> bool {
380 if self.get_session(login_id).await.is_none() {
381 return false;
382 }
383 self.manager
384 .get_token_value_list_by_login_id(LOGIN_TYPE_SSO, login_id, None)
385 .await
386 .map(|v| !v.is_empty())
387 .unwrap_or(false)
388 }
389}
390
391pub struct SsoClient {
394 manager: Arc<SaTokenManager>,
395 server_url: String,
396 service_url: String,
397 logout_callback: Option<LogoutCallback>,
398 checker: Option<Arc<dyn TicketChecker>>,
399}
400
401impl std::fmt::Debug for SsoClient {
402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403 f.write_str("SsoClient { .. }")
404 }
405}
406
407impl SsoClient {
408 pub fn new(manager: Arc<SaTokenManager>, server_url: String, service_url: String) -> Self {
411 Self {
412 manager,
413 server_url,
414 service_url,
415 logout_callback: None,
416 checker: None,
417 }
418 }
419
420 pub fn with_logout_callback<F>(mut self, callback: F) -> Self
423 where
424 F: Fn(&str) -> bool + Send + Sync + 'static,
425 {
426 self.logout_callback = Some(Arc::new(callback));
427 self
428 }
429
430 pub fn with_ticket_checker(mut self, checker: Arc<dyn TicketChecker>) -> Self {
433 self.checker = Some(checker);
434 self
435 }
436
437 pub fn get_login_url(&self) -> String {
440 format!(
441 "{}?service={}",
442 self.server_url,
443 urlencoding::encode(&self.service_url)
444 )
445 }
446
447 pub fn get_logout_url(&self) -> String {
450 format!(
451 "{}/logout?service={}",
452 self.server_url,
453 urlencoding::encode(&self.service_url)
454 )
455 }
456
457 pub async fn check_local_login(&self, login_id: &str) -> bool {
460 let sso_ok = self
461 .manager
462 .get_token_value_list_by_login_id(LOGIN_TYPE_SSO_CLIENT, login_id, None)
463 .await
464 .map(|v| !v.is_empty())
465 .unwrap_or(false);
466 if sso_ok {
467 return true;
468 }
469 self.manager
470 .get_token_value_list_by_login_id(LOGIN_TYPE_DEFAULT, login_id, None)
471 .await
472 .map(|v| !v.is_empty())
473 .unwrap_or(false)
474 }
475
476 pub async fn process_ticket(&self, ticket: &str, service: &str) -> SaTokenResult<String> {
479 if service != self.service_url {
480 return Err(SaTokenError::ServiceMismatch);
481 }
482 let checker = self.checker.as_ref().ok_or_else(|| {
483 SaTokenError::ConfigError("SSO ticket checker is not configured".into())
484 })?;
485 checker.check_and_consume(ticket, service).await
486 }
487
488 pub async fn login_by_ticket(&self, login_id: String) -> SaTokenResult<String> {
491 let token = self
492 .manager
493 .login_with_options(
494 &login_id,
495 Some(LOGIN_TYPE_SSO_CLIENT.to_string()),
496 None,
497 Some(serde_json::json!({
498 "sso_client": true,
499 "service_url": self.service_url.clone()
500 })),
501 None,
502 None,
503 )
504 .await?;
505 Ok(token.to_string())
506 }
507
508 pub async fn handle_logout(&self, login_id: &str) -> SaTokenResult<()> {
511 if let Some(callback) = &self.logout_callback {
512 callback(login_id);
513 }
514 let _ = self
515 .manager
516 .logout_by_login_id(LOGIN_TYPE_SSO_CLIENT, login_id)
517 .await;
518 self.manager
519 .logout_by_login_id(LOGIN_TYPE_DEFAULT, login_id)
520 .await?;
521 Ok(())
522 }
523
524 pub fn server_url(&self) -> &str {
527 &self.server_url
528 }
529
530 pub fn service_url(&self) -> &str {
533 &self.service_url
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct SsoConfig {
541 pub server_url: String,
544 pub ticket_timeout: i64,
547 pub allow_cross_domain: bool,
550 pub allowed_origins: Vec<String>,
553 pub sign_secret: String,
556 pub sign_window_secs: i64,
559}
560
561impl Default for SsoConfig {
562 fn default() -> Self {
563 Self {
564 server_url: "http://localhost:8080/sso".to_string(),
565 ticket_timeout: 300,
566 allow_cross_domain: false,
567 allowed_origins: vec![],
568 sign_secret: String::new(),
569 sign_window_secs: 300,
570 }
571 }
572}
573
574impl SsoConfig {
575 pub fn builder() -> SsoConfigBuilder {
578 SsoConfigBuilder::default()
579 }
580}
581
582#[derive(Default)]
585pub struct SsoConfigBuilder {
586 config: SsoConfig,
587}
588
589impl std::fmt::Debug for SsoConfigBuilder {
590 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591 f.write_str("SsoConfigBuilder { .. }")
592 }
593}
594
595impl SsoConfigBuilder {
596 pub fn server_url(mut self, url: impl Into<String>) -> Self {
599 self.config.server_url = url.into();
600 self
601 }
602
603 pub fn ticket_timeout(mut self, timeout: i64) -> Self {
606 self.config.ticket_timeout = timeout;
607 self
608 }
609
610 pub fn allow_cross_domain(mut self, allow: bool) -> Self {
613 self.config.allow_cross_domain = allow;
614 self
615 }
616
617 pub fn allowed_origins(mut self, origins: Vec<String>) -> Self {
620 self.config.allowed_origins = origins;
621 self
622 }
623
624 pub fn add_allowed_origin(mut self, origin: String) -> Self {
627 self.config.allowed_origins.push(origin);
628 self
629 }
630
631 pub fn sign_secret(mut self, secret: impl Into<String>) -> Self {
634 self.config.sign_secret = secret.into();
635 self
636 }
637
638 pub fn sign_window_secs(mut self, secs: i64) -> Self {
641 self.config.sign_window_secs = secs;
642 self
643 }
644
645 pub fn build(self) -> SsoConfig {
648 self.config
649 }
650}
651
652pub struct SsoManager {
655 server: Option<Arc<SsoServer>>,
656 client: Option<Arc<SsoClient>>,
657 config: SsoConfig,
658}
659
660impl std::fmt::Debug for SsoManager {
661 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
662 f.write_str("SsoManager { .. }")
663 }
664}
665
666impl SsoManager {
667 pub fn new(config: SsoConfig) -> Self {
670 Self {
671 server: None,
672 client: None,
673 config,
674 }
675 }
676
677 pub fn with_server(mut self, server: Arc<SsoServer>) -> Self {
680 self.server = Some(server);
681 self
682 }
683
684 pub fn with_client(mut self, client: Arc<SsoClient>) -> Self {
687 self.client = Some(client);
688 self
689 }
690
691 pub fn server(&self) -> Option<&Arc<SsoServer>> {
694 self.server.as_ref()
695 }
696
697 pub fn client(&self) -> Option<&Arc<SsoClient>> {
700 self.client.as_ref()
701 }
702
703 pub fn config(&self) -> &SsoConfig {
706 &self.config
707 }
708
709 pub fn is_allowed_origin(&self, origin: &str) -> bool {
712 if !self.config.allow_cross_domain {
713 return false;
714 }
715 self.config
716 .allowed_origins
717 .iter()
718 .any(|allowed| allowed == "*" || allowed == origin)
719 }
720}