跳到主要内容

使用 Axum 实现 JWT 授权:从密钥生成到中间件应用

鱼雪

Axum 是一个基于 Hyper 的 Rust Web 框架,它提供了一种简单、高效的方式来构建 Web 应用。 在实际应用中,我们通常需要对 API 端点进行授权,以确保只有授权用户才能访问受保护的资源。 JWT(JSON Web Token)是一种流行的授权机制,它可以帮助我们实现这一目标。

本文大体分为三部分:

  • 生成一个新的密钥对
  • 生成和验证Token
  • 在Axum中使用授权中间件

Token的签发详细过程的时序图

1. 生成 Ed25519 的公私钥

在我们的授权系统中,我们使用 Ed25519 算法来生成公私钥对。 Ed25519 是一种现代的、安全的数字签名算法,它提供了高安全性和高性能。

首先,让我们看看生成密钥对的函数:

use anyhow::Result;
use jwt_simple::prelude::*;
use std::fs::File;

fn main() -> Result<()> {
generate_and_save_keys()?;
Ok(())
}

fn generate_and_save_keys() -> Result<()> {
let key_pair = Ed25519KeyPair::generate();

// 保存私钥(只包含私钥信息)
let private_key_pem = key_pair.to_pem();
let mut private_key_file = File::create("private_key.pem")?;
private_key_file.write_all(private_key_pem.as_bytes())?;

// 保存公钥(只包含公钥信息)
let public_key_pem = key_pair.public_key().to_pem();
let mut public_key_file = File::create("public_key.pem")?;
public_key_file.write_all(public_key_pem.as_bytes())?;

Ok(())
}

这个函数完成了以下几个步骤:

  1. 使用 Ed25519KeyPair::generate() 生成一个新的密钥对。
  2. 将私钥转换为 PEM 格式,并保存到 "private_key.pem" 文件中。
  3. 将公钥转换为 PEM 格式,并保存到 "public_key.pem" 文件中。

要点:

  • 密钥对只需要生成一次,然后可以重复使用。
  • 私钥必须保密,而公钥可以公开。
  • PEM 格式是一种常用的密钥存储格式,易于管理和使用。
提示

把公钥和私钥分开存储,是为了保证私钥的安全性。 私钥只有在生成 token 时才会用到,而公钥则用于验证 token 的签名。 可以将私钥存储在安全的地方,而公钥可以公开发布。 可以将授权系统的公钥存储在一个公开的地方,以便其他服务可以验证 token。

2. 生成 token 和验证 token

接下来,我们来看看如何生成和验证 JWT token。

生成 token

fn sign(user: impl Into<User>) -> Result<String> {
let private_key_pem = read_to_string("private_key.pem")?;
let key_pair = Ed25519KeyPair::from_pem(&private_key_pem)?;

let user = user.into();
let claims = Claims::with_custom_claims(user, Duration::from_secs(JWT_DURATION));
let claims = claims.with_issuer(JWT_ISS).with_audience(JWT_AUD);

let token = key_pair.sign(claims)?;
Ok(token)
}

这个函数完成了以下步骤:

  1. 从文件中读取私钥
  2. 创建一个包含用户信息的 claims 对象。
  3. 设置 token 的有效期发行者issuer)和受众audience)。
  4. 使用私钥签名 claims,生成 JWT token。

验证 token

fn verify(token: &str) -> Result<User, Box<dyn std::error::Error>> {
let public_key_pem = read_to_string("public_key.pem")?;
let public_key = Ed25519PublicKey::from_pem(&public_key_pem)?;

let options = VerificationOptions {
allowed_issuers: Some(HashSet::from_strings(&[JWT_ISS])),
allowed_audiences: Some(HashSet::from_strings(&[JWT_AUD])),
..Default::default()
};

let claims = public_key.verify_token::<User>(token, Some(options))?;
Ok(claims.custom)
}

验证函数执行以下步骤:

  1. 从文件中读取公钥。
  2. 设置验证选项,包括允许的发行者和受众。
  3. 使用公钥验证 token 的签名,并解析出 claims。
  4. 返回自定义的 User 数据。

要点:

  • 生成 token 时使用私钥,验证 token 时使用公钥。
  • 设置适当的 token 有效期、发行者和受众可以增加安全性。
  • 验证时要检查这些元数据以确保 token 的有效性。

3. 将 token 的签发和验证封装成中间件,在 Axum 中使用授权中间件

最后,我们来看看如何在 Axum 中实现和使用授权中间件。

定义 AuthUser 结构体和 FromRequestParts trait 实现

struct AuthUser(User);

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

async fn from_request_parts<'life0, 'life1>(
parts: &'life0 mut Parts,
_state: &'life1 S,
) -> Result<Self, Self::Rejection>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
let token = parts
.headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = verify(token).map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user))
}
}

这个实现允许我们直接从请求中提取已认证的用户信息。

定义授权中间件

async fn auth_middleware(
AuthUser(user): AuthUser,
req: Request<Body>,
next: Next,
) -> impl IntoResponse {
info!("Authenticated user: {}", user.username);
next.run(req).await
}

这个中间件会记录认证用户的信息,然后继续处理请求。

在 Axum 中使用中间件

let app = Router::new()
.route("/login", post(get_token))
.route("/protected", get(protected_route).layer(from_fn(auth_middleware)));

这里我们定义了两个路由:

  • /login:用于获取 token,不需要认证。
  • /protected受保护的路由,使用 auth_middleware 进行认证。

登录和获取 token

async fn get_token(
Json(payload): Json<TokenRequest>
) -> Result<Json<TokenResponse>, StatusCode> {
let user = User {
username: payload.username,
created_at: Utc::now(),
scope: vec!["read".to_string(), "write".to_string()],
};
let token = sign(user).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(TokenResponse { token }))
}

这个函数处理登录请求,创建用户对象,然后生成并返回 JWT token。

受保护的路由

async fn protected_route(AuthUser(user): AuthUser) -> impl IntoResponse {
format!("Hello, {}! Your scopes are: {:?}", user.username, user.scope)
}

这个路由只有在用户通过认证后才能访问。

要点:

  • 使用 FromRequestParts trait 可以方便地从请求中提取认证信息。
  • 中间件可以用来统一处理认证逻辑,简化路由处理函数。
  • 将认证应用到特定路由,而不是全局应用,可以提供更灵活的控制。

测试

使用提供的 REST Client 代码,我们可以测试整个流程:

  1. 首先,发送 POST 请求到 /login 获取 token
  2. 然后,使用获得的 token 访问受保护的路由
### Get Token
# @name signin
POST http://localhost:3000/login
Content-Type: application/json

{
"username": "admin"
}

@token = {{signin.response.body.token}}

### Auth user
GET http://localhost:3000/protected
Authorization: Bearer {{token}}
备注

这里使用了VSCode REST Client 插件编写的请求代码。 首先需要发送 POST 请求到 /login 获取 token,然后使用获得的 token 访问受保护的路由。

这里使用了VScode REST Client插件中定义变量的方式,可以在后续的多个请求中使用, 避免每次复制新的token。

完整代码示例

Cargo.toml依赖库

[package]
name = "axum-template"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.86"
axum = "0.7.5"
chrono = { version = "0.4.38", features = ["serde"] }
jwt-simple = "0.12.9"
serde = { version = "1.0.204", features = ["derive"] }
tokio = { version = "1.39.1", features = ["net", "rt", "rt-multi-thread"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

Rust代码

use anyhow::Result;
use chrono::{DateTime, Utc};
use jwt_simple::prelude::*;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;

use tracing::{info, level_filters::LevelFilter};
use tracing_subscriber::{
fmt::Layer, layer::SubscriberExt,
util::SubscriberInitExt,
Layer as _
};

use std::{
collections::HashSet,
fs::{read_to_string, File},
io::Write,
net::SocketAddr,
};

use axum::{
async_trait, body::Body,
extract::FromRequestParts,
http::{request::Parts, Request,StatusCode},
middleware::{from_fn, Next},
response::IntoResponse,
routing::{get, post},
Json, Router
};

const JWT_DURATION: u64 = 60 * 60 * 24;
const JWT_ISS: &str = "my_service";
const JWT_AUD: &str = "my_app";

#[derive(Debug, Serialize, Deserialize)]
struct User {
username: String,
created_at: DateTime<Utc>,
scope: Vec<String>,
}

struct AuthUser(User);

#[derive(Debug, Serialize, Deserialize)]
struct TokenRequest {
username: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct TokenResponse {
token: String,
}


#[tokio::main]
async fn main() -> Result<()> {
let layer = Layer::new().with_filter(LevelFilter::INFO);
tracing_subscriber::registry().with(layer).init();
let app = Router::new()
.route("/login", post(get_token))
.route("/protected", get(protected_route).layer(from_fn(auth_middleware)));

let addr: SocketAddr = "0.0.0.0:3000".parse().unwrap();
info!("Listening on {}", &addr);
let listener = TcpListener::bind(&addr).await?;
axum::serve(listener, app.into_make_service()).await?;

Ok(())
}

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

async fn from_request_parts<'life0, 'life1>(
parts: &'life0 mut Parts,
_state: &'life1 S,
) -> Result<Self, Self::Rejection>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
let token = parts
.headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = verify(token).map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user))
}
}

