我在 Rust 全栈项目里用 JWT 做无状态认证

最近在写家政 CRM 的认证和权限模块时,我一直在纠结一个问题:登录态到底要不要存在服务端?

传统做法很熟悉:

text 复制代码
用户登录成功
  -> 服务端生成 session_id
  -> 写入 Redis / session 表
  -> 浏览器 Cookie 只保存 session_id
  -> 每次请求服务端查 session

这套方案当然没问题,尤其适合需要强制踢人、单端登录、设备管理的系统。

但 Pico-CRM 的 MVP 场景没有这么重。它是一个家政 SaaS:商户登录后台,管理客户、订单、排班和完工;平台管理员登录后台,管理商户和系统配置。登录态的核心诉求是:

  • 能确认"这个请求是谁发的"
  • 能知道"属于哪个商户"
  • 能知道"是什么角色"
  • Cookie 过期后能自然回登录页
  • 用户禁用、商户停用时能及时拦截

最后我没有上 Redis,也没有建 session 表,而是用了一个更直接的方案:

服务端签发 JWT,浏览器通过 HttpOnly Cookie 自动携带,Axum 中间件统一验签和注入上下文。

这篇文章就拆这个链路。

一、先说结论:JWT 省掉的是 session 存储,不是安全校验

我见过两种很极端的说法。

一种是:"JWT 无状态,所以后端不用查库了。"

另一种是:"JWT 不能主动失效,所以完全不能用。"

我现在的看法是:JWT 只是把登录凭证从服务端存储挪到了客户端携带,安全边界并不会自动消失,也不会自动完整。

Pico-CRM 里 JWT 做三件事:

rust 复制代码
// backend/src/domain/identity/auth/claims.rs

pub struct JwtClaims {
    pub sub: String,
    pub user_name: String,
    pub merchant_id: String,
    pub role: String,
    pub exp: i64,
}

这 5 个字段够用了:

  • sub:用户 UUID,标识谁登录了
  • user_name:用户名,用于后续查用户状态
  • merchant_id:租户 ID,所有业务数据都靠它隔离
  • role:角色,区分 adminmerchantoperatoruser
  • exp:过期时间戳

这里故意没有放手机号、邮箱、头像、权限列表这种东西。

JWT 一旦签发出去,在过期前就会被客户端反复携带。放得越多,越容易变成"缓存了一份用户资料"。用户资料是会变的,登录凭证应该尽量小。

一句话总结:JWT 里只放认证和路由权限需要的最小信息。

二、登录时签发:密码用 Argon2,Token 用 HS256

先看普通商户用户登录。

Leptos server function 接到 /api/login 后,会校验用户名密码,再调用后端的认证 provider:

rust 复制代码
// app/src/pages/login.rs

#[server(
    name = UserLoginAction,
    prefix = "/api",
    endpoint = "/login",
)]
pub async fn user_login_action(
    user_name: String,
    password: String,
) -> Result<LoginResponse, ServerFnError> {
    let (user_name, password) = validate_login_request(user_name, password)?;

    let pool = expect_context::<Database>();
    let auth = JwtAuthProvider::new(pool.connection.clone());

    let token = auth
        .authenticate(&user_name, &password)
        .await
        .map_err(ServerFnError::new)?
        .0;

    set_session_cookie(&token)?;

    let claims = auth.get_claims(&token).map_err(ServerFnError::new)?;

    Ok(LoginResponse {
        role: claims.role,
        redirect_to: "/".to_string(),
    })
}

这段代码里有两个关键点。

第一,登录成功后并不把 token 返回给前端 JS 存起来,而是直接在服务端设置 Cookie。前端只拿到 roleredirect_to,用于页面跳转。

第二,认证逻辑在 JwtAuthProvider::authenticate() 里,页面层不直接碰密码细节。

密码校验没有用"MD5 加盐"这种祖传做法,而是用 argon2

rust 复制代码
// backend/src/domain/identity/user/model.rs

fn hash_password_inner(password: &str) -> Result<String, String> {
    if password.is_empty() {
        return Err("错误:密码不能为空".to_string());
    }

    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let hash = argon2
        .hash_password(password.as_bytes(), &salt)
        .map_err(|e| format!("生成密码哈希失败:{}", e))?;

    Ok(hash.to_string())
}

