Skip to main content

tdm_server_rust/middleware/
auth.rs

1//! 登录鉴权与路由权限中间件
2//!
3//! 对齐 Java `LoginCheckInterceptor` + `PermissionCheckInterceptor`。
4//!
5//! ## 鉴权流程
6//!
7//! 1. 放行 `/login`、`/reg`、`OPTIONS` 请求
8//! 2. 从 Header `token` 或 `Authorization` 提取 JWT
9//! 3. 解析 Token → 加载组员信息
10//! 4. 特殊路由权限校验(evaluations、takeEpisode、download 等)
11//! 5. 注入 [`AuthMember`] 到请求扩展
12
13use crate::{
14    app::AppState,
15    cache::get_auth_snapshot_cached,
16    common::result::permission_denied_response,
17    entity::{
18        enums::{MemberInternEnum, PostEnum},
19        member::Member,
20    },
21    error::AppError,
22    utils::jwt::{JwtClaims, JwtUtil},
23};
24use axum::{
25    body::Body,
26    extract::State,
27    http::{Method, Request},
28    middleware::Next,
29    response::Response,
30};
31
32/// 请求扩展中的当前组员
33#[derive(Debug, Clone)]
34pub struct AuthMember(pub Option<Member>);
35
36/// 鉴权中间件
37#[tracing::instrument(skip_all, level = "info")]
38pub async fn auth_middleware(
39    State(state): State<AppState>,
40    mut req: Request<Body>,
41    next: Next,
42) -> Result<Response, AppError> {
43    let uri = req.uri().path().to_string();
44    let method = req.method().clone();
45
46    if is_public_auth_path(&uri) {
47        req.extensions_mut().insert(AuthMember(None));
48        return Ok(next.run(req).await);
49    }
50
51    if method == Method::OPTIONS {
52        req.extensions_mut().insert(AuthMember(None));
53        return Ok(next.run(req).await);
54    }
55
56    if uri.contains("undefined") {
57        return Err(AppError::login_expired("未登录哦 快加入提灯喵接坑吧喵!"));
58    }
59
60    let token = extract_token(req.headers());
61    if method != Method::GET && token.is_none() {
62        return Err(AppError::login_expired("请登录一下哦喵……"));
63    }
64
65    let mut member: Option<Member> = None;
66    if let Some(jwt) = token.as_deref() {
67        let claims = parse_token(&state, jwt)?;
68        member = Some(load_member(&state, claims.id).await?);
69    }
70
71    if requires_reviewer_permission(&uri, &method)
72        && !member.as_ref().is_some_and(Member::has_reviewer_permission)
73    {
74        return Ok(permission_denied_response());
75    }
76
77    check_route_permission(&uri, token.as_deref())?;
78
79    if uri.starts_with("/api/evaluations") {
80        if let Some(ref m) = member {
81            let posts = &m.post_ids;
82            let has = posts.contains(&2) || posts.contains(&4);
83            if !has && !posts.contains(&4) {
84                return Ok(permission_denied_response());
85            }
86        } else {
87            return Ok(permission_denied_response());
88        }
89    }
90
91    req.extensions_mut().insert(AuthMember(member));
92    Ok(next.run(req).await)
93}
94
95/// 判断请求是否属于仅审稿岗位可用的管理功能。
96///
97/// 管理页面虽然会隐藏入口,但真正的权限边界必须落在后端,避免普通组员直接调用接口。
98fn requires_reviewer_permission(uri: &str, method: &Method) -> bool {
99    if uri.starts_with("/api/admin/") {
100        return true;
101    }
102
103    let path = uri.trim_end_matches('/');
104    match *method {
105        Method::POST | Method::PUT | Method::DELETE => {
106            path == "/api/mangas"
107                || path == "/api/members"
108                || path == "/api/authors"
109                || path == "/api/magazines"
110                || path == "/api/episodes"
111                || path == "/api/mangas/station/admin"
112                || is_numeric_child(path, "/api/mangas/")
113                || is_numeric_child(path, "/api/members/")
114                || is_numeric_child(path, "/api/authors/")
115                || is_numeric_child(path, "/api/magazines/")
116                || is_numeric_child(path, "/api/episodes/")
117        }
118        _ => false,
119    }
120}
121
122/// 判断组员能否操作指定岗位的上传对象。
123///
124/// 正式组员和审稿可处理全部受支持岗位;实习组员仍仅可操作本人岗位。
125/// 始终校验岗位名白名单,避免将任意对象键前缀签发为上传目标。
126pub(crate) fn member_can_upload_for_post(member: &Member, post_name: &str) -> bool {
127    if !MemberInternEnum::is_working_member(member.intern) {
128        return false;
129    }
130    let required = match post_name.to_ascii_lowercase().as_str() {
131        "provider" => PostEnum::PROVIDER,
132        "translator" => PostEnum::TRANSLATOR,
133        "proofreader" => PostEnum::PROOFREADER,
134        "letterer" => PostEnum::LETTERER,
135        "timer" => PostEnum::TIMER,
136        _ => return false,
137    };
138    member.intern == MemberInternEnum::REGULAR
139        || member.has_reviewer_permission()
140        || member.post_ids.contains(&required)
141}
142
143/// 判断路径是否为指定资源下的单个数字 ID,避免误拦截收藏、交稿等普通组员接口。
144fn is_numeric_child(path: &str, prefix: &str) -> bool {
145    path.strip_prefix(prefix)
146        .is_some_and(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_digit()))
147}
148
149/// 解析 JWT token
150#[tracing::instrument(name = "auth::parse_token", skip(state, token), level = "info")]
151fn parse_token(state: &AppState, token: &str) -> Result<JwtClaims, AppError> {
152    let util = JwtUtil::new(&state.config.jwt.sign_key, state.config.jwt.expire_ms);
153    util.parse(token).map_err(|e| {
154        if e.to_string().contains("ExpiredSignature") {
155            AppError::login_expired("登录数据过期,请重新登录喵!")
156        } else {
157            AppError::login_expired("Token解析失败喵!请重新登录喵!")
158        }
159    })
160}
161
162/// 从数据库加载组员
163#[tracing::instrument(name = "auth::load_member", skip(state), level = "info")]
164async fn load_member(state: &AppState, member_id: i32) -> Result<Member, AppError> {
165    let m = get_auth_snapshot_cached(state, member_id).await?;
166    if crate::entity::enums::MemberInternEnum::is_left(m.intern) {
167        return Err(AppError::login_expired("你已经退出了提灯喵猫娘化计划喵……"));
168    }
169    Ok(m)
170}
171
172/// 路由级权限校验
173#[tracing::instrument(name = "auth::check_permission", skip(uri, token), level = "info")]
174fn check_route_permission(uri: &str, token: Option<&str>) -> Result<(), AppError> {
175    let needs_strict =
176        uri.contains("takeEpisode") || uri.contains("invitationCodes") || uri.contains("download");
177    if needs_strict && token.is_none() {
178        return Err(AppError::login_expired("必须要登录才能访问这里喵!"));
179    }
180    Ok(())
181}
182
183/// 免登录鉴权路径(精确匹配,避免 `/pages/register` 误命中 `reg`)
184fn is_public_auth_path(uri: &str) -> bool {
185    matches!(uri, "/api/login" | "/api/reg" | "/login" | "/reg")
186}
187
188/// 从请求头提取 token
189fn extract_token(headers: &axum::http::HeaderMap) -> Option<String> {
190    headers
191        .get("token")
192        .or_else(|| headers.get("Authorization"))
193        .and_then(|v| v.to_str().ok())
194        .map(|s| s.to_string())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{is_public_auth_path, member_can_upload_for_post, requires_reviewer_permission};
200    use crate::entity::{
201        enums::{MemberInternEnum, PostEnum},
202        member::Member,
203    };
204    use axum::http::Method;
205
206    #[test]
207    fn public_auth_path_only_login_and_reg() {
208        assert!(is_public_auth_path("/api/login"));
209        assert!(is_public_auth_path("/api/reg"));
210        assert!(is_public_auth_path("/login"));
211        assert!(is_public_auth_path("/reg"));
212        assert!(!is_public_auth_path(
213            "/api/editor/episodes/1/pages/register"
214        ));
215        assert!(!is_public_auth_path("/editor/episodes/1/pages/register"));
216        assert!(!is_public_auth_path("/api/members/18"));
217    }
218
219    #[test]
220    fn admin_pages_and_management_writes_require_reviewer() {
221        assert!(requires_reviewer_permission(
222            "/api/admin/taskTracking/episodeTasks",
223            &Method::GET
224        ));
225        assert!(requires_reviewer_permission("/api/members", &Method::PUT));
226        assert!(requires_reviewer_permission(
227            "/api/mangas/42",
228            &Method::DELETE
229        ));
230        assert!(requires_reviewer_permission(
231            "/api/mangas/station/admin",
232            &Method::POST
233        ));
234    }
235
236    #[test]
237    fn ordinary_member_actions_are_not_treated_as_admin_writes() {
238        assert!(!requires_reviewer_permission(
239            "/api/members/self",
240            &Method::PUT
241        ));
242        assert!(!requires_reviewer_permission(
243            "/api/members/submitEpisode",
244            &Method::PATCH
245        ));
246        assert!(!requires_reviewer_permission(
247            "/api/mangas/collect",
248            &Method::POST
249        ));
250        assert!(!requires_reviewer_permission(
251            "/api/episodes/rollback/42/translator",
252            &Method::PUT
253        ));
254    }
255
256    #[test]
257    fn regular_members_can_upload_to_any_supported_post() {
258        let mut member = Member::default();
259        assert!(member_can_upload_for_post(&member, "provider"));
260        assert!(member_can_upload_for_post(&member, "translator"));
261        assert!(member_can_upload_for_post(&member, "proofreader"));
262        assert!(member_can_upload_for_post(&member, "letterer"));
263        assert!(member_can_upload_for_post(&member, "timer"));
264
265        member.intern = MemberInternEnum::INTERN;
266        member.post_ids = vec![PostEnum::TRANSLATOR];
267        assert!(member_can_upload_for_post(&member, "translator"));
268        assert!(!member_can_upload_for_post(&member, "provider"));
269
270        member.post_ids = vec![PostEnum::REVIEWER];
271        assert!(member_can_upload_for_post(&member, "provider"));
272        assert!(!member_can_upload_for_post(&member, "../../backup"));
273
274        member.intern = MemberInternEnum::RETIRED;
275        assert!(!member.has_reviewer_permission());
276        assert!(!member_can_upload_for_post(&member, "translator"));
277    }
278}