Az előző leckében a sqlx-szel és a Postgres-szel raktuk le az adatréteg alapjait: connection poolt hoztunk létre, migrációkat futtattunk, lekérdezésekkel töltöttünk fel és olvastunk állapotot. Egy API-nak azonban ritkán elég csak adatot tárolnia — tudnia kell azt is, ki kéri az adatot, és mihez van jogosultsága. Itt jön képbe az autentikáció: ebben a leckében egy stateless, széles körben elterjedt megoldást építünk fel, a JWT-t (JSON Web Token), és megnézzük, hogyan illeszkedik bele természetesen az Axum extractor-rendszerébe, amit a Path/Query/Json/State kapcsán már megismertél.

A JWT szerkezete

Egy JWT nem más, mint egy pontokkal (.) elválasztott, három részből álló szöveg: header.payload.signature. Mindhárom rész Base64URL-kódolású, tehát bárki el tudja olvasni a tartalmát — a JWT nem titkosítás, hanem aláírás. A header egy kis JSON objektum, amely megmondja, milyen algoritmussal (pl. HS256) történt az aláírás. A payload a claims-eket (állításokat) tartalmazza: ki a felhasználó (sub), mikor jár le a token (exp), mikor lett kiállítva (iat), és bármi egyéb, amit belerakunk. A signature a header és a payload Base64-kódolt összefűzéséből és egy titkos kulcsból számolt kriptográfiai lenyomat, amely garantálja, hogy a tartalmat nem módosították útközben.

Jó tudni

Mivel a payload csak Base64-kódolt, nem titkosított, soha ne tegyél bele jelszót vagy más bizalmas adatot. A signature csak azt garantálja, hogy nem hamisították meg a tartalmat — azt nem, hogy ne lehessen elolvasni.

Nézzünk meg egy (kitalált, nem valós aláírású) tokent szétszedve:

fn main() {
    let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcxMDAwMDAwMH0.dGhpc2lzbm90YXJlYWxzaWduYXR1cmU";
    let parts: Vec<&str> = token.split('.').collect();

    println!("Header (base64url):    {}", parts[0]);
    println!("Payload (base64url):   {}", parts[1]);
    println!("Signature (base64url): {}", parts[2]);
}

A kimenet három sort ad, mindegyik egy-egy Base64URL-blokk. A header dekódolva kb. {"alg":"HS256"}, a payload pedig valami olyasmi, mint {"sub":"42","exp":1710000000}. A jsonwebtoken crate elvégzi helyettünk ezt a kódolást/dekódolást és az aláírás-ellenőrzést is — nem kell manuálisan Base64-et bűvölnünk.

Token generálás és ellenőrzés a jsonwebtoken crate-tel

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

[dependencies]
jsonwebtoken = "10"
serde = { version = "1", features = ["derive"] }

A claims-eket egy sima, Serialize/Deserialize struct-tal írjuk le — ez pontosan az a mintázat, amit a Json extractornál is használtál. A jsonwebtoken::encode a header, a claims és egy EncodingKey alapján állít elő tokent; a decode pedig egy DecodingKey és egy Validation alapján ellenőrzi és visszafejti azt. A Validation::default() alapból ellenőrzi a lejárati időt (exp) is, tehát egy lejárt tokenre hibát fogunk kapni anélkül, hogy ezt manuálisan kellene vizsgálnunk.

use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    sub: String, // a felhasználó azonosítója
    exp: usize,  // lejárati időpont (unix timestamp, másodpercben)
}

fn expiration_timestamp(valid_for: Duration) -> usize {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("az idő nem mehet vissza 1970 előtt");
    (now + valid_for).as_secs() as usize
}

fn create_token(user_id: &str, secret: &[u8]) -> Result<String, jsonwebtoken::errors::Error> {
    let claims = Claims {
        sub: user_id.to_owned(),
        exp: expiration_timestamp(Duration::from_secs(3600)),
    };
    encode(&Header::default(), &claims, &EncodingKey::from_secret(secret))
}

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

fn main() {
    let secret = b"nagyon-titkos-kulcs";
    let token = create_token("42", secret).expect("token generálás sikertelen");
    println!("Token: {token}");

    match verify_token(&token, secret) {
        Ok(claims) => println!("Érvényes token, user_id = {}", claims.sub),
        Err(err) => println!("Érvénytelen token: {err}"),
    }
}

Header::default() a HS256 algoritmust választja, ami egy szimmetrikus, közös titkos kulcson alapuló aláírás — a szerver ugyanazt a kulcsot használja generálásra és ellenőrzésre is.

Tipp

A jsonwebtoken crate támogat aszimmetrikus algoritmusokat is (pl. RS256), ahol a token generálására egy privát kulcsot, ellenőrzésére pedig egy nyilvános kulcsot használunk. Ez akkor hasznos, ha több szolgáltatás ellenőrzi a tokent, de csak egy állítja ki — ebben a leckében a HS256-tal maradunk, mert a legtöbb saját API-nak ez elég.

Auth extractor: a bejövő kérés hitelesítése

