最近在写家政 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:角色,区分admin、merchant、operator、userexp:过期时间戳
这里故意没有放手机号、邮箱、头像、权限列表这种东西。
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。前端只拿到 role 和 redirect_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(())
}
这里有几个取舍。
1. Cookie 名叫 user_session
中间件只认这个名字:
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 阶段先把默认边界拉起来。
4. Cookie 只活 2 小时
这里比较有意思。
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 只能访问商户业务
logout和get_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_id 和 role,中间件解析后统一注入 TenantContext。后面的业务代码只认 tenant,不关心 token 怎么来的。
这和 DDD 分层也比较契合:认证在边界层完成,应用服务拿到的是已经解析好的上下文。
再说缺点。
1. 不能天然主动吊销
JWT 签发后,在 exp 前天然有效。清 Cookie 只能影响当前浏览器,不能让 token 本身消失。
所以我才把 Cookie 设成 2 小时,并且每次请求查用户和商户状态。
2. Claims 里的角色有滞后
如果管理员把用户角色从 merchant 改成 user,旧 token 里的 role 还是旧值,直到 Cookie/JWT 过期。
这在 Pico-CRM 当前场景可以接受,因为角色变更低频,登录态时间也不长。
如果你的系统权限变更非常敏感,那就不能只靠 claims,需要每次请求查实时权限,或者引入 session version。
3. Cookie 方案仍然要考虑 CSRF
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:
sub、user_name、merchant_id、role、exp - Cookie 用
HttpOnly + SameSite=Lax + 2h max_age - JWT 默认 24 小时过期,但浏览器携带时间更短
- 中间件验签后,还要查用户、管理员、商户状态
- 认证通过后,统一注入
TenantContext - 退出登录只清 Cookie,不维护服务端 session
我的感受是:JWT 真正省掉的是 session 存储,不是认证系统的责任。
你做后台系统时,登录态会放 Redis session,还是只用 JWT + Cookie?评论区聊聊你的取舍。