Axum + JWT 认证实战:给 Rust 后端加上登录体系
上一篇我们把数据接入了 PostgreSQL,服务终于不再“重启就丢数据”。但还有一个很现实的问题:怎么判断请求来自谁?怎么保护接口不被随便调用?
这篇文章基于之前的 todo_service,给它加上 JWT 登录认证,并接入数据库用户表,让它更接近真实后端。
为什么用 JWT?
JWT 的核心思想很简单:用户登录成功后,服务器签发一个带签名的 Token,后续请求带上它,服务器就能确认“这个请求属于哪个用户”。
它有几个特点:
- 无状态:服务端不需要每次都查 Session 表,只要验证签名和过期时间。
- 跨端友好:前端、移动端、第三方服务都可以使用。
- 适合 Rust 后端:Axum 的中间件 + 提取器机制,可以很自然地接入 JWT。
但 JWT 也不是万能钥匙。它默认只是“签名”,不是“加密”,所以 不要在 JWT 里放密码、身份证号、银行卡号等敏感信息。
安装依赖
在之前的项目里新增几个依赖:
cargo add axum-extra --features cookie
cargo add jsonwebtoken
cargo add argon2
cargo add uuid --features v4
cargo add chrono --features serde
这里 jsonwebtoken 负责签发和验证 Token,argon2 负责密码哈希,axum-extra 提供 Cookie 支持。
第一步:用户表迁移
之前只有 todos 表,现在需要一张用户表。
新建迁移:
sqlx migrate add create_users_table
迁移内容:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
运行迁移:
sqlx migrate run
真实项目里密码绝对不能明文存储。这里用 argon2 做哈希,登录时再对比哈希值。
第二步:定义 Claims
Claims 就是 JWT 里保存的信息。我们可以只放用户 ID 和过期时间:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // user_id
pub exp: usize, // 过期时间
}
sub 是 JWT 标准字段,通常用来表示用户标识。
第三步:签发和验证 Token
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use std::time::{Duration, SystemTime};
fn create_token(user_id: i32, secret: &[u8]) -> Result<String, jsonwebtoken::errors::Error> {
let exp = SystemTime::now()
.checked_add(Duration::from_secs(3600))
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs() as usize;
let claims = Claims {
sub: user_id.to_string(),
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret),
)
}
fn verify_token(token: &str, secret: &[u8]) -> Result<Claims, jsonwebtoken::errors::Error> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret),
&Validation::default(),
)?;
Ok(token_data.claims)
}
这里先设置 1 小时过期。生产环境通常会区分 Access Token 和 Refresh Token,Access Token 短过期,Refresh Token 长过期并支持轮换。
第四步:注册和登录接口
注册接口:
use axum::{extract::State, http::StatusCode, Json};
async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<StatusCode, AppError> {
let password_hash = hash_password(&payload.password)?;
sqlx::query!(
"INSERT INTO users (username, password_hash) VALUES ($1, $2)",
payload.username,
password_hash
)
.execute(&*state.db)
.await?;
Ok(StatusCode::CREATED)
}
登录接口:
async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
let user = sqlx::query!(
"SELECT id, username, password_hash FROM users WHERE username = $1",
payload.username
)
.fetch_optional(&*state.db)
.await?
.ok_or(AppError::Unauthorized)?;
if !verify_password(&payload.password, &user.password_hash)? {
return Err(AppError::Unauthorized);
}
let secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set")
.into_bytes();
let token = create_token(user.id, &secret)?;
Ok(Json(LoginResponse { token }))
}
这里用 fetch_optional 而不是 fetch_one,避免用户不存在时直接报数据库错误。
第五步:认证提取器
Axum 很擅长用类型表达“这个 handler 需要认证”。我们可以写一个自定义提取器:
use axum::{
async_trait,
extract::{FromRequestParts, TypedHeader},
headers::{authorization::Bearer, Authorization},
http::request::Parts,
};
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: i32,
}
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "missing token"))?;
let secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set")
.into_bytes();
let claims = verify_token(bearer.token(), &secret)
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid token"))?;
let user_id = claims
.sub
.parse::<i32>()
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid user id"))?;
Ok(AuthUser { user_id })
}
}
之后任何需要登录的接口,只要在参数里写 AuthUser,Axum 就会自动验证 Token:
async fn me(auth: AuthUser) -> Json<serde_json::Value> {
Json(serde_json::json!({
"user_id": auth.user_id
}))
}
如果 Token 缺失、过期或签名不对,请求会直接返回 401。
第六步:挂载路由
let app = Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/me", get(me))
.with_state(state);
/register 和 /login 不需要认证,/me 需要认证。因为 me 的参数里有 AuthUser,所以只有携带有效 Token 的请求才能进入。
安全上要注意几点
- 密码必须哈希存储,推荐 Argon2 或 bcrypt。
- JWT 不要放敏感信息,只放用户 ID、角色等必要字段。
- 密钥不要写死在代码里,从环境变量或配置中心读取。
- Access Token 过期时间宜短,比如 15 分钟到 2 小时。
- 生产环境建议加上 Refresh Token,并把 Refresh Token 放在 HttpOnly Cookie 里。
- 接口必须走 HTTPS,否则 Token 容易被拦截。
接下来还能做什么?
这个版本已经能跑通“注册、登录、鉴权”的基本闭环。后面可以继续扩展:
- 接入 Refresh Token 轮换机制
- 增加角色字段,做简单权限控制
- 给
/todos接口加上“只能看自己的待办” - 用
tracing记录登录和鉴权日志 - 增加注册限流,防止暴力注册
Rust 做认证最舒服的地方在于,很多边界会被类型系统提前拦住:Token 解析失败不能悄悄变成 None,用户不存在不能直接 unwrap,密码验证也必须显式处理错误。刚开始会觉得步骤多,但越往后越能感受到这种“编译期把关”的价值。
- 点赞
- 收藏
- 关注作者
评论(0)