Sesi & cookie
Sesi berbasis cookie sederhana, bisa dicabut seketika, dan sudah cukup untuk sebagian besar aplikasi web. Yang membuatnya rawan bukan konsepnya, melainkan empat atribut yang sering salah disetel.
Intisari
HttpOnly,Secure,SameSite, danPathโ keempatnya wajib, dan defaultnya tidak aman.- ID sesi dibuat dari
crypto/rand, minimal 32 byte. Jangan pernahmath/rand. - Sesi server bisa dicabut seketika; itu keunggulan utamanya atas JWT.
- Putar ID sesi setelah login untuk mencegah session fixation.
- Simpan sesi di Redis begitu ada container kedua โ sesi di memori berarti pengguna ter-logout acak.
Cookie yang benar
http.SetCookie(w, &http.Cookie{
Name: "sesi",
Value: idSesi,
Path: "/",
MaxAge: int((24 * time.Hour).Seconds()),
HttpOnly: true, // JavaScript tidak bisa membacanya
Secure: true, // hanya lewat HTTPS
SameSite: http.SameSiteLaxMode, // pertahanan CSRF lapis pertama
})
| Atribut | Kalau salah |
|---|---|
HttpOnly: false | Satu XSS = seluruh sesi pengguna dicuri lewat document.cookie |
Secure: false | Cookie terkirim lewat HTTP polos dan bisa disadap |
SameSite: None | Situs lain bisa memicu permintaan terautentikasi (CSRF) |
Path terlalu luas / domain induk | Cookie ikut ke subdomain yang mungkin dikelola orang lain |
Tanpa MaxAge | Cookie sesi browser โ hilang saat tutup, tapi tidak pernah kedaluwarsa di server |
SameSite | Perilaku | Untuk |
|---|---|---|
Strict | Tidak pernah ikut dari situs lain โ termasuk saat pengguna mengklik tautan ke situsmu | Perbankan, panel admin |
Lax | Ikut pada navigasi GET teratas, tidak pada POST | Default yang tepat |
None | Selalu ikut; wajib disertai Secure | Hanya kalau memang lintas situs |
Membuat ID sesi
import "crypto/rand"
func idSesiBaru() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { // crypto/rand: gagal = jangan lanjut
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
math/rand bisa ditebak. Ia deterministik dari benihnya; dengan beberapa keluaran,
penyerang bisa menghitung ID sesi berikutnya. Ini bukan kelemahan teoretis โ ia sudah dipakai untuk
membajak akun di sistem nyata. Untuk apa pun yang bersifat rahasia โ ID sesi, token, kode verifikasi,
nama berkas unggahan โ selalu crypto/rand.
Penyimpanan sesi
type Sesi struct {
ID string
PenggunaID int64
Kedaluwarsa time.Time
IP string
UserAgent string
}
type PenyimpanSesi interface {
Buat(ctx context.Context, s Sesi) error
Ambil(ctx context.Context, id string) (Sesi, error)
Hapus(ctx context.Context, id string) error
HapusSemuaMilik(ctx context.Context, penggunaID int64) error // "keluar dari semua perangkat"
}
| Penyimpanan | Cocok kalau | Catatan |
|---|---|---|
| Memori | Satu container, pengembangan | Pengguna ter-logout acak begitu ada task kedua |
| Postgres | Sesi sedikit, sudah ada database | Satu kueri per permintaan โ perlu index |
| Redis | Pilihan default di AWS | TTL otomatis, cepat, bersama antar task |
| Cookie terenkripsi | Tanpa penyimpanan sama sekali | Tidak bisa dicabut โ dan ukuran cookie terbatas 4 KB |
Sesi di memori adalah bug yang menunggu autoscaling. Dengan dua task di belakang ALB, permintaan pengguna mendarat di task acak โ dan hanya satu dari keduanya yang mengenali sesinya. Gejalanya: pengguna ter-logout "kadang-kadang", dan tidak bisa direproduksi di laptop. Begitu ada container kedua, sesi harus bersama.
Middleware autentikasi
func (a *Auth) Wajib(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("sesi")
if err != nil {
a.tolak(w, r)
return
}
s, err := a.simpan.Ambil(r.Context(), c.Value)
if err != nil || time.Now().After(s.Kedaluwarsa) {
a.hapusCookie(w)
a.tolak(w, r)
return // โ tanpa return, handler tetap jalan (Fase 3)
}
// Perpanjang sesi yang aktif โ tapi jangan menulis ke Redis
// di setiap permintaan; cukup kalau sisanya di bawah separuh.
if time.Until(s.Kedaluwarsa) < 12*time.Hour {
s.Kedaluwarsa = time.Now().Add(24 * time.Hour)
_ = a.simpan.Buat(r.Context(), s)
a.setCookie(w, s)
}
ctx := DenganPengguna(r.Context(), s.PenggunaID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Session fixation
func (h *Handler) Masuk(w http.ResponseWriter, r *http.Request) {
u, err := h.auth.Masuk(r.Context(), req.Email, req.Sandi)
if err != nil {
h.tulisError(w, r, err)
return
}
// WAJIB: buang sesi lama, buat ID baru. Tanpa ini, penyerang yang
// berhasil menanamkan ID sesi pilihannya sebelum korban login akan
// memegang sesi yang SAMA setelah korban login.
if c, err := r.Cookie("sesi"); err == nil {
_ = h.simpan.Hapus(r.Context(), c.Value)
}
s, err := h.auth.BuatSesi(r.Context(), u.ID, r)
if err != nil {
h.tulisError(w, r, err)
return
}
h.setCookie(w, s)
...
}
Keluar yang benar
func (h *Handler) Keluar(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("sesi"); err == nil {
_ = h.simpan.Hapus(r.Context(), c.Value) // hapus di SERVER
}
// Dan hapus di klien: nilai kosong + MaxAge negatif.
http.SetCookie(w, &http.Cookie{
Name: "sesi", Value: "", Path: "/",
MaxAge: -1, HttpOnly: true, Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
Menghapus cookie saja bukan logout. Kalau sesinya masih hidup di server, siapa pun yang sempat menyalin nilai cookie itu tetap bisa memakainya. Logout harus menghapus di kedua sisi โ dan itu persis kemampuan yang tidak dimiliki JWT tanpa penyimpanan tambahan (materi berikutnya).
Latihan: bangun login berbasis sesi dengan penyimpanan Redis dan keempat atribut cookie di atas. Lalu jalankan dua instans aplikasimu di port berbeda dan buktikan sesinya berlaku di keduanya. Terakhir, hapus baris pemutaran ID sesi saat login dan tunjukkan pada dirimu sendiri bahwa ID sebelum dan sesudah login jadi sama.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.