pub fn verify_password(&self, password: &str) -> Result<bool, String> {
    if password.is_empty() {
        return Err("错误:待验证的密码不能为空".to_string());
    }
    if self.password.is_empty() {
        return Err("错误:存储的哈希字符串不能为空".to_string());
    }

    let parsed_hash = PasswordHash::new(&self.password)
        .map_err(|e| format!("解析密码哈希字符串失败:{}", e))?;
    let argon2 = Argon2::default();
    let is_match = argon2
        .verify_password(password.as_bytes(), &parsed_hash)
        .map_err(|e| format!("验证密码失败:{}", e))
        .is_ok();

    Ok(is_match)
}

这块没什么花活:

  • OsRng 生成随机盐
  • Argon2 生成 PHC 格式哈希字符串
  • 登录时 parse hash 再 verify

密码这件事我不想自己"创新"。认证系统里很多坑都不是业务复杂,而是开发者觉得自己可以发明密码学。

JWT 签发也很直白:

rust 复制代码
// backend/src/infrastructure/auth/jwt_provider.rs

fn generate_jwt(
    &self,
    user_uuid: String,
    user_name: String,
    merchant_id: String,
    role: String,
) -> Result<String, String> {
    let expiration = Utc::now() + Duration::hours(self.jwt_config.expiry_hours);
    let claims = JwtClaims {
        sub: user_uuid,
        user_name,
        merchant_id,
        role,
        exp: expiration.timestamp(),
    };

    encode(
        &Header::new(self.jwt_config.algorithm),
        &claims,
        &EncodingKey::from_secret(self.jwt_config.secret.as_bytes()),
    )
    .map_err(|err| err.to_string())
}

项目里算法固定是 HS256:

rust 复制代码
// backend/src/infrastructure/config/jwt.rs

Ok(Self {
    secret,
    algorithm: Algorithm::HS256,
    expiry_hours,
    refresh_expiry_days,
})

生产环境密钥来自 JWT_SECRET,本地开发才走 JwtConfig::dev()。过期时间默认 24 小时:

rust 复制代码
let expiry_hours = env::var("JWT_EXPIRY_HOURS")
    .unwrap_or_else(|_| "24".into())
    .parse::<i64>()?;

这里有一个容易被忽略的点:JWT 的过期时间和 Cookie 的过期时间不是一回事。

下面单独说。

三、Cookie 承载:不用 localStorage,让浏览器自动带

JWT 签出来以后,怎么给前端?

很多前端项目会把 token 放到 localStorage,然后每次请求手动加 Authorization: Bearer xxx

Pico-CRM 没这么做,而是写进 Cookie:

rust 复制代码
// app/src/pages/login.rs

pub(crate) fn set_session_cookie(token: &str) -> Result<(), ServerFnError> {
    use cookie::{time::Duration, Cookie, SameSite};
    use http::header::SET_COOKIE;
    use leptos_axum::ResponseOptions;

    let response = expect_context::<ResponseOptions>();
    let session_cookie = Cookie::build(("user_session", token.to_string()))
        .path("/")
        .http_only(true)
        .same_site(SameSite::Lax)
        .max_age(Duration::hours(2))
        .build();

    let header_value = session_cookie.to_string().parse()?;
    response.insert_header(SET_COOKIE, header_value);
    Ok(())
}

这里有几个取舍。

中间件只认这个名字:

rust 复制代码
let session = cookie_jar
    .get("user_session")
    .map(|c| c.value().to_string())
    .unwrap_or_default();

名字不重要,重要的是全链路统一。登录写它,中间件读它,退出清它。

2. HttpOnly=true

这意味着浏览器里的 JS 读不到这个 Cookie。

它不能防止所有 XSS 后果,但至少能避免恶意脚本直接 localStorage.getItem("token") 把 token 拿走。

对一个 Leptos SSR 项目来说,这个体验也更自然:server function 请求同源 API 时,浏览器自动携带 Cookie,不需要前端每个请求手写 header。

3. SameSite=Lax

这个配置不是 CSRF 的万能解药,但对后台系统是一个不错的默认值。

它允许用户从外部链接正常打开系统,同时减少跨站 POST 自动携带 Cookie 的风险。