async fn protected_route(AuthUser(user): AuthUser) -> impl IntoResponse {
format!("Hello, {}! Your scopes are: {:?}", user.username, user.scope)
}

async fn get_token(
Json(payload): Json<TokenRequest>
) -> Result<Json<TokenResponse>, StatusCode> {
let user = User {
username: payload.username,
created_at: Utc::now(),
scope: vec!["read".to_string(), "write".to_string()],
};
let token = sign(user).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(TokenResponse { token }))
}

#[allow(unused)]
fn generate_and_verify_token() -> Result<()> {
// generate_and_save_keys()?;
let user = User {
username: "hal".to_string(),
created_at: Utc::now(),
scope: vec!["read".to_string(), "write".to_string()],
};
let token = sign(user)?;
println!("Token: {}", &token);
let claims = verify(&token).unwrap();
println!("Claims: {:?}", claims);
Ok(())
}


async fn auth_middleware(
AuthUser(user): AuthUser,
req: Request<Body>,
next: Next,
) -> impl IntoResponse {
info!("Authenticated user: {}", user.username);
next.run(req).await
}

fn sign(user: impl Into<User>) -> Result<String> {
let private_key_pem = read_to_string("private_key.pem")?;
let key_pair = Ed25519KeyPair::from_pem(&private_key_pem)?;

let user = user.into();
let claims = Claims::with_custom_claims(user, Duration::from_secs(JWT_DURATION));
let claims = claims.with_issuer(JWT_ISS).with_audience(JWT_AUD);

let token = key_pair.sign(claims)?;
Ok(token)
}

fn verify(token: &str) -> Result<User, Box<dyn std::error::Error>> {
let public_key_pem = read_to_string("public_key.pem")?;
let public_key = Ed25519PublicKey::from_pem(&public_key_pem)?;

let options = VerificationOptions {
allowed_issuers: Some(HashSet::from_strings(&[JWT_ISS])),
allowed_audiences: Some(HashSet::from_strings(&[JWT_AUD])),
..Default::default()
};

let claims = public_key.verify_token::<User>(token, Some(options))?;
Ok(claims.custom)
}

#[allow(unused)]
fn generate_and_save_keys() -> Result<()> {
let key_pair = Ed25519KeyPair::generate();

// 保存私钥(只包含私钥信息)
let private_key_pem = key_pair.to_pem();
let mut private_key_file = File::create("private_key.pem")?;
private_key_file.write_all(private_key_pem.as_bytes())?;

// 保存公钥(只包含公钥信息)
let public_key_pem = key_pair.public_key().to_pem();
let mut public_key_file = File::create("public_key.pem")?;
public_key_file.write_all(public_key_pem.as_bytes())?;

Ok(())
}

运行结果

Axum 命令行运行结果 VSCode REST Client运行结果

结论

通过这个实现,我们成功地在 Axum 中集成了基于 JWT 的授权系统。 这个系统包括了密钥生成、token 的签发和验证,以及在 Axum 中使用授权中间件。 这种方法提供了一个安全、灵活的方式来保护你的 API 端点。

在实际应用中,你可能还需要考虑更多的安全措施,如 token 刷新、权限管理、密钥轮换等。 但这个实现为你提供了一个坚实的基础,你可以在此基础上进行进一步的开发和优化。

链接