Protokol HTTP tidak punya memori: setiap permintaan diperlakukan sebagai tamu baru, padahal aplikasi perlu tahu bahwa peminta sudah login. Session management menambal kelemahan itu dengan menyimpan status login di server, lalu menyerahkan satu penanda — session ID — kepada browser lewat cookie. Setiap permintaan berikutnya membawa penanda tersebut, dan server mencocokkannya dengan data tersimpannya. Chapter ini membangun penyimpanan sesi in-memory lengkap dengan login, pemeriksaan sesi, logout, kedaluwarsa, dan regenerasi ID.

HTTP yang Stateless dan Kebutuhan Sesi #

Login yang berhasil tidak mengubah permintaan HTTP berikutnya apa pun. Solusinya: saat Budi login, server membuat entri sesi — siapa pemiliknya dan kapan berakhir — lalu mengirim ID unik entri itu sebagai cookie. Browser melampirkan cookie secara otomatis pada setiap permintaan ke domain yang sama, sehingga handler cukup membaca ID, mencari entri di server, dan mengetahui pemilik permintaan. Status tetap di server; klien hanya memegang kuncinya.

Session ID Harus Acak dan Panjang #

Session ID adalah kredensial: siapa pun yang memegang ID tersebut dapat mengaku sebagai pemiliknya, sehingga ID harus tidak mungkin ditebak. ID berbasis nomor urut, username, atau timestamp jatuh oleh tebakan sederhana. Standar praktiknya memakai crypto/rand sebanyak 32 byte — 256 bit entropi — yang disandikan menjadi 64 karakter heksadesimal, seperti pada program di bawah. Angka acak math/rand tidak memenuhi syarat karena generatornya dapat diprediksi.

Program: Sesi Login In-Memory #

Program session-demo.go membangun alur lengkap: handler login memverifikasi kredensial lalu membuat sesi, handler profil berperan sebagai middleware penolak permintaan tanpa sesi sah, dan handler logout menghapus sesi. Seluruh interaksi berjalan lewat httptest dengan dua klien: satu memakai cookie jar seperti browser, satu lagi polos tanpa cookie.

go
package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"time"
)

// waktuSekarang dibekukan agar contoh deterministik; produksi memakai time.Now().
var waktuSekarang = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

type sesiPengguna struct {
	nama     string
	berakhir time.Time
}

// penyimpananSesi menyimpan sesi di memori. Mutex wajib karena map
// tidak aman dibaca dan ditulis banyak goroutine secara bersamaan.
type penyimpananSesi struct {
	mu   sync.Mutex
	sesi map[string]sesiPengguna
}

// buat membuat session ID acak 32 byte lalu menyimpan datanya.
func (p *penyimpananSesi) buat(nama string, umur time.Duration) (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	id := hex.EncodeToString(b)
	p.mu.Lock()
	p.sesi[id] = sesiPengguna{nama: nama, berakhir: waktuSekarang.Add(umur)}
	p.mu.Unlock()
	return id, nil
}

// ambil mengembalikan data sesi hanya bila ID ada dan belum kedaluwarsa.
func (p *penyimpananSesi) ambil(id string) (sesiPengguna, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	data, ok := p.sesi[id]
	if !ok || !waktuSekarang.Before(data.berakhir) {
		return sesiPengguna{}, false
	}
	return data, true
}

func (p *penyimpananSesi) hapus(id string) {
	p.mu.Lock()
	delete(p.sesi, id)
	p.mu.Unlock()
}

type aplikasi struct {
	store penyimpananSesi
}

// kredensial contoh; produksi memverifikasi hash bcrypt, bukan pembandingan langsung.
var kredensial = map[string]string{
	"budi": "sandiRahasia123",
	"siti": "sandiSiti456",
}

func (a *aplikasi) login(w http.ResponseWriter, r *http.Request) {
	nama := r.PostFormValue("username")
	password := r.PostFormValue("password")
	if kredensial[nama] == "" || kredensial[nama] != password {
		http.Error(w, "kredensial salah", http.StatusUnauthorized)
		return
	}
	// Regenerasi: sesi lama dibuang supaya ID lama tidak dipakai ulang
	// (pertahanan terhadap session fixation).
	if c, err := r.Cookie("session_id"); err == nil {
		a.store.hapus(c.Value)
	}
	id, err := a.store.buat(nama, 30*time.Minute)
	if err != nil {
		http.Error(w, "gagal membuat sesi", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    id,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})
	http.Redirect(w, r, "/profil", http.StatusSeeOther)
}

