Anatomi Masalah: Sesi Konkuren Pasca Pergantian Kredensial
Saat pengguna mereset kata sandi akibat indikasi kompromi akun, seluruh sesi aktif di perangkat lain harus segera dibatalkan (termasuk sesi milik penyerang). Secara default, Django menyediakan fungsi update_session_auth_hash(request, user) untuk mencegah sesi pengguna saat ini keluar (logged out) setelah hash password diubah.
Mekanisme bawaan django.contrib.auth.middleware.AuthenticationMiddleware bekerja secara pasif: sesi di perangkat lain baru dinyatakan tidak valid ketika sesi tersebut mengirimkan request baru dan Django mendapati ketidaksesuaian antara nilai HASH_SESSION_KEY di session store dengan user.get_session_auth_hash(). Namun, terdapat limitasi arsitektural:
- Data sesi perangkat lama tetap tersimpan di backend penyimpanan (database
django_sessionatau Redis) sampai masa kedaluwarsa habis, membuka risiko pada sistem yang menginspeksi status login secara parsial. - Pada arsitektur API decoupled (misalnya Django REST Framework dengan session-based auth atau custom token wrapper) atau sistem dengan custom session backend, validasi hash ini kerap terlewat jika middleware standar Django dimodifikasi atau dilewati.
- Tidak ada mekanisme notifikasi langsung untuk mendepak sesi penyerang seketika dari storage tanpa menunggu request berikutnya tiba.
Validasi Kredensial Fingerprint via Middleware
Django menghitung get_session_auth_hash() menggunakan HMAC berbasis SECRET_KEY dan hash password pengguna (user.password). Ketika password diperbarui via user.set_password(), nilai fingerprint ini berubah seketika.
Jika aplikasi Anda melayani API atau memerlukan penanganan seragam untuk HTTP 401 Unauthorized (alih-alih redirect 302 login), middleware kustom berikut memvalidasi integritas kredensial pada setiap request yang masuk:
from django.contrib.auth import logout
from django.http import JsonResponse
class EnforceSessionIntegrityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
session_hash = request.session.get('_auth_user_hash')
expected_hash = request.user.get_session_auth_hash()
if not session_hash or session_hash != expected_hash:
logout(request)
if request.path.startswith('/api/'):
return JsonResponse(
{'error': 'Session revoked due to credential modification.'},
status=401
)
return self.get_response(request)
Purge Aktif Data Sesi pada Backend django_session
Membiarkan record sesi yatim (orphaned sessions) di database meningkatkan beban query dan risiko kebocoran data jika storage terkompromi. Untuk backend django.contrib.sessions.backends.db, data sesi dienkripsi/diserialisasi dalam format base64.
Gunakan service function berikut untuk memindai dan menghapus semua record sesi milik user tertentu di tabel django_session, sembari mempertahankan sesi aktif pemohon:
from django.contrib.sessions.models import Session
from django.utils import timezone
def revoke_all_user_sessions(user_id, current_session_key=None):
"""
Menghapus seluruh sesi aktif milik user_id dari django_session,
kecuali current_session_key pemanggil reset.
"""
active_sessions = Session.objects.filter(expire_date__gte=timezone.now())
sessions_to_delete = []
for session in active_sessions.iterator(chunk_size=1000):
if session.session_key == current_session_key:
continue
data = session.get_decoded()
if data.get('_auth_user_id') == str(user_id):
sessions_to_delete.append(session.session_key)
if sessions_to_delete:
Session.objects.filter(session_key__in=sessions_to_delete).delete()
Catatan: Operasi
get_decoded()pada tabeldjango_sessionberskala O(N) terhadap total sesi aktif di sistem. Jika basis data sesi melebihi puluhan ribu record, gunakan tabel relasi khususUserSession(user, session)atau migrasi ke backend Redis.
Hardening Konfigurasi Session Cookie
Pastikan session cookie dilindungi dari eksfiltrasi via skrip sisi klien (XSS) dan intersepsi jaringan man-in-the-middle (MITM). Tambahkan konfigurasi berikut pada settings.py:
# Mencegah akses cookie via JavaScript (Mitigasi XSS)
SESSION_COOKIE_HTTPONLY = True
# Memastikan cookie hanya dikirim melalui koneksi terenkripsi (HTTPS)
SESSION_COOKIE_SECURE = True
# Mencegah cookie dikirim pada permintaan lintas situs (Mitigasi CSRF)
SESSION_COOKIE_SAMESITE = 'Lax'
# Sesi otomatis hangus saat browser ditutup jika diinginkan
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
# Masa aktif sesi dalam detik (misal: 12 jam)
SESSION_COOKIE_AGE = 43200
# Regenerasi session key saat hak akses berubah
SESSION_SAVE_EVERY_REQUEST = False
Analisis Performa: Database vs Cache Backend
| Metrik / Aspek | Database Backend (django_session) | Cache Backend (Redis / django-redis) |
|---|---|---|
| Kompleksitas Purge | O(N) full-scan table decoding, lambat pada traffic tinggi. | O(1) jika memetakan set ID user ke keys user_sessions:<id>. |
| Latensi Validasi | 0.5 - 2.0 ms per read (overhead query SQL). | < 0.2 ms per get (in-memory lookup). |
| Persistensi Data | Sangat reliabel (ACID), tahan restart server. | Memerlukan konfigurasi AOF/RDB agar tidak hilang saat restart. |
| Rekomendasi Skala | Cocok untuk < 5.000 user aktif bersamaan. | Direkomendasikan untuk sistem skala menengah hingga besar. |
Unit Test: Verifikasi Revokasi Sesi Konkuren
Berikut implementasi TestCase yang membuktikan bahwa pergantian password langsung menggugurkan sesi konkuren di perangkat lain:
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.urls import reverse
User = get_user_model()
class ConcurrentSessionRevocationTests(TestCase):
def setUp(self):
self.username = 'audit_user'
self.old_password = 'InitialSecurePass123!'
self.new_password = 'UpdatedSecurePass456!'
self.user = User.objects.create_user(
username=self.username,
password=self.old_password
)
self.protected_url = reverse('dashboard')
def test_concurrent_session_revocation_on_password_change(self):
# 1. Simulasikan login dari Device A (pengguna utama)
device_a = Client()
device_a.login(username=self.username, password=self.old_password)
# 2. Simulasikan login dari Device B (sesi konkuren / penyerang)
device_b = Client()
device_b.login(username=self.username, password=self.old_password)
# Verifikasi awal: kedua device dapat mengakses endpoint terproteksi
self.assertEqual(device_a.get(self.protected_url).status_code, 200)
self.assertEqual(device_b.get(self.protected_url).status_code, 200)
# 3. Device A mengganti password
self.user.set_password(self.new_password)
self.user.save()
# Update session auth hash hanya untuk Device A
# (Mereplikasi fungsi django.contrib.auth.update_session_auth_hash)
request_mock = device_a.request().wsgi_request
from django.contrib.auth import update_session_auth_hash
update_session_auth_hash(request_mock, self.user)
device_a.session['_auth_user_hash'] = self.user.get_session_auth_hash()
device_a.session.save()
# 4. Device A tetap memiliki akses
response_a = device_a.get(self.protected_url)
self.assertEqual(response_a.status_code, 200)
# 5. Device B harus tertolak (302 redirect ke login atau 401 unauthorized)
response_b = device_b.get(self.protected_url)
self.assertIn(response_b.status_code, [302, 401])
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!