如果后面系统出现高风险写操作,比如批量删除、资金结算,那还可以再叠 CSRF token 或二次确认。MVP 阶段先把默认边界拉起来。

这里比较有意思。

JWT 默认 24 小时过期,但 Cookie 设置的是 2 小时:

rust 复制代码
.max_age(Duration::hours(2))

这不是写错了,而是我故意让浏览器携带凭证的时间更短一点。

我的理解是:

text 复制代码
JWT exp 决定 token 本身什么时候无效
Cookie max_age 决定浏览器什么时候不再携带 token

也就是说,即使 token 还能验签,只要 Cookie 到期,浏览器就不会再带它,用户也就回到未登录状态。

这套设计不是最精细,但对后台系统很实用。白天开后台操作,离开一段时间,回来重新登录,成本可以接受。

四、中间件校验:无状态,但不是不查库

请求进来后,Axum 中间件先从 Cookie 里取 token:

rust 复制代码
// server/src/middlewares/auth_middleware.rs

fn server_auth_check(cookie_jar: &CookieJar) -> Result<String, String> {
    let session = cookie_jar
        .get("user_session")
        .map(|c| c.value().to_string())
        .unwrap_or_default();

    match session.as_str() {
        "" => Err("未登录,请先登录".to_string()),
        token => Ok(token.to_string()),
    }
}

然后解析 JWT:

rust 复制代码
let auth = JwtAuthProvider::new(db.connection.clone());
let claims = auth.get_claims(&token).map_err(|err| {
    println!("error: {:?}", err);
    StatusCode::UNAUTHORIZED
})?;

get_claims() 内部就是 jsonwebtoken::decode

rust 复制代码
// backend/src/infrastructure/auth/jwt_provider.rs

fn validate_jwt(&self, token: &str) -> Result<JwtClaims, String> {
    decode::<JwtClaims>(
        token,
        &DecodingKey::from_secret(self.jwt_config.secret.as_bytes()),
        &Validation::new(self.jwt_config.algorithm),
    )
    .map(|data| data.claims)
    .map_err(|err| err.to_string())
}

验签、算法、过期时间这些交给 jsonwebtoken 处理。

但 Pico-CRM 没有到这里就直接放行。它后面还做了两类数据库校验。

1. 普通商户用户:查用户和商户状态

商户用户进来后,中间件会先查商户状态:

rust 复制代码
let merchant_status = fetch_merchant_status(&db, &merchant_id).await?;
if !merchant_status.is_active {
    return handle_auth_failure(&path).await;
}

fetch_merchant_status() 查的是 public.merchant

rust 复制代码
let stmt = Statement::from_sql_and_values(
    DatabaseBackend::Postgres,
    "SELECT status, expired_at FROM public.merchant WHERE uuid = $1",
    vec![merchant_uuid.into()],
);

然后判断两个条件:

rust 复制代码
if status != "active" {
    return Ok(MerchantStatus { is_active: false });
}

if let Some(expired_at) = expired_at {
    if expired_at <= Utc::now() {
        return Ok(MerchantStatus { is_active: false });
    }
}

这一步很关键。

JWT 还没过期,不代表商户还有效。比如商户套餐已经过期,或者被平台停用,不能等 token 自己过期才拦。

商户状态通过后,再查用户:

rust 复制代码
let user = auth
    .get_current_user(&AuthCredential(token.clone()))
    .await?;

match user {
    Some(user) if user.is_active() => Some(user.into()),
    _ => return handle_auth_failure(&path).await,
};

get_current_user() 不是按用户名全局查,而是带 merchant_id 查:

rust 复制代码
let user = Entity::find()
    .filter(Column::MerchantUuid.eq(merchant_uuid))
    .filter(Column::UserName.eq(user_name))
    .one(&self.db_conn)
    .await?;

这和项目的多租户模型有关:共享表按 merchant_id 隔离。认证链路也必须遵守这个规则。

2. 平台管理员:查 admin_users 表

admin 是另一条链路。

Pico-CRM 里平台管理员存在 admin_users 表,不和普通 users 表混在一起。管理员登录成功时,签发的 token 是:

rust 复制代码
// backend/src/application/commands/platform/admin_auth.rs

let token = self
    .jwt
    .issue_token(&admin, "public".to_string(), "admin".to_string())?;

