Skip to main content

sa_token_core/service/
auth_service.rs

1//! 认证服务:登录、登出、踢人、续期的唯一业务入口。
2//!
3//! 本模块承担「跨仓储编排」职责:单个仓储只保证单键操作正确,
4//! 而一次登录要同时改动 6 个键、一次下线要同时改动 5 个键,
5//! 这些复合操作的顺序、失败补偿与并发保护全部收敛在这里。
6//!
7//! Authentication service: the single entry point for login, logout, kickout and
8//! renewal. Because `SaStorage` only guarantees single-key atomicity, a login is
9//! made near-transactional through staged writes, reverse compensation, and a
10//! compare-and-swap commit point on the `login:token` mapping.
11
12use std::collections::HashSet;
13use std::sync::Arc;
14
15use chrono::{Duration as ChronoDuration, Utc};
16
17use crate::config::{LogoutMode, LogoutRange, ReplacedLoginExitMode, ReplacedRange, SaTokenConfig};
18use crate::dao::SaTokenDao;
19use crate::distributed::DistributedSessionManager;
20use crate::error::{SaTokenError, SaTokenResult};
21use crate::event::{SaTokenEvent, SaTokenEventBus};
22use crate::keys::{AccountNs, LoginId, SaKeys};
23use crate::nonce::NonceManager;
24use crate::online::OnlineManager;
25use crate::refresh::RefreshTokenManager;
26use crate::repository::{SessionRepo, TokenIdMapping, TokenRepo};
27use crate::service::compensate::LoginCompensator;
28use crate::service::login_request::LoginRequest;
29use crate::session::SaTerminalInfo;
30use crate::token::{TokenGenerator, TokenInfo, TokenValue};
31
32/// 下线时解析出的账号身份 | Account identity resolved during logout
33struct LogoutIdentity {
34    login_type: String,
35    login_id: String,
36    /// token 体是否存在(决定是否需要清理终端与 Session)
37    /// Whether the token body existed, deciding terminal/session cleanup
38    _body_existed: bool,
39}
40
41/// 认证领域服务 | Authentication domain service
42pub struct AuthService {
43    dao: Arc<SaTokenDao>,
44    token_repo: Arc<TokenRepo>,
45    session_repo: Arc<SessionRepo>,
46    config: Arc<SaTokenConfig>,
47    event_bus: SaTokenEventBus,
48    online_manager: Option<Arc<OnlineManager>>,
49    /// Optional cross-service session; None keeps current login behaviour.
50    /// 可选跨服务会话;None 时登录行为与现在一致。
51    distributed: Option<Arc<DistributedSessionManager>>,
52}
53
54impl std::fmt::Debug for AuthService {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str("AuthService { .. }")
57    }
58}
59
60impl AuthService {
61    /// 构造服务。
62    pub fn new(
63        dao: Arc<SaTokenDao>,
64        config: Arc<SaTokenConfig>,
65        token_repo: Arc<TokenRepo>,
66        session_repo: Arc<SessionRepo>,
67        event_bus: SaTokenEventBus,
68        online_manager: Option<Arc<OnlineManager>>,
69        distributed: Option<Arc<DistributedSessionManager>>,
70    ) -> Self {
71        Self {
72            dao,
73            token_repo,
74            session_repo,
75            config,
76            event_bus,
77            online_manager,
78            distributed,
79        }
80    }
81
82    /// Token 仓储 | Token repository
83    pub fn token_repo(&self) -> &Arc<TokenRepo> {
84        &self.token_repo
85    }
86
87    /// Session 仓储 | Session repository
88    pub fn session_repo(&self) -> &Arc<SessionRepo> {
89        &self.session_repo
90    }
91
92    /// 账号命名空间构造(统一校验入口)| Build the account namespace with validation
93    fn account_ns(login_type: &str, login_id: &str) -> SaTokenResult<AccountNs> {
94        let id =
95            LoginId::try_new(login_id).map_err(|e| SaTokenError::ConfigError(e.to_string()))?;
96        Ok(SaKeys::account_ns(login_type, &id))
97    }
98
99    /// 登录主流程:阶段化写入 + 逆序补偿 + CAS 提交点。
100    pub async fn login(&self, req: LoginRequest) -> SaTokenResult<TokenValue> {
101        let login_type = req.effective_login_type().to_string();
102        let login_id = req.login_id.clone();
103        let ns = Self::account_ns(&login_type, &login_id)?;
104
105        let mut compensator = LoginCompensator::new();
106
107        // 默认 login 服务封禁时拒绝登录(与 check_disable 契约对齐)
108        {
109            use crate::disable::{DEFAULT_DISABLE_SERVICE, MIN_DISABLE_LEVEL, NOT_DISABLE_LEVEL};
110            let key = self
111                .dao
112                .keys()
113                .disable(&login_type, &login_id, DEFAULT_DISABLE_SERVICE);
114            if let Some(raw) = self.dao.get_string(&key).await? {
115                let level: i32 = raw.parse().unwrap_or(MIN_DISABLE_LEVEL);
116                if level != NOT_DISABLE_LEVEL && level >= MIN_DISABLE_LEVEL {
117                    return Err(SaTokenError::AccountBanned(format!(
118                        "service={DEFAULT_DISABLE_SERVICE} level={level}"
119                    )));
120                }
121            }
122        }
123
124        if self.config.enable_nonce
125            && let Some(ref nonce_str) = req.nonce
126        {
127            self.consume_nonce(nonce_str, &login_id, &mut compensator)
128                .await?;
129        }
130
131        if self.config.is_share
132            && let Some(existing) = self
133                .token_repo
134                .get_login_mapping(&login_type, &login_id)
135                .await?
136        {
137            let existing_token = TokenValue::new(existing);
138            if self
139                .token_repo
140                .load_valid_token_info(&existing_token)
141                .await
142                .is_ok()
143            {
144                compensator.commit();
145                if self.config.is_log {
146                    tracing::info!(login_id = %login_id, "login success");
147                }
148                return Ok(existing_token);
149            }
150        }
151
152        let mut token_info = self.build_token_info(&req, &login_type).await?;
153        let token = token_info.token.clone();
154
155        let mapping_before = self
156            .token_repo
157            .get_login_mapping(&login_type, &login_id)
158            .await?;
159
160        if !self.config.is_concurrent {
161            match self
162                .handle_replaced_on_login(&login_type, &login_id, &ns, &req, token.as_str())
163                .await
164            {
165                Ok(()) => {}
166                Err(e) => {
167                    let _ = compensator.rollback(&self.dao).await;
168                    return Err(e);
169                }
170            }
171        }
172
173        let refresh_mgr = if self.config.enable_refresh_token {
174            Some(RefreshTokenManager::new(
175                self.dao.clone(),
176                self.token_repo.clone(),
177                self.config.clone(),
178            ))
179        } else {
180            None
181        };
182        if let Some(ref mgr) = refresh_mgr {
183            token_info.refresh_token = Some(mgr.generate(&login_id));
184            if self.config.refresh_token_timeout > 0 {
185                token_info.refresh_token_expire_time =
186                    Some(Utc::now() + ChronoDuration::seconds(self.config.refresh_token_timeout));
187            }
188        }
189
190        let write_result = self
191            .write_login_stages(
192                &login_type,
193                &login_id,
194                &ns,
195                &req,
196                &token_info,
197                mapping_before.as_deref(),
198                refresh_mgr.as_ref(),
199                &mut compensator,
200            )
201            .await;
202
203        if let Err(e) = write_result {
204            let _ = compensator.rollback(&self.dao).await;
205            return Err(e);
206        }
207
208        if let Err(e) = self.enforce_max_login_count(&login_type, &login_id).await {
209            tracing::warn!(
210                login_id = %login_id,
211                error = %e,
212                "max_login_count enforcement failed after commit, login still succeeds"
213            );
214        }
215
216        compensator.commit();
217
218        if let Some(online) = &self.online_manager {
219            let device = req.effective_device().unwrap_or("unknown");
220            let mut user = crate::online::OnlineUser::new(
221                login_id.clone(),
222                token.as_str().to_string(),
223                device.to_string(),
224            );
225            user.login_type = login_type.clone();
226            if let Err(e) = online.mark_online(user).await {
227                tracing::warn!(error = %e, login_id = %login_id, "failed to mark online after login");
228            }
229        }
230
231        if let Some(dm) = &self.distributed {
232            if let Err(e) = dm
233                .create_session(login_id.clone(), token.as_str().to_string())
234                .await
235            {
236                tracing::warn!(error = %e, login_id = %login_id, "distributed session create failed after login commit");
237            }
238        }
239
240        let event =
241            SaTokenEvent::login(login_id.clone(), token.as_str()).with_login_type(&login_type);
242        self.event_bus.publish(event).await;
243
244        if self.config.is_log {
245            tracing::info!(login_id = %login_id, "login success");
246        }
247
248        Ok(token)
249    }
250
251    async fn consume_nonce(
252        &self,
253        nonce_str: &str,
254        login_id: &str,
255        compensator: &mut LoginCompensator,
256    ) -> SaTokenResult<()> {
257        let nonce_timeout = if self.config.nonce_timeout > 0 {
258            self.config.nonce_timeout
259        } else {
260            self.config.timeout
261        };
262
263        let nonce_key = self.dao.keys().nonce(nonce_str);
264        let snapshot = self.dao.get_string(&nonce_key).await?;
265
266        let nonce_mgr = NonceManager::from_dao(self.dao.clone(), nonce_timeout);
267        nonce_mgr.validate_and_consume(nonce_str, login_id).await?;
268
269        if let Some(raw) = snapshot {
270            let ttl = if nonce_timeout > 0 {
271                Some(std::time::Duration::from_secs(nonce_timeout as u64))
272            } else {
273                None
274            };
275            compensator.on_fail_restore(nonce_key, raw, ttl);
276        }
277
278        Ok(())
279    }
280
281    async fn build_token_info(
282        &self,
283        req: &LoginRequest,
284        login_type: &str,
285    ) -> SaTokenResult<TokenInfo> {
286        let token = match req.preset_token.as_deref() {
287            Some(preset) if !preset.is_empty() => TokenValue::new(preset),
288            _ => {
289                let extra = req.extra_data.clone();
290                let login_id = req.login_id.clone();
291                let cfg = self.config.clone();
292                crate::token::generate_unique(
293                    cfg.max_try_times,
294                    || match extra.as_ref() {
295                        Some(extra) => {
296                            TokenGenerator::generate_with_login_id_and_extra(&cfg, &login_id, extra)
297                        }
298                        None => TokenGenerator::generate_with_login_id(&cfg, &login_id),
299                    },
300                    |t| {
301                        let repo = self.token_repo.clone();
302                        let token = t.to_string();
303                        async move { Ok(repo.get_token_info(&token).await?.is_some()) }
304                    },
305                )
306                .await?
307            }
308        };
309
310        let mut info = TokenInfo::new(token, req.login_id.as_str());
311        info.login_type = crate::token::intern_login_type(login_type);
312        info.device = req.device.clone();
313        info.extra_data = req.extra_data.clone();
314        info.nonce = req.nonce.clone();
315        info.update_active_time();
316
317        if let Some(expire) = req.expire_time {
318            info.expire_time = Some(expire);
319        } else if let Some(timeout) = self.config.timeout_duration() {
320            let d = ChronoDuration::from_std(timeout).map_err(|_| {
321                SaTokenError::ConfigError("timeout value is out of supported range".to_string())
322            })?;
323            info.expire_time = Some(Utc::now() + d);
324        }
325
326        Ok(info)
327    }
328
329    #[allow(clippy::too_many_arguments)]
330    async fn write_login_stages(
331        &self,
332        login_type: &str,
333        login_id: &str,
334        ns: &AccountNs,
335        req: &LoginRequest,
336        token_info: &TokenInfo,
337        mapping_before: Option<&str>,
338        refresh_mgr: Option<&RefreshTokenManager>,
339        compensator: &mut LoginCompensator,
340    ) -> SaTokenResult<()> {
341        let token = token_info.token.as_str();
342        let keys = self.dao.keys();
343
344        self.token_repo
345            .append_index(login_type, login_id, token)
346            .await?;
347        compensator.on_fail_list_remove(keys.login_token_index(login_type, login_id), token);
348
349        let session_key = keys
350            .session_by_ns(ns)
351            .map_err(|e| SaTokenError::ConfigError(e.to_string()))?;
352        match self.session_repo.snapshot_account_session(ns).await? {
353            Some(old_raw) => {
354                compensator.on_fail_restore(session_key, old_raw, self.dao.default_ttl())
355            }
356            None => compensator.on_fail_delete(session_key),
357        }
358        let mut terminal = SaTerminalInfo::new(token, req.effective_device().unwrap_or(""));
359        if let Some(extra) = req.extra_data.clone() {
360            terminal = terminal.with_extra_data(extra);
361        }
362        self.session_repo.add_terminal(ns, terminal).await?;
363
364        self.token_repo
365            .save_token_id_mapping(token, login_type, login_id)
366            .await?;
367        compensator.on_fail_delete(keys.token_id_mapping(token));
368
369        self.token_repo.save_token_info(token_info).await?;
370        compensator.on_fail_delete(keys.token_info(token));
371
372        if self.config.right_now_create_token_session {
373            self.session_repo
374                .create_token_session(&token_info.token)
375                .await?;
376            compensator.on_fail_delete(keys.token_session(token));
377        }
378
379        if let Some(mgr) = refresh_mgr
380            && let Some(ref rt) = token_info.refresh_token
381        {
382            mgr.store_with_extra(
383                rt,
384                token,
385                login_type,
386                login_id,
387                token_info.extra_data.as_ref(),
388            )
389            .await?;
390            compensator.on_fail_delete(keys.refresh(rt));
391        }
392
393        self.commit_login_mapping(login_type, login_id, token, mapping_before, compensator)
394            .await
395    }
396
397    async fn commit_login_mapping(
398        &self,
399        login_type: &str,
400        login_id: &str,
401        token: &str,
402        mapping_before: Option<&str>,
403        compensator: &mut LoginCompensator,
404    ) -> SaTokenResult<()> {
405        let key = self.dao.keys().login_token(login_type, login_id);
406
407        if self.config.is_concurrent {
408            self.token_repo
409                .save_login_mapping(login_type, login_id, token)
410                .await?;
411        } else {
412            let swapped = self
413                .token_repo
414                .cas_login_mapping(login_type, login_id, mapping_before, token)
415                .await?;
416            if !swapped {
417                let swapped_absent = self
418                    .token_repo
419                    .cas_login_mapping(login_type, login_id, None, token)
420                    .await?;
421                if !swapped_absent {
422                    tracing::warn!(
423                        login_id = %login_id,
424                        login_type = %login_type,
425                        "concurrent login detected on commit point, rolling back this attempt"
426                    );
427                    return Err(SaTokenError::AccountReplaced);
428                }
429            }
430        }
431
432        compensator.on_fail_delete(key);
433        Ok(())
434    }
435
436    async fn handle_replaced_on_login(
437        &self,
438        login_type: &str,
439        login_id: &str,
440        ns: &AccountNs,
441        req: &LoginRequest,
442        new_token: &str,
443    ) -> SaTokenResult<()> {
444        let device = req.effective_device();
445        let effective_range = match (self.config.replaced_range, device) {
446            (ReplacedRange::CurrDeviceType, None) => {
447                tracing::debug!(
448                    login_id = %login_id,
449                    "device type absent, replaced_range degraded to AllDeviceType"
450                );
451                ReplacedRange::AllDeviceType
452            }
453            (range, _) => range,
454        };
455
456        let mut targets: HashSet<String> = HashSet::new();
457
458        match effective_range {
459            ReplacedRange::CurrDeviceType => {
460                // 仅收集同设备类型终端;不把 login:token 映射一律纳入,
461                // 否则异端登录仍会顶掉其它设备(违背 CurrDeviceType)。
462                for t in self.session_repo.get_terminal_list(ns, device).await? {
463                    targets.insert(t.token_value);
464                }
465            }
466            ReplacedRange::AllDeviceType => {
467                for t in self.token_repo.list_tokens(login_type, login_id).await? {
468                    targets.insert(t);
469                }
470                if let Some(old) = self
471                    .token_repo
472                    .get_login_mapping(login_type, login_id)
473                    .await?
474                {
475                    targets.insert(old);
476                }
477            }
478        }
479
480        targets.remove(new_token);
481
482        if targets.is_empty() {
483            return Ok(());
484        }
485
486        match self.config.replaced_login_exit_mode {
487            ReplacedLoginExitMode::NewDevice => Err(SaTokenError::AccountReplaced),
488            ReplacedLoginExitMode::OldDevice => {
489                for t in targets {
490                    if let Err(e) = self.logout_replaced(&TokenValue::new(t.clone())).await {
491                        tracing::warn!(token = %t, error = %e, "replace of stale token failed");
492                    }
493                }
494                Ok(())
495            }
496        }
497    }
498
499    /// 登出(LOGOUT 模式)| Logout
500    pub async fn logout(&self, token: &TokenValue, keep_token_session: bool) -> SaTokenResult<()> {
501        let result = match self.config.logout_range {
502            LogoutRange::Token => {
503                self.logout_internal(token, LogoutMode::Logout, keep_token_session)
504                    .await
505            }
506            LogoutRange::Account => match self.resolve_logout_identity(token.as_str()).await? {
507                Some(id) => self.logout_by_login_id(&id.login_type, &id.login_id).await,
508                None => {
509                    self.logout_internal(token, LogoutMode::Logout, keep_token_session)
510                        .await
511                }
512            },
513        };
514        if result.is_ok() && self.config.is_log {
515            tracing::info!(token = %token.as_str(), "logout success");
516        }
517        result
518    }
519
520    /// 踢下线(KICKOUT 模式,标记 -5)| Kick out, marker `-5`
521    pub async fn kick_out_by_token(
522        &self,
523        token: &TokenValue,
524        keep_token_session: bool,
525    ) -> SaTokenResult<()> {
526        self.logout_internal(token, LogoutMode::KickOut, keep_token_session)
527            .await
528    }
529
530    /// 顶下线(REPLACED 模式,标记 -4)| Replace, marker `-4`
531    pub async fn logout_replaced(&self, token: &TokenValue) -> SaTokenResult<()> {
532        self.logout_internal(
533            token,
534            LogoutMode::Replaced,
535            self.config.is_logout_keep_token_session,
536        )
537        .await
538    }
539
540    async fn resolve_logout_identity(&self, token: &str) -> SaTokenResult<Option<LogoutIdentity>> {
541        if let Some(info) = self.token_repo.get_token_info(token).await? {
542            return Ok(Some(LogoutIdentity {
543                login_type: info.login_type.to_string(),
544                login_id: info.login_id.to_string(),
545                _body_existed: true,
546            }));
547        }
548
549        match self.token_repo.get_token_id_mapping(token).await? {
550            Some(TokenIdMapping::Identity {
551                login_type,
552                login_id,
553            }) => Ok(Some(LogoutIdentity {
554                login_type,
555                login_id,
556                _body_existed: false,
557            })),
558            _ => Ok(None),
559        }
560    }
561
562    async fn logout_internal(
563        &self,
564        token: &TokenValue,
565        mode: LogoutMode,
566        keep_token_session: bool,
567    ) -> SaTokenResult<()> {
568        let token_str = token.as_str();
569        tracing::debug!(mode = ?mode, token = %token_str, "logout_internal");
570
571        let identity = self.resolve_logout_identity(token_str).await?;
572
573        self.token_repo.delete_token_info(token_str).await?;
574
575        if !keep_token_session {
576            let _ = self.session_repo.delete_token_session(token).await;
577        }
578
579        match mode {
580            LogoutMode::Logout => self.token_repo.delete_token_id_mapping(token_str).await?,
581            LogoutMode::KickOut => {
582                self.token_repo
583                    .mark_token_id(token_str, self.token_repo.kick_out_marker())
584                    .await?
585            }
586            LogoutMode::Replaced => {
587                self.token_repo
588                    .mark_token_id(token_str, self.token_repo.replaced_marker())
589                    .await?
590            }
591        }
592
593        let Some(identity) = identity else {
594            tracing::debug!(token = %token_str, "logout target has no resolvable identity, skipping account-level cleanup");
595            return Ok(());
596        };
597
598        let lt = identity.login_type.as_str();
599        let lid = identity.login_id.as_str();
600
601        if let Err(e) = self.token_repo.remove_index(lt, lid, token_str).await {
602            tracing::warn!(token = %token_str, error = %e, "failed to remove token from login index");
603        }
604
605        if let Ok(ns) = Self::account_ns(lt, lid) {
606            let removed = self
607                .session_repo
608                .remove_terminal(&ns, token_str)
609                .await
610                .unwrap_or(false);
611
612            if removed {
613                let count = self.session_repo.terminal_count(&ns).await.unwrap_or(0);
614                if count == 0 && mode != LogoutMode::Replaced {
615                    let _ = self.session_repo.delete_by_ns(&ns).await;
616                }
617            }
618        }
619
620        if mode == LogoutMode::Logout {
621            let _ = self
622                .token_repo
623                .cas_delete_login_mapping(lt, lid, token_str)
624                .await;
625        }
626
627        if let Some(dm) = &self.distributed {
628            if let Err(e) = dm.delete_sessions_by_token(lid, token_str).await {
629                tracing::warn!(error = %e, "distributed session delete failed on logout");
630            }
631        }
632
633        if let Some(online) = &self.online_manager {
634            if let Err(e) = online.mark_offline_with_type(lt, lid, token_str).await {
635                tracing::warn!(error = %e, "failed to clear online presence on logout");
636            }
637        }
638
639        let event = match mode {
640            LogoutMode::Logout => SaTokenEvent::logout(lid, token_str),
641            LogoutMode::KickOut => SaTokenEvent::kick_out(lid, token_str),
642            LogoutMode::Replaced => SaTokenEvent::replaced(lid, token_str),
643        };
644        self.event_bus.publish(event.with_login_type(lt)).await;
645
646        Ok(())
647    }
648
649    async fn collect_account_tokens(
650        &self,
651        login_type: &str,
652        login_id: &str,
653    ) -> SaTokenResult<Vec<String>> {
654        let (alive, pruned) = self.token_repo.prune_index(login_type, login_id).await?;
655        if pruned > 0 {
656            tracing::debug!(pruned, login_id = %login_id, "pruned orphan index entries");
657        }
658        if !alive.is_empty() {
659            return Ok(alive);
660        }
661
662        let mut result = Vec::new();
663        let keys = self.dao.keys();
664        let pattern = keys.token_scan_pattern(Some(login_type));
665        let mut cursor = 0u64;
666
667        loop {
668            let page = match self.dao.scan(&pattern, cursor, 100).await {
669                Ok(p) => p,
670                Err(e) => {
671                    tracing::debug!(error = %e, "scan fallback unavailable");
672                    break;
673                }
674            };
675
676            for key in &page.keys {
677                let Some(token) = keys.parse_token_from_key(key, Some(login_type)) else {
678                    continue;
679                };
680                if let Ok(Some(info)) = self.token_repo.get_token_info(token).await
681                    && info.login_id.as_ref() == login_id
682                    && info.login_type.as_ref() == login_type
683                {
684                    result.push(token.to_string());
685                }
686            }
687
688            if page.next_cursor == 0 {
689                break;
690            }
691            cursor = page.next_cursor;
692        }
693
694        if result.is_empty()
695            && let Some(one) = self
696                .token_repo
697                .get_login_mapping(login_type, login_id)
698                .await?
699        {
700            result.push(one);
701        }
702
703        Ok(result)
704    }
705
706    /// 按账号登出全部 token(LOGOUT 模式)。
707    ///
708    /// Always uses per-token [`logout_internal`] so `logout_range=Account` cannot recurse.
709    /// 始终按单 token 调用 [`logout_internal`],避免 `logout_range=Account` 时递归。
710    pub async fn logout_by_login_id(&self, login_type: &str, login_id: &str) -> SaTokenResult<()> {
711        let tokens = self.collect_account_tokens(login_type, login_id).await?;
712        let keep = self.config.is_logout_keep_token_session;
713        for t in tokens {
714            if let Err(e) = self
715                .logout_internal(&TokenValue::new(t.clone()), LogoutMode::Logout, keep)
716                .await
717            {
718                tracing::warn!(token = %t, error = %e, "logout of one token failed during account logout");
719            }
720        }
721        Ok(())
722    }
723
724    /// 按账号踢下线全部 token(KICKOUT 模式)。
725    pub async fn kick_out(&self, login_type: &str, login_id: &str) -> SaTokenResult<()> {
726        if let Some(online) = &self.online_manager {
727            let _ = online
728                .mark_offline_all_with_type(login_type, login_id)
729                .await;
730            let _ = online
731                .kick_out_notify(login_id, "Account kicked out".to_string())
732                .await;
733        }
734
735        let tokens = self.collect_account_tokens(login_type, login_id).await?;
736        for t in tokens {
737            if let Err(e) = self
738                .kick_out_by_token(
739                    &TokenValue::new(t.clone()),
740                    self.config.is_logout_keep_token_session,
741                )
742                .await
743            {
744                tracing::warn!(token = %t, error = %e, "kickout of one token failed");
745            }
746        }
747
748        if let Ok(ns) = Self::account_ns(login_type, login_id) {
749            let _ = self.session_repo.delete_by_ns(&ns).await;
750        }
751        Ok(())
752    }
753
754    /// 读取并校验 token(按策略自动续签)。
755    pub async fn get_token_info(&self, token: &TokenValue) -> SaTokenResult<TokenInfo> {
756        match self.token_repo.load_valid_token_info(token).await {
757            Ok(info) => Ok(info),
758            Err(SaTokenError::TokenExpired) => {
759                let _ = self
760                    .logout(token, self.config.is_logout_keep_token_session)
761                    .await;
762                Err(SaTokenError::TokenExpired)
763            }
764            Err(other) => Err(other),
765        }
766    }
767
768    /// token 是否有效 | Whether the token is valid
769    pub async fn is_valid(&self, token: &TokenValue) -> bool {
770        self.get_token_info(token).await.is_ok()
771    }
772
773    /// 手动续期到指定秒数(修 B1-29:只写一次存储)。
774    pub async fn renew_timeout(
775        &self,
776        token: &TokenValue,
777        timeout_seconds: i64,
778    ) -> SaTokenResult<()> {
779        let mut info = self.token_repo.load_token_info_no_renew(token).await?;
780
781        info.update_active_time();
782        let ttl = if timeout_seconds > 0 {
783            info.expire_time = Some(Utc::now() + ChronoDuration::seconds(timeout_seconds));
784            Some(std::time::Duration::from_secs(timeout_seconds as u64))
785        } else {
786            info.expire_time = None;
787            None
788        };
789
790        let key = self.dao.keys().token_info(token.as_str());
791        self.dao.set_object(&key, &info, ttl).await?;
792
793        let event =
794            SaTokenEvent::renew_timeout(info.login_id.as_ref(), token.as_str(), timeout_seconds)
795                .with_login_type(info.login_type.as_ref());
796        self.event_bus.publish(event).await;
797
798        Ok(())
799    }
800
801    async fn enforce_max_login_count(&self, login_type: &str, login_id: &str) -> SaTokenResult<()> {
802        if self.config.max_login_count <= 0 || !self.config.is_concurrent {
803            return Ok(());
804        }
805
806        let (alive, pruned) = self.token_repo.prune_index(login_type, login_id).await?;
807        if pruned > 0 {
808            tracing::debug!(
809                pruned,
810                login_id = %login_id,
811                "pruned orphan tokens before enforcing max_login_count"
812            );
813        }
814
815        let max = self.config.max_login_count as usize;
816        if alive.len() <= max {
817            return Ok(());
818        }
819        let overflow = alive.len() - max;
820
821        for stale in alive.iter().take(overflow) {
822            let _ = self
823                .token_repo
824                .remove_index(login_type, login_id, stale)
825                .await;
826
827            let token = TokenValue::new(stale.clone());
828            let keep = self.config.is_logout_keep_token_session;
829            let outcome = match self.config.overflow_logout_mode {
830                LogoutMode::Logout => self.logout(&token, keep).await,
831                LogoutMode::KickOut => self.kick_out_by_token(&token, keep).await,
832                LogoutMode::Replaced => self.logout_replaced(&token).await,
833            };
834            if let Err(e) = outcome {
835                tracing::warn!(token = %stale, error = %e, "overflow eviction failed");
836            }
837        }
838
839        Ok(())
840    }
841}