Jika Anda ingin membangun autentikasi web di Rust dengan Axum, pendekatan yang aman biasanya bukan menyimpan status login sepenuhnya di browser, melainkan memakai session cookie yang hanya berisi identifier acak, sementara data sesi disimpan di server. Dengan pola ini, Anda bisa mencabut sesi kapan saja, menerapkan idle timeout dan absolute timeout, merotasi session ID setelah login, dan menambahkan proteksi CSRF untuk request yang mengubah state.
Artikel ini fokus pada implementasi praktis Rust Axum: session cookie aman, rotasi token, dan CSRF. Contohnya dibuat cukup generik agar mudah diadaptasi ke penyimpanan seperti Redis atau database, tanpa bergantung terlalu keras pada detail versi library tertentu.
Arsitektur yang disarankan
Desain yang aman untuk autentikasi berbasis sesi di Axum umumnya seperti ini:
- Browser menyimpan cookie sesi berisi session ID acak, bukan data autentikasi sensitif.
- Server menyimpan record sesi: user ID, waktu dibuat, waktu aktivitas terakhir, waktu kedaluwarsa, status dicabut, dan metadata tambahan bila perlu.
- Setelah login berhasil, server merotasi session ID untuk mencegah session fixation.
- Setiap request yang butuh autentikasi memuat sesi dari storage, memvalidasi timeout, lalu mengisi konteks user ke request.
- Request yang mengubah state (POST, PUT, PATCH, DELETE) wajib melewati validasi CSRF token bila autentikasi memakai cookie.
- Logout harus menghapus cookie di browser dan mencabut sesi di server-side storage.
Poin pentingnya: cookie hanya alat referensi. Otoritas sebenarnya tetap di server.
Struktur data sesi dan storage server-side
Minimal, record sesi server-side sebaiknya menyimpan informasi berikut:
session_id: token acak, panjang cukup, dihasilkan dengan RNG kriptografis.user_id: identitas user yang terautentikasi.created_at: waktu sesi dibuat.last_seen_at: aktivitas terakhir untuk idle timeout.expires_at: batas mutlak sesi.csrf_token: token CSRF yang terkait dengan sesi.revoked_atatau flag setara: menandai sesi sudah dicabut.
Contoh struktur yang mudah dipakai:
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub user_id: i64,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub csrf_token: String,
pub revoked: bool,
}
impl Session {
pub fn is_idle_expired(&self, idle_timeout: Duration, now: DateTime<Utc>) -> bool {
now > self.last_seen_at + idle_timeout
}
pub fn is_absolute_expired(&self, now: DateTime<Utc>) -> bool {
now > self.expires_at
}
}
#[async_trait]
pub trait SessionStore: Send + Sync {
async fn create(&self, session: Session) -> anyhow::Result<()>;
async fn find(&self, session_id: &str) -> anyhow::Result<Option<Session>>;
async fn update(&self, session: Session) -> anyhow::Result<()>;
async fn delete(&self, session_id: &str) -> anyhow::Result<()>;
async fn rotate(&self, old_session_id: &str, new_session: Session) -> anyhow::Result<()>;
}Interface seperti ini memudahkan Anda mengganti implementasi storage. Untuk produksi, pilihan umum adalah:
- Redis: sederhana untuk TTL, cepat, cocok untuk sesi.
- Database relasional: lebih mudah diaudit, cocok bila Anda ingin query dan histori lebih kaya.
Kenapa server-side session lebih cocok untuk kasus ini?
Karena Anda membutuhkan invalidasi dan kontrol penuh. Jika seluruh state autentikasi dibawa klien, misalnya dalam token yang diverifikasi lokal, logout dan pencabutan sesi menjadi lebih sulit atau butuh strategi tambahan seperti denylist. Untuk aplikasi web klasik berbasis cookie, sesi server-side biasanya lebih mudah diamankan dan dioperasikan.
Cookie sesi yang aman di Axum
Cookie sesi harus dibuat cukup ketat. Atribut yang paling penting:
- HttpOnly: mencegah JavaScript membaca cookie sesi. Ini mengurangi dampak XSS terhadap pencurian sesi.
- Secure: cookie hanya dikirim lewat HTTPS. Jangan matikan di production.
- SameSite: membantu mengurangi CSRF, tetapi bukan pengganti token CSRF.
- Path: batasi cakupan jika memungkinkan.
- Domain: jangan dibuat terlalu luas bila tidak perlu.
Secara praktis, banyak aplikasi memilih:
SameSite=Laxuntuk web app biasa.SameSite=Strictbila flow aplikasi memungkinkan dan Anda ingin lebih ketat.SameSite=Nonehanya bila benar-benar butuh cookie lintas situs, dan ini mensyaratkanSecure.
Catatan:
SameSitemembantu, tetapi tidak cukup untuk semua skenario CSRF. Jika request state-changing dilakukan lewat cookie, tetap gunakan token CSRF.
Contoh helper untuk membuat cookie sesi:
use axum_extra::extract::cookie::{Cookie, SameSite};
fn build_session_cookie(session_id: String, max_age_seconds: i64) -> Cookie<'static> {
Cookie::build(("sid", session_id))
.http_only(true)
.secure(true)
.same_site(SameSite::Lax)
.path("/")
.max_age(time::Duration::seconds(max_age_seconds))
.build()
}
fn build_expired_session_cookie() -> Cookie<'static> {
Cookie::build(("sid", ""))
.http_only(true)
.secure(true)
.same_site(SameSite::Lax)
.path("/")
.max_age(time::Duration::seconds(0))
.build()
}Jangan menyimpan data sensitif seperti role lengkap, email, atau token reset password di cookie sesi. Simpan hanya identifier acak yang tidak bermakna tanpa lookup ke server.
Login yang benar: verifikasi kredensial dan rotasi session ID
Flow login yang aman:
- User mengirim username/email dan password.
- Server memverifikasi password hash.
- Jika valid, buat session ID baru dengan RNG kriptografis.
- Buat CSRF token baru untuk sesi itu.
- Simpan sesi ke storage server-side.
- Kirim cookie sesi baru ke browser.
Mengapa perlu rotasi? Untuk mencegah session fixation. Serangan ini terjadi ketika penyerang berhasil membuat korban memakai session ID yang sudah diketahui sebelumnya. Setelah korban login, penyerang bisa ikut memakai session ID itu jika server tidak menggantinya saat perubahan status autentikasi.
Contoh fungsi utilitas:
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
fn random_token(bytes_len: usize) -> String {
let mut bytes = vec![0u8; bytes_len];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}Contoh handler login yang disederhanakan:
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use axum_extra::extract::cookie::CookieJar;
use chrono::{Duration, Utc};
use serde::Deserialize;
#[derive(Clone)]
pub struct AppState {
pub sessions: Arc<dyn SessionStore>,
pub idle_timeout: Duration,
pub absolute_timeout: Duration,
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
pub async fn login_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, StatusCode> {
let user_id = verify_user_credentials(&payload.email, &payload.password)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let now = Utc::now();
let session = Session {
session_id: random_token(32),
user_id,
created_at: now,
last_seen_at: now,
expires_at: now + state.absolute_timeout,
csrf_token: random_token(32),
revoked: false,
};
state.sessions.create(session.clone()).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let cookie = build_session_cookie(
session.session_id.clone(),
state.absolute_timeout.num_seconds(),
);
let jar = jar.add(cookie);
Ok((jar, Json(serde_json::json!({
"ok": true,
"csrf_token": session.csrf_token
}))))
}
async fn verify_user_credentials(email: &str, password: &str) -> anyhow::Result<i64> {
let _ = (email, password);
// Lookup user, verifikasi hash password, lalu kembalikan user_id.
Ok(1)
}Di contoh ini, token CSRF dikembalikan ke klien dalam body JSON agar frontend bisa mengirimkannya pada request state-changing berikutnya, misalnya melalui header X-CSRF-Token.
Rotasi ulang saat privilege berubah
Selain setelah login, rotasi session ID juga layak dilakukan saat ada perubahan penting, misalnya:
- user baru saja menyelesaikan MFA,
- privilege naik ke mode admin,
- re-authentication untuk aksi sensitif berhasil.
Prinsipnya sama: jangan mempertahankan identifier lama untuk transisi keamanan penting.
Middleware autentikasi: validasi sesi dan timeout
Middleware atau extractor autentikasi bertugas:
- membaca cookie sesi,
- mengambil record dari storage,
- menolak sesi yang tidak ada atau sudah dicabut,
- memeriksa idle timeout dan absolute timeout,
- memperbarui
last_seen_atbila request valid.
Contoh struktur middleware yang disederhanakan:
use axum::{
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
use axum_extra::extract::cookie::CookieJar;
use chrono::Utc;
#[derive(Clone, Debug)]
pub struct AuthContext {
pub user_id: i64,
pub session_id: String,
pub csrf_token: String,
}
pub async fn auth_middleware(
State(state): State<AppState>,
jar: CookieJar,
mut req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
let sid = jar.get("sid")
.map(|c| c.value().to_owned())
.ok_or(StatusCode::UNAUTHORIZED)?;
let mut session = state.sessions.find(&sid).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
if session.revoked {
return Err(StatusCode::UNAUTHORIZED);
}
let now = Utc::now();
if session.is_absolute_expired(now) || session.is_idle_expired(state.idle_timeout, now) {
let _ = state.sessions.delete(&sid).await;
return Err(StatusCode::UNAUTHORIZED);
}
session.last_seen_at = now;
state.sessions.update(session.clone()).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
req.extensions_mut().insert(AuthContext {
user_id: session.user_id,
session_id: session.session_id,
csrf_token: session.csrf_token,
});
Ok(next.run(req).await)
}Idle timeout dan absolute timeout melayani dua kebutuhan berbeda:
- Idle timeout: sesi mati jika tidak aktif selama periode tertentu.
- Absolute timeout: sesi mati setelah durasi maksimum tertentu, meskipun aktif terus.
Kombinasi keduanya lebih aman daripada hanya salah satu. Idle timeout membatasi sesi yang terlupa logout, sedangkan absolute timeout mencegah sesi bertahan terlalu lama.
Trade-off update last_seen_at pada setiap request
Memperbarui last_seen_at di setiap request paling mudah dipahami, tetapi bisa menambah write load. Optimisasi yang umum adalah hanya update jika selisih waktu sejak update terakhir melewati ambang tertentu, misalnya beberapa menit. Hati-hati agar optimisasi ini tidak membuat enforcement timeout menjadi membingungkan.
Proteksi CSRF untuk request state-changing
Karena browser otomatis mengirim cookie ke server sesuai kebijakan cookie, request lintas situs bisa ikut membawa sesi user. Itulah mengapa autentikasi berbasis cookie membutuhkan proteksi CSRF.
Pola yang mudah diimplementasikan adalah synchronizer token pattern:
- Server membuat
csrf_tokenacak dan menyimpannya di record sesi. - Frontend mengambil token itu dari response login atau endpoint khusus.
- Untuk request state-changing, frontend mengirim token pada header seperti
X-CSRF-Token. - Server membandingkan header dengan token di sesi server-side.
Keuntungan pendekatan ini: token CSRF tidak menjadi sumber kebenaran utama; sumber kebenaran tetap sesi server-side.
Contoh middleware CSRF sederhana:
use axum::{
http::{Request, Method, StatusCode},
middleware::Next,
response::Response,
};
pub async fn csrf_middleware(
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
let method = req.method().clone();
let needs_csrf = matches!(method, Method::POST | Method::PUT | Method::PATCH | Method::DELETE);
if !needs_csrf {
return Ok(next.run(req).await);
}
let auth = req.extensions()
.get::<AuthContext>()
.ok_or(StatusCode::UNAUTHORIZED)?;
let provided = req.headers()
.get("x-csrf-token")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::FORBIDDEN)?;
if provided != auth.csrf_token {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}Beberapa catatan penting:
- CSRF token sebaiknya tidak diambil dari cookie HttpOnly, karena frontend tidak bisa membacanya.
- Jika Anda memilih pola double submit cookie, implementasinya berbeda dan harus hati-hati terhadap desain cookie dan validasinya.
- Request GET idealnya tidak mengubah state. Jika ada endpoint GET yang ternyata mengubah data, desain itu sendiri sudah bermasalah.
Logout yang benar-benar mencabut sesi
Kesalahan umum saat logout adalah hanya menghapus cookie di browser. Itu belum cukup. Jika session ID masih valid di storage server-side, siapa pun yang masih memegang nilainya bisa tetap memakai sesi tersebut.
Logout yang benar:
- Ambil session ID dari cookie.
- Hapus atau tandai sesi sebagai dicabut di storage server-side.
- Kirim cookie kosong atau kedaluwarsa ke browser.
Contoh handler logout:
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use axum_extra::extract::cookie::CookieJar;
pub async fn logout_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<impl IntoResponse, StatusCode> {
if let Some(cookie) = jar.get("sid") {
let sid = cookie.value().to_owned();
state.sessions.delete(&sid).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
let jar = jar.add(build_expired_session_cookie());
Ok((jar, StatusCode::NO_CONTENT))
}Untuk kebutuhan keamanan yang lebih tinggi, Anda juga bisa:
- mencabut semua sesi milik user saat password diganti,
- mencabut sesi lain kecuali sesi saat ini,
- menyimpan metadata perangkat atau IP terakhir untuk audit, tanpa menjadikannya satu-satunya dasar validasi.
Contoh susunan router dan lapisan middleware
Susunan router berikut cukup mudah diadaptasi:
use axum::{middleware, routing::{get, post}, Router};
fn app(state: AppState) -> Router {
let protected = Router::new()
.route("/me", get(me_handler))
.route("/logout", post(logout_handler))
.route("/profile", post(update_profile_handler))
.layer(middleware::from_fn(csrf_middleware))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
Router::new()
.route("/login", post(login_handler))
.merge(protected)
.with_state(state)
}
async fn me_handler() -> &'static str { "ok" }
async fn update_profile_handler() -> &'static str { "updated" }Urutannya penting secara konsep: autentikasi harus berjalan dulu agar middleware CSRF punya akses ke konteks sesi. Detail pemasangan bisa sedikit berbeda tergantung gaya integrasi yang Anda pakai, tetapi prinsipnya tetap sama.
Kesalahan umum yang sering terjadi
Menyimpan secret di repository
Secret seperti kunci enkripsi cookie, kredensial database, atau signing key jangan disimpan di repo. Gunakan environment variable, secret manager, atau mekanisme deployment yang aman. Jika secret sudah telanjur masuk repo, anggap sudah bocor: rotasi secret, hapus akses lama, dan audit dampaknya.
Cookie terlalu longgar
Cookie tanpa HttpOnly, tanpa Secure, atau Domain terlalu luas memperbesar permukaan serangan. Di production, default aman biasanya:
HttpOnly=trueSecure=truePath=/atau lebih sempit bila cocokSameSite=Laxatau lebih ketat jika flow memungkinkan
Tidak merotasi session ID setelah login
Ini membuka peluang session fixation. Setiap transisi dari anonim ke terautentikasi harus menghasilkan session ID baru.
CSRF dianggap selesai hanya dengan SameSite
SameSite membantu, tetapi jangan menggantungkan seluruh proteksi di sana. Untuk endpoint yang mengubah state, token CSRF tetap praktik yang jauh lebih aman.
Logout hanya menghapus cookie
Jika sesi di server tidak dihapus atau dicabut, logout belum benar-benar selesai. Ini salah satu bug keamanan paling umum pada sistem session-based.
Session ID lemah atau bisa ditebak
Jangan membuat session ID dari pola yang mudah ditebak, timestamp, user ID, atau hash sederhana. Gunakan generator acak kriptografis dengan entropi cukup.
Debugging dan verifikasi keamanan
Saat implementasi, beberapa pemeriksaan berikut sangat membantu:
- Pastikan response login benar-benar mengirim
Set-CookiedenganHttpOnly,Secure, danSameSiteyang diharapkan. - Periksa apakah session ID berubah setelah login atau setelah kenaikan privilege.
- Uji idle timeout: diamkan sesi, lalu pastikan request berikutnya ditolak.
- Uji absolute timeout: walaupun user aktif, sesi harus berhenti setelah batas mutlak.
- Uji logout: setelah logout, pakai ulang cookie lama secara manual dan pastikan server menolak.
- Uji CSRF: kirim request state-changing tanpa header token atau dengan token salah, lalu pastikan hasilnya
403 Forbidden.
Untuk debugging lokal, Anda mungkin perlu menyesuaikan Secure bila belum memakai HTTPS. Namun jangan sampai konfigurasi development yang longgar ikut terbawa ke production. Pisahkan konfigurasi environment dengan jelas.
Rekomendasi implementasi praktis
Jika Anda ingin baseline yang aman dan mudah dioperasikan, kombinasi berikut biasanya masuk akal:
- Session server-side di Redis atau database.
- Cookie sesi berisi ID acak saja.
HttpOnly,Secure,SameSite=Lax.- Rotasi session ID setelah login dan perubahan privilege penting.
- Idle timeout + absolute timeout.
- CSRF token per sesi untuk semua request state-changing.
- Logout yang mencabut sesi di server-side storage.
Desain ini tidak menghilangkan semua risiko. Anda tetap perlu menangani XSS, hashing password yang benar, rate limiting login, audit log, dan kebijakan secret management. Namun untuk autentikasi web berbasis cookie di Axum, fondasi ini sudah jauh lebih aman dibanding implementasi minimal yang hanya menaruh token di cookie tanpa invalidasi server-side.
Penutup
Membangun autentikasi yang aman di Axum bukan soal menambah satu library lalu selesai. Kuncinya ada pada desain sesi: cookie yang ketat, session ID acak, penyimpanan server-side, rotasi setelah login, timeout yang jelas, proteksi CSRF, dan logout yang benar-benar mencabut sesi. Jika Anda menerapkan seluruh bagian ini secara konsisten, Anda akan mendapatkan sistem autentikasi yang lebih mudah diaudit, lebih mudah dicabut, dan lebih tahan terhadap kesalahan implementasi yang umum.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!