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.
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