A JWT (JSON Web Token) mára az egyik legelterjedtebb megoldás stateless autentikációra webes API-knál, és a Rust ökoszisztéma – hála az axum-nak és a jsonwebtoken crate-nek – kényelmesen kezeli ezt a mintát. Ebben a cikkben egy egyszerű, de valósághű JWT-alapú autentikációt építünk fel egy Axum 0.6-os szolgáltatásban.

Hogyan működik a JWT webes kontextusban?

A JWT lényegében egy digitálisan aláírt, base64-kódolt JSON struktúra, ami három részből áll: header, payload (claims) és aláírás. A szerver bejelentkezéskor generál egy tokent, amit a kliens minden további kérésnél elküld az Authorization: Bearer <token> fejlécben. A szerver ellenőrzi az aláírást és a lejárati időt, és ha minden rendben, kiszolgálja a kérést – anélkül, hogy session-t kellene tárolnia adatbázisban vagy memóriában.

Megjegyzés

A JWT önmagában nem titkosít semmit, csak aláír. A payload bárki számára olvasható, aki dekódolja a base64-et – szóval sose tegyünk bele jelszót vagy más érzékeny adatot.

A claims mezők közül a legfontosabbak:

  • sub (subject) – a felhasználó azonosítója
  • exp (expiration) – lejárati idő unix timestampben
  • iat (issued at) – kiállítás ideje

Szükséges crate-ek és a projekt alapjai

A Cargo.toml-ban a következő függőségekre lesz szükségünk:

[dependencies]
axum = { version = "0.6", features = ["headers"] }
tokio = { version = "1", features = ["full"] }
jsonwebtoken = "8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
tower = "0.4"
tower-http = "0.4"

A headers feature azért fontos, mert ezzel kapjuk meg az axum::TypedHeader extractort, amivel könnyen kiszedhetjük az Authorization fejlécet a kérésből.

Először definiáljuk a claims struktúrát, amit a token payload-jaként fogunk használni:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: String, // felhasználó azonosító
    pub exp: usize,  // lejárati idő (unix timestamp)
    pub iat: usize,  // kiállítás ideje
}

Bejelentkezési endpoint és token generálás

A token generálásához egy egyszerű függvényt írunk, amely a jsonwebtoken::encode hívást csomagolja be:

use chrono::{Duration, Utc};
use jsonwebtoken::{encode, EncodingKey, Header};

pub fn generate_token(user_id: &str, secret: &[u8]) -> Result<String, jsonwebtoken::errors::Error> {
    let now = Utc::now();
    let claims = Claims {
        sub: user_id.to_owned(),
        iat: now.timestamp() as usize,
        exp: (now + Duration::minutes(15)).timestamp() as usize,
    };

    encode(&Header::default(), &claims, &EncodingKey::from_secret(secret))
}
Tipp

A 15 perces lejárati idő szándékosan rövid – ez a jó gyakorlat: a rövid életű access token mellett egy hosszabb élettartamú refresh tokent szokás használni, amiről majd később lesz szó.

Ezután jöhet a bejelentkezési handler. Egy valós alkalmazásban itt adatbázis-lekérdezés és jelszó-hash ellenőrzés (pl. argon2 crate-tel) történne, de a példa kedvéért egyszerűsítünk:

use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

#[derive(Serialize)]
pub struct LoginResponse {
    pub access_token: String,
    pub token_type: String,
}

pub async fn login(Json(payload): Json<LoginRequest>) -> Result<Json<LoginResponse>, StatusCode> {
    if payload.username == "demo" && payload.password == "titok123" {
        let secret = b"nagyon-titkos-kulcs";
        let token = generate_token(&payload.username, secret)
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        Ok(Json(LoginResponse {
            access_token: token,
            token_type: "Bearer".to_string(),
        }))
    } else {
        Err(StatusCode::UNAUTHORIZED)
    }
}
Figyelem

A secret sose legyen hardcode-olva a forráskódban éles rendszerben! Használj környezeti változót (std::env::var), és tárold biztonságosan, például egy secret manager mögött.

Védett route-ok létrehozása middleware-rel

Az Axum 0.6-ban a legelegánsabb módja a védett route-ok kialakításának, ha a Claims struktúrára implementáljuk a FromRequestParts traitet. Ezzel a handler argumentumaként simán kikérhetjük a bejelentkezett felhasználó adatait, és az extractor automatikusan elvégzi a token validálást.

