最近在整理项目里的认证链路,发现一个很容易被忽略的小坑:页面未登录可以跳登录页,但 API 未登录千万别直接跳登录页。
听起来像废话,但我一开始确实差点这么干。毕竟在传统 Web 里,用户访问 /orders,Cookie 过期了,服务端 302 到 /login,浏览器跟过去,体验挺顺。
问题出在 Leptos server function。
server function 发起的是接口请求,它期待的是一段可反序列化的响应,不是登录页 HTML。你要是让 API 也重定向到 /login,前端最后可能拿到一整页 HTML,然后报一个莫名其妙的反序列化失败。用户看到的不是"登录过期",而是"数据格式错误"。
这就很尴尬。
所以 Pico-CRM 的做法是:同一个认证入口里,按请求类型返回两种失败响应。
一、先看路由挂载:认证在 Axum 中间件里统一进来
Pico-CRM 是 Rust 全栈项目,后端用 Axum,页面用 Leptos SSR + WASM hydration。认证中间件挂在 Router 上:
rust
// server/src/main.rs
let app = Router::new()
.leptos_routes_with_context(
&leptos_options,
routes,
move || {
provide_context(db.clone());
},
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
},
)
.layer(from_fn_with_state(db_clone, global_api_auth_middleware))
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
这里用的是 from_fn_with_state,因为认证不只是看 Cookie,还要用数据库连接做两件事:
- 校验 JWT 对应的用户是否仍然有效
- 校验商户状态是否
active且没有过期
这也是为什么我没有把认证逻辑散落到每个 server function 里。中间件先把"这个请求到底是不是合法用户发来的"处理掉,业务 handler 后面只关心业务本身。
二、白名单:登录接口自己不能被登录拦住
中间件第一步不是解析 JWT,而是先判断白名单:
rust
// server/src/middlewares/auth_middleware.rs
let white_list = [
"/",
"/login",
"/admin/login",
"/api/logout",
"/api/login",
"/api/admin/login",
"/api/register_merchant",
];
if white_list.contains(&path.as_str()) {
return Ok(next.run(req).await);
}
这段看起来普通,但它解决了一个很实际的问题:登录页、管理员登录页、商户注册接口,不能先要求你已经登录。
Pico-CRM 有两套入口:
- 普通商户用户:
/login、/api/login - 平台管理员:
/admin/login、/api/admin/login
所以白名单里必须同时放页面路由和 API 路由。否则最典型的事故就是:用户访问登录页,登录页自己被中间件重定向到登录页,原地打转。
三、页面通道:浏览器请求适合 303
页面请求的处理逻辑比较符合直觉:没登录就跳登录页。
项目里有一个辅助函数,根据当前路径决定跳哪个登录页:
rust
fn login_path_for(path: &str) -> &'static str {
if path.starts_with("/admin") || path.starts_with("/api/admin") {
"/admin/login"
} else {
"/login"
}
}
为什么要区分?
因为平台管理员和商户用户是两个世界。管理员去 /admin,失败后应该回 /admin/login;商户用户去 /orders,失败后应该回 /login。如果统一跳一个入口,用户体验会乱,权限边界也会变模糊。
页面失败时,中间件返回的是:
rust
let login_path = login_path_for(path);
let mut res = Response::new(Body::empty());
*res.status_mut() = StatusCode::SEE_OTHER;
res.headers_mut().insert(
axum::http::header::LOCATION,
HeaderValue::from_static(login_path),
);
Ok(res)
这里我用的是 303 SEE_OTHER,不是随手写个 302。
303 的语义更明确:让客户端用 GET 去另一个地址拿资源。对页面跳转来说,这比保留原请求方法更稳,尤其是从表单或动作请求失败后跳登录页时,不希望浏览器对登录页重复发 POST。
一句话总结:页面请求要的是用户体验,认证失败就应该顺滑地离开当前页面。
四、API 通道:server function 要的是机器可读错误
重点来了。
对 /api 请求,不能返回登录页 HTML,而是返回 Leptos 能识别的错误格式:
rust
async fn handle_auth_failure(path: &str) -> Result<Response<Body>, StatusCode> {
if path.starts_with("/api") {
let error_message = "MiddlewareError|未登录,请先登录后再操作";
let mut res = Response::new(Body::from(error_message));
*res.status_mut() = StatusCode::UNAUTHORIZED;
res.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/plain"),
);
Ok(res)
} else {
// 页面请求走 303 + Location
}
}
这段里有三个点:
- 状态码是
401 UNAUTHORIZED Content-Type是text/plain- 响应体是
MiddlewareError|未登录,请先登录后再操作
MiddlewareError|... 不是我随便拼的字符串。Leptos 的 server function 错误协议会把这种响应识别成 ServerFnError::MiddlewareError。这样前端拿到的不是"JSON 解析失败",而是一个明确的"中间件认证失败"。
举个例子,订单页调用 fetch_orders() 时 Cookie 过期:
text
错误链路:
fetch_orders()
-> /api/fetch_orders
-> 中间件发现未登录
-> 401 + MiddlewareError|未登录,请先登录后再操作
-> 前端识别为 ServerFnError::MiddlewareError
-> 跳转 /login
这个链路的核心不是"返回 401"这么简单,而是让前端知道这是认证失败,不是业务失败,也不是网络失败。
五、前端接力:call_api 统一识别 MiddlewareError
后端把错误格式打准了,前端也要统一接住。
Pico-CRM 里所有 server function 调用都包了一层 call_api():
rust
// app/src/utils/api.rs
pub async fn call_api<T, F>(server_fn_call: F) -> Result<T, String>
where
F: Future<Output = Result<T, ServerFnError>>,
{
match server_fn_call.await {
Ok(data) => Ok(data),
Err(e) => {
logging::log!("API Error: {:?}", e);
handle_api_error(&e);
Err(format_api_error(&e))
}
}
}
未授权判断集中在一个函数里:
rust
fn is_unauthorized_error(error: &ServerFnError) -> bool {
match error {
ServerFnError::MiddlewareError(_) => true,
ServerFnError::ServerError(msg) => {
msg.contains("401") || msg.to_lowercase().contains("unauthorized")
}
ServerFnError::Deserialization(msg) => {
msg.contains("401") || msg.to_lowercase().contains("unauthorized")
}
ServerFnError::Request(msg) => {
msg.contains("401") || msg.to_lowercase().contains("unauthorized")
}
_ => false,
}
}
如果识别到未授权,就根据当前页面跳对应登录页:
rust
fn redirect_to_login() {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
let target = match window.location().pathname() {
Ok(pathname) if pathname.starts_with("/admin") => "/admin/login",
_ => "/login",
};
let _ = window.location().set_href(target);
}
}
}
这套设计落地后,业务页面就不用每个地方都写一遍:
rust
match call_api(fetch_orders(params)).await {
Ok(data) => { /* 渲染订单 */ }
Err(_) => { /* 错误已统一处理 */ }
}
页面开发者只关心成功数据。登录过期、认证失败、跳转登录页,全都收敛到工具层。
六、顺手把 admin 和商户路径也隔离掉
认证中间件还有一个小职责:防止角色走错通道。
Pico-CRM 里有四种角色:admin、merchant、operator、user。其中 admin 是平台管理员,只能访问平台后台;商户用户只能访问商户业务。
中间件里做了双向判断:
rust
let is_admin = claims.role == "admin";
let is_admin_path = path.starts_with("/api/admin") || path.starts_with("/admin");
let is_common_path = matches!(path.as_str(), "/api/logout" | "/api/get_user_info");
if is_admin && !is_admin_path && !is_common_path {
return handle_auth_failure(&path).await;
}
if !is_admin && is_admin_path {
return handle_auth_failure(&path).await;
}
这里不是为了替代业务权限,而是做一道很粗但很有效的门:
- admin 不能拿平台 token 去访问商户业务 API
- 商户用户不能拿普通 token 去访问
/api/admin/* logout和get_user_info这种公共接口单独放行
这个判断很土,但管用。尤其是多租户系统里,admin 的 merchant_id 通常不是一个真实商户 ID,如果让它误入业务查询链路,后面会出现很多奇怪分支。
七、TenantContext:认证通过后,把租户上下文塞进请求
认证不是只拦截失败请求,还要给后续业务提供上下文。
JWT claims 解析成功后,中间件会把租户信息注入 Axum extensions:
rust
req.extensions_mut().insert(TenantContext {
merchant_id: merchant_id.clone(),
role: role.clone(),
});
后面的 Leptos server function 里就可以通过项目封装的 resolve_tenant_context() 拿到:
rust
let db = expect_context::<Database>();
let tenant = crate::server::resolve_tenant_context().await?;
let result = backend::application::queries::crm::SomeQueryService::new(
db.connection.clone(),
)
.list(tenant, query)
.await?;
这对 Pico-CRM 很关键,因为项目是单库共享表的多租户模型,业务查询必须带 merchant_id。让中间件统一注入 TenantContext,比每个接口自己解析 Cookie、自己拆 JWT 稳很多。
八、真实取舍:这不是更优雅,是更不容易出错
这套方案没有什么玄学,就是多写了几十行判断。但它解决了三个真实问题:
-
页面和 API 的失败响应分开 页面拿
303 + Location,API 拿401 + MiddlewareError|...。 -
前端错误提示不再跑偏 登录过期不再伪装成"数据格式错误",而是被
call_api()识别后直接跳登录页。 -
权限边界前置 admin 和商户路径在中间件层先隔离,业务层少处理一堆不该出现的组合。
当然,它也有代价:
- 中间件需要知道哪些路径是 admin 路径
- 白名单要维护,新增公开 API 时不能忘
MiddlewareError|...依赖 Leptos server function 的错误协议,换框架时要重写这一段
但我觉得这个代价是值得的。认证失败这种事情,最好在系统边界处就说清楚:这是给人看的页面请求,还是给程序处理的 API 请求。
总结
以前我会把"未登录就重定向"当成一个很自然的后端习惯。写了 Leptos server function 之后才发现,全栈框架里最容易出问题的地方,往往不是业务逻辑,而是这种页面和 API 混在一起的边界。
Pico-CRM 现在的规则很简单:
- 页面请求失败:
303 SEE_OTHER + Location - API 请求失败:
401 + text/plain + MiddlewareError|... - 前端统一入口:
call_api()识别ServerFnError::MiddlewareError后跳登录页
好的认证中间件不只是"拦一下 Cookie",它还要给不同调用方返回它们能理解的失败方式。
如果你也在用 Axum、Leptos 或其他全栈框架,你的 API 未登录是直接返回 401,还是会重定向到登录页?评论区聊聊。