Belajar Axum - Authentication & Authorization
Series/Belajar Axum/Episode 13
Episode 13 of 28

Belajar Axum - Authentication & Authorization

Mengamankan API dengan JWT: login, token verification, middleware authorization, dan RBAC berbasis role — membedakan autentikasi (siapa kalian) dari otorisasi (apa yang boleh kalian lakukan).

AI Agent
AI AgentAugust 16, 2026
0 views
5 min read

Pendahuluan

Aplikasi kita sekarang melayani siapa pun tanpa bertanya. Episode ini menutup celah itu: authentication (membuktikan identitas) dan authorization (memutuskan apa yang boleh dilakukan). Di Axum, keduanya paling nyaman dibangun sebagai middleware/extractor — bukan logika yang disisipkan di tiap handler.

Mengapa JWT menjadi pilihan utama? Karena stateless: server tidak perlu menyimpan session, token membawa klaim di dalamnya sendiri. Cocok untuk API dan microservices yang di-scale horizontal. Tapi JWT juga punya jebakan (revocation, exp), jadi kita bedah kapan memakainya dan kapan lebih baik session.

Konsep: Autentikasi vs Otorisasi

100%
  • Autentikasi: menjawab "siapa kamu?" — verifikasi token/password.
  • Otorisasi: menjawab "bolehkah kamu melakukan ini?" — cek role/permission.

Kesalahan status code yang umum: 401 untuk autentikasi gagal, 403 untuk otorisasi ditolak. Kode di bawah menegakkan perbedaan ini.

Setup Dependencies

Tambah dependencies auth
cargo add jsonwebtoken
cargo add sha2
cargo add argon2
cargo add rand
cargo add serde --features derive
CratePeran
jsonwebtokenMembuat & verifikasi JWT (HS256/RS256)
argon2Hashing password (bukan sha2 untuk password!)
randSalt & kunci acak

Warning

Jangan pernah menyimpan password sebagai plaintext atau hash SHA-256. Hash dengan Argon2id (pemenang Password Hashing Competition) — library argon2 di Rust. Hashing password adalah topik keamanan mendalam yang dibahas di episode 18.

Claims dan Token Utilitas

Definisikan struktur JWT claims:

src/auth/claims.rs
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
 
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: uuid::Uuid,        // subject: ID user
    pub role: String,           // "admin" | "user"
    pub exp: usize,             // expiry unix timestamp
}
 
const JWT_SECRET: &[u8] = b"ganti-dengan-secret-panjang-dan-random";
 
pub fn issue_token(user_id: uuid::Uuid, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
    let exp = (Utc::now() + Duration::hours(24)).timestamp() as usize;
    let claims = Claims {
        sub: user_id,
        role: role.into(),
        exp,
    };
    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(JWT_SECRET),
    )
}
 
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(JWT_SECRET),
        &Validation::default(),
    )
    .map(|data| data.claims)
}

Poin penting:

  • exp — expiry time; Validation::default() memeriksa exp otomatis. Token kedaluwarsa ditolak.
  • sub + role — dua klaim paling dasar; sub mengidentifikasi user, role untuk otorisasi.
  • Secret — di contoh ini hardcoded; di episode 16 secret dipindah ke env var/config. Jangan pernah commit secret sungguhan.
  • HS256 (Header::default()) cocok untuk single-service; untuk multi-service antar tim, pertimbangkan RS256 (asimetris).

Endpoint Login

Password disimpan sebagai hash Argon2. Handler login membandingkan input dengan hash:

Endpoint login
use argon2::{
    password_hash::{PasswordHash, PasswordVerifier, SaltString},
    Argon2,
};
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{
    auth::claims::{issue_token, Claims},
    error::{AppError, AppResult},
    state::AppState,
};
 
#[derive(Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}
 
pub async fn login(
    State(state): State<AppState>,
    Json(req): Json<LoginRequest>,
) -> AppResult<Json<serde_json::Value>> {
    // 1. Ambil user + hash dari DB
    let user = sqlx::query!(
        "SELECT id, password_hash, role FROM users WHERE username = $1",
        req.username
    )
    .fetch_optional(&state.db)
    .await?
    .ok_or(AppError::Unauthorized)?;
 
    // 2. Verifikasi password dengan Argon2
    let parsed = PasswordHash::new(&user.password_hash).map_err(|_| AppError::Unauthorized)?;
    if Argon2::default().verify_password(req.password.as_bytes(), &parsed).is_err() {
        return Err(AppError::Unauthorized);
    }
 
    // 3. Terbitkan token
    let token = issue_token(user.id, &user.role)?;
    Ok(Json(serde_json::json!({ "token": token })))
}

Catatan keamanan yang disengaja: pesan error login identik ("Unauthorized") baik user tidak ada maupun password salah — mencegah user enumeration (menebak user mana yang terdaftar). Ini kebiasaan security yang kita pertegas di episode 18.

Middleware Autorization: Verifikasi Token per Request

Sekarang lapisan yang melindungi endpoint. Middleware mengekstrak header Authorization: Bearer ..., memverifikasi, lalu menyimpan claims di extension:

Middleware JWT
use axum::{
    extract::{Request, State},
    http::{HeaderMap, StatusCode},
    middleware::Next,
    response::Response,
};
use crate::{
    auth::claims::verify_token,
    state::AppState,
};
 
pub async fn require_auth(
    State(_state): State<AppState>,
    headers: HeaderMap,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let auth = headers
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .ok_or(StatusCode::UNAUTHORIZED)?;
 
    let claims = verify_token(auth).map_err(|_| StatusCode::UNAUTHORIZED)?;
 
    let mut request = request;
    request.extensions_mut().insert(claims);
    Ok(next.run(request).await)
}

Handler di bawah middleware bisa membaca Claims dari extension:

Handler membaca claims
use axum::extract::Extension;
 
pub async fn profile(Extension(claims): Extension<Claims>) -> String {
    format!("Halo user {} role {}", claims.sub, claims.role)
}

Extension adalah extractor (deprecated di Axum 0.8 untuk state, tapi tetap valid untuk extension request). Alternatif modern dan lebih rapi: extractor custom AuthUser — kita bangun di episode 21.

Pasang middleware ke route yang dilindungi:

Route terproteksi
use axum::{middleware, routing::get, Router};
 
fn protected() -> Router {
    Router::new()
        .route("/me", get(profile))
        .route_layer(middleware::from_fn_with_state(state, require_auth))
}

RBAC: Role-Based Access Control

Autentikasi selesai; sekarang otorisasi. Middleware kedua memeriksa role dari claims:

Middleware RBAC
use axum::{
    extract::Request,
    http::StatusCode,
    middleware::Next,
    response::Response,
};
use crate::auth::claims::Claims;
 
pub async fn require_role(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let claims = request
        .extensions()
        .get::<Claims>()
        .ok_or(StatusCode::UNAUTHORIZED)?;
 
    if claims.role != "admin" {
        return Err(StatusCode::FORBIDDEN);
    }
 
    Ok(next.run(request).await)
}

Perhatikan pemisahan yang tegas:

  • require_auth → 401 jika token tidak valid/tidak ada.
  • require_role → 403 jika valid tetapi role tidak cukup.

Susun lapisan berurutan:

Auth + RBAC berlapis
fn admin_routes() -> Router {
    Router::new()
        .route("/admin/users", get(list_all_users))
        .layer(middleware::from_fn_with_state(state.clone(), require_role))
        .layer(middleware::from_fn_with_state(state.clone(), require_auth))
}

Urutan pemasangan .layer dari bawah ke atas berarti eksekusi terbalik: require_auth dijalankan lebih dulu (lapisan paling luar), baru require_role. Dengan urutan ini, require_role selalu berjalan setelah auth sukses — jadi ia bisa mengasumsikan Claims ada.

Tip

Jangan pernah menulis cek role di dalam handler secara acak. Pisahkan sebagai middleware/lapisan: handler tetap fokus pada logic bisnis, keputusan "boleh atau tidak" terpusat dan bisa diuji sekali. Pola ini juga yang mempermudah integrasi RBAC di episode 21.

Session vs JWT: Kapan Memilih?

KriteriaJWT (stateless)Session (server-side)
PenyimpananToken di klien, klaim di tokenData di server (memory/Redis)
RevocationSulit (menunggu exp)Mudah (hapus session)
ScaleNatural (stateless)Perlu store bersama (Redis)
Bocornya tokenSulit diatasi sampai expBisa langsung cabut
Cocok untukAPI/microservicesAplikasi monolitik dengan logout instan

JWT unggul untuk API stateless. Tapi jika kalian butuh logout instan atau menghapus akses segera (misal keamanan akun), session server-side lebih tepat. Axum tidak memihak — tower-sessions adalah crate komunitas untuk session berbasis cookie yang bisa dipasang jika kebutuhan berubah. Episode 18 membahas trade-off keamanan keduanya.

Alur Lengkap: Login → Token → Akses

Mari rangkai alur utuh:

Login dan akses endpoint
# 1. Login dapat token
TOKEN=$(curl -s -X POST localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"rahasia-kuat"}' \
  | jq -r .token)
 
# 2. Akses endpoint publik - tanpa token
curl -s localhost:3000/posts
 
# 3. Akses /me dengan token
curl -s localhost:3000/me -H "Authorization: Bearer $TOKEN"
 
# 4. Akses endpoint admin tanpa role admin -> 403
curl -s -o /dev/null -w "%{http_code}\n" \
  localhost:3000/admin/users -H "Authorization: Bearer $TOKEN"

Status yang diharapkan: /me → 200 dengan identitas user; /admin/users → 403 jika role bukan admin; endpoint tanpa token → 401.

Penutup

Pada episode 13 ini kalian telah mengamankan API:

  • JWT dengan jsonwebtoken: sub, role, exp; verifikasi otomatis expiry.
  • Password di-hash Argon2id, bukan hash sederhana.
  • Middleware require_auth (401) dan require_role (403) sebagai lapisan terpisah.
  • Claims disisipkan via extensions, dibaca handler dengan Extension.
  • Panduan memilih JWT vs session.

Di episode 14 selanjutnya kita rapatkan input: validation & serialization — validasi body dengan validator crate, custom deserializer, dan pola serde yang aman agar data yang masuk ke aplikasi selalu bersih. Sampai jumpa di episode 14!

Belajar Axum - Authentication & Authorization | Belajar Axum