use axum::{
    async_trait,
    extract::FromRequestParts,
    headers::{authorization::Bearer, Authorization},
    http::{request::Parts, StatusCode},
    RequestPartsExt, TypedHeader,
};
use jsonwebtoken::{decode, DecodingKey, Validation};

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

    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)?;

        let secret = b"nagyon-titkos-kulcs";
        let token_data = decode::<Claims>(
            bearer.token(),
            &DecodingKey::from_secret(secret),
            &Validation::default(),
        )
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

        Ok(token_data.claims)
    }
}

Ezután a védett handler már gyerekjáték:

pub async fn protected_route(claims: Claims) -> String {
    format!("Szia, {}! Ez egy védett végpont.", claims.sub)
}

A router összeállítása:

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

pub fn app() -> Router {
    Router::new()
        .route("/login", post(login))
        .route("/protected", get(protected_route))
}
Jó tudni

Ha nem akarjuk minden handler paraméterlistájában megismételni a Claims extractort, érdemes egy tower::Layer-t vagy axum::middleware::from_fn-t írni, amely egy csoportnyi route elé illesztve végzi el az ellenőrzést. Nagyobb projekteknél ez tisztábban skálázódik.

Token frissítés és hibakezelés jó gyakorlatai

A rövid élettartamú access token mellett szükségünk van egy refresh mechanizmusra, hogy a felhasználónak ne kelljen percenként újra bejelentkeznie. A bevált minta a következő:

  1. A bejelentkezéskor egy hosszabb élettartamú (pl. 7 napos) refresh tokent is generálunk, amit külön, HttpOnly cookie-ban tárolunk.
  2. Egy /refresh végponton, ha a refresh token még érvényes, kiállítunk egy új access tokent.
  3. A refresh tokeneket érdemes egy adatbázisban is nyilvántartani (revocation list), hogy kijelentkezéskor vagy jelszócsere esetén érvényteleníthetők legyenek.
pub async fn refresh(claims: Claims) -> Result<Json<LoginResponse>, StatusCode> {
    let secret = b"nagyon-titkos-kulcs";
    let new_token = generate_token(&claims.sub, secret)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(LoginResponse {
        access_token: new_token,
        token_type: "Bearer".to_string(),
    }))
}

A hibakezelésnél célszerű egy egységes AppError típust bevezetni, amely implementálja az IntoResponse traitet, így a middleware-ekben és handlerekben egyaránt konzisztens JSON hibaválaszokat adhatunk:

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

pub enum AppError {
    InvalidToken,
    ExpiredToken,
    MissingCredentials,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::InvalidToken => (StatusCode::UNAUTHORIZED, "Érvénytelen token"),
            AppError::ExpiredToken => (StatusCode::UNAUTHORIZED, "Lejárt token"),
            AppError::MissingCredentials => (StatusCode::BAD_REQUEST, "Hiányzó hitelesítő adatok"),
        };

        (status, Json(json!({ "error": message }))).into_response()
    }
}

A jsonwebtoken crate decode függvénye részletes hibatípusokat ad vissza (jsonwebtoken::errors::ErrorKind), amiket a fenti AppError-ra tudunk map-elni – így a kliens pontosan megkapja, hogy lejárt vagy csak érvénytelen volt a token, ez pedig segíti a frontend hibaüzenetek pontosabb megjelenítését.

Tipp

A Validation struktúrán keresztül finomhangolhatod, hogy milyen algoritmust fogadj el (Validation::new(Algorithm::HS256)), és beállíthatsz leeway-t is a szerverek közötti óraeltérés kompenzálására.

Összefoglalás

Egy JWT-alapú autentikáció Axumban valójában néhány jól elkülönített építőkockából áll: egy claims struktúrából, egy token-generáló függvényből, egy FromRequestParts alapú extractorból a validáláshoz, és egy tisztán megírt hibakezelő rétegből. Ez a stateless megközelítés kiválóan skálázódik, mert a szervernek nem kell session-állapotot tartania – cserébe neked kell odafigyelned a token élettartamára, a titkos kulcs biztonságos kezelésére és a refresh mechanizmus helyes megvalósítására. Ha ezekre figyelsz, egy gyors, biztonságos és jól karbantartható autentikációs réteget kapsz az API-d elé.