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.
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.
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 })
}
}
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.
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:
- Állítsd az
expiration_timestamphívást egészen rövidre, pl.Duration::from_secs(1). - Generálj egy tokent, várj két másodpercet (
std::thread::sleep), majd hívd meg averify_tokenfüggvényt. - Nézd meg, milyen hibaüzenetet ad a
jsonwebtoken— keresd meg a kiírt szövegben, hogy szerepel-e benne azExpiredSignatureszó.
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ás
Mit garantál pontosan a JWT signature, és mit NEM garantál?Megoldás
A 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ás
Miért implementáljuk az `AuthUser`-hez a `FromRequestParts` trait-et, nem pedig a `FromRequest` trait-et?Megoldás
A `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 `JsonMegoldás
Mi 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ás
Semmi 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/decodefüggvényei generálják és ellenőrzik a tokeneket; aValidation::default()automatikusan ellenőrzi a lejárati időt (exp). - Egy saját
AuthUsertípus és aFromRequestPartstrait 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áltAuthErrorenum és egyIntoResponseimplementá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: