Ha webes API-t írsz Rust-ban, előbb-utóbb szembejön a kérdés: hogyan azonosítsd a felhasználót anélkül, hogy szerver oldali session-tárolót kelljen fenntartanod? A JWT (JSON Web Token) erre kínál elegáns, stateless megoldást, az Actix-web pedig kifejezetten jól illeszkedik hozzá az extractor-rendszerének köszönhetően. Ebben a cikkben végigmegyünk egy teljes, működőképes megoldáson.

Actix-web projekt alapjai és middleware bevezetése

Először hozzunk létre egy sima Actix-web 4-es projektet. A Cargo.toml-ban a következő függőségekre lesz szükségünk:

[dependencies]
actix-web = "4.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "8.2"
bcrypt = "0.14"
chrono = { version = "0.4", features = ["serde"] }
futures-util = "0.3"
Megjegyzés

A tokio-t nem kell explicit módon megadnod, az Actix-web futásideje maga hozza magával a szükséges tokio komponenseket — felesleges és félrevezető lenne saját magad pinelni egy verziót.

A middleware koncepció Actix-webben egyszerű: minden request végigmegy egy lánc-szerű struktúrán, mielőtt eléri a handlert. Autentikációhoz kétféle megközelítést szokás alkalmazni: vagy egy globális middleware-t írunk, ami minden kérésnél ellenőrzi a tokent, vagy — amit ebben a cikkben preferálok — egy egyedi extractort használunk, amit csak azokon a route-okon kérünk el, ahol tényleg szükség van rá. Ez sokkal rugalmasabb, mert nem kell külön kizárni a publikus végpontokat (pl. login, regisztráció) egy globális middleware alól.

JWT token generálása és validálása jsonwebtoken crate-tel

A jsonwebtoken crate a facto standard JWT-kezelésre Rust-ban. Első lépésként definiáljuk a claims struktúrát, majd írjunk egy generáló és egy validáló függvényt.

use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use chrono::{Utc, Duration};

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

const JWT_SECRET: &[u8] = b"nagyon-titkos-kulcs-produkcioban-envbol";

pub fn generate_token(user_id: &str) -> Result<String, jsonwebtoken::errors::Error> {
    let expiration = Utc::now()
        .checked_add_signed(Duration::hours(1))
        .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(JWT_SECRET),
    )
}

pub fn validate_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
    let data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(JWT_SECRET),
        &Validation::default(),
    )?;
    Ok(data.claims)
}
Figyelem

Éles környezetben a titkos kulcsot soha ne hardkódold a forráskódba! Használj környezeti változót (std::env::var), és a .env fájlt tedd .gitignore-ba.

Bejelentkezési endpoint és jelszó hash-elés bcrypt-tel

A jelszavakat sosem tároljuk plain textként. A bcrypt crate egyszerű API-t ad a hash-eléshez és ellenőrzéshez.

use actix_web::{post, web, HttpResponse, Responder};
use serde::Deserialize;
use bcrypt::verify;

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

// Egyszerűsített "adatbázis" lekérdezés helyett - a valóságban ez
// egy SQL query lenne, pl. sqlx-szel.
fn find_user_hash(username: &str) -> Option<(&'static str, &'static str)> {
    if username == "admin" {
        // ez a hash a "titkosjelszo" bcrypt hash-e
        Some(("admin", "$2b$12$KIXQ2hFZ8n0O8w1J3v5xUuY9Zc5Q6q7T8v1W2E3R4T5Y6U7I8O9P0"))
    } else {
        None
    }
}

#[post("/login")]
#[must_use]
async fn login(payload: web::Json<LoginRequest>) -> impl Responder {
    let Some((user_id, hash)) = find_user_hash(&payload.username) else {
        return HttpResponse::Unauthorized().body("Hibás felhasználónév vagy jelszó");
    };

    match verify(&payload.password, hash) {
        Ok(true) => match crate::jwt::generate_token(user_id) {
            Ok(token) => HttpResponse::Ok().json(serde_json::json!({ "token": token })),
            Err(_) => HttpResponse::InternalServerError().body("Token generálási hiba"),
        },
        Ok(false) => HttpResponse::Unauthorized().body("Hibás felhasználónév vagy jelszó"),
        Err(_) => HttpResponse::InternalServerError().body("Hash ellenőrzési hiba"),
    }
}

Figyeld meg, hogy a let-else szintaxist használtam a felhasználó megkeresésénél — ez a Rust 1.65 óta stabil konstrukció remekül illik az "early return, ha nincs érték" mintára, és sokkal olvashatóbb, mint egy beágyazott match.

Tipp

A bcrypt::hash hívás CPU-intenzív, blokkoló művelet. Nagy terhelésű rendszerben érdemes web::block-kal külön szálra tenni, hogy ne akassza meg az async runtime-ot.

