Kembali ke Artikel

Belajar Golang dari Nol #25: Autentikasi Register, Login, dan JWT

Solusi IT

Di Belajar Golang dari Nol #24 kita mempercepat API dengan caching Redis. API kita sekarang cepat, punya database rapi, dan punya middleware. Tapi ada satu lubang besar. Semua orang yang tahu API key statis dari bagian 13 bisa mengakses semua data. Server tidak tahu siapa yang sedang memakai API. Di bagian ini kita tutup lubang itu. Kita bangun autentikasi user sungguhan dengan register, login, dan JWT. Kalau kamu baru bergabung, cek dulu daftar lengkap seri supaya tidak ada bagian yang terlewat.

Autentikasi vs Otorisasi, Jangan Tertukar

Dua istilah ini sering dicampur, padahal beda. Autentikasi menjawab pertanyaan “kamu siapa”. Otorisasi menjawab pertanyaan “kamu boleh apa”. Urutannya selalu autentikasi dulu, baru otorisasi. Server harus tahu identitasmu sebelum bisa memutuskan hakmu.

API key statis di bagian 13 sebenarnya autentikasi juga, tapi levelnya aplikasi, bukan user. Semua pemegang key dianggap orang yang sama. Sekarang kita naik level. Setiap user punya akun sendiri, dan server tahu persis siapa yang mengirim tiap request. Bagian ini fokus ke autentikasi saja. Otorisasi kita bahas di bagian berikutnya.

Tabel Users Lewat Migrasi

Kita mulai dari database. Sesuai kebiasaan sejak bagian 23, perubahan skema selalu lewat file migrasi, bukan SQL manual di terminal. Buat sepasang file migrasi baru dengan nomor urut berikutnya di proyekmu.

Isi file up:

-- 000004_create_users.up.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Dan file down untuk jalan mundurnya:

-- 000004_create_users.down.sql
DROP TABLE users;

Perhatikan nama kolomnya: password_hash, bukan password. Ini disengaja. Password tidak pernah disimpan mentah di database. Tidak boleh, dalam kondisi apa pun. Kalau database bocor dan password tersimpan mentah, semua akun user langsung jatuh, termasuk akun mereka di layanan lain yang memakai password sama. Yang kita simpan hanya hasil hash, yaitu sidik jari matematis yang tidak bisa dibalik jadi password asli.

Jalankan migrasinya seperti biasa dengan migrate -path ./migrations -database "$DATABASE_URL" up.

Hash Password dengan Bcrypt

Untuk hashing kita pakai bcrypt dari paket resmi Go. Pasang dulu:

go get golang.org/x/crypto/bcrypt

Kenapa bukan MD5 atau SHA-256? Karena keduanya dirancang untuk cepat. Cepat itu bagus untuk checksum file, tapi bencana untuk password. Penyerang yang mencuri database bisa menebak jutaan kombinasi per detik dengan hash yang cepat. Bcrypt sengaja dibuat lambat, sekitar puluhan milidetik per hash. Untuk satu kali login, lambatnya tidak terasa. Untuk penyerang yang harus mencoba miliaran tebakan, lambat itu jadi tembok. Jadi lambat di sini bukan kelemahan, justru fitur utamanya. Bonusnya, bcrypt otomatis menambahkan salt, jadi dua user dengan password sama tetap punya hash berbeda.

Kita cuma butuh dua fungsi:

package main

import "golang.org/x/crypto/bcrypt"

func hashPassword(password string) (string, error) {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(hash), nil
}

func cocokkanPassword(hash, password string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

GenerateFromPassword dipakai saat register. CompareHashAndPassword dipakai saat login. Kita tidak pernah membandingkan string password secara langsung. Biarkan bcrypt yang menghitung apakah password yang dikirim cocok dengan hash di database.

Endpoint Register

Sekarang endpoint pertama. Alurnya: terima email dan password, validasi, hash, simpan.

type authRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
	var req authRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "body tidak valid", http.StatusBadRequest)
		return
	}

	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
	if _, err := mail.ParseAddress(req.Email); err != nil {
		http.Error(w, "format email tidak valid", http.StatusBadRequest)
		return
	}
	if len(req.Password) < 8 {
		http.Error(w, "password minimal 8 karakter", http.StatusBadRequest)
		return
	}

	hash, err := hashPassword(req.Password)
	if err != nil {
		http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
		return
	}

	_, err = s.db.ExecContext(r.Context(),
		"INSERT INTO users (email, password_hash) VALUES ($1, $2)",
		req.Email, hash)
	if err != nil {
		http.Error(w, "registrasi tidak dapat diproses", http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"pesan": "registrasi berhasil"})
}

Ada satu keputusan keamanan yang halus di sini. Saat email sudah terdaftar, constraint UNIQUE membuat insert gagal, dan kita membalas dengan pesan generik “registrasi tidak dapat diproses”. Kenapa tidak jujur bilang “email sudah terdaftar”? Karena pesan sedetail itu bisa dipakai penyerang untuk memetakan siapa saja yang punya akun di sistemmu. Teknik ini disebut user enumeration. Penyerang cukup mencoba ribuan email dan mencatat mana yang ditolak. Pesan generik menutup celah itu. Untuk aplikasi internal kamu boleh lebih longgar, tapi biasakan pola aman sejak awal.

Kenalan dengan JWT

Register beres. Sekarang bagian menariknya: bagaimana server mengenali user di request berikutnya?

Cara klasik adalah session. Server menyimpan catatan “user 42 sedang login” di memori atau database, lalu memberi browser sebuah cookie berisi ID session. Cara ini bekerja, tapi server harus menyimpan dan mencari catatan itu di setiap request.

JWT, singkatan dari JSON Web Token, memakai pendekatan lain. Bayangkan tiket konser yang ditandatangani panitia. Di tiket tertulis namamu dan tanggal berlakunya. Petugas di pintu tidak perlu menelepon kantor pusat untuk mengecek daftar pembeli. Dia cukup memeriksa tanda tangannya asli atau tidak. Kalau asli, isi tiket dipercaya. JWT persis seperti itu. Token berisi klaim tentang user, ditandatangani dengan kunci rahasia server, dan server bisa memverifikasinya tanpa menyimpan session sama sekali.

Bentuk JWT adalah tiga bagian yang dipisah titik: header.payload.signature. Header berisi jenis algoritma. Payload berisi klaim, misalnya ID user dan waktu kadaluarsa. Signature adalah tanda tangan kriptografis atas dua bagian pertama. Kalau ada satu karakter payload yang diubah, tanda tangan tidak akan cocok lagi dan server menolak token itu.

Supaya adil, kita bahas juga minusnya. Pertama, JWT tidak bisa dicabut sebelum kadaluarsa. Server tidak menyimpan daftar token aktif, jadi token yang sudah terbit akan tetap valid sampai exp lewat, meskipun usernya menekan tombol logout. Kedua, payload hanya di-encode dengan base64, bukan dienkripsi. Siapa pun yang memegang token bisa membaca isinya. Jadi jangan pernah menaruh data sensitif seperti password atau nomor identitas di payload. Cukup ID user dan waktu kadaluarsa.

Login dan Pembuatan Token

Kita pakai library yang paling umum di ekosistem Go:

go get github.com/golang-jwt/jwt/v5

Kunci rahasia diambil dari environment variable, sama seperti konfigurasi database kita selama ini. Jangan tulis langsung di kode.

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

func buatToken(userID int64) (string, error) {
	claims := jwt.MapClaims{
		"sub": strconv.FormatInt(userID, 10),
		"iat": time.Now().Unix(),
		"exp": time.Now().Add(24 * time.Hour).Unix(),
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

Klaim sub alias subject berisi ID user. Klaim exp membuat token hangus otomatis setelah 24 jam. Handler loginnya:

func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	var req authRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "body tidak valid", http.StatusBadRequest)
		return
	}
	req.Email = strings.ToLower(strings.TrimSpace(req.Email))

	var id int64
	var hash string
	err := s.db.QueryRowContext(r.Context(),
		"SELECT id, password_hash FROM users WHERE email = $1",
		req.Email).Scan(&id, &hash)
	if err != nil || !cocokkanPassword(hash, req.Password) {
		http.Error(w, "email atau password salah", http.StatusUnauthorized)
		return
	}

	tokenString, err := buatToken(id)
	if err != nil {
		http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
		return
	}

	json.NewEncoder(w).Encode(map[string]string{"token": tokenString})
}

Perhatikan lagi pesan errornya. Email tidak ketemu dan password salah dibalas dengan kalimat yang sama persis. Alasannya sama dengan register tadi: jangan beri petunjuk gratis ke penyerang.

Middleware JWT Menggantikan API Key

Di bagian 13 kita membuat middleware yang mengecek API key statis dari header. Sekarang middleware itu kita pensiunkan dan ganti dengan pemeriksa JWT. Polanya masih sama dengan middleware request ID di bagian 18: periksa request, taruh data di context, teruskan ke handler berikutnya.

type contextKey string

const kunciUserID contextKey = "userID"

func middlewareJWT(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		header := r.Header.Get("Authorization")
		tokenString, ok := strings.CutPrefix(header, "Bearer ")
		if !ok || tokenString == "" {
			http.Error(w, "token tidak ditemukan", http.StatusUnauthorized)
			return
		}

		token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
			if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, errors.New("metode tanda tangan tidak dikenal")
			}
			return jwtSecret, nil
		})
		if err != nil || !token.Valid {
			http.Error(w, "token tidak valid atau kadaluarsa", http.StatusUnauthorized)
			return
		}

		sub, err := token.Claims.GetSubject()
		if err != nil || sub == "" {
			http.Error(w, "token tidak valid", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), kunciUserID, sub)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Tiga hal yang dicek middleware ini. Pertama, header Authorization harus berformat Bearer token. Kedua, tanda tangan harus valid dan algoritmanya harus HMAC, bukan algoritma lain yang diselundupkan penyerang. Ketiga, jwt.Parse otomatis menolak token yang exp nya sudah lewat. Kalau semua lolos, ID user masuk ke context dan handler di belakangnya tinggal membaca.

Handler profil yang membaca userID dari context:

func (s *Server) handleProfil(w http.ResponseWriter, r *http.Request) {
	userID, _ := r.Context().Value(kunciUserID).(string)

	var email string
	var createdAt time.Time
	err := s.db.QueryRowContext(r.Context(),
		"SELECT email, created_at FROM users WHERE id = $1",
		userID).Scan(&email, &createdAt)
	if err != nil {
		http.Error(w, "user tidak ditemukan", http.StatusNotFound)
		return
	}

	json.NewEncoder(w).Encode(map[string]any{
		"id":        userID,
		"email":     email,
		"terdaftar": createdAt,
	})
}

Terakhir, daftarkan semua route. Register dan login terbuka, profil dilindungi middleware:

mux := http.NewServeMux()
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("POST /login", s.handleLogin)
mux.Handle("GET /profil", middlewareJWT(http.HandlerFunc(s.handleProfil)))

Praktik Aman yang Wajib Kamu Pegang

Sebelum lanjut ke latihan, catat empat aturan main ini.

Pertama, secret harus panjang dan acak. Minimal 32 byte. Bangkitkan sekali dengan openssl rand -base64 32 lalu simpan di environment variable. Secret pendek seperti “rahasia123” bisa ditebak dengan brute force, dan siapa pun yang tahu secret bisa memalsukan token atas nama user mana saja.

Kedua, HTTPS wajib di production. Token dikirim di header pada setiap request. Tanpa TLS yang sudah kita siapkan di bagian 15, token bisa dibaca siapa saja yang mengintip jaringan, dan mereka langsung bisa menyamar jadi usermu.

Ketiga, buat expiry pendek. Contoh kita memakai 24 jam supaya enak dicoba, tapi di production banyak tim memakai 15 sampai 60 menit. Supaya user tidak perlu login ulang terus, biasanya ada token kedua bernama refresh token, yang berumur lebih panjang dan disimpan server, khusus untuk menukar access token baru. Konsepnya cukup kamu kenal dulu, implementasi penuhnya di luar cakupan bagian ini.

Keempat, jangan pernah menulis token ke log. Token di log sama bahayanya dengan password di log. Kalau perlu debugging, log kan user ID nya saja.

Latihan: Uji Alur Lengkapnya

Jalankan server, lalu uji tiga endpoint tadi dengan curl. Pertama register:

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"email":"budi@contoh.com","password":"rahasiabanget"}'

{"pesan":"registrasi berhasil"}

Lalu login untuk mendapatkan token:

curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"budi@contoh.com","password":"rahasiabanget"}'

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3d..."}

Simpan token itu, lalu akses profil dengan menyertakannya di header:

TOKEN="tempel-token-kamu-di-sini"

curl http://localhost:8080/profil \
  -H "Authorization: Bearer $TOKEN"

{"id":"1","email":"budi@contoh.com","terdaftar":"2026-07-27T09:15:02Z"}

Terakhir, buktikan penjagaannya bekerja. Akses tanpa token:

curl -i http://localhost:8080/profil

HTTP/1.1 401 Unauthorized

token tidak ditemukan

Dapat 200 saat bawa token dan 401 saat tidak, berarti autentikasimu sudah jalan. Coba juga skenario lain: register dengan email yang sama dua kali, login dengan password salah, dan akses profil dengan token yang kamu ubah satu hurufnya. Perhatikan responsnya, lalu telusuri di kode kenapa hasilnya begitu.

Penutup

API kita akhirnya kenal usernya. Password tersimpan aman sebagai hash bcrypt, login menghasilkan JWT bertanda tangan, dan middleware memastikan hanya pemegang token valid yang bisa masuk. Fondasi ini yang dipakai hampir semua API modern, dari aplikasi kasir sampai sistem informasi sekolah.

Tapi ingat, server baru tahu kamu siapa. Dia belum bisa membedakan admin dari user biasa. Itu ranah otorisasi, dan kita bahas di bagian 26: Role dan Otorisasi: Membatasi Siapa Boleh Apa.

Kalau kamu sedang membangun aplikasi dengan kebutuhan login, role, dan keamanan data yang serius, tim Arrazy bisa bantu lewat jasa pembuatan sistem aplikasi. Sampai jumpa di bagian berikutnya.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel