Access token kita di Part 8 sengaja dibuat berumur pendek (15 menit). Bagus untuk keamanan, tapi kalau tidak ada mekanisme lain, user harus login ulang setiap 15 menit — pengalaman yang buruk. Di part ini kita tambahkan refresh token: token berumur panjang yang dipakai khusus untuk menukar access token baru, tanpa perlu kirim ulang email/password.
Model RefreshToken
Buat models/refresh_token.go:
package models
import (
"time"
"gorm.io/gorm"
)
type RefreshToken struct {
gorm.Model
UserID uint `gorm:"index;not null" json:"user_id"`
Token string `gorm:"size:255;uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
}
Beda dengan access token yang statless (cukup verifikasi signature JWT-nya), refresh token kita simpan di database. Ini penting supaya kita bisa mencabut (revoke) refresh token kapan saja — sesuatu yang tidak bisa dilakukan pada JWT murni tanpa daftar blacklist.
Daftarkan ke AutoMigrate:
db.AutoMigrate(&models.Post{}, &models.User{}, &models.RefreshToken{})
Generate & Hash Refresh Token
Refresh token dibuat dari byte acak, bukan JWT — cukup string panjang dengan entropi tinggi. Tambahkan ke auth/refresh_token.go:
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
)
const RefreshTokenTTLDays = 7
func GenerateRefreshToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// HashToken satu arah (bukan bcrypt) sudah cukup di sini karena refresh token
// sudah random & tinggi entropinya (beda dengan password yang ditebak manusia) -
// tujuannya cuma supaya token mentah tidak langsung reusable kalau tabel ini bocor.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
Kenapa di-hash? Kalau kita simpan refresh token apa adanya di kolom Token, dan suatu saat database bocor (backup ke-expose, SQL injection di endpoint lain, dst), penyerang otomatis dapat semua token yang masih aktif dan bisa langsung dipakai. Dengan menyimpan hash SHA-256-nya saja, token mentah cuma pernah ada di response ke client — kalau tabel refresh_tokens bocor, isinya tidak berguna buat penyerang. Ini pola yang sama dengan kenapa kita hash password pakai bcrypt di Part 8, walau di sini SHA-256 polos sudah cukup (refresh token sudah random 256-bit, beda kasus dengan password yang bisa ditebak manusia).
Menerbitkan Refresh Token Saat Login
Update handlers/auth_handler.go, tambahkan helper issueRefreshToken dan panggil di akhir Login:
func (h *AuthHandler) issueRefreshToken(userID uint) (string, error) {
token, err := auth.GenerateRefreshToken()
if err != nil {
return "", err
}
refreshToken := models.RefreshToken{
UserID: userID,
Token: auth.HashToken(token),
ExpiresAt: time.Now().AddDate(0, 0, auth.RefreshTokenTTLDays),
}
if err := h.DB.Create(&refreshToken).Error; err != nil {
return "", err
}
return token, nil
}
Perhatikan: yang disimpan ke database adalah auth.HashToken(token), sementara yang di-return (dan nantinya dikirim ke client) adalah token mentah. Update bagian akhir Login:
refreshToken, err := h.issueRefreshToken(user.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "gagal membuat refresh token",
})
}
return c.JSON(fiber.Map{
"message": "login berhasil",
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": "Bearer",
})
Endpoint Refresh
type refreshRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
var req refreshRequest
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 stored models.RefreshToken
if err := h.DB.Where("token = ?", auth.HashToken(req.RefreshToken)).First(&stored).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"message": "refresh token tidak valid",
})
}
if stored.Revoked || time.Now().After(stored.ExpiresAt) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"message": "refresh token sudah tidak berlaku",
})
}
// Rotasi: refresh token lama langsung dicabut supaya tidak bisa dipakai dua kali.
stored.Revoked = true
if err := h.DB.Save(&stored).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "gagal memproses refresh token",
})
}
accessToken, err := auth.GenerateAccessToken(stored.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "gagal membuat token",
})
}
newRefreshToken, err := h.issueRefreshToken(stored.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "gagal membuat refresh token",
})
}
return c.JSON(fiber.Map{
"message": "token berhasil diperbarui",
"access_token": accessToken,
"refresh_token": newRefreshToken,
"token_type": "Bearer",
})
}
Setiap kali endpoint ini dipanggil, refresh token lama langsung di-set Revoked = true dan refresh token baru diterbitkan — ini disebut rotasi refresh token. Kalau ada refresh token yang dicuri lalu dipakai penyerang, dan pemilik asli juga memakai token yang sama untuk refresh, salah satu dari mereka akan gagal (karena token sudah revoked oleh pemakaian pertama) — sinyal yang bisa dipakai untuk mendeteksi pencurian token.
Endpoint Logout
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
var req refreshRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "body request tidak valid",
})
}
if err := h.DB.Model(&models.RefreshToken{}).
Where("token = ?", auth.HashToken(req.RefreshToken)).
Update("revoked", true).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "gagal logout",
})
}
return c.JSON(fiber.Map{
"message": "logout berhasil",
})
}
Logout di sini artinya mencabut refresh token yang bersangkutan — access token JWT yang sudah terlanjur di-generate tetap valid sampai masa berlakunya habis (maksimal 15 menit), karena JWT bersifat stateless dan tidak kita cek ke database setiap request. Ini keterbatasan yang wajar diketahui: kalau butuh "logout seketika juga untuk access token", perlu mekanisme blacklist tambahan — di luar scope seri "Dasar" ini.
Menambahkan Route
authGroup.Post("/refresh", authHandler.Refresh)
authGroup.Post("/logout", authHandler.Logout)
Testing End-to-End
# Login, simpan access_token dan refresh_token dari response
LOGIN=$(curl -s -X POST http://127.0.0.1:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"budi@example.com","password":"rahasia123"}')
REFRESH=$(echo "$LOGIN" | jq -r .refresh_token)
# Tukar refresh token dengan access token baru
curl -X POST http://127.0.0.1:3000/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"$REFRESH\"}"
# Refresh token lama (yang sudah dipakai) sekarang ditolak
curl -X POST http://127.0.0.1:3000/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"$REFRESH\"}"
# -> 401 "refresh token sudah tidak berlaku"
# Logout pakai refresh token yang masih aktif
curl -X POST http://127.0.0.1:3000/api/v1/auth/logout \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"<refresh_token_terbaru>\"}"
Sistem autentikasi dasar kita sekarang lengkap: register, login, access token pendek + refresh token dengan rotasi, dan logout. Di bagian selanjutnya (Part 11 dan seterusnya) kita akan kembali ke fitur-fitur blog: standarisasi response, pagination, relasi Post-User, sampai deploy.
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