Védett route-ok kialakítása extractor segítségével

Az Actix-web FromRequest trait-jét implementálva saját extractort írhatunk, amivel a handler szignatúrájában egyszerűen kérhetjük az autentikált felhasználót.

use actix_web::{dev::Payload, Error, FromRequest, HttpRequest};
use actix_web::error::ErrorUnauthorized;
use futures_util::future::{ready, Ready};

pub struct AuthenticatedUser {
    pub user_id: String,
}

impl FromRequest for AuthenticatedUser {
    type Error = Error;
    type Future = Ready<Result<Self, Self::Error>>;

    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
        let auth_header = req.headers().get("Authorization");

        let Some(header_value) = auth_header else {
            return ready(Err(ErrorUnauthorized("Hiányzó Authorization header")));
        };

        let Ok(header_str) = header_value.to_str() else {
            return ready(Err(ErrorUnauthorized("Érvénytelen header formátum")));
        };

        let Some(token) = header_str.strip_prefix("Bearer ") else {
            return ready(Err(ErrorUnauthorized("Hiányzó Bearer prefix")));
        };

        match crate::jwt::validate_token(token) {
            Ok(claims) => ready(Ok(AuthenticatedUser { user_id: claims.sub })),
            Err(_) => ready(Err(ErrorUnauthorized("Érvénytelen vagy lejárt token"))),
        }
    }
}

Ezután a védett handlerben csak simán paraméterként kérjük:

use actix_web::{get, HttpResponse, Responder};

#[get("/profile")]
async fn profile(user: AuthenticatedUser) -> impl Responder {
    HttpResponse::Ok().json(serde_json::json!({
        "message": format!("Üdv, {}!", user.user_id)
    }))
}

Ha a token hiányzik, hibás vagy lejárt, az extractor már a handler meghívása előtt visszadob egy 401-es választ — a handler kódjában így egyáltalán nem kell foglalkoznunk az autentikáció részleteivel.

Hibaüzenetek és token lejárat kezelése

A jsonwebtoken::decode több különböző hibát dobhat: lejárt token (ExpiredSignature), érvénytelen aláírás, rossz formátum. Érdemes ezeket egy saját hibatípusba csoportosítani, hogy a kliens értelmes választ kapjon.

use jsonwebtoken::errors::ErrorKind;

pub fn describe_jwt_error(err: &jsonwebtoken::errors::Error) -> &'static str {
    match err.kind() {
        ErrorKind::ExpiredSignature => "A token lejárt, jelentkezz be újra",
        ErrorKind::InvalidToken => "Érvénytelen token formátum",
        ErrorKind::InvalidSignature => "A token aláírása nem egyezik",
        _ => "Ismeretlen autentikációs hiba",
    }
}
Jó tudni

A lejárati időt (exp) mindig állítsd be reálisan rövidre (pl. 15-60 perc), és refresh token mechanizmussal hosszabbítsd meg a session-t, ne magát az access tokent tedd örökéletűvé.

A #[must_use] async fn hasznossága a hibák elkerülésében

Egy gyakran alábecsült, de nagyon hasznos trükk: az async fn fölé tett #[must_use] attribútum. Mivel a Result típus már eleve #[must_use], ha egy handler Result-t ad vissza és elfelejted .await-elni a futurét, a fordító nem fog figyelmeztetni — hiszen a Result csak azután létezik, hogy a future lefutott. Ha viszont magára az async fn-re teszed a #[must_use]-t, a visszaadott (opaque) Future típus is megkapja ezt a jelzést, és a fordító azonnal szól, ha valahol elfelejtetted awaitolni vagy egyáltalán felhasználni a hívás eredményét:

#[must_use]
async fn login(payload: web::Json<LoginRequest>) -> impl Responder {
    // ...
}

Ha valaki véletlenül csak meghívja a függvényt awaiter nélkül (ami async kontextusban könnyen előfordulhat egy elgépelés miatt), a cargo build figyelmeztetést dob: "unused implementer of Future that must be used". Ez apró dolognak tűnik, de autentikációs logikánál — ahol egy elmaradt ellenőrzés komoly biztonsági rést jelenthet — külön hasznos védőháló.

Összefoglalás

Egy stateless JWT-alapú autentikáció Actix-webben tulajdonképpen három jól elkülöníthető részből áll: a token generálásából és validálásából (jsonwebtoken), a jelszavak biztonságos tárolásából (bcrypt), és egy egyedi FromRequest extractorból, ami a védett route-oknál automatikusan kikényszeríti az érvényes tokent. Ha ehhez hozzáadod a let-else szintaxist a kód olvashatóságáért, és a #[must_use] attribútumot a kritikus async függvényeidnél, egy tiszta, biztonságos és karbantartható autentikációs réteget kapsz — mindezt külső auth szolgáltató vagy komplex session-store nélkül.