Rust中实现JWT身份验证

我们将讨论如何在 Rust 中使用 JSON Web Tokens (JWT) 实现身份验证。

什么是 JWT?
JSON Web 令牌 (JWT) 是一种紧凑、URL 安全的方式,用于通过 Web 在两方之间传输数据(“声明”)。数据通常使用 JSON Web 签名进行编码或作为 JSON Web 加密 (JWE) 结构的一部分,并且可以加密(和签名!)。

在决定身份验证策略时,JWT 是一个流行的选择。客户端通过 JWT 存储所有信息,从而实现无状态 API。在某些情况下,这可以使用户身份验证变得更加容易。虽然未加密和未签名的 JWT 可以被操纵,但只要用于创建 JWT 的密钥保持秘密,服务器就很容易忽略被操纵的 JWT。

依赖
首先,让我们使用 初始化一个项目cargo shuttle init,确保选择 Axum 作为框架。

然后我们将添加我们的依赖项:


cargo add axum-extra@0.9.2 -F typed-header
cargo add chrono@0.4.34 -F serde,clock
cargo add jsonwebtoken@9.2.0
cargo add once_cell@1.19.0
cargo add serde@1.0.196 -F derive
cargo add serde-json@1.0.113


设置密钥
首先,我们需要声明一个保存解码和编码密钥的结构体,并使用一个可以接收 &[u8] (u8 片)的方法来生成结构体:

use jsonwebtoken::{DecodingKey, EncodingKey};

struct Keys {
    encoding: EncodingKey,
    decoding: DecodingKey,
}

impl Keys {
    fn new(secret: &[u8]) -> Self {
        Self {
            encoding: EncodingKey::from_secret(secret),
            decoding: DecodingKey::from_secret(secret),
        }
    }
}

该结构需要从秘钥生成,因为我们将用它来生成 JWT。在本例中,我们将从字符串随机生成字节,然后将其转化为字节。然后,它将存储在 once_cell::LazyCell 中,可以在我们的应用程序中全局访问:

use once_cell::sync::Lazy;

static KEYS: Lazy<Keys> = Lazy::new(|| {
    let secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 60);
    Keys::new(secret.as_bytes())
});

请注意,生成字节数组的来源有很多种,生成随机字符串并将其转化为字节只是其中之一。对于生产用途,您可能还需要使用加密安全的算法。

编写我们的 JWT 声明
下一步是实现我们的请求。声称(在 JWT 上下文中)是 JWT 传输的数据,由服务器进行编码或解码。我们可以创建一个结构来保存用户名和到期日期,然后为该结构实现 FromRequestParts 特性(来自 Axum),从而编写自己的索赔实现。这样,我们就可以将其用作 Axum 提取器,而无需实现任何中间件!

不过,在编写实际实现之前,FromRequestParts 要求我们有一个自定义错误类型。我们可以编写一个表示 JWT 失败的错误类型,并为其实现 IntoResponse - 这样我们就可以在实现中使用它了。

use axum::response::{ IntoResponse, Response };
use axum::http::StatusCode;
use serde_json::json;

pub enum AuthError {
    InvalidToken,
    WrongCredentials,
    TokenCreation,
    MissingCredentials,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            AuthError::WrongCredentials => (StatusCode::UNAUTHORIZED, "Wrong credentials"),
            AuthError::MissingCredentials => (StatusCode::BAD_REQUEST,
"Missing credentials"),
            AuthError::TokenCreation => (StatusCode::INTERNAL_SERVER_ERROR,
"Token creation error"),
            AuthError::InvalidToken => (StatusCode::BAD_REQUEST,
"Invalid token"),
        };
        let body = Json(json!({
           
"error": error_message,
        }));
        (status, body).into_response()
    }
}

