别让 API 跳去登录页:我在 Axum 里做了认证失败双通道

最近在整理项目里的认证链路,发现一个很容易被忽略的小坑:页面未登录可以跳登录页,但 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-Typetext/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 里有四种角色:adminmerchantoperatoruser。其中 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/*
  • logoutget_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 稳很多。

八、真实取舍:这不是更优雅,是更不容易出错

这套方案没有什么玄学,就是多写了几十行判断。但它解决了三个真实问题:

  1. 页面和 API 的失败响应分开 页面拿 303 + Location,API 拿 401 + MiddlewareError|...

  2. 前端错误提示不再跑偏 登录过期不再伪装成"数据格式错误",而是被 call_api() 识别后直接跳登录页。

  3. 权限边界前置 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,还是会重定向到登录页?评论区聊聊。

相关推荐
Cache技术分享10 小时前
416. 现代 Java I/O 最佳实践 - 高效、简洁、安全地处理文本与数据
前端·后端
倚栏听风雨10 小时前
EdgeValue 详细分析
后端
前端繁华如梦10 小时前
three.js从盒子到链条的程序化三维实现
前端·javascript
用户7138742290010 小时前
OAuth 2.0 中的state参数:从规范到实践的深度解析
后端
倚栏听风雨10 小时前
StateGraph 详细分析
后端
用户7138742290010 小时前
Cookie 深度技术指南:从原理到安全实践
后端
用户802238477340710 小时前
Tailwind CSS 生产环境部署优化与 CDN 使用规范
前端
倚栏听风雨10 小时前
AsyncCommandAction 详细分析
后端
倚栏听风雨10 小时前
Edge 详细分析
后端