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.

Megjegyzés

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"] }
Tipp

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))
}
Figyelem

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 })
    }
}
Jó tudni

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();
}
Tipp

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.