也就是说:

text 复制代码
merchant_id = "public"
role = "admin"

中间件识别到 admin 后,会查管理员账号状态:

rust 复制代码
let admin_repo = SeaOrmAdminUserRepository::new(db.connection.clone());
let admin_user = admin_repo.find_by_username(&user_name).await?;

match admin_user {
    Some(user) if user.is_active() => Some(user.into()),
    _ => return handle_auth_failure(&path).await,
}

这也是"JWT 无状态但仍查状态"的典型场景:管理员被禁用后,不能继续拿旧 token 访问后台。

五、角色和路径:Token 能验过,不代表能访问这里

JWT 校验通过后,还有一个很实际的问题:

admin 能不能拿自己的 token 访问商户 API?商户用户能不能访问 /api/admin/*

当然不能。

Pico-CRM 的中间件里有一道路径校验:

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 只能访问平台后台
  • 商户 token 只能访问商户业务
  • logoutget_user_info 这种公共接口单独放行

为什么这里不做成复杂 RBAC?

因为这个边界不是细粒度权限,而是系统入口边界。admin 的 merchant_id"public",它本来就不应该进入客户、订单、排班这些业务查询。商户用户也不应该碰平台商户管理。

这种判断越靠前越好。能在中间件拦住,就不要让请求一路跑到业务 handler 里再报错。

六、认证通过后:把租户上下文塞给后面的业务代码

JWT 不只是用来拦请求,还要给业务层提供上下文。

中间件解析出 claims 后,会把 TenantContext 注入 Axum extensions:

rust 复制代码
req.extensions_mut().insert(TenantContext {
    merchant_id: merchant_id.clone(),
    role: role.clone(),
});

后面的 Leptos server function 就可以这样拿:

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 特别重要。

项目是单 PostgreSQL 数据库、共享表、多租户字段隔离。客户、订单、排班、服务目录这些业务查询都必须带 merchant_id

所以认证链路最后产出的不只是"这个人登录了",而是:

text 复制代码
这个请求属于哪个 merchant_id
这个请求是什么 role
这个请求对应哪个 User

这三个上下文一旦在中间件里统一注入,后面的业务函数就不用自己解析 Cookie,也不用自己 decode JWT。职责边界会清楚很多。

七、退出登录:清 Cookie,不需要删 session

因为服务端没有 session 存储,所以退出登录也很简单:覆盖同名 Cookie,把它过期掉。

rust 复制代码
// app/src/components/features/navbar.rs

#[server(
    name = Logout,
    prefix = "/api",
    endpoint = "/logout",
)]
pub async fn logout() -> Result<(), ServerFnError> {
    use cookie::{time::Duration, Cookie, SameSite};
    use http::header::SET_COOKIE;
    use leptos_axum::ResponseOptions;

    let response = expect_context::<ResponseOptions>();

    let clear_session_cookie = Cookie::build(("user_session", ""))
        .path("/")
        .http_only(true)
        .same_site(SameSite::Lax)
        .max_age(Duration::ZERO)
        .expires(cookie::time::OffsetDateTime::UNIX_EPOCH)
        .build();

    let header_value: http::HeaderValue = clear_session_cookie.to_string().parse()?;
    response.insert_header(SET_COOKIE, header_value);

    Ok(())
}

注意,这里的"退出"是浏览器侧退出:让当前浏览器不再携带 token。

它不等于让已经签发的 JWT 在全世界立刻失效。如果有人在 Cookie 清除前已经复制了 token,只要 token 没过期,理论上仍然可能被使用。

这就是 JWT 方案的边界。

如果业务需要"管理员点一下,所有设备立刻下线",那就要加服务端状态,比如:

  • token blacklist
  • session version
  • user token epoch
  • Redis session
  • refresh token + access token 双 token 体系

Pico-CRM 现在没做,是因为 MVP 阶段没这个强需求。后台系统 2 小时 Cookie + 24 小时 JWT + 每次请求查用户/商户状态,已经覆盖了当前主要风险。

八、这套方案的真实优缺点

先说优点。

1. 服务端少一个状态源

没有 session 表,没有 Redis session key,也就少了一个需要维护的登录态存储。

请求进来,凭证就在 Cookie 里;服务端只需要密钥、算法和数据库里的用户状态。

2. Leptos server function 调用更自然

因为 Cookie 是浏览器自动携带的,前端调用 server function 不需要每个请求手动塞 token。

这对全栈框架很舒服。页面、API、SSR 都在同一个站点下,认证凭证跟着请求走。

3. 租户上下文统一生成

JWT claims 里有 merchant_idrole,中间件解析后统一注入 TenantContext。后面的业务代码只认 tenant,不关心 token 怎么来的。

这和 DDD 分层也比较契合:认证在边界层完成,应用服务拿到的是已经解析好的上下文。

再说缺点。

1. 不能天然主动吊销

JWT 签发后,在 exp 前天然有效。清 Cookie 只能影响当前浏览器,不能让 token 本身消失。

所以我才把 Cookie 设成 2 小时,并且每次请求查用户和商户状态。

2. Claims 里的角色有滞后

如果管理员把用户角色从 merchant 改成 user,旧 token 里的 role 还是旧值,直到 Cookie/JWT 过期。

这在 Pico-CRM 当前场景可以接受,因为角色变更低频,登录态时间也不长。

如果你的系统权限变更非常敏感,那就不能只靠 claims,需要每次请求查实时权限,或者引入 session version。

HttpOnly 主要防 token 被 JS 直接读取,不是 CSRF 方案。

SameSite=Lax 能挡一部分跨站场景,但不能替代所有写操作保护。高风险接口还需要额外校验。

九、我为什么没有上 Redis Session

最后聊一下取舍。

如果是下面这些场景,我会更倾向 Redis session:

  • 强制单端登录
  • 后台能查看在线设备
  • 管理员能立即踢用户下线
  • 权限变更必须秒级生效
  • 多端登录要分别管理
  • 审计要求记录每个登录会话

但 Pico-CRM 当前不是这个阶段。

它首先要跑通的是客户、订单、排班、完工这条业务主线。认证系统要可靠,但不能把整个 MVP 拖进复杂会话管理里。

所以我选了一个很朴素的折中:

text 复制代码
登录时:
  Argon2 校验密码
  HS256 签发 JWT
  HttpOnly Cookie 写入 user_session

请求时:
  中间件读 Cookie
  jsonwebtoken 验签和 exp
  查用户 / 管理员状态
  查商户 active 和 expired_at
  注入 TenantContext 和 User

退出时:
  覆盖 user_session
  max_age = 0
  expires = UNIX_EPOCH

这套方案不炫技,但足够清楚。

总结

以前我对"JWT 无状态认证"的理解比较粗暴:只要 token 能验签,请求就算合法。

真正落到项目里才发现,重点不是"服务端完全不查库",而是服务端不存登录会话,但仍然保留实时状态校验

Pico-CRM 现在的规则是:

  • JWT 只放最小 claims:subuser_namemerchant_idroleexp
  • Cookie 用 HttpOnly + SameSite=Lax + 2h max_age
  • JWT 默认 24 小时过期,但浏览器携带时间更短
  • 中间件验签后,还要查用户、管理员、商户状态
  • 认证通过后,统一注入 TenantContext
  • 退出登录只清 Cookie,不维护服务端 session

我的感受是:JWT 真正省掉的是 session 存储,不是认证系统的责任。

你做后台系统时,登录态会放 Redis session,还是只用 JWT + Cookie?评论区聊聊你的取舍。

相关推荐
Rabbit_c4 小时前
前端基于JSON Schema 配置驱动的DSL架构实践
前端
anyup4 小时前
uni-app X 全屏引导页组件,一套支持 App、H5、小程序多端引导
前端·架构·uni-app
苍何4 小时前
实测 GLM5.1 高速版,快到离谱还不掉智商
后端
苍何4 小时前
企业微信新出的 AI 能力,用完想安利给全公司
后端
苍何4 小时前
每月省一千,我雇了支 7×24 云端 Agent 团队
后端
苍何4 小时前
12 天 4.2K 的 Star,我的 GPT-image2 开源项目火了!
后端
苍何4 小时前
我的 AI 视频团队入职腾讯了!
后端
苍何4 小时前
终于找到解决手机消息轰炸的 AI 神器,有点离谱...
后端
楼田莉子4 小时前
C++17新特性:optional/variant/any/string_view
c++·后端·学习