为 AuthError 实现 IntoResponse 可将其用作 FromRequestParts 特质中的拒绝类型。请注意,为了能在 FromPartsRequest 特质中返回 AuthError,我们使用了 map_err,将错误类型转化为 AuthError,以便它能被传播。我们在这里还使用了去结构化技术,从 TypedHeader<Authorization<Bearer>> 类型中提取 bearer 结构,因为它更易于访问。

use serde::{ Serialize, Deserialize };
use axum::{ http::{ request::Parts }, extract::FromRequestParts, RequestPartsExt };

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    username: String,
    exp: usize,
}

#[async_trait]
impl<S> FromRequestParts<S> for Claims where S: Send + Sync {
    type Rejection = AuthError;

    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(|_| AuthError::InvalidToken)?;
       
// 解码用户数据
        let token_data = decode::<Claims>(
            bearer.token(),
            &KEYS.decoding,
            &Validation::default()
        ).map_err(|_| AuthError::InvalidToken)?;

        Ok(token_data.claims)
    }
}

现在,我们已经完成了 JWT 运行方式的模板设置,接下来就可以创建路由了!

创建路由
下一步是在授权用户时编写端点--在返回令牌时,我们将创建一个新的 AuthBody,其中包含令牌和令牌类型。稍后我们将使用它:

#[derive(Debug, Serialize)]
struct AuthBody {
    access_token: String,
    token_type: String,
}

impl AuthBody {
    fn new(access_token: String) -> Self {
        Self {
            access_token,
            token_type: "Bearer".to_string(),
        }
    }
}

现在我们已经创建了 AuthBody,可以创建一个端点,它将接收客户端 ID 和密文并进行验证。然后,它会创建一个请求,对其进行编码并以 JSON 格式返回。

use axum::Json;
use chrono::Utc;

#[derive(Debug, Deserialize)]
struct AuthPayload {
    client_id: String,
    client_secret: String,
}

async fn authorize(Json(payload): Json<AuthPayload>) -> Result<Json<AuthBody>, AuthError> {
    //检查用户是否发送了证书
    if payload.client_id.is_empty() || payload.client_secret.is_empty() {
        return Err(AuthError::MissingCredentials);
    }
   
// 这里使用的是基本验证,但通常会使用数据库
    if &payload.client_id !=
"foo" || &payload.client_secret != "bar" {
        return Err(AuthError::WrongCredentials);
    }

   
// 为过期时间创建时间戳--此处的过期时间为 1 天
   
// 在生产中,您可能不希望 JWT 的有效期如此之长
    let exp = (Utc::now().naive_utc() + chrono::naive::Days::new(1)).timestamp() as usize;
    let claims = Claims {
        username: payload.client_id,
        exp,
    };
   
// 创建授权令牌
    let token = encode(&Header::default(), &claims, &KEYS.encoding).map_err(
        |_| AuthError::TokenCreation
    )?;

   
// 发送授权令牌
    Ok(Json(AuthBody::new(token)))
}

现在我们有了生成 JWT 的路由,可以创建一个路由来试用我们的令牌了!

async fn protected(claims: Claims) -> String {
    // 向用户发送受保护的数据
    format!(
"Welcome to the protected area, {}!", claims.username)
}

现在,所有路由都已编写完毕,我们可以将其连接到主函数:

use axum::{Router, routing::{get, post}};

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    let router = Router::new()
        .route("/", get(hello_world))
        .route(
"/protected", get(protected))
        .route(
"/login", post(authorize));

    Ok(router.into())
}

部署
现在我们已经编写了整个项目,可以进行部署了!键入 cargo shuttle deploy 并按回车键(如果在脏 Git 分支上,则添加--ad 标志,以允许脏部署)。完成后,我们的终端应该会打印出有关部署和项目的所有数据,以及一个指向实时项目的链接!

有兴趣扩展这个项目吗?这里有几个想法:

  • 使用 Postgres 存储用户登录信息。
  • 尝试使用加密和签名来加强 JWT,并将其存储在 cookie 中。
  • 尝试添加集成测试,这样就不需要手动测试了!