Az Axum egyre népszerűbb választás Rust-os webes backendekhez, mert a Tower ökoszisztémára épül, típusbiztos extractorai vannak, és nincs benne felesleges mágia. Ebben a cikkben egy egyszerű, de valóságos API-t építünk fel: felhasználók regisztrálhatnak, bejelentkezhetnek, és egy JWT token birtokában érhetik el a védett végpontokat. Az adatbázis-kezelést sqlx-szel oldjuk meg, PostgreSQL-lel.
A cikk írásakor a legfrissebb stabil Rust az 1.68, az Axum pedig a 0.6-os ágban tart. Minden kódrészlet ezekkel a verziókkal lett tesztelve.
Projekt előkészítése: Axum, sqlx és tokio
Kezdjük egy friss projekttel:
cargo new axum-jwt-demo
cd axum-jwt-demo
A Cargo.toml-ba a következő függőségeket vesszük fel:
[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.6", features = ["postgres", "runtime-tokio-rustls", "macros", "chrono", "uuid"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "8"
bcrypt = "0.14"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
tower = "0.4"
tower-http = { version = "0.4", features = ["trace"] }
Ha nem szeretnél önkezűleg SQL-t írni minden lekérdezéshez, a sqlx::query_as! makró compile-time-ban validálja a lekérdezést az adatbázis sémája ellen — ehhez viszont futó Postgres kapcsolat kell build közben, vagy a sqlx-cli-vel generált offline cache.
Adatbázis kapcsolat és migrációk sqlx-szel
Ellenőrizzük, hogy fut-e a Postgres, majd telepítsük a sqlx-cli-t:
cargo install sqlx-cli --no-default-features --features postgres,rustls
sqlx database create
Hozzunk létre egy migrációt:
sqlx migrate add create_users_table
A generált SQL fájlban:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
A kapcsolatot egy PgPool-lal hozzuk létre, amit majd megosztott állapotként adunk át a handlereknek:
use sqlx::postgres::PgPoolOptions;
async fn create_pool() -> sqlx::Pool<sqlx::Postgres> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL env változó hiányzik");
PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("nem sikerült kapcsolódni az adatbázishoz")
}
Felhasználói regisztráció és jelszó hash-elés
A jelszavakat soha ne tároljuk plain text-ben — erre a bcrypt crate-et használjuk. A regisztrációs handler egy JSON body-t vár, hash-eli a jelszót, majd beszúrja a felhasználót.
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct RegisterInput {
email: String,
password: String,
}
#[derive(Serialize)]
pub struct RegisterResponse {
id: Uuid,
email: String,
}
pub async fn register(
State(pool): State<PgPool>,
Json(input): Json<RegisterInput>,
) -> Result<Json<RegisterResponse>, (axum::http::StatusCode, String)> {
let hashed = bcrypt::hash(&input.password, bcrypt::DEFAULT_COST)
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let rec = sqlx::query_as!(
RegisterResponse,
r#"INSERT INTO users (email, password_hash) VALUES ($1, $2)
RETURNING id, email"#,
input.email,
hashed
)
.fetch_one(&pool)
.await
.map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Json(rec))
}
A query_as! makró közvetlenül a struktúra mezőneveit próbálja megfeleltetni az SQL RETURNING oszlopainak. Ha eltér a névezés, adj meg explicit aliast az SQL-ben (AS mezonev).
JWT token generálása és validálása middleware-ben
A bejelentkezésnél a jsonwebtoken crate-tel generálunk egy tokent, amiben a sub claim a felhasználó UUID-je, és van egy lejárati idő is.
use jsonwebtoken::{encode, decode, EncodingKey, DecodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use chrono::{Utc, Duration};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
}
pub fn create_token(user_id: &str, secret: &[u8]) -> Result<String, jsonwebtoken::errors::Error> {
let expiration = Utc::now()
.checked_add_signed(Duration::hours(24))
.expect("érvényes időpont")
.timestamp() as usize;
let claims = Claims {
sub: user_id.to_owned(),
exp: expiration,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret))
}
pub fn verify_token(token: &str, secret: &[u8]) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret),
&Validation::default(),
)?;
Ok(data.claims)
}
A middleware-t Axum extractorként implementáljuk, ami minden védett végpont elé beékelődik. Az Authorization: Bearer <token> fejlécet olvassuk ki és validáljuk:
use axum::{
async_trait,
extract::{FromRequestParts, TypedHeader},
headers::{authorization::Bearer, Authorization},
http::{request::Parts, StatusCode},
};
pub struct AuthUser {
pub user_id: String,
}
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "hiányzó vagy hibás token".into()))?;
let secret = std::env::var("JWT_SECRET").unwrap_or_default();
let claims = verify_token(bearer.token(), secret.as_bytes())
.map_err(|_| (StatusCode::UNAUTHORIZED, "érvénytelen token".into()))?;
Ok(AuthUser { user_id: claims.sub })
}
}
A TypedHeader extractorhoz az axum::headers feature-t kell engedélyezni a Cargo.toml-ban (axum = { version = "0.6", features = ["headers"] }), különben fordítási hibát kapsz.
Védett végpontok kialakítása extractorokkal
Mivel az AuthUser implementálja a FromRequestParts traitet, egyszerűen paraméterként felvehetjük bármelyik handlerbe — Axum automatikusan végrehajtja a validálást, mielőtt a handler törzse elindulna.
async fn me(auth_user: AuthUser) -> Json<serde_json::Value> {
Json(serde_json::json!({ "user_id": auth_user.user_id }))
}
Ha a token hiányzik vagy érvénytelen, a handler törzse el sem indul, a kliens rögtön 401-et kap. Ez az egyik legszebb tulajdonsága az Axum extractor-rendszerének: a hitelesítési logika nem keveredik bele az üzleti logikába.
Megosztott állapot kezelése az alkalmazásban
A PgPool és a JWT secret megosztásához nem szükséges semmilyen extra crate: Axum 0.6-ban a State extractor pontosan erre való. Egy AppState struktúrát hozunk létre, ami Clone-olható (a PgPool belül Arc-ot használ, így a klónozás olcsó):
use axum::{Router, routing::{get, post}};
#[derive(Clone)]
struct AppState {
pool: sqlx::PgPool,
jwt_secret: String,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let pool = create_pool().await;
sqlx::migrate!().run(&pool).await.expect("migráció sikertelen");
let state = AppState {
pool,
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET hiányzik"),
};
let app = Router::new()
.route("/register", post(register))
.route("/me", get(me))
.with_state(state.pool.clone());
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
Ha több különálló állapotot (pool, secret, konfig) is meg kell osztanod, érdemes egyetlen Arc<AppState>-be csomagolni őket, és azt adni át a with_state-nek — így elkerülöd, hogy minden handler szignatúrája külön extractorokkal duzzadjon fel. Ha a jövőben megjelenik egy lazy inicializálásra alkalmas std eszköz (jelenleg csak RFC/roadmap szinten van szó ilyenről), az tovább egyszerűsítheti majd a globális singletonok kezelését, de most az Arc-os megosztás a bevált, típusbiztos út.
Ha a register handler State(pool): State<PgPool>-t vár, de a router szintjén AppState-et adnál át, az Axum .with_state() metódusa a teljes állapotot kéri — ezért praktikus, ha a handlerek State<AppState>-et fogadnak, és onnan érik el a poolt és a secretet:
async fn login(
State(state): State<AppState>,
Json(input): Json<RegisterInput>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user = sqlx::query!(
"SELECT id, password_hash FROM users WHERE email = $1",
input.email
)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "hibás email vagy jelszó".into()))?;
let valid = bcrypt::verify(&input.password, &user.password_hash)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let Ok(true) = Ok::<bool, ()>(valid) else {
return Err((StatusCode::UNAUTHORIZED, "hibás email vagy jelszó".into()));
};
let token = create_token(&user.id.to_string(), state.jwt_secret.as_bytes())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({ "token": token })))
}
Ebben a példában a let-else szintaxist is felhasználtuk (stabil funkció, tehát nyugodtan használhatod), ami sokkal olvashatóbbá teszi a korai-return mintákat, mint egy egymásba ágyazott if let.
Összefoglalás
Egy Axum + sqlx alapú REST API felépítése meglepően kevés boilerplate-tel jár, ha kihasználod az extractor-rendszer erejét: a State a megosztott erőforrásokhoz, a saját FromRequestParts implementáció pedig a hitelesítéshez. A bcrypt gondoskodik a jelszavak biztonságos tárolásáról, a jsonwebtoken pedig a state-less autentikációról. Ha ezt az alapot bővítenéd, érdemes lehet refresh tokeneket, rate limitinget (tower-http middleware-ekkel) és részletesebb hibakezelést (saját IntoResponse implementáció) hozzáadni — de a mag, amit itt megépítettünk, már önmagában egy stabil, típusbiztos kiindulópont egy valódi projekthez.