func (a *aplikasi) profil(w http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("session_id")
	if err != nil {
		http.Error(w, "belum login", http.StatusUnauthorized)
		return
	}
	data, ok := a.store.ambil(c.Value)
	if !ok {
		http.Error(w, "sesi tidak valid", http.StatusUnauthorized)
		return
	}
	fmt.Fprintf(w, "pengguna: %s", data.nama)
}

func (a *aplikasi) logout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie("session_id"); err == nil {
		a.store.hapus(c.Value)
	}
	// MaxAge negatif memerintahkan browser menghapus cookie.
	http.SetCookie(w, &http.Cookie{Name: "session_id", Value: "", Path: "/", MaxAge: -1})
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

// kirim menjalankan satu permintaan lalu mengembalikan status dan isi respons.
func kirim(client *http.Client, method, target, form string) (int, string) {
	var b io.Reader
	if form != "" {
		b = strings.NewReader(form)
	}
	req, err := http.NewRequest(method, target, b)
	if err != nil {
		log.Fatal(err)
	}
	if form != "" {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	isi, _ := io.ReadAll(resp.Body)
	return resp.StatusCode, string(isi)
}

func cookieSesi(jar *cookiejar.Jar, serverURL string) string {
	u, err := url.Parse(serverURL)
	if err != nil {
		log.Fatal(err)
	}
	for _, c := range jar.Cookies(u) {
		if c.Name == "session_id" {
			return c.Value
		}
	}
	return ""
}

func main() {
	a := &aplikasi{store: penyimpananSesi{sesi: map[string]sesiPengguna{}}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /login", a.login)
	mux.HandleFunc("GET /profil", a.profil)
	mux.HandleFunc("POST /logout", a.logout)
	server := httptest.NewServer(mux)
	defer server.Close()

	jar, err := cookiejar.New(nil)
	if err != nil {
		log.Fatal(err)
	}
	// Jangan ikuti redirect agar kode 303 terlihat pada output.
	teruskan := func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	// Klien dengan cookie jar seperti browser, dan klien polos tanpa cookie.
	clientBudi := &http.Client{Jar: jar, CheckRedirect: teruskan}
	clientPolos := &http.Client{CheckRedirect: teruskan}

	kode, _ := kirim(clientBudi, "POST", server.URL+"/login", "username=budi&password=passwordSalah")
	fmt.Printf("skenario  1: login dengan password salah            -> %d\n", kode)

	kode, _ = kirim(clientBudi, "POST", server.URL+"/login", "username=budi&password=sandiRahasia123")
	fmt.Printf("skenario  2: login dengan kredensial benar          -> %d\n", kode)

	idLama := cookieSesi(jar, server.URL)
	fmt.Printf("             cookie session_id terbit, panjang ID: %d karakter hex\n", len(idLama))

	kode, isi := kirim(clientBudi, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario  3: akses /profil dengan cookie sesi       -> %d (%s)\n", kode, isi)

	kode, _ = kirim(clientPolos, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario  4: akses /profil tanpa cookie             -> %d\n", kode)

	reqPalsu, err := http.NewRequest("GET", server.URL+"/profil", nil)
	if err != nil {
		log.Fatal(err)
	}
	reqPalsu.AddCookie(&http.Cookie{Name: "session_id", Value: strings.Repeat("61", 32)})
	respPalsu, err := clientPolos.Do(reqPalsu)
	if err != nil {
		log.Fatal(err)
	}
	respPalsu.Body.Close()
	fmt.Printf("skenario  5: akses /profil dengan ID dipalsukan     -> %d\n", respPalsu.StatusCode)

	kode, _ = kirim(clientBudi, "POST", server.URL+"/login", "username=budi&password=sandiRahasia123")
	fmt.Printf("skenario  6: login ulang, ID sesi diregenerasi      -> %d\n", kode)

	idBaru := cookieSesi(jar, server.URL)
	fmt.Printf("             ID baru berbeda dari ID lama: %v\n", idBaru != idLama)

	reqLama, err := http.NewRequest("GET", server.URL+"/profil", nil)
	if err != nil {
		log.Fatal(err)
	}
	reqLama.AddCookie(&http.Cookie{Name: "session_id", Value: idLama})
	respLama, err := clientPolos.Do(reqLama)
	if err != nil {
		log.Fatal(err)
	}
	respLama.Body.Close()
	fmt.Printf("skenario  7: akses /profil dengan ID lama           -> %d\n", respLama.StatusCode)

	kode, _ = kirim(clientBudi, "POST", server.URL+"/logout", "")
	fmt.Printf("skenario  8: logout                                 -> %d\n", kode)

	kode, _ = kirim(clientBudi, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario  9: akses /profil setelah logout           -> %d\n", kode)

	kirim(clientBudi, "POST", server.URL+"/login", "username=siti&password=sandiSiti456")
	waktuSekarang = waktuSekarang.Add(31 * time.Minute)
	kode, _ = kirim(clientBudi, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario 10: sesi Siti kedaluwarsa (waktu maju)     -> %d\n", kode)
}

Output yang Dihasilkan:

text
skenario  1: login dengan password salah            -> 401
skenario  2: login dengan kredensial benar          -> 303
             cookie session_id terbit, panjang ID: 64 karakter hex
skenario  3: akses /profil dengan cookie sesi       -> 200 (pengguna: budi)
skenario  4: akses /profil tanpa cookie             -> 401
skenario  5: akses /profil dengan ID dipalsukan     -> 401
skenario  6: login ulang, ID sesi diregenerasi      -> 303
             ID baru berbeda dari ID lama: true
skenario  7: akses /profil dengan ID lama           -> 401
skenario  8: logout                                 -> 303
skenario  9: akses /profil setelah logout           -> 401
skenario 10: sesi Siti kedaluwarsa (waktu maju)     -> 401

Mari kita telaah baris demi baris program di atas:

  1. Baris 18-19: waktuSekarang dibekukan pada tanggal tetap agar output contoh dapat diprediksi; aplikasi produksi memakai time.Now().
  2. Baris 28-31: struct penyimpananSesi membungkus map dengan sync.Mutex karena map tidak aman saat dibaca dan ditulis banyak goroutine bersamaan.
  3. Baris 33-44: method buat mengisi 32 byte dari crypto/rand, menyandikannya menjadi heksadesimal, lalu menyimpan sesi dengan waktu berakhir umur ke depan.
  4. Baris 46-55: method ambil mengembalikan data hanya bila ID ditemukan dan waktuSekarang masih sebelum berakhir — sesi kedaluwarsa diperlakukan seperti tidak ada.
  5. Baris 73-98: handler login menolak kredensial salah dengan 401, membuang sesi lama bila cookie lama ikut terkirim (baris 82-84, regenerasi anti session fixation), membuat ID baru, lalu mengirim ID itu sebagai cookie HttpOnly dengan SameSite=Lax.
  6. Baris 100-112: handler profil membaca cookie; cookie yang hilang atau ID yang tidak ditemukan sama-sama dibalas 401, dan hanya sesi sah yang sampai ke fmt.Fprintf.
  7. Baris 230-233: setelah Siti login, jam dimajukan 31 menit sehingga melewati umur sesi 30 menit — skenario 10 membuktikan permintaan berikutnya ditolak karena kedaluwarsa.

Regenerasi ID dan Logout #

Serangan session fixation menempatkan ID yang sudah diketahui penyerang di browser korban — lewat tautan atau cookie dari subdomain — lalu menunggu korban login memakai ID tersebut. Bila server mempertahankan ID itu, penyerang langsung memiliki kredensial sah setelah login korban. Regenerasi pada baris 82-84 memutus rantainya: login selalu membuang ID lama dan menerbitkan ID segar. Logout bekerja sebaliknya: sesi dihapus dari penyimpanan (baris 115-117) dan cookie dibersihkan dengan MaxAge: -1 (baris 119).

Konkurensi dan Penyimpanan di Luar Memori #

Penyimpanan berbasis map memerlukan sync.Mutex karena net/http menjalankan tiap permintaan pada goroutine sendiri; dua permintaan bersamaan dapat menulis map yang sama, dan kondisi balapan seperti itu terdeteksi oleh race detector. Map in-memory punya dua batasan: seluruh sesi hilang saat proses restart, dan instance kedua tidak melihat sesi milik instance pertama. Aplikasi satu proses cukup memakai map; begitu berjalan di beberapa instance di belakang load balancer, pindahkan penyimpanan ke Redis atau Memcached agar semua instance berbagi sesi. Pola handler profil — baca cookie lalu cari sesi — juga dapat diangkat menjadi middleware agar seluruh endpoint terlindungi sekaligus, seperti dibahas pada chapter Middleware .

Session ID aman selama cookie pembawanya tidak dibocorkan; pengaturan cookie yang benar itulah topik chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

session-demo.go

Unduh berkas
go
package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"time"
)

// waktuSekarang dibekukan agar contoh deterministik; produksi memakai time.Now().
var waktuSekarang = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

type sesiPengguna struct {
	nama     string
	berakhir time.Time
}

// penyimpananSesi menyimpan sesi di memori. Mutex wajib karena map
// tidak aman dibaca dan ditulis banyak goroutine secara bersamaan.
type penyimpananSesi struct {
	mu   sync.Mutex
	sesi map[string]sesiPengguna
}

// buat membuat session ID acak 32 byte lalu menyimpan datanya.
func (p *penyimpananSesi) buat(nama string, umur time.Duration) (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	id := hex.EncodeToString(b)
	p.mu.Lock()
	p.sesi[id] = sesiPengguna{nama: nama, berakhir: waktuSekarang.Add(umur)}
	p.mu.Unlock()
	return id, nil
}

// ambil mengembalikan data sesi hanya bila ID ada dan belum kedaluwarsa.
func (p *penyimpananSesi) ambil(id string) (sesiPengguna, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	data, ok := p.sesi[id]
	if !ok || !waktuSekarang.Before(data.berakhir) {
		return sesiPengguna{}, false
	}
	return data, true
}

func (p *penyimpananSesi) hapus(id string) {
	p.mu.Lock()
	delete(p.sesi, id)
	p.mu.Unlock()
}

type aplikasi struct {
	store penyimpananSesi
}

// kredensial contoh; produksi memverifikasi hash bcrypt, bukan pembandingan langsung.
var kredensial = map[string]string{
	"budi": "sandiRahasia123",
	"siti": "sandiSiti456",
}

func (a *aplikasi) login(w http.ResponseWriter, r *http.Request) {
	nama := r.PostFormValue("username")
	password := r.PostFormValue("password")
	if kredensial[nama] == "" || kredensial[nama] != password {
		http.Error(w, "kredensial salah", http.StatusUnauthorized)
		return
	}
	// Regenerasi: sesi lama dibuang supaya ID lama tidak dipakai ulang
	// (pertahanan terhadap session fixation).
	if c, err := r.Cookie("session_id"); err == nil {
		a.store.hapus(c.Value)
	}
	id, err := a.store.buat(nama, 30*time.Minute)
	if err != nil {
		http.Error(w, "gagal membuat sesi", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    id,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})
	http.Redirect(w, r, "/profil", http.StatusSeeOther)
}

func (a *aplikasi) profil(w http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("session_id")
	if err != nil {
		http.Error(w, "belum login", http.StatusUnauthorized)
		return
	}
	data, ok := a.store.ambil(c.Value)
	if !ok {
		http.Error(w, "sesi tidak valid", http.StatusUnauthorized)
		return
	}
	fmt.Fprintf(w, "pengguna: %s", data.nama)
}

func (a *aplikasi) logout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie("session_id"); err == nil {
		a.store.hapus(c.Value)
	}
	// MaxAge negatif memerintahkan browser menghapus cookie.
	http.SetCookie(w, &http.Cookie{Name: "session_id", Value: "", Path: "/", MaxAge: -1})
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

// kirim menjalankan satu permintaan lalu mengembalikan status dan isi respons.
func kirim(client *http.Client, method, target, form string) (int, string) {
	var b io.Reader
	if form != "" {
		b = strings.NewReader(form)
	}
	req, err := http.NewRequest(method, target, b)
	if err != nil {
		log.Fatal(err)
	}
	if form != "" {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	isi, _ := io.ReadAll(resp.Body)
	return resp.StatusCode, string(isi)
}

func cookieSesi(jar *cookiejar.Jar, serverURL string) string {
	u, err := url.Parse(serverURL)
	if err != nil {
		log.Fatal(err)
	}
	for _, c := range jar.Cookies(u) {
		if c.Name == "session_id" {
			return c.Value
		}
	}
	return ""
}

func main() {
	a := &aplikasi{store: penyimpananSesi{sesi: map[string]sesiPengguna{}}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /login", a.login)
	mux.HandleFunc("GET /profil", a.profil)
	mux.HandleFunc("POST /logout", a.logout)
	server := httptest.NewServer(mux)
	defer server.Close()

	jar, err := cookiejar.New(nil)
	if err != nil {
		log.Fatal(err)
	}
	// Jangan ikuti redirect agar kode 303 terlihat pada output.
	teruskan := func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	// Klien dengan cookie jar seperti browser, dan klien polos tanpa cookie.
	clientBudi := &http.Client{Jar: jar, CheckRedirect: teruskan}
	clientPolos := &http.Client{CheckRedirect: teruskan}

	kode, _ := kirim(clientBudi, "POST", server.URL+"/login", "username=budi&password=passwordSalah")
	fmt.Printf("skenario  1: login dengan password salah            -> %d\n", kode)

	kode, _ = kirim(clientBudi, "POST", server.URL+"/login", "username=budi&password=sandiRahasia123")
	fmt.Printf("skenario  2: login dengan kredensial benar          -> %d\n", kode)

	idLama := cookieSesi(jar, server.URL)
	fmt.Printf("             cookie session_id terbit, panjang ID: %d karakter hex\n", len(idLama))

	kode, isi := kirim(clientBudi, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario  3: akses /profil dengan cookie sesi       -> %d (%s)\n", kode, isi)

	kode, _ = kirim(clientPolos, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario  4: akses /profil tanpa cookie             -> %d\n", kode)

	reqPalsu, err := http.NewRequest("GET", server.URL+"/profil", nil)
	if err != nil {
		log.Fatal(err)
	}
	reqPalsu.AddCookie(&http.Cookie{Name: "session_id", Value: strings.Repeat("61", 32)})
	respPalsu, err := clientPolos.Do(reqPalsu)
	if err != nil {
		log.Fatal(err)
	}
	respPalsu.Body.Close()
	fmt.Printf("skenario  5: akses /profil dengan ID dipalsukan     -> %d\n", respPalsu.StatusCode)

	kode, _ = kirim(clientBudi, "POST", server.URL+"/login", "username=budi&password=sandiRahasia123")
	fmt.Printf("skenario  6: login ulang, ID sesi diregenerasi      -> %d\n", kode)

	idBaru := cookieSesi(jar, server.URL)
	fmt.Printf("             ID baru berbeda dari ID lama: %v\n", idBaru != idLama)

	reqLama, err := http.NewRequest("GET", server.URL+"/profil", nil)
	if err != nil {
		log.Fatal(err)
	}
	reqLama.AddCookie(&http.Cookie{Name: "session_id", Value: idLama})
	respLama, err := clientPolos.Do(reqLama)
	if err != nil {
		log.Fatal(err)
	}
	respLama.Body.Close()
	fmt.Printf("skenario  7: akses /profil dengan ID lama           -> %d\n", respLama.StatusCode)

	kode, _ = kirim(clientBudi, "POST", server.URL+"/logout", "")
	fmt.Printf("skenario  8: logout                                 -> %d\n", kode)

	kode, _ = kirim(clientBudi, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario  9: akses /profil setelah logout           -> %d\n", kode)

	kirim(clientBudi, "POST", server.URL+"/login", "username=siti&password=sandiSiti456")
	waktuSekarang = waktuSekarang.Add(31 * time.Minute)
	kode, _ = kirim(clientBudi, "GET", server.URL+"/profil", "")
	fmt.Printf("skenario 10: sesi Siti kedaluwarsa (waktu maju)     -> %d\n", kode)
}