Axumban a Path/Query/Json/State extractorok mind egy közös ötletet valósítanak meg: a handler függvény paraméterei önmagukat "szedik ki" a kérésből. Ugyanezt a mintát tudjuk használni autentikációra is, egy saját típus és a FromRequestParts trait implementálásával. Ez lesz a mi "auth middleware"-ünk — nem egy külön tower-réteg, hanem egy típusos extractor, amit a handler paraméterlistájában kérünk el, és ha hiányzik vagy hibás a token, a handler kódja el sem indul.

Először a hibatípus, thiserrorral, ami IntoResponse-ra van fordítva, hogy Axum tudja HTTP válasszá alakítani:

// részlet, a teljes kód lentebb
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
enum AuthError {
    #[error("hiányzó vagy hibás formátumú Authorization fejléc")]
    MissingHeader,
    #[error("érvénytelen token: {0}")]
    InvalidToken(#[from] jsonwebtoken::errors::Error),
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let body = Json(json!({ "error": self.to_string() }));
        (StatusCode::UNAUTHORIZED, body).into_response()
    }
}

Most jöhet a maga az extractor. Az AuthUser struct azt a hitelesített felhasználót képviseli, aki a token alapján igazolta magát:

// részlet, a teljes kód lentebb
use axum::{extract::FromRequestParts, http::request::Parts};
use jsonwebtoken::{decode, DecodingKey, Validation};

struct AuthUser {
    user_id: String,
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let header = parts
            .headers
            .get("Authorization")
            .and_then(|value| value.to_str().ok())
            .ok_or(AuthError::MissingHeader)?;

        let token = header.strip_prefix("Bearer ").ok_or(AuthError::MissingHeader)?;

        let data = decode::<Claims>(token, &DecodingKey::from_secret(JWT_SECRET), &Validation::default())?;

        Ok(AuthUser { user_id: data.claims.sub })
    }
}
Jó tudni

A where S: Send + Sync klóz a generikus S állapottípusra vonatkozik. A Send-et már ismered: azt garantálja, hogy egy érték biztonságosan átadható egyik szálról a másikra. A Sync a testvére — azt garantálja, hogy egy típusra biztonságos egyszerre több szálról, referenciákon keresztül hozzáférni. Axum ezt minden state-re megköveteli, mert a Tokio háttérben bármelyik worker szál kiszolgálhatja a következő beérkező kérést, és az állapotot (jellemzően egy Arc-ba csomagolva) meg kell osztani közöttük.

A from_request_parts a fejlécekből (parts.headers) olvassa ki az Authorization fejlécet, leszedi róla a "Bearer " előtagot, majd a korábban megismert decode függvénnyel ellenőrzi a tokent. Ha bármelyik lépés hibázik, a ? operátor és a #[from] attribútum gondoskodik róla, hogy a hiba automatikusan AuthError-rá alakuljon, amit Axum egyenesen 401-es válasszá tud fordítani.

Védett végpont bekötése és hibakezelés

Most rakjuk össze a teljes programot: egy /login végpontot, amely (a valóságban jelszó-ellenőrzés után) tokent állít ki, és egy /profile védett végpontot, amely csak érvényes token birtokában válaszol.

use axum::{
    extract::{FromRequestParts, State},
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;

const JWT_SECRET: &[u8] = b"nagyon-titkos-kulcs"; // élesben ez env változóból jönne

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    sub: String,
    exp: usize,
}

#[derive(Debug, Error)]
enum AuthError {
    #[error("hiányzó vagy hibás formátumú Authorization fejléc")]
    MissingHeader,
    #[error("érvénytelen token: {0}")]
    InvalidToken(#[from] jsonwebtoken::errors::Error),
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let body = Json(json!({ "error": self.to_string() }));
        (StatusCode::UNAUTHORIZED, body).into_response()
    }
}

struct AuthUser {
    user_id: String,
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let header = parts
            .headers
            .get("Authorization")
            .and_then(|value| value.to_str().ok())
            .ok_or(AuthError::MissingHeader)?;

        let token = header.strip_prefix("Bearer ").ok_or(AuthError::MissingHeader)?;

        let data = decode::<Claims>(token, &DecodingKey::from_secret(JWT_SECRET), &Validation::default())?;

        Ok(AuthUser { user_id: data.claims.sub })
    }
}

#[derive(Clone)]
struct AppState {
    // ide kerülne a korábbi leckéből ismert connection pool (PgPool)
}

fn expiration_timestamp(valid_for: Duration) -> usize {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("az idő nem mehet vissza 1970 előtt");
    (now + valid_for).as_secs() as usize
}

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

async fn login_handler(Json(payload): Json<LoginRequest>) -> Json<serde_json::Value> {
    // valós appban itt jönne a jelszó-ellenőrzés az adatbázis felé
    let claims = Claims {
        sub: payload.username,
        exp: expiration_timestamp(Duration::from_secs(3600)),
    };
    let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(JWT_SECRET))
        .expect("token generálás nem sikerülhet érvénytelen kulccsal");
    Json(json!({ "token": token }))
}

async fn profile_handler(auth: AuthUser, State(_state): State<Arc<AppState>>) -> Json<serde_json::Value> {
    Json(json!({ "user_id": auth.user_id }))
}

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {});

    let app = Router::new()
        .route("/login", post(login_handler))
        .route("/profile", get(profile_handler))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Figyeld meg, hogy a profile_handler paraméterlistájában az AuthUser egyszerű paraméterként szerepel, ugyanúgy, mint korábban a Path vagy a Json. Ha a kérésből hiányzik az Authorization fejléc, vagy a token érvénytelen/lejárt, a from_request_parts hibával tér vissza, Axum ezt automatikusan AuthError::into_response-ra fordítja, és a handler törzse el sem indul — pontosan úgy működik, mint egy middleware, csak típusosan, a handler aláírásából is látszik, hogy ott hitelesítés van.

Figyelem

A JWT_SECRET konstans csak demonstrációs célból van a kódba írva. Élesben ezt sose commitold — env változóból vagy egy titokkezelőből olvasd be. A következő, Docker + Fly.io deploy leckében pontosan ezzel is foglalkozunk: hogyan adjunk át ilyen titkokat egy konténerizált alkalmazásnak futásidőben.

A hibakezelés tehát két szinten dolgozik: a AuthError enum megkülönbözteti a hiányzó fejléc és az érvénytelen token eseteket (ami maga is lehet lejárt, hibás aláírású vagy hibás formátumú token — mindezt a jsonwebtoken::errors::Error fedi le), de kifelé, a kliens felé egységesen 401-es státuszkóddal és egy JSON hibaüzenettel válaszolunk. Ez direkt szándékos: nem akarjuk elárulni a kliensnek, hogy pontosan miért bukott a hitelesítés (pl. hogy létezik-e egyáltalán ilyen felhasználó), csak azt, hogy nem hitelesített.

Próbáld ki!

Vedd elő a token generálás/ellenőrzés programot a lecke elejéről, és:

  1. Állítsd az expiration_timestamp hívást egészen rövidre, pl. Duration::from_secs(1).
  2. Generálj egy tokent, várj két másodpercet (std::thread::sleep), majd hívd meg a verify_token függvényt.
  3. Nézd meg, milyen hibaüzenetet ad a jsonwebtoken — keresd meg a kiírt szövegben, hogy szerepel-e benne az ExpiredSignature szó.

Ezután próbáld meg ugyanezt egy érvényes tokennel is, de módosíts egyetlen karaktert a signature részén (a harmadik, pont utáni szakaszon), és nézd meg, milyen hibát kapsz most.

Önellenőrzés

MegoldásMit garantál pontosan a JWT signature, és mit NEM garantál?
MegoldásA signature azt garantálja, hogy a header és a payload nem változott meg azóta, hogy a szerver a titkos kulccsal aláírta — tehát integritást biztosít. NEM garantálja a bizalmasságot: a payload Base64URL-kódolt, nem titkosított, bárki el tudja olvasni tartalmát, csak a kulcs nélkül nem tudja hihetően megváltoztatni.
MegoldásMiért implementáljuk az `AuthUser`-hez a `FromRequestParts` trait-et, nem pedig a `FromRequest` trait-et?
MegoldásA `FromRequestParts` csak a kérés fejrészéhez (fejlécek, metódus, URI) fér hozzá, a body-t nem konzumálja. Mivel a token ellenőrzéséhez csak az `Authorization` fejlécre van szükségünk, nem kell (és nem is szabad) hozzáférnünk a request body-jához — így más extractorok (pl. egy `Json`, ami a body-t olvassa) is szabadon használhatók ugyanazon handleren belül.
MegoldásMi történik, ha a `profile_handler`-ből elhagynánk az `AuthUser` paramétert, de a kliens mégis küld egy `Authorization` fejlécet?
MegoldásSemmi hitelesítés nem történne — a fejléc jelenléte önmagában nem véd semmit, csak akkor van hatása, ha a handler egy `AuthUser` (vagy hasonló) extractort kér a paraméterlistájában. Az Axum extractor-alapú modelljében a védelem explicit: ha egy handler nem kér `AuthUser`-t, az a végpont nyitva marad, függetlenül attól, hogy a kliens küld-e tokent.

Összefoglalás és további lépés

  • A JWT három Base64URL-kódolt részből áll (header.payload.signature); ez aláírás, nem titkosítás — a payload tartalma bárki számára olvasható.
  • A jsonwebtoken crate encode/decode függvényei generálják és ellenőrzik a tokeneket; a Validation::default() automatikusan ellenőrzi a lejárati időt (exp).
  • Egy saját AuthUser típus és a FromRequestParts trait implementálásával típusos, middleware-szerű autentikációt építhetünk az Axum handlerek paraméterlistájába — nincs szükség külön tower-rétegre.
  • A hibákat egy thiserror-ral definiált AuthError enum és egy IntoResponse implementáció alakítja egységes, 401-es JSON válasszá.
  • A következő leckében ezt az API-t konténerizáljuk és élesítjük: Docker + Fly.io deploy, ahol többek között arról is szó lesz, hogyan kezeljük futásidőben a JWT_SECRET-hez hasonló titkokat.

További olvasmányok: