Error HTTP 401 Unauthorized yang muncul secara sporadis pada aplikasi mobile sering kali bukan disebabkan oleh kredensial kedaluwarsa, melainkan race condition saat proses rotasi token. Masalah ini lazim ditemukan pada aplikasi React Native yang memuat beberapa sumber data secara bersamaan ketika membuka layar baru.
Gejala: Force Logout Acak pada Request Konkuren
Gejala umum dari masalah ini adalah pengguna tiba-tiba terlempar ke halaman login (force logout) saat membuka layar kompleks seperti Dashboard. Layar tersebut umumnya mengeksekusi beberapa pemanggilan API secara paralel:
// Contoh pemanggilan paralel di React Native
await Promise.all([
fetchUserProfile(),
fetchNotifications(),
fetchDashboardMetrics()
]);Jika access token habis masa berlakunya tepat sebelum pemanggilan ini dieksekusi, ketiga request tersebut akan menerima respons 401 Unauthorized hampir pada milidetik yang sama. Masing-masing request kemudian mencoba memicu alur refresh token secara independen ke backend.
Analisis Root Cause: Token Rotation Tanpa Grace Period
Standar keamanan RFC 6819 merekomendasikan mekanisme Refresh Token Rotation (RTR). Setiap kali refresh token digunakan, backend menerbitkan pasangan access token dan refresh token baru, lalu membatalkan (revoke) token lama.
Untuk mencegah pencurian token, backend menerapkan Token Reuse Detection. Jika token yang sudah di-revoke dikirim ulang, backend mengasumsikan token telah disusupi oleh penyerang dan otomatis membatalkan seluruh silsilah sesi (token family) pengguna tersebut.
Kronologi kegagalan sistem:
- Request A dan Request B gagal dengan 401, keduanya memegang Refresh Token versi 1 (
RT_v1). - Request A mencapai backend lebih dulu. Backend menerbitkan
RT_v2dan menandaiRT_v1sebagai revoked. - Request B mencapai backend beberapa milidetik kemudian dengan membawa
RT_v1. - Backend mendeteksi penggunaan ulang
RT_v1, mengidentifikasinya sebagai ancaman keamanan, dan mencabutRT_v2beserta seluruh sesi pengguna. - Hasil: Klien menerima respons 401 permanen dan dipaksa logout.
Solusi Backend: Grace Period dan Distributed Lock Redis
Untuk mengatasi jeda jaringan tanpa mengorbankan keamanan, backend membutuhkan dua mekanisme: toleransi waktu (grace period) dan penguncian terdistribusi (distributed lock).
1. Grace Period Window
Saat token dirotasi, simpan pemetaan token lama ke token baru di memori cepat (Redis) dengan masa aktif sangat singkat (10–30 detik). Jika request konkuren datang membawa token lama dalam jendela waktu ini, backend tidak memicu invalidasi keluarga token, melainkan mengembalikan pasangan token yang baru saja dibuat.
2. Distributed Lock dengan Redis
Penerapan lock mencegah eksekusi rotasi ganda secara paralel pada server backend terdistribusi.
// Implementasi konseptual pada backend controller/service
async function handleTokenRefresh(oldRefreshToken) {
const lockKey = `lock:refresh:${oldRefreshToken}`;
const graceKey = `grace:${oldRefreshToken}`;
// 1. Cek apakah token berada dalam grace period
const cachedTokens = await redis.get(graceKey);
if (cachedTokens) {
return JSON.parse(cachedTokens);
}
// 2. Amankan proses rotasi dengan Redis NX Lock
const acquiredLock = await redis.set(lockKey, 'locked', 'PX', 5000, 'NX');
if (!acquiredLock) {
// Request konkuren lain sedang memproses token ini
await sleep(250);
const retriedTokens = await redis.get(graceKey);
if (retriedTokens) return JSON.parse(retriedTokens);
throw new UnauthorizedException('Token rotation lock timeout');
}
try {
const tokenRecord = await db.token.findUnique({ where: { token: oldRefreshToken } });
if (!tokenRecord || tokenRecord.isRevoked) {
// Terbukti reuse di luar batas toleransi: cabut seluruh sesi
await revokeTokenFamily(tokenRecord?.familyId);
throw new UnauthorizedException('Invalid or reused refresh token');
}
// Buat token baru
const newTokens = generateTokenPair(tokenRecord.userId, tokenRecord.familyId);
// Update database & simpan riwayat ke grace period
await db.token.update({ where: { id: tokenRecord.id }, data: { isRevoked: true } });
await redis.set(graceKey, JSON.stringify(newTokens), 'EX', 15); // Grace 15 detik
return newTokens;
} finally {
await redis.del(lockKey);
}
}Solusi Client: Interceptor Antrean pada React Native
Meskipun backend memiliki proteksi grace period, aplikasi klien harus meminimalisasi pengiriman request refresh yang redundan dengan mengimplementasikan antrean (queue) pada HTTP client seperti Axios.
import axios from 'axios';
const apiClient = axios.create({ baseURL: 'https://api.domain.com' });
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Tahan request baru ke dalam antrean promise hingga proses refresh selesai
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return apiClient(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const currentRefreshToken = await getStoredRefreshToken();
const response = await axios.post('https://api.domain.com/auth/refresh', {
refreshToken: currentRefreshToken,
});
const { accessToken, refreshToken: newRefreshToken } = response.data;
await storeTokens(accessToken, newRefreshToken);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
processQueue(null, accessToken);
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
await triggerForceLogout();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);Pengujian Konkurensi Backend
Untuk memastikan backend tidak mengalami regresi terhadap kondisi race condition, jalankan skrip pengujian konkurensi langsung ke endpoint refresh:
// Script pengujian konkurensi dengan Node.js
const axios = require('axios');
const assert = require('assert');
async function runConcurrencyTest() {
const tokenTarget = 'VALID_INITIAL_REFRESH_TOKEN';
// Eksekusi 5 request serentak dengan token yang sama
const requests = Array.from({ length: 5 }).map(() =>
axios.post('http://localhost:3000/auth/refresh', { refreshToken: tokenTarget })
.then(res => ({ status: res.status, data: res.data }))
.catch(err => ({ status: err.response?.status, data: err.response?.data }))
);
const results = await Promise.all(requests);
// Pastikan tidak ada respons 401 selama grace period aktif
const statusCodes = results.map(r => r.status);
const hasUnauthorized = statusCodes.includes(401);
assert.strictEqual(hasUnauthorized, false, 'Regression: Token reuse falsely triggered 401');
console.log('Semua request konkurensi sukses ditangani grace period.');
}
runConcurrencyTest();Catatan Arsitektur: Grace period adalah solusi kompromi yang aman. Jendela waktu 15 detik sudah cukup untuk menyelesaikan latensi seluler tinggi tanpa memberikan celah signifikan bagi penyerang yang mengeksploitasi token curian.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!