sz-rust-auth-facade 0.6.6

Auth facade for sz-rust framework — WeChat, OAuth2, Gateway
Documentation
//! Redis 存储后端 — RedisRefreshTokenStore + RedisTokenBlacklist
//!
//! 对齐 spec.md FR-1 ~ FR-6,design.md §2.1 ~ §2.6。
//!
//! ## 核心组件
//!
//! - [`RedisConfig`]:Redis 连接配置(URL + key 前缀 + 超时),Debug 脱敏密码
//! - [`RedisRefreshTokenStore`]:实现 [`RefreshTokenStore`] trait(GET / INCR)
//! - [`RedisTokenBlacklist`]:实现 [`TokenBlacklist`] trait(EXISTS / SETEX)
//! - [`create_redis_stores`]:便捷工厂,一次创建 Store + Blacklist 共享 ConnectionManager

use crate::refresh::{RefreshTokenError, RefreshTokenStore, TokenBlacklist};
use redis::aio::ConnectionManager;
use redis::AsyncCommands;
use std::fmt;
use std::time::Duration;

// ── RedisConfig ──

/// Redis 存储配置
///
/// 对齐 design.md §2.2。URL 中的密码在 Debug 输出时自动脱敏。
#[derive(Clone)]
pub struct RedisConfig {
    /// Redis 连接 URL(如 `redis://:password@127.0.0.1:6379/0`)
    pub url: String,
    /// 版本号 key 前缀(默认 `sso:ver`)
    pub key_prefix_ver: String,
    /// 黑名单 key 前缀(默认 `sso:bl`)
    pub key_prefix_bl: String,
    /// 连接超时(默认 3s)
    pub connection_timeout: Duration,
    /// 命令超时(默认 2s)
    pub command_timeout: Duration,
}

impl Default for RedisConfig {
    fn default() -> Self {
        Self {
            url: "redis://127.0.0.1:6379".to_string(),
            key_prefix_ver: "sso:ver".to_string(),
            key_prefix_bl: "sso:bl".to_string(),
            connection_timeout: Duration::from_secs(3),
            command_timeout: Duration::from_secs(2),
        }
    }
}

impl RedisConfig {
    /// 从 URL 创建配置,其余字段使用默认值
    pub fn from_url(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            ..Default::default()
        }
    }

    /// 构造版本号 key:`{prefix}:{user_id}`
    fn ver_key(&self, user_id: i64) -> String {
        format!("{}:{}", self.key_prefix_ver, user_id)
    }

    /// 构造黑名单 key:`{prefix}:{jti}`
    fn bl_key(&self, jti: &str) -> String {
        format!("{}:{}", self.key_prefix_bl, jti)
    }
}

/// Debug 实现脱敏 URL 中的密码
impl fmt::Debug for RedisConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let redacted_url = redact_redis_url(&self.url);
        f.debug_struct("RedisConfig")
            .field("url", &redacted_url)
            .field("key_prefix_ver", &self.key_prefix_ver)
            .field("key_prefix_bl", &self.key_prefix_bl)
            .field("connection_timeout", &self.connection_timeout)
            .field("command_timeout", &self.command_timeout)
            .finish()
    }
}

/// 脱敏 Redis URL 中的密码部分
///
/// `redis://:secret@host:port` → `redis://[REDACTED]@host:port`
fn redact_redis_url(url: &str) -> String {
    if let Some(at_pos) = url.find('@') {
        if let Some(scheme_end) = url.find("://") {
            let password_start = scheme_end + 3;
            if at_pos > password_start {
                let (before, after) = url.split_at(at_pos);
                let scheme = &before[..password_start];
                return format!("{}[REDACTED]{}", scheme, after);
            }
        }
    }
    url.to_string()
}

// ── RedisRefreshTokenStore ──

/// Redis 版本号存储
///
/// 实现 [`RefreshTokenStore`] trait,使用 Redis `GET` / `INCR` 命令。
/// key 格式:`{key_prefix_ver}:{user_id}`,不存在时返回 0(与 Memory 行为一致)。
pub struct RedisRefreshTokenStore {
    conn: ConnectionManager,
    config: RedisConfig,
}

impl RedisRefreshTokenStore {
    /// 创建 Redis 版本号存储
    ///
    /// 内部建立 `ConnectionManager`(自动重连 + 连接池复用)。
    pub async fn new(config: RedisConfig) -> Result<Self, RefreshTokenError> {
        let client = redis::Client::open(config.url.as_str())
            .map_err(|e| RefreshTokenError::Cache(format!("redis client open failed: {e}")))?;

        let conn = tokio::time::timeout(config.connection_timeout, client.get_connection_manager())
            .await
            .map_err(|_| RefreshTokenError::ServiceUnavailable)?
            .map_err(|e| RefreshTokenError::Cache(format!("redis connect failed: {e}")))?;

        Ok(Self { conn, config })
    }
}

#[async_trait::async_trait]
impl RefreshTokenStore for RedisRefreshTokenStore {
    async fn get_version(&self, user_id: i64) -> Result<u64, RefreshTokenError> {
        let key = self.config.ver_key(user_id);
        let mut conn = self.conn.clone();
        let result: Option<u64> = tokio::time::timeout(
            self.config.command_timeout,
            conn.get::<&str, Option<u64>>(&key),
        )
        .await
        .map_err(|_| RefreshTokenError::ServiceUnavailable)?
        .map_err(|e| RefreshTokenError::Cache(format!("redis GET failed: {e}")))?;

        Ok(result.unwrap_or(0))
    }

    async fn increment_version(&self, user_id: i64) -> Result<u64, RefreshTokenError> {
        let key = self.config.ver_key(user_id);
        let mut conn = self.conn.clone();
        let new_version: u64 = tokio::time::timeout(
            self.config.command_timeout,
            conn.incr::<&str, u64, u64>(&key, 1),
        )
        .await
        .map_err(|_| RefreshTokenError::ServiceUnavailable)?
        .map_err(|e| RefreshTokenError::Cache(format!("redis INCR failed: {e}")))?;

        Ok(new_version)
    }
}

// ── RedisTokenBlacklist ──

/// Redis Token 黑名单
///
/// 实现 [`TokenBlacklist`] trait,使用 Redis `EXISTS` / `SETEX` 命令。
/// key 格式:`{key_prefix_bl}:{jti}`,TTL 由调用方传入(Token 剩余有效期)。
pub struct RedisTokenBlacklist {
    conn: ConnectionManager,
    config: RedisConfig,
}

impl RedisTokenBlacklist {
    /// 创建 Redis Token 黑名单
    pub async fn new(config: RedisConfig) -> Result<Self, RefreshTokenError> {
        let client = redis::Client::open(config.url.as_str())
            .map_err(|e| RefreshTokenError::Cache(format!("redis client open failed: {e}")))?;

        let conn = tokio::time::timeout(config.connection_timeout, client.get_connection_manager())
            .await
            .map_err(|_| RefreshTokenError::ServiceUnavailable)?
            .map_err(|e| RefreshTokenError::Cache(format!("redis connect failed: {e}")))?;

        Ok(Self { conn, config })
    }
}

#[async_trait::async_trait]
impl TokenBlacklist for RedisTokenBlacklist {
    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RefreshTokenError> {
        if ttl_secs == 0 {
            return Ok(());
        }
        let key = self.config.bl_key(jti);
        let mut conn = self.conn.clone();
        tokio::time::timeout(
            self.config.command_timeout,
            conn.set_ex::<&str, &str, ()>(&key, "1", ttl_secs),
        )
        .await
        .map_err(|_| RefreshTokenError::ServiceUnavailable)?
        .map_err(|e| RefreshTokenError::Cache(format!("redis SETEX failed: {e}")))?;

        Ok(())
    }

    async fn is_revoked(&self, jti: &str) -> Result<bool, RefreshTokenError> {
        let key = self.config.bl_key(jti);
        let mut conn = self.conn.clone();
        let exists: bool =
            tokio::time::timeout(self.config.command_timeout, conn.exists::<&str, bool>(&key))
                .await
                .map_err(|_| RefreshTokenError::ServiceUnavailable)?
                .map_err(|e| RefreshTokenError::Cache(format!("redis EXISTS failed: {e}")))?;

        Ok(exists)
    }
}

// ── 便捷工厂 ──

/// 一次创建 Redis Store + Blacklist,共享同一 ConnectionManager
///
/// 对齐 design.md §2.5。返回 `(Store, Blacklist)`,两者各自持有独立的
/// `ConnectionManager` clone(内部 Arc 共享连接池)。
pub async fn create_redis_stores(
    config: RedisConfig,
) -> Result<
    (
        std::sync::Arc<dyn RefreshTokenStore>,
        std::sync::Arc<dyn TokenBlacklist>,
    ),
    RefreshTokenError,
> {
    let store = RedisRefreshTokenStore::new(config.clone()).await?;
    let blacklist = RedisTokenBlacklist::new(config).await?;
    Ok((std::sync::Arc::new(store), std::sync::Arc::new(blacklist)))
}

// ── 单元测试 ──

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_redis_config_default() {
        let config = RedisConfig::default();
        assert_eq!(config.url, "redis://127.0.0.1:6379");
        assert_eq!(config.key_prefix_ver, "sso:ver");
        assert_eq!(config.key_prefix_bl, "sso:bl");
        assert_eq!(config.connection_timeout, Duration::from_secs(3));
        assert_eq!(config.command_timeout, Duration::from_secs(2));
    }

    #[test]
    fn test_redis_config_from_url() {
        let config = RedisConfig::from_url("redis://localhost:6380/1");
        assert_eq!(config.url, "redis://localhost:6380/1");
        assert_eq!(config.key_prefix_ver, "sso:ver");
    }

    #[test]
    fn test_redis_config_debug_redacts_password() {
        let config = RedisConfig::from_url("redis://:secret_pass@127.0.0.1:6379");
        let debug_str = format!("{:?}", config);
        assert!(debug_str.contains("[REDACTED]"));
        assert!(!debug_str.contains("secret_pass"));
    }

    #[test]
    fn test_redis_config_debug_no_password() {
        let config = RedisConfig::from_url("redis://127.0.0.1:6379");
        let debug_str = format!("{:?}", config);
        assert!(!debug_str.contains("[REDACTED]"));
        assert!(debug_str.contains("127.0.0.1:6379"));
    }

    #[test]
    fn test_ver_key_format() {
        let config = RedisConfig::default();
        assert_eq!(config.ver_key(1), "sso:ver:1");
        assert_eq!(config.ver_key(42), "sso:ver:42");
    }

    #[test]
    fn test_bl_key_format() {
        let config = RedisConfig::default();
        assert_eq!(config.bl_key("abc123"), "sso:bl:abc123");
    }

    #[test]
    fn test_redact_redis_url_with_password() {
        let redacted = redact_redis_url("redis://:mypassword@host:6379/0");
        assert!(redacted.contains("[REDACTED]"));
        assert!(!redacted.contains("mypassword"));
        assert!(redacted.contains("host:6379"));
    }

    #[test]
    fn test_redact_redis_url_without_password() {
        let redacted = redact_redis_url("redis://127.0.0.1:6379");
        assert_eq!(redacted, "redis://127.0.0.1:6379");
    }

    #[test]
    fn test_redact_redis_url_with_user_and_password() {
        let redacted = redact_redis_url("redis://user:pass@host:6379");
        assert!(redacted.contains("[REDACTED]"));
        assert!(!redacted.contains("pass"));
    }
}