Skip to main content

sz_rust_core/middleware/
auth.rs

1//! Auth 中间件 — JWT 校验 + 白名单跳过(对齐 PHP `addons\BaseController`)
2//!
3//! 对齐 PHP `addons\BaseController.php` 的鉴权流程:
4//!
5//! ```php
6//! public function initialize() {
7//!     $this->getRouteinfo();          // 解析当前路由
8//!     $this->user = $this->getToken(); // JWT 校验
9//!     $this->checkLogin();             // 验证登录状态
10//! }
11//!
12//! public function getToken() {
13//!     if (!$token = Token::getUserId(request()->header('Authorization'))) {
14//!         if (in_array($this->routeUri, $this->allowAllAction)) {
15//!             return true;             // 白名单放行
16//!         } else {
17//!             throw new BaseException(['msg' => '缺少必要的参数,请重新登陆!']);
18//!         }
19//!     }
20//!     return $token;
21//! }
22//!
23//! private function checkLogin(): void {
24//!     if (in_array($this->routeUri, $this->allowAllAction)) {
25//!         return;                      // 白名单放行
26//!     }
27//!     if (!empty($this->user) && $this->user['is_login'] == 1) {
28//!         return;
29//!     }
30//!     throw new BaseException(['code' => -1, 'msg' => 'not_login']);
31//! }
32//! ```
33//!
34//! ## PHP 端默认白名单
35//!
36//! ```php
37//! protected array $allowAllAction = [
38//!     '/passport/login',
39//!     '/task/task/userClerk',
40//! ];
41//! ```
42//!
43//! ## PHP JWT 配置(`app\common\service\jwt\Token`)
44//!
45//! - 签发人:`https://mall.ljclz.shop`
46//! - 接收人:`https://mall.ljclz.shop`
47//! - 密钥:`shengzhuang`
48//! - 有效期:30 天(`3600 * 24 * 30` 秒)
49//! - 算法:HS256
50//! - 自定义 claim:`user_id`
51//!
52//! PHP `Token::getUserId` 验证流程:
53//! 1. 从 `Authorization` header 取 token(去除 `bearer` 前缀,大小写不敏感)
54//! 2. 检查 token 是否在 `cache('delete_token')` 注销列表中(注销逻辑,缓存层实现)
55//! 3. 解析 JWT
56//! 4. 验证签发人(`IssuedBy`)
57//! 5. 验证接收人(`PermittedFor`)
58//! 6. 验证过期(`LooseValidAt`,时区 `Asia/Shanghai`)
59//! 7. 取出 `user_id`
60//!
61//! ## Rust 端实现说明
62//!
63//! 复用 `sz-orm-auth` 的 `JwtEncoder::decode` 进行签名 + 过期校验,
64//! 并在中间件层补充 PHP 端的额外校验:
65//!
66//! | PHP 验证项 | sz-orm-auth | Rust 中间件层补充 |
67//! |-----------|-------------|------------------|
68//! | 签名 | ✅ `JwtEncoder::decode` | — |
69//! | 过期 | ✅ `JwtEncoder::decode` | — |
70//! | 算法(HS256) | ✅ `JwtEncoder::decode` | — |
71//! | 签发人(`iss`) | ❌ 不校验 | ✅ 本模块补充 |
72//! | 接收人(`aud`) | ❌ 不校验 | ⚠️ 延迟到后续(Rust JwtClaims 无 `aud` 字段) |
73//! | `bearer` 前缀去除 | ❌ | ✅ 本模块补充 |
74//! | 白名单跳过 | ❌ | ✅ 本模块补充 |
75//! | 注销列表 | ❌ | ⚠️ 延迟到缓存层 |
76//!
77//! ## 错误码对齐
78//!
79//! | 场景 | PHP code | PHP msg | Rust ErrorCode |
80//! |------|---------|---------|---------------|
81//! | 缺少 Authorization header + 非白名单 | -1 | `缺少必要的参数,请重新登陆!` | `NotLogin` |
82//! | JWT 解析/校验失败 + 非白名单 | -1 | `缺少必要的参数,请重新登陆!` | `NotLogin` |
83//! | user_id 缺失/无效 + 非白名单 | -1 | `not_login` | `NotLogin` |
84//! | 白名单路由 | — | — | 放行(不校验) |
85//!
86//! PHP 端 `getToken()` 失败和 `checkLogin()` 失败都使用 `code = -1`,
87//! Rust 端统一使用 `ErrorCode::NotLogin`(`-1`)。
88
89use axum::extract::Request;
90use axum::http::StatusCode;
91use axum::middleware::Next;
92use axum::response::{IntoResponse, Response};
93use sz_orm_auth::jwt::{JwtClaims, JwtEncoder};
94
95use crate::error::{BaseException, ErrorCode};
96
97/// PHP 端默认白名单(对齐 `addons\BaseController::$allowAllAction`)
98///
99/// ```php
100/// protected array $allowAllAction = [
101///     '/passport/login',
102///     '/task/task/userClerk',
103/// ];
104/// ```
105pub const DEFAULT_ALLOW_ALL_ACTION: &[&str] = &["/passport/login", "/task/task/userClerk"];
106
107/// PHP JWT 默认签发人(对齐 `app\common\service\jwt\Token::$_config['issuer']`)
108///
109/// **注意**:仅用于测试与文档对照。生产环境必须通过 `SZ_JWT_ISSUER` 环境变量覆盖。
110pub const DEFAULT_ISSUER: &str = "https://mall.ljclz.shop";
111
112/// PHP JWT 默认密钥(对齐 `app\common\service\jwt\Token::$_config['sign']`)。
113///
114/// **安全警告**:此常量保留 PHP 原始值仅供**测试与文档对照**使用。
115/// 生产环境必须通过 `SZ_JWT_SECRET` 环境变量提供密钥。
116/// `AuthConfig::default()` 会优先从 `SZ_JWT_SECRET` 环境变量读取;
117/// 仅在 `cfg(test)` 下回退到此常量以保持测试兼容性。
118#[cfg(test)]
119pub const DEFAULT_SECRET: &str = "shengzhuang";
120
121/// 生产环境占位符(非测试构建下 `DEFAULT_SECRET` 不可用)
122#[cfg(not(test))]
123pub const DEFAULT_SECRET: &str = "<must-set-SZ_JWT_SECRET-env>";
124
125/// PHP JWT 默认有效期(秒)(对齐 `app\common\service\jwt\Token::$_config['expire'] = 3600 * 24 * 30`)
126pub const DEFAULT_EXPIRATION: u64 = 3600 * 24 * 30;
127
128/// Auth 中间件配置
129///
130/// 对齐 PHP `addons\BaseController` + `app\common\service\jwt\Token` 的配置。
131#[derive(Debug, Clone)]
132pub struct AuthConfig {
133    /// JWT 密钥(PHP `Token::$_config['sign']`)
134    pub secret: String,
135    /// JWT 签发人(PHP `Token::$_config['issuer']`)
136    pub issuer: String,
137    /// JWT 有效期(秒)(PHP `Token::$_config['expire']`)
138    pub expiration: u64,
139    /// 白名单路由列表(PHP `BaseController::$allowAllAction`)
140    ///
141    /// 支持通配符 `*`(对齐 PHP `AuthService::$allowAllAction` 中的 `/upload.library/*`)
142    pub allow_all_action: Vec<String>,
143}
144
145impl Default for AuthConfig {
146    fn default() -> Self {
147        // 生产环境:优先从环境变量读取 JWT 密钥
148        // 测试环境:回退到 DEFAULT_SECRET 常量(PHP 原始值)以保持测试兼容
149        let secret = std::env::var("SZ_JWT_SECRET").unwrap_or_else(|_| {
150            #[cfg(test)]
151            {
152                DEFAULT_SECRET.to_string()
153            }
154            #[cfg(not(test))]
155            {
156                panic!("SZ_JWT_SECRET 环境变量未设置 — 生产环境必须通过环境变量提供 JWT 密钥");
157            }
158        });
159        let issuer = std::env::var("SZ_JWT_ISSUER").unwrap_or_else(|_| DEFAULT_ISSUER.to_string());
160        Self {
161            secret,
162            issuer,
163            expiration: DEFAULT_EXPIRATION,
164            allow_all_action: DEFAULT_ALLOW_ALL_ACTION
165                .iter()
166                .map(|s| s.to_string())
167                .collect(),
168        }
169    }
170}
171
172impl AuthConfig {
173    /// 从环境变量构造配置(生产环境推荐用法)
174    ///
175    /// # Errors
176    /// 当 `SZ_JWT_SECRET` 环境变量未设置时返回错误
177    pub fn from_env() -> Result<Self, std::env::VarError> {
178        Ok(Self {
179            secret: std::env::var("SZ_JWT_SECRET")?,
180            issuer: std::env::var("SZ_JWT_ISSUER").unwrap_or_else(|_| DEFAULT_ISSUER.to_string()),
181            expiration: DEFAULT_EXPIRATION,
182            allow_all_action: DEFAULT_ALLOW_ALL_ACTION
183                .iter()
184                .map(|s| s.to_string())
185                .collect(),
186        })
187    }
188
189    /// 创建带自定义白名单的配置
190    pub fn with_allow_all_action(mut self, allow: Vec<String>) -> Self {
191        self.allow_all_action = allow;
192        self
193    }
194
195    /// 创建带自定义密钥的配置
196    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
197        self.secret = secret.into();
198        self
199    }
200
201    /// 创建带自定义签发人的配置
202    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
203        self.issuer = issuer.into();
204        self
205    }
206}
207
208/// Auth 中间件 — 对齐 PHP `addons\BaseController::initialize` 流程
209///
210/// ## 校验流程
211///
212/// 1. **路由白名单检查**:当前路由在 `allow_all_action` 中(含通配符匹配)→ 放行
213/// 2. **取 Authorization header**:缺失 → `BaseException(['code' => -1, 'msg' => '缺少必要的参数,请重新登陆!'])`
214/// 3. **去除 `bearer` 前缀**(大小写不敏感,对齐 PHP `Token::getRequestToken`)
215/// 4. **JWT 校验**:`JwtEncoder::decode` + 签发人校验
216///    - 解析失败 → `BaseException(['code' => -1, 'msg' => '缺少必要的参数,请重新登陆!'])`
217///    - 过期 → `BaseException(['code' => -1, 'msg' => '缺少必要的参数,请重新登陆!'])`
218///    - 签发人不匹配 → `BaseException(['code' => -1, 'msg' => '缺少必要的参数,请重新登陆!'])`
219/// 5. **user_id 校验**:`claims.user_id` 为 `None` 或 `Some(0)` →
220///    `BaseException(['code' => -1, 'msg' => 'not_login'])`
221/// 6. **通过校验**:将 `user_id` 插入 request extensions,调用 `next`
222///
223/// ## 用法
224///
225/// ```ignore
226/// use sz_rust_core::middleware::auth::{auth_middleware, AuthConfig};
227/// use axum::Router;
228///
229/// let config = AuthConfig::default();
230/// let app: Router = Router::new()
231///     .route("/", axum::routing::get(|| async { "ok" }))
232///     .layer(axum::middleware::from_fn_with_state(config, auth_middleware));
233/// ```
234#[tracing::instrument(skip_all)]
235pub async fn auth_middleware(
236    axum::extract::State(config): axum::extract::State<AuthConfig>,
237    req: Request,
238    next: Next,
239) -> Response {
240    // 1. 路由白名单检查(对齐 PHP `checkLogin` 中的 `in_array($this->routeUri, $this->allowAllAction)`)
241    let route_uri = extract_route_uri(&req);
242    if is_route_allowed(&route_uri, &config.allow_all_action) {
243        return next.run(req).await.into_response();
244    }
245
246    // 2. 取 Authorization header(对齐 PHP `Token::getUserId(request()->header('Authorization'))`)
247    let auth_header = req.headers().get(axum::http::header::AUTHORIZATION);
248    let token = match auth_header {
249        Some(value) => {
250            let raw = value.to_str().unwrap_or("");
251            // 3. 去除 bearer 前缀(大小写不敏感,对齐 PHP `trim(str_ireplace('bearer', '', $header))`)
252            extract_token_from_header(raw)
253        }
254        None => None,
255    };
256
257    let token = match token {
258        Some(t) if !t.is_empty() => t,
259        _ => {
260            // PHP: `throw new BaseException(['msg' => '缺少必要的参数,请重新登陆!'])`
261            return base_exception_to_response(BaseException::not_login(
262                "缺少必要的参数,请重新登陆!",
263            ));
264        }
265    };
266
267    // 4. JWT 校验:JwtEncoder::decode + 签发人校验
268    let encoder = JwtEncoder::new(&config.secret);
269    let claims = match encoder.decode(&token) {
270        Ok(c) => c,
271        Err(_) => {
272            // PHP 端 `Token::getUserId` 在任何 JWT 校验失败时都返回 null,
273            // `BaseController::getToken` 把 null 当作「缺少必要的参数」处理
274            return base_exception_to_response(BaseException::not_login(
275                "缺少必要的参数,请重新登陆!",
276            ));
277        }
278    };
279
280    // 4.1 签发人校验(对齐 PHP `IssuedBy(self::$_config['issuer'])`)
281    if !verify_issuer(&claims, &config.issuer) {
282        return base_exception_to_response(BaseException::not_login("缺少必要的参数,请重新登陆!"));
283    }
284
285    // 5. user_id 校验(对齐 PHP `checkLogin` 中的 `$this->user['is_login'] == 1`)
286    let user_id = match claims.user_id {
287        Some(id) if id > 0 => id,
288        _ => {
289            // PHP: `throw new BaseException(['code' => -1, 'msg' => 'not_login'])`
290            return base_exception_to_response(BaseException::not_login("not_login"));
291        }
292    };
293
294    // 6. 通过校验:将 user_id 插入 request extensions,调用 next
295    let mut req = req;
296    req.extensions_mut().insert(AuthenticatedUser { user_id });
297    next.run(req).await.into_response()
298}
299
300/// 将 `BaseException` 转换为 HTTP 响应
301///
302/// 使用 `BaseException::code` 对应的 HTTP 状态码(通过 `ErrorCode::from(code).http_status()`),
303/// 响应体为标准 JSON 格式 `{"code":<code>,"msg":"<msg>","data":{}}`(对齐 PHP `renderJson`)。
304pub fn base_exception_to_response(exc: BaseException) -> Response {
305    let http_status = ErrorCode::from(exc.code).http_status();
306    let body = exc.to_json().to_string();
307    (
308        StatusCode::from_u16(http_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
309        [(
310            axum::http::header::CONTENT_TYPE,
311            "application/json; charset=utf-8",
312        )],
313        body,
314    )
315        .into_response()
316}
317
318/// 已认证用户信息(插入 request extensions,供后续 handler 使用)
319#[derive(Debug, Clone, Copy)]
320pub struct AuthenticatedUser {
321    /// 用户 ID
322    pub user_id: i64,
323}
324
325/// 从 Authorization header 值中提取 token(去除 `bearer` 前缀)
326///
327/// 对齐 PHP `Token::getRequestToken`:
328/// ```php
329/// $method = 'bearer';
330/// return trim(str_ireplace($method, '', $header));
331/// ```
332///
333/// PHP 的 `str_ireplace` 是大小写不敏感的字符串替换,会替换所有出现位置。
334/// Rust 端去除前缀(`Bearer ` / `bearer` / `BEARER` 等),对齐 PHP 行为:
335/// - `Bearer xxx` → `xxx`
336/// - `bearer xxx` → `xxx`(大小写不敏感)
337/// - `bearerxxx` → `xxx`(对齐 PHP `str_ireplace` 无空格替换)
338/// - `xxx` → `xxx`(无 bearer 前缀时返回原值)
339pub fn extract_token_from_header(header: &str) -> Option<String> {
340    let trimmed = header.trim();
341    if trimmed.is_empty() {
342        return None;
343    }
344    // 大小写不敏感匹配 bearer 前缀(对齐 PHP `str_ireplace`)
345    // bearer 全部为 ASCII,使用 strip_prefix 后剩余部分用原始字符串截取以保留 token 大小写
346    let lower = trimmed.to_lowercase();
347    if let Some(suffix_len) = lower.strip_prefix("bearer ").map(|s| s.len()) {
348        // 去除前缀后剩余部分(用原始字符串截取以保留大小写)
349        let rest = &trimmed[trimmed.len() - suffix_len..];
350        Some(rest.trim().to_string())
351    } else if let Some(suffix_len) = lower.strip_prefix("bearer").map(|s| s.len()) {
352        // 处理 `bearerxxx`(无空格)的情况,对齐 PHP `str_ireplace` 行为
353        let rest = &trimmed[trimmed.len() - suffix_len..];
354        Some(rest.trim().to_string())
355    } else {
356        // 无 bearer 前缀,直接返回原值(对齐 PHP:`str_ireplace` 找不到时不替换)
357        Some(trimmed.to_string())
358    }
359}
360
361/// 提取请求的路由 URI(用于白名单匹配)
362///
363/// 对齐 PHP `BaseController::getRouteinfo`:
364/// ```php
365/// $this->routeUri = '/' . $this->controller . '/' . $this->action;
366/// ```
367///
368/// Rust 端使用 `req.uri().path()`,并去掉查询字符串。
369pub fn extract_route_uri(req: &Request) -> String {
370    req.uri().path().to_string()
371}
372
373/// 判断路由是否在白名单中(支持通配符 `*`)
374///
375/// 对齐 PHP `AuthService::$allowAllAction` 中的通配符匹配:
376/// - 精确匹配:`/passport/login` == `/passport/login` → true
377/// - 通配符匹配:`/upload.library/*` 匹配 `/upload.library/any` → true
378/// - 通配符匹配:`/upload.library/*` 匹配 `/upload.library/sub/deep` → true
379///   (PHP `fnmatch` 的 `*` 匹配任意字符包括 `/`)
380pub fn is_route_allowed(route_uri: &str, allow_list: &[String]) -> bool {
381    for pattern in allow_list {
382        if pattern == route_uri {
383            return true;
384        }
385        if pattern.contains('*') && wildcard_match(pattern, route_uri) {
386            return true;
387        }
388    }
389    false
390}
391
392/// 通配符匹配(`*` 匹配任意字符包括 `/`,对齐 PHP `fnmatch`)
393///
394/// 仅支持 `*` 通配符(对齐 PHP `AuthService` 白名单的实际使用场景)。
395/// 不支持 `?`、`[`、`]` 等 fnmatch 特殊字符(PHP `fnmatch` 支持,
396/// 但 PHP `AuthService` 的白名单中只用了 `*`)。
397pub fn wildcard_match(pattern: &str, text: &str) -> bool {
398    simple_wildcard_match(pattern, text)
399}
400
401/// 简单通配符匹配(仅支持 `*`,对齐 PHP `fnmatch` 中 `*` 的语义)
402///
403/// 算法:动态规划,时间复杂度 O(m*n)
404fn simple_wildcard_match(pattern: &str, text: &str) -> bool {
405    let p: Vec<char> = pattern.chars().collect();
406    let t: Vec<char> = text.chars().collect();
407    let m = p.len();
408    let n = t.len();
409
410    // dp[i][j] = pattern[0..i] 匹配 text[0..j]
411    let mut dp = vec![vec![false; n + 1]; m + 1];
412    dp[0][0] = true;
413
414    // pattern 以 * 开头时可以匹配空字符串
415    for i in 1..=m {
416        if p[i - 1] == '*' {
417            dp[i][0] = dp[i - 1][0];
418        }
419    }
420
421    for i in 1..=m {
422        for j in 1..=n {
423            if p[i - 1] == '*' {
424                // * 匹配 0 个字符(dp[i-1][j])或多个字符(dp[i][j-1])
425                dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
426            } else if p[i - 1] == t[j - 1] {
427                dp[i][j] = dp[i - 1][j - 1];
428            }
429        }
430    }
431
432    dp[m][n]
433}
434
435/// 校验 JWT 签发人(对齐 PHP `IssuedBy` constraint)
436///
437/// PHP 端使用 `Lcobucci\JWT\Validation\Constraint\IssuedBy`:
438/// ```php
439/// $issued = new IssuedBy(self::$_config['issuer']);
440/// if (!$config->validator()->validate($token, $issued)) {
441///     return null;
442/// }
443/// ```
444///
445/// Rust 端 `JwtEncoder::decode` 不校验签发人,需在本模块补充。
446#[tracing::instrument(skip(claims))]
447pub fn verify_issuer(claims: &JwtClaims, expected_issuer: &str) -> bool {
448    match &claims.iss {
449        Some(iss) => iss == expected_issuer,
450        None => false,
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use axum::body::Body;
458    use axum::http::StatusCode;
459    use axum::Router;
460    use http_body_util::BodyExt;
461    use tower::ServiceExt;
462
463    // ====================================================================
464    // 辅助函数
465    // ====================================================================
466
467    async fn read_body(resp: Response) -> String {
468        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
469        String::from_utf8(bytes.to_vec()).unwrap()
470    }
471
472    fn make_request_with_uri(method: &str, uri: &str) -> Request {
473        Request::builder()
474            .method(method)
475            .uri(uri)
476            .body(Body::empty())
477            .unwrap()
478    }
479
480    fn make_request_with_auth(method: &str, uri: &str, auth: &str) -> Request {
481        Request::builder()
482            .method(method)
483            .uri(uri)
484            .header("Authorization", auth)
485            .body(Body::empty())
486            .unwrap()
487    }
488
489    /// 生成有效 JWT token(用于测试)
490    fn make_test_token(secret: &str, issuer: &str, user_id: i64, exp_offset_secs: i64) -> String {
491        let encoder = JwtEncoder::new(secret);
492        let now = std::time::SystemTime::now()
493            .duration_since(std::time::UNIX_EPOCH)
494            .unwrap()
495            .as_secs() as i64;
496        let claims = JwtClaims::new("test_user", now + exp_offset_secs)
497            .with_issuer(issuer)
498            .with_user_id(user_id);
499        encoder.encode(&claims).expect("encode token")
500    }
501
502    // ====================================================================
503    // extract_token_from_header 单元测试
504    // ====================================================================
505
506    #[test]
507    fn test_extract_token_from_header_with_bearer_prefix() {
508        // 对齐 PHP: `Bearer xxx` → `xxx`
509        let token = extract_token_from_header("Bearer abc123");
510        assert_eq!(token, Some("abc123".to_string()));
511    }
512
513    #[test]
514    fn test_extract_token_from_header_with_lowercase_bearer() {
515        // 对齐 PHP: `bearer xxx` → `xxx`(大小写不敏感)
516        let token = extract_token_from_header("bearer abc123");
517        assert_eq!(token, Some("abc123".to_string()));
518    }
519
520    #[test]
521    fn test_extract_token_from_header_with_uppercase_bearer() {
522        // 对齐 PHP: `BEARER xxx` → `xxx`(大小写不敏感)
523        let token = extract_token_from_header("BEARER abc123");
524        assert_eq!(token, Some("abc123".to_string()));
525    }
526
527    #[test]
528    fn test_extract_token_from_header_without_bearer_prefix() {
529        // 对齐 PHP: 无 bearer 前缀时直接返回原值
530        let token = extract_token_from_header("abc123");
531        assert_eq!(token, Some("abc123".to_string()));
532    }
533
534    #[test]
535    fn test_extract_token_from_header_with_empty_string() {
536        let token = extract_token_from_header("");
537        assert_eq!(token, None);
538    }
539
540    #[test]
541    fn test_extract_token_from_header_with_only_whitespace() {
542        let token = extract_token_from_header("   ");
543        assert_eq!(token, None);
544    }
545
546    #[test]
547    fn test_extract_token_from_header_with_bearer_no_space() {
548        // 对齐 PHP `str_ireplace('bearer', '', 'bearerabc')` → `abc`
549        let token = extract_token_from_header("bearerabc");
550        assert_eq!(token, Some("abc".to_string()));
551    }
552
553    #[test]
554    fn test_extract_token_from_header_trims_whitespace() {
555        // 对齐 PHP `trim(...)` 去除首尾空白
556        let token = extract_token_from_header("  Bearer   abc123  ");
557        assert_eq!(token, Some("abc123".to_string()));
558    }
559
560    // ====================================================================
561    // is_route_allowed / wildcard_match 单元测试
562    // ====================================================================
563
564    #[test]
565    fn test_is_route_allowed_exact_match() {
566        let allow = vec!["/passport/login".to_string()];
567        assert!(is_route_allowed("/passport/login", &allow));
568        assert!(!is_route_allowed("/passport/logout", &allow));
569    }
570
571    #[test]
572    fn test_is_route_allowed_multiple_entries() {
573        let allow = vec![
574            "/passport/login".to_string(),
575            "/task/task/userClerk".to_string(),
576        ];
577        assert!(is_route_allowed("/passport/login", &allow));
578        assert!(is_route_allowed("/task/task/userClerk", &allow));
579        assert!(!is_route_allowed("/passport/logout", &allow));
580    }
581
582    #[test]
583    fn test_is_route_allowed_wildcard_suffix() {
584        // 对齐 PHP `AuthService::$allowAllAction` 中的 `/upload.library/*`
585        let allow = vec!["/upload.library/*".to_string()];
586        assert!(is_route_allowed("/upload.library/any", &allow));
587        assert!(is_route_allowed("/upload.library/sub/deep", &allow));
588        assert!(!is_route_allowed("/upload.library", &allow)); // 缺少分隔
589        assert!(!is_route_allowed("/other/path", &allow));
590    }
591
592    #[test]
593    fn test_is_route_allowed_empty_list() {
594        let allow: Vec<String> = vec![];
595        assert!(!is_route_allowed("/any/path", &allow));
596    }
597
598    #[test]
599    fn test_wildcard_match_plain() {
600        assert!(wildcard_match("/upload/*", "/upload/any"));
601        assert!(wildcard_match("/upload/*", "/upload/sub/deep"));
602        assert!(!wildcard_match("/upload/*", "/other/any"));
603    }
604
605    #[test]
606    fn test_wildcard_match_exact_no_star() {
607        // 无 * 时退化为精确匹配
608        assert!(wildcard_match("/passport/login", "/passport/login"));
609        assert!(!wildcard_match("/passport/login", "/passport/logout"));
610    }
611
612    #[test]
613    fn test_wildcard_match_multiple_stars() {
614        assert!(wildcard_match("/*/*", "/a/b"));
615        assert!(wildcard_match("/*/*", "/abc/def"));
616        assert!(!wildcard_match("/*/*", "/a"));
617    }
618
619    #[test]
620    fn test_wildcard_match_star_at_end() {
621        assert!(wildcard_match("/api/*", "/api/v1/users"));
622        assert!(wildcard_match("/api/*", "/api/"));
623        assert!(!wildcard_match("/api/*", "/api"));
624    }
625
626    #[test]
627    fn test_wildcard_match_empty_pattern_and_text() {
628        assert!(wildcard_match("", ""));
629        assert!(!wildcard_match("", "abc"));
630        assert!(!wildcard_match("abc", ""));
631    }
632
633    #[test]
634    fn test_wildcard_match_star_only() {
635        // `*` 匹配任意字符串(包括空)
636        assert!(wildcard_match("*", ""));
637        assert!(wildcard_match("*", "anything"));
638        assert!(wildcard_match("*", "/path/to/anything"));
639    }
640
641    // ====================================================================
642    // verify_issuer 单元测试
643    // ====================================================================
644
645    #[test]
646    fn test_verify_issuer_matches() {
647        let claims = JwtClaims::new("user", 9999999999).with_issuer("https://mall.ljclz.shop");
648        assert!(verify_issuer(&claims, "https://mall.ljclz.shop"));
649    }
650
651    #[test]
652    fn test_verify_issuer_mismatch() {
653        let claims = JwtClaims::new("user", 9999999999).with_issuer("https://evil.com");
654        assert!(!verify_issuer(&claims, "https://mall.ljclz.shop"));
655    }
656
657    #[test]
658    fn test_verify_issuer_missing() {
659        // 无签发人时校验失败(对齐 PHP `IssuedBy` 约束失败)
660        let claims = JwtClaims::new("user", 9999999999);
661        assert!(!verify_issuer(&claims, "https://mall.ljclz.shop"));
662    }
663
664    // ====================================================================
665    // AuthConfig 单元测试
666    // ====================================================================
667
668    #[test]
669    fn test_auth_config_default_matches_php() {
670        // 对齐 PHP `Token::$_config` 默认值
671        let config = AuthConfig::default();
672        assert_eq!(config.secret, "shengzhuang");
673        assert_eq!(config.issuer, "https://mall.ljclz.shop");
674        assert_eq!(config.expiration, 3600 * 24 * 30);
675        // 对齐 PHP `BaseController::$allowAllAction`
676        assert_eq!(
677            config.allow_all_action,
678            vec![
679                "/passport/login".to_string(),
680                "/task/task/userClerk".to_string(),
681            ]
682        );
683    }
684
685    #[test]
686    fn test_auth_config_default_allow_all_action_constant() {
687        // 验证常量与 Default 一致
688        assert_eq!(DEFAULT_ALLOW_ALL_ACTION.len(), 2);
689        assert_eq!(DEFAULT_ALLOW_ALL_ACTION[0], "/passport/login");
690        assert_eq!(DEFAULT_ALLOW_ALL_ACTION[1], "/task/task/userClerk");
691    }
692
693    #[test]
694    fn test_auth_config_builder_methods() {
695        let config = AuthConfig::default()
696            .with_secret("custom-secret")
697            .with_issuer("https://custom.com")
698            .with_allow_all_action(vec!["/custom/login".to_string()]);
699
700        assert_eq!(config.secret, "custom-secret");
701        assert_eq!(config.issuer, "https://custom.com");
702        assert_eq!(config.allow_all_action, vec!["/custom/login".to_string()]);
703    }
704
705    #[test]
706    fn test_auth_default_constants_match_php() {
707        // 对齐 PHP `Token::$_config` 常量
708        assert_eq!(DEFAULT_ISSUER, "https://mall.ljclz.shop");
709        assert_eq!(DEFAULT_SECRET, "shengzhuang");
710        assert_eq!(DEFAULT_EXPIRATION, 3600 * 24 * 30);
711    }
712
713    // ====================================================================
714    // extract_route_uri 单元测试
715    // ====================================================================
716
717    #[test]
718    fn test_extract_route_uri_strips_query_string() {
719        let req = Request::builder()
720            .uri("/passport/login?foo=bar&baz=qux")
721            .body(Body::empty())
722            .unwrap();
723        assert_eq!(extract_route_uri(&req), "/passport/login");
724    }
725
726    #[test]
727    fn test_extract_route_uri_no_query() {
728        let req = Request::builder()
729            .uri("/api/users")
730            .body(Body::empty())
731            .unwrap();
732        assert_eq!(extract_route_uri(&req), "/api/users");
733    }
734
735    #[test]
736    fn test_extract_route_uri_root() {
737        let req = Request::builder().uri("/").body(Body::empty()).unwrap();
738        assert_eq!(extract_route_uri(&req), "/");
739    }
740
741    // ====================================================================
742    // auth_middleware 集成测试(通过 Router 验证)
743    // ====================================================================
744
745    /// 构建测试用 Router,使用给定 AuthConfig
746    fn build_app(config: AuthConfig) -> Router {
747        Router::new()
748            .route("/protected", axum::routing::get(|| async { "protected" }))
749            .route("/passport/login", axum::routing::get(|| async { "login" }))
750            .route(
751                "/upload.library/test",
752                axum::routing::get(|| async { "upload" }),
753            )
754            .layer(axum::middleware::from_fn_with_state(
755                config,
756                auth_middleware,
757            ))
758    }
759
760    #[tokio::test]
761    async fn test_auth_middleware_allows_whitelisted_route() {
762        // 对齐 PHP: 白名单 `/passport/login` 跳过 Auth 校验
763        let app = build_app(AuthConfig::default());
764        let resp = app
765            .oneshot(make_request_with_uri("GET", "/passport/login"))
766            .await
767            .unwrap();
768        assert_eq!(resp.status(), StatusCode::OK);
769        let body = read_body(resp).await;
770        assert_eq!(body, "login");
771    }
772
773    #[tokio::test]
774    async fn test_auth_middleware_rejects_missing_authorization_header() {
775        // 对齐 PHP: 无 Authorization header → BaseException(['msg' => '缺少必要的参数,请重新登陆!'])
776        let app = build_app(AuthConfig::default());
777        let resp = app
778            .oneshot(make_request_with_uri("GET", "/protected"))
779            .await
780            .unwrap();
781        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // NotLogin → 401
782        let body = read_body(resp).await;
783        assert!(body.contains("\"code\":-1"));
784        assert!(body.contains("缺少必要的参数,请重新登陆!"));
785    }
786
787    #[tokio::test]
788    async fn test_auth_middleware_rejects_empty_authorization_header() {
789        // 对齐 PHP: Authorization header 为空字符串
790        let app = build_app(AuthConfig::default());
791        let resp = app
792            .oneshot(make_request_with_auth("GET", "/protected", ""))
793            .await
794            .unwrap();
795        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
796        let body = read_body(resp).await;
797        assert!(body.contains("\"code\":-1"));
798    }
799
800    #[tokio::test]
801    async fn test_auth_middleware_rejects_invalid_token() {
802        // 对齐 PHP: JWT 解析失败 → BaseException(['msg' => '缺少必要的参数,请重新登陆!'])
803        let app = build_app(AuthConfig::default());
804        let resp = app
805            .oneshot(make_request_with_auth(
806                "GET",
807                "/protected",
808                "invalid.token.here",
809            ))
810            .await
811            .unwrap();
812        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
813        let body = read_body(resp).await;
814        assert!(body.contains("\"code\":-1"));
815        assert!(body.contains("缺少必要的参数,请重新登陆!"));
816    }
817
818    #[tokio::test]
819    async fn test_auth_middleware_rejects_expired_token() {
820        // 对齐 PHP: token 过期 → BaseException(['msg' => '缺少必要的参数,请重新登陆!'])
821        let config = AuthConfig::default();
822        // 生成已过期的 token(exp = now - 3600)
823        let token = make_test_token(&config.secret, &config.issuer, 1, -3600);
824        let app = build_app(config.clone());
825        let resp = app
826            .oneshot(make_request_with_auth("GET", "/protected", &token))
827            .await
828            .unwrap();
829        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
830        let body = read_body(resp).await;
831        assert!(body.contains("\"code\":-1"));
832    }
833
834    #[tokio::test]
835    async fn test_auth_middleware_rejects_wrong_secret_token() {
836        // 对齐 PHP: 用错误密钥签发的 token 无法通过签名校验
837        let config = AuthConfig::default();
838        let token = make_test_token("wrong-secret", &config.issuer, 1, 3600);
839        let app = build_app(config.clone());
840        let resp = app
841            .oneshot(make_request_with_auth("GET", "/protected", &token))
842            .await
843            .unwrap();
844        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
845    }
846
847    #[tokio::test]
848    async fn test_auth_middleware_rejects_wrong_issuer_token() {
849        // 对齐 PHP: 签发人不匹配 → IssuedBy 约束失败
850        let config = AuthConfig::default();
851        let token = make_test_token(&config.secret, "https://evil.com", 1, 3600);
852        let app = build_app(config.clone());
853        let resp = app
854            .oneshot(make_request_with_auth("GET", "/protected", &token))
855            .await
856            .unwrap();
857        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
858        let body = read_body(resp).await;
859        assert!(body.contains("缺少必要的参数,请重新登陆!"));
860    }
861
862    #[tokio::test]
863    async fn test_auth_middleware_rejects_token_without_user_id() {
864        // 对齐 PHP: user_id 缺失 → BaseException(['code' => -1, 'msg' => 'not_login'])
865        let config = AuthConfig::default();
866        let encoder = JwtEncoder::new(&config.secret);
867        let now = std::time::SystemTime::now()
868            .duration_since(std::time::UNIX_EPOCH)
869            .unwrap()
870            .as_secs() as i64;
871        // 不设置 user_id
872        let claims = JwtClaims::new("test_user", now + 3600).with_issuer(&config.issuer);
873        let token = encoder.encode(&claims).unwrap();
874        let app = build_app(config.clone());
875        let resp = app
876            .oneshot(make_request_with_auth("GET", "/protected", &token))
877            .await
878            .unwrap();
879        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
880        let body = read_body(resp).await;
881        assert!(body.contains("\"code\":-1"));
882        assert!(body.contains("not_login"));
883    }
884
885    #[tokio::test]
886    async fn test_auth_middleware_rejects_token_with_zero_user_id() {
887        // user_id = 0 视为无效(对齐 PHP `$this->user['is_login'] == 1` 检查)
888        let config = AuthConfig::default();
889        let token = make_test_token(&config.secret, &config.issuer, 0, 3600);
890        let app = build_app(config.clone());
891        let resp = app
892            .oneshot(make_request_with_auth("GET", "/protected", &token))
893            .await
894            .unwrap();
895        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
896        let body = read_body(resp).await;
897        assert!(body.contains("not_login"));
898    }
899
900    #[tokio::test]
901    async fn test_auth_middleware_accepts_valid_token_with_bearer_prefix() {
902        // 对齐 PHP: `Bearer <token>` 通过校验
903        let config = AuthConfig::default();
904        let token = make_test_token(&config.secret, &config.issuer, 42, 3600);
905        let app = build_app(config.clone());
906        let resp = app
907            .oneshot(make_request_with_auth(
908                "GET",
909                "/protected",
910                &format!("Bearer {}", token),
911            ))
912            .await
913            .unwrap();
914        assert_eq!(resp.status(), StatusCode::OK);
915        let body = read_body(resp).await;
916        assert_eq!(body, "protected");
917    }
918
919    #[tokio::test]
920    async fn test_auth_middleware_accepts_valid_token_without_bearer_prefix() {
921        // 对齐 PHP: `str_ireplace` 找不到 bearer 时直接返回原 token
922        let config = AuthConfig::default();
923        let token = make_test_token(&config.secret, &config.issuer, 42, 3600);
924        let app = build_app(config.clone());
925        let resp = app
926            .oneshot(make_request_with_auth("GET", "/protected", &token))
927            .await
928            .unwrap();
929        assert_eq!(resp.status(), StatusCode::OK);
930    }
931
932    #[tokio::test]
933    async fn test_auth_middleware_accepts_lowercase_bearer_prefix() {
934        // 对齐 PHP: `bearer <token>` 大小写不敏感
935        let config = AuthConfig::default();
936        let token = make_test_token(&config.secret, &config.issuer, 42, 3600);
937        let app = build_app(config.clone());
938        let resp = app
939            .oneshot(make_request_with_auth(
940                "GET",
941                "/protected",
942                &format!("bearer {}", token),
943            ))
944            .await
945            .unwrap();
946        assert_eq!(resp.status(), StatusCode::OK);
947    }
948
949    #[tokio::test]
950    async fn test_auth_middleware_supports_wildcard_whitelist() {
951        // 对齐 PHP `AuthService::$allowAllAction` 中的 `/upload.library/*`
952        let config =
953            AuthConfig::default().with_allow_all_action(vec!["/upload.library/*".to_string()]);
954        let app = build_app(config);
955        let resp = app
956            .oneshot(make_request_with_uri("GET", "/upload.library/test"))
957            .await
958            .unwrap();
959        assert_eq!(resp.status(), StatusCode::OK);
960        let body = read_body(resp).await;
961        assert_eq!(body, "upload");
962    }
963
964    #[tokio::test]
965    async fn test_auth_middleware_wildcard_does_not_overmatch() {
966        // 通配符 `/upload.library/*` 不应匹配 `/upload.library`(无尾部路径)
967        let config =
968            AuthConfig::default().with_allow_all_action(vec!["/upload.library/*".to_string()]);
969        let app = build_app(config);
970        // `/upload.library/test` 在白名单中,但 `/protected` 不在
971        let resp = app
972            .oneshot(make_request_with_uri("GET", "/protected"))
973            .await
974            .unwrap();
975        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
976    }
977
978    #[tokio::test]
979    async fn test_auth_middleware_injects_user_id_into_extensions() {
980        // 验证通过校验后,user_id 被插入 request extensions
981        let config = AuthConfig::default();
982        let token = make_test_token(&config.secret, &config.issuer, 99, 3600);
983        let app = Router::new()
984            .route(
985                "/protected",
986                axum::routing::get(|req: Request| async move {
987                    let user = req.extensions().get::<AuthenticatedUser>().unwrap();
988                    format!("user_id:{}", user.user_id)
989                }),
990            )
991            .layer(axum::middleware::from_fn_with_state(
992                config.clone(),
993                auth_middleware,
994            ));
995        let resp = app
996            .oneshot(make_request_with_auth("GET", "/protected", &token))
997            .await
998            .unwrap();
999        assert_eq!(resp.status(), StatusCode::OK);
1000        let body = read_body(resp).await;
1001        assert_eq!(body, "user_id:99");
1002    }
1003
1004    #[tokio::test]
1005    async fn test_auth_middleware_returns_correct_error_code_for_missing_token() {
1006        // 验证错误码对齐 PHP `BaseException(['code' => -1, ...])`
1007        let app = build_app(AuthConfig::default());
1008        let resp = app
1009            .oneshot(make_request_with_uri("GET", "/protected"))
1010            .await
1011            .unwrap();
1012        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // ErrorCode::NotLogin → 401
1013        let body = read_body(resp).await;
1014        // JSON 响应格式:{"code":-1,"msg":"...","data":{}}
1015        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1016        assert_eq!(json["code"], -1);
1017        assert_eq!(json["msg"], "缺少必要的参数,请重新登陆!");
1018        assert_eq!(json["data"], serde_json::json!({}));
1019    }
1020
1021    #[tokio::test]
1022    async fn test_auth_middleware_returns_correct_error_code_for_not_login() {
1023        // 验证 user_id 缺失时错误码为 -1,msg 为 "not_login"
1024        let config = AuthConfig::default();
1025        let encoder = JwtEncoder::new(&config.secret);
1026        let now = std::time::SystemTime::now()
1027            .duration_since(std::time::UNIX_EPOCH)
1028            .unwrap()
1029            .as_secs() as i64;
1030        let claims = JwtClaims::new("test_user", now + 3600).with_issuer(&config.issuer);
1031        let token = encoder.encode(&claims).unwrap();
1032        let app = build_app(config.clone());
1033        let resp = app
1034            .oneshot(make_request_with_auth("GET", "/protected", &token))
1035            .await
1036            .unwrap();
1037        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1038        let body = read_body(resp).await;
1039        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1040        assert_eq!(json["code"], -1);
1041        assert_eq!(json["msg"], "not_login");
1042    }
1043
1044    #[tokio::test]
1045    async fn test_auth_middleware_custom_secret_and_issuer() {
1046        // 验证自定义密钥和签发人配置生效
1047        let config = AuthConfig::default()
1048            .with_secret("custom-secret")
1049            .with_issuer("https://custom.com");
1050        let token = make_test_token("custom-secret", "https://custom.com", 1, 3600);
1051        let app = build_app(config);
1052        let resp = app
1053            .oneshot(make_request_with_auth("GET", "/protected", &token))
1054            .await
1055            .unwrap();
1056        assert_eq!(resp.status(), StatusCode::OK);
1057    }
1058
1059    #[tokio::test]
1060    async fn test_auth_middleware_rejects_token_signed_with_default_secret_when_custom_configured()
1061    {
1062        // 自定义密钥后,用默认密钥签发的 token 应被拒绝
1063        let config = AuthConfig::default().with_secret("custom-secret");
1064        let token = make_test_token("shengzhuang", &config.issuer, 1, 3600);
1065        let app = build_app(config);
1066        let resp = app
1067            .oneshot(make_request_with_auth("GET", "/protected", &token))
1068            .await
1069            .unwrap();
1070        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1071    }
1072
1073    #[tokio::test]
1074    async fn test_auth_middleware_preserves_query_string_in_route_match() {
1075        // 验证带查询参数的白名单路由仍能匹配
1076        let app = build_app(AuthConfig::default());
1077        let resp = app
1078            .oneshot(make_request_with_uri(
1079                "GET",
1080                "/passport/login?redirect=/home",
1081            ))
1082            .await
1083            .unwrap();
1084        assert_eq!(resp.status(), StatusCode::OK);
1085    }
1086
1087    #[tokio::test]
1088    async fn test_auth_middleware_handles_token_with_only_bearer_prefix() {
1089        // `Bearer ` 后无内容 → 视为空 token
1090        let app = build_app(AuthConfig::default());
1091        let resp = app
1092            .oneshot(make_request_with_auth("GET", "/protected", "Bearer "))
1093            .await
1094            .unwrap();
1095        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1096    }
1097
1098    // ====================================================================
1099    // HeaderMap 与 PHP 对齐验证
1100    // ====================================================================
1101
1102    #[test]
1103    fn test_authorization_header_name_aligns_with_php() {
1104        // PHP: `request()->header('Authorization')`
1105        // Rust: `req.headers().get(axum::http::header::AUTHORIZATION)`
1106        // 两者都使用标准 HTTP header 名 `Authorization`
1107        let header_name = axum::http::header::AUTHORIZATION;
1108        assert_eq!(header_name.as_str(), "authorization");
1109        // axum/http 的 header 名是 lowercase,PHP 端不区分大小写
1110    }
1111
1112    // ====================================================================
1113    // PHP 行为对齐验证测试
1114    // ====================================================================
1115
1116    #[test]
1117    fn test_php_default_allow_all_action_matches_rust() {
1118        // PHP `BaseController::$allowAllAction` 默认值
1119        let php_allow = vec!["/passport/login", "/task/task/userClerk"];
1120        // Rust `DEFAULT_ALLOW_ALL_ACTION`
1121        assert_eq!(php_allow, DEFAULT_ALLOW_ALL_ACTION);
1122    }
1123
1124    #[test]
1125    fn test_php_jwt_config_matches_rust() {
1126        // PHP `Token::$_config`
1127        let php_issuer = "https://mall.ljclz.shop";
1128        let php_secret = "shengzhuang";
1129        let php_expire = 3600 * 24 * 30;
1130
1131        // Rust 默认常量
1132        assert_eq!(php_issuer, DEFAULT_ISSUER);
1133        assert_eq!(php_secret, DEFAULT_SECRET);
1134        assert_eq!(php_expire, DEFAULT_EXPIRATION);
1135    }
1136}