Axum + JWT 认证实战:给 Rust 后端加上登录体系

举报
yd_232564628 发表于 2026/10/23 10:55:16 2026/10/23
【摘要】 上一篇我们把数据接入了 PostgreSQL,服务终于不再“重启就丢数据”。但还有一个很现实的问题:怎么判断请求来自谁?怎么保护接口不被随便调用?这篇文章基于之前的 todo_service,给它加上 JWT 登录认证,并接入数据库用户表,让它更接近真实后端。 为什么用 JWT?JWT 的核心思想很简单:用户登录成功后,服务器签发一个带签名的 Token,后续请求带上它,服务器就能确认“这个...

上一篇我们把数据接入了 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,密码验证也必须显式处理错误。刚开始会觉得步骤多,但越往后越能感受到这种“编译期把关”的价值。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。