From 58f1a981d37c242134bab0b82872d2a58b18d69c Mon Sep 17 00:00:00 2001 From: xfy Date: Sat, 4 Jul 2026 22:53:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20SSR=20=E5=B1=82=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=AE=88=E5=8D=AB=EF=BC=8C=E6=9C=AA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=B7=B3=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前后台鉴权完全在客户端 WASM 完成:SSR 渲染骨架屏 → 浏览器下载 /编译 WASM → hydrate → 异步 get_current_user() → 客户端 navigator. push(Login)。整条链串行,未登录用户首屏要空白好久才跳转登录。 新增 admin_guard 中间件:/admin* 请求在进入 Dioxus SSR 渲染器之前 读 session cookie → get_user_by_token 校验 admin 角色 → 失败直接 302 到 /login。未登录用户首屏即登录页,零 SSR 渲染开销。 - 置于 app_routes layer 链最外层(CSRF/cache/SSR 之前短路) - 复用 get_user_by_token:命中内存缓存 + session_generation 校验, 与客户端鉴权同一套语义 - DB 错误 fail-open(避免抖动把已登录管理员踢登录页),客户端 AdminLayout 仍作兜底 - /admin 与 /login 本就不进 SSR 缓存,302 不会被缓存 --- src/main.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/main.rs b/src/main.rs index a989219..47b74df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,6 +209,63 @@ async fn add_cache_control( response } +/// Axum 中间件:`/admin*` 的 SSR 层认证守卫。 +/// +/// 未登录访问后台时,服务端**直接 302 跳转 `/login`**,根本不进入 Dioxus +/// SSR 渲染器。此前后台鉴权完全在客户端 WASM 完成(SSR 渲染骨架屏 → WASM +/// 下载/编译 → hydrate → 异步 `get_current_user()` → 客户端 `navigator.push`), +/// 整条链串行,未登录用户首屏要"空白好久"才跳登录。 +/// +/// - 只匹配 `/admin*`,其它路径(`/login`、公开页、`/api/*`)直接放行。 +/// - 复用 `get_user_by_token`:命中内存缓存 + 校验 `session_generation` +/// (封禁/降级后旧 session 立即失效),与客户端鉴权同一套语义。 +/// - DB 错误时 **fail-open**(放行进入 SSR):避免数据库抖动把已登录的 +/// 管理员也踢到登录页;客户端 `AdminLayout` 仍有兜底校验。 +/// - `/admin` 与 `/login` 本就不进 `cache_control_for_path` 缓存,302 不会被缓存。 +#[cfg(feature = "server")] +async fn admin_guard( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::body::Body; + use axum::http::{header, StatusCode}; + use axum::response::Response; + use crate::models::user::UserRole; + + let path = req.uri().path().to_string(); + if !path.starts_with("/admin") { + return next.run(req).await; + } + + // 从 Cookie 头读 session token(与 export.rs / upload.rs 同款手法)。 + let cookie = req + .headers() + .get("cookie") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let token = crate::auth::session::parse_session_token(cookie); + + let is_admin = match token { + Some(t) => match crate::api::auth::get_user_by_token(t).await { + Ok(Some(user)) => user.role == UserRole::Admin, + // Err(DB 抖动)/ Ok(None)(token 无效):fail-open,交给客户端兜底。 + _ => true, + }, + // 无 token:明确未登录,拦截。 + None => false, + }; + + if is_admin { + next.run(req).await + } else { + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/login") + .body(Body::empty()) + .unwrap() + } +} + /// 程序入口 fn main() { // server feature:启动服务端 @@ -425,6 +482,10 @@ fn main() { StatusCode::REQUEST_TIMEOUT, Duration::from_secs(30), )); + // admin_guard 置于最外层(最后添加 = 最先执行):未登录的 /admin* 请求 + // 在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。 + let app_routes = + app_routes.layer(axum::middleware::from_fn(admin_guard)); // 静态资源路由:图片文件服务。 // 注意:`dioxus::server::serve()` 接管了 listener 与 `into_make_service`