M
Mr Sugiarto
Developer
26 Aug 2026 7 min read

Mulai part ini kita masuk ke sisi autentikasi. Kita akan buat model User, endpoint register & login, dan sistem token JWT (JSON Web Token) yang nantinya dipakai untuk melindungi endpoint tertentu.

Install Dependency

go get golang.org/x/crypto/bcrypt github.com/golang-jwt/jwt/v5
  • golang.org/x/crypto/bcrypt — untuk hashing password (jangan pernah simpan password polos di database!)
  • github.com/golang-jwt/jwt/v5 — untuk generate dan verifikasi JWT

Model User

Buat models/user.go:

package models

import "gorm.io/gorm"

type User struct {
	gorm.Model
	Name     string `gorm:"size:255;not null" json:"name"`
	Email    string `gorm:"size:255;uniqueIndex;not null" json:"email"`
	Password string `gorm:"size:255;not null" json:"-"`
}

Perhatikan tag json:"-" pada Password — ini memastikan field password tidak pernah ikut ter-serialize ke response JSON, walaupun kita return seluruh struct User apa adanya.

Daftarkan model ini ke AutoMigrate di database/migrate.go:

func Migrate(db *gorm.DB) {
	db.AutoMigrate(&models.Post{}, &models.User{})
}

Helper JWT

Buat auth/jwt.go untuk generate dan parse token:

package auth

import (
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	UserID uint `json:"user_id"`
	jwt.RegisteredClaims
}

func GenerateAccessToken(userID uint) (string, error) {
	claims := Claims{
		UserID: userID,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	return token.SignedString([]byte(os.Getenv("JWT_SECRET")))
}

func ParseAccessToken(tokenString string) (*Claims, error) {
	claims := &Claims{}

	token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
		return []byte(os.Getenv("JWT_SECRET")), nil
	}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
	if err != nil {
		return nil, err
	}

	if !token.Valid {
		return nil, jwt.ErrTokenSignatureInvalid
	}

	return claims, nil
}

Perhatikan opsi jwt.WithValidMethods(...) di ParseWithClaims. Ini bukan sekadar detail kecil — tanpa opsi ini, kode rentan terhadap serangan klasik bernama algorithm confusion: penyerang bisa membuat token dengan header "alg":"none" (atau algoritma lain) untuk mencoba melewati verifikasi signature. WithValidMethods memastikan kita hanya menerima token yang benar-benar ditandatangani pakai HS256, sesuai yang kita pakai saat generate token. Selalu pin algoritma yang diharapkan setiap kali parsing JWT dari sumber luar.

Access token sengaja dibuat berumur pendek (15 menit) — praktik umum supaya kalau token bocor, dampaknya terbatas. Nanti di Part 10 kita tambahkan refresh token untuk memperpanjang sesi tanpa perlu login ulang terus-menerus.

Tambahkan JWT_SECRET ke .env dan .env.example:

JWT_SECRET=ganti-dengan-secret-yang-panjang-dan-acak

Validasi JWT_SECRET saat Startup

Kalau JWT_SECRET lupa di-set, os.Getenv("JWT_SECRET") akan mengembalikan string kosong — dan kode di atas akan tetap jalan, cuma menandatangani semua token pakai key kosong (""). Ini bahaya diam-diam: aplikasi kelihatan berfungsi normal, padahal siapapun yang tahu bahwa key-nya kosong bisa memalsukan token dengan mudah.

Supaya gagal lebih cepat dan jelas, tambahkan pengecekan di main.go sebelum server dijalankan:

if len(os.Getenv("JWT_SECRET")) < 32 {
	log.Fatal("JWT_SECRET wajib diisi di .env, minimal 32 karakter")
}

Dengan ini, aplikasi langsung berhenti dengan pesan error yang jelas kalau JWT_SECRET belum di-set atau terlalu pendek untuk dianggap aman — jauh lebih baik daripada baru sadar ada masalah setelah production kebobolan.

Handler Register

Buat handlers/auth_handler.go:

type registerRequest struct {
	Name     string `json:"name" validate:"required,min=3,max=255"`
	Email    string `json:"email" validate:"required,email,max=255"`
	Password string `json:"password" validate:"required,min=6"`
}

func (h *AuthHandler) Register(c *fiber.Ctx) error {
	var req registerRequest
	if err := c.BodyParser(&req); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"message": "body request tidak valid",
		})
	}

	if err := validation.Validate.Struct(req); err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
			"message": "validasi gagal",
			"errors":  validation.FormatErrors(err),
		})
	}

	var existing models.User
	if err := h.DB.Where("email = ?", req.Email).First(&existing).Error; err == nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
			"message": "validasi gagal",
			"errors":  fiber.Map{"Email": "Email sudah terdaftar"},
		})
	}

	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": "gagal memproses password",
		})
	}

	user := models.User{
		Name:     req.Name,
		Email:    req.Email,
		Password: string(hashedPassword),
	}

	if err := h.DB.Create(&user).Error; err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": "gagal membuat user",
		})
	}

	return c.Status(fiber.StatusCreated).JSON(fiber.Map{
		"message": "registrasi berhasil",
		"data":    user,
	})
}

bcrypt.GenerateFromPassword men-hash password dengan salt otomatis — hasil hash-nya berbeda setiap kali dipanggil walaupun passwordnya sama, jadi aman dari rainbow table attack.

Catatan keamanan: pesan "Email sudah terdaftar" di atas secara teknis membuka celah account enumeration — orang luar bisa mencoba-coba email untuk mengetahui email mana saja yang sudah terdaftar di sistem kita. Ini trade-off yang sengaja diambil banyak API populer (GitHub, Gmail, dst juga melakukan hal serupa saat registrasi) demi UX yang jelas buat user. Mitigasi penuhnya (misal: selalu balas "cek email kamu untuk konfirmasi" tanpa mengonfirmasi apakah email sudah ada, lalu verifikasi lewat email) butuh alur verifikasi email yang di luar scope seri "Dasar" ini — jadi kita terima trade-off-nya secara sadar, bukan karena luput diperiksa.

Handler Login

type loginRequest struct {
	Email    string `json:"email" validate:"required,email"`
	Password string `json:"password" validate:"required"`
}

func (h *AuthHandler) Login(c *fiber.Ctx) error {
	var req loginRequest
	if err := c.BodyParser(&req); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"message": "body request tidak valid",
		})
	}

	if err := validation.Validate.Struct(req); err != nil {
		return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
			"message": "validasi gagal",
			"errors":  validation.FormatErrors(err),
		})
	}

	var user models.User
	if err := h.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"message": "email atau password salah",
			})
		}
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": "gagal memproses login",
		})
	}

	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"message": "email atau password salah",
		})
	}

	accessToken, err := auth.GenerateAccessToken(user.ID)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": "gagal membuat token",
		})
	}

	return c.JSON(fiber.Map{
		"message":      "login berhasil",
		"access_token": accessToken,
		"token_type":   "Bearer",
	})
}

Perhatikan pesan error saat email tidak ditemukan dan password salah sengaja dibuat sama ("email atau password salah"). Ini praktik keamanan umum — kalau pesannya dibedakan (misal "email tidak ditemukan" vs "password salah"), penyerang bisa menebak email mana saja yang terdaftar di sistem kita.

Menambahkan Route

authGroup := api.Group("/auth")
authGroup.Post("/register", authHandler.Register)
authGroup.Post("/login", authHandler.Login)

Testing

# Register
curl -X POST http://127.0.0.1:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Budi Santoso","email":"budi@example.com","password":"rahasia123"}'

# Login
curl -X POST http://127.0.0.1:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"budi@example.com","password":"rahasia123"}'

Response login akan berisi access_token berupa string JWT. Simpan token ini — di part berikutnya kita akan pakai untuk mengakses endpoint yang diproteksi lewat middleware.

M
Mr Sugiarto

Developer

Bagian dari Series: GoFiber Dasar - Belajar Fundamental Lewat REST API Blog

Belajar konsep-konsep dasar GoFiber (routing, handler, GORM, MySQL, JWT auth) dengan membangun REST API blog sederhana dari nol - lengkap dengan auten...

Lihat Series Lengkap
Artikel Terkait