Kalau diperhatikan lagi, JWT access token yang kita buat sejak Part 7 masa berlakunya cuma 10 menit — cukup singkat untuk alasan keamanan, tapi kalau user harus login ulang tiap 10 menit tentu pengalamannya buruk. Solusi umum untuk ini adalah refresh token: token berumur panjang yang disimpan di database, dipakai khusus untuk menukar access token baru tanpa perlu login ulang. Di part ini kita juga akan menambahkan endpoint Logout, dan dua praktik keamanan yang sering terlewat: menyimpan refresh token dalam bentuk hash (bukan plain text), dan rotasi token supaya token yang dicuri bisa terdeteksi.
Kenapa Refresh Token Perlu Disimpan di Database?
Access token (JWT) sifatnya stateless — begitu diterbitkan, server tidak menyimpan catatannya sama sekali, jadi tidak bisa "dicabut" sebelum masa berlakunya habis. Ini bagus untuk performa, tapi jadi masalah kalau kita butuh fitur Logout yang benar-benar mematikan sesi user.
Refresh token beda cerita — kita simpan di tabel refresh_tokens, sehingga bisa kita hapus (revoke) kapan saja, misalnya saat user logout. Access token yang lama tetap akan expired sendiri dalam 10 menit, tapi tidak bisa diperpanjang lagi begitu refresh token-nya dicabut.
Migration Tabel Refresh Tokens
go run . artisan make:migration create_refresh_tokens_table
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"github.com/goravel/framework/facades"
)
type M20260822000001CreateRefreshTokensTable struct{}
func (r *M20260822000001CreateRefreshTokensTable) Signature() string {
return "20260822000001_create_refresh_tokens_table"
}
func (r *M20260822000001CreateRefreshTokensTable) Up() error {
if !facades.Schema().HasTable("refresh_tokens") {
return facades.Schema().Create("refresh_tokens", func(table schema.Blueprint) {
table.ID()
table.UnsignedBigInteger("user_id")
table.String("token", 255)
table.TimestampTz("expires_at")
table.TimestampsTz()
})
}
return nil
}
func (r *M20260822000001CreateRefreshTokensTable) Down() error {
return facades.Schema().DropIfExists("refresh_tokens")
}
Kita juga tambahkan satu migration lagi untuk kolom revoked_at — akan dijelaskan kegunaannya di bagian rotasi token nanti:
func (r *M20260824000001AddRevokedAtToRefreshTokensTable) Up() error {
if facades.Schema().HasTable("refresh_tokens") && !facades.Schema().HasColumn("refresh_tokens", "revoked_at") {
return facades.Schema().Table("refresh_tokens", func(table schema.Blueprint) {
table.TimestampTz("revoked_at").Nullable()
})
}
return nil
}
Model RefreshToken
package models
import (
"time"
"github.com/goravel/framework/database/orm"
)
type RefreshToken struct {
orm.Model
UserID uint `json:"user_id"`
Token string `json:"-"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"-"`
}
Field Token dan RevokedAt diberi tag json:"-" supaya tidak pernah ikut ter-serialize ke response API, bagaimanapun caranya model ini dipakai nanti.
Kenapa Refresh Token Harus Di-hash, Bukan Disimpan Plain Text
Ini poin yang gampang terlewat: kalau kolom token di database menyimpan nilai mentahnya persis seperti yang dikirim ke client, maka siapa pun yang berhasil membaca database (lewat SQL injection, backup yang bocor, database dump yang salah kirim, dll) otomatis punya refresh token siap pakai untuk semua user — sama persis seperti kalau kita menyimpan password user dalam bentuk plain text.
Solusinya sama seperti password: jangan simpan nilai aslinya, simpan hash-nya. Bedanya, untuk password kita pakai bcrypt (lambat secara sengaja, karena password manusia rendah entropi dan rentan brute-force). Untuk refresh token, nilainya sudah berupa 32 byte random dari crypto/rand — entropinya sudah sangat tinggi, jadi hash cepat seperti SHA-256 sudah cukup aman dan jauh lebih murah secara komputasi dibanding bcrypt.
Helper Refresh Token (dengan Hashing dan Rotasi)
package helpers
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"myblog/app/models"
"github.com/goravel/framework/facades"
)
const RefreshTokenTTL = 7 * 24 * time.Hour
var (
errNotFound = errors.New("refresh token not found")
errExpired = errors.New("refresh token expired")
errReused = errors.New("refresh token reuse detected")
)
func IsRefreshTokenExpired(err error) bool {
return errors.Is(err, errExpired)
}
func IsRefreshTokenReused(err error) bool {
return errors.Is(err, errReused)
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func GenerateRefreshToken(userID uint) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
rawToken := hex.EncodeToString(raw)
refreshToken := models.RefreshToken{
UserID: userID,
Token: hashToken(rawToken),
ExpiresAt: time.Now().Add(RefreshTokenTTL),
}
if err := facades.Orm().Query().Create(&refreshToken); err != nil {
return "", err
}
return rawToken, nil
}
func FindValidRefreshToken(rawToken string) (*models.RefreshToken, error) {
var refreshToken models.RefreshToken
if err := facades.Orm().Query().Where("token", hashToken(rawToken)).First(&refreshToken); err != nil {
return nil, err
}
if refreshToken.ID == 0 {
return nil, errNotFound
}
if refreshToken.RevokedAt != nil {
return &refreshToken, errReused
}
if time.Now().After(refreshToken.ExpiresAt) {
return &refreshToken, errExpired
}
return &refreshToken, nil
}
func RevokeRefreshToken(id uint) error {
now := time.Now()
_, err := facades.Orm().Query().Model(&models.RefreshToken{}).Where("id", id).Update("revoked_at", now)
return err
}
func RevokeAllRefreshTokensForUser(userID uint) error {
_, err := facades.Orm().Query().Where("user_id", userID).Delete(&models.RefreshToken{})
return err
}
GenerateRefreshToken sekarang menyimpan hashToken(rawToken) ke database, bukan rawToken itu sendiri — hanya raw token yang dikembalikan ke caller (untuk dikirim ke client), nilai aslinya tidak pernah disimpan di mana pun. Saat validasi, FindValidRefreshToken melakukan hash yang sama terhadap token yang dikirim client, lalu mencocokkannya ke kolom token yang tersimpan.
Perhatikan juga RevokeRefreshToken tidak menghapus baris-nya — hanya mengisi revoked_at. Ini bukan kebetulan, tapi kunci dari mekanisme rotasi & deteksi reuse yang kita bahas berikutnya.
Rotasi Token & Deteksi Reuse
Refresh token yang bisa dipakai berkali-kali tanpa batas adalah risiko: kalau suatu saat token itu dicuri (misalnya lewat log yang bocor, atau device yang di-compromise), pencuri bisa terus memakainya diam-diam selama 7 hari tanpa pernah ketahuan.
Pola yang lebih aman: refresh token bersifat sekali pakai. Setiap kali dipakai untuk /refresh, token itu langsung di-nonaktifkan (revoked_at diisi) dan token baru diterbitkan sebagai gantinya — inilah rotasi. Konsekuensinya: kalau ada dua pihak yang sama-sama punya refresh token yang sama (pemilik asli dan pencuri), siapa pun yang memakainya duluan akan berhasil dan mendapat token baru; pihak yang satunya, begitu mencoba memakai token lama yang sudah dipakai, akan ditolak — dan ini sinyal kuat bahwa ada penyalahgunaan. Momen itulah kita treat sebagai reuse detected, dan responnya tegas: cabut semua refresh token milik user tersebut, paksa semua sesi login ulang dari awal.
Ini kenapa RevokeRefreshToken tidak menghapus baris (soft-revoke lewat revoked_at) — kalau langsung dihapus, percobaan pakai token lama akan terlihat sama persis dengan token yang memang tidak pernah ada ("not found"), bukan "token ini sudah pernah dipakai sebelumnya". Bedanya penting: yang pertama tidak mengindikasikan apa-apa, yang kedua adalah alarm.
Update Register dan Login
Sekarang setiap Register dan Login juga menerbitkan refresh_token, selain token (access token) yang sudah ada:
token, err := helpers.GenerateJWT(fmt.Sprint(user.ID))
// ...
refreshToken, err := helpers.GenerateRefreshToken(user.ID)
// ...
return helpers.Success(ctx, http.StatusOK, "Login successful", http.Json{
"token": token,
"refresh_token": refreshToken,
"user": http.Json{ ... },
})
Endpoint Refresh (dengan Rotasi)
func (r *UserController) Refresh(ctx http.Context) http.Response {
validator, err := ctx.Request().Validate(map[string]string{
"refresh_token": "required",
})
if err != nil {
return helpers.Error(ctx, http.StatusInternalServerError, "Failed to validate request", err.Error())
}
if validator.Fails() {
return helpers.Error(ctx, http.StatusUnprocessableEntity, "Validation failed", validator.Errors().All())
}
tokenString := ctx.Request().Input("refresh_token")
refreshToken, err := helpers.FindValidRefreshToken(tokenString)
if err != nil {
if helpers.IsRefreshTokenReused(err) {
facades.Log().Warningf("Refresh token reuse detected for user_id=%d — revoking all sessions", refreshToken.UserID)
if revokeErr := helpers.RevokeAllRefreshTokensForUser(refreshToken.UserID); revokeErr != nil {
facades.Log().Errorf("Failed to revoke sessions after refresh token reuse: %v", revokeErr)
}
return helpers.Error(ctx, http.StatusUnauthorized, "Refresh token reuse detected, all sessions revoked", nil)
}
if helpers.IsRefreshTokenExpired(err) {
return helpers.Error(ctx, http.StatusUnauthorized, "Refresh token expired", nil)
}
return helpers.Error(ctx, http.StatusUnauthorized, "Invalid refresh token", nil)
}
newToken, err := helpers.GenerateJWT(fmt.Sprint(refreshToken.UserID))
if err != nil {
return helpers.Error(ctx, http.StatusInternalServerError, "Failed to generate token", nil)
}
// Rotasi: token yang baru saja dipakai langsung diganti dengan yang baru.
newRefreshToken, err := helpers.GenerateRefreshToken(refreshToken.UserID)
if err != nil {
return helpers.Error(ctx, http.StatusInternalServerError, "Failed to generate refresh token", nil)
}
if err := helpers.RevokeRefreshToken(refreshToken.ID); err != nil {
return helpers.Error(ctx, http.StatusInternalServerError, "Failed to rotate refresh token", nil)
}
return helpers.Success(ctx, http.StatusOK, "Token refreshed successfully", http.Json{
"token": newToken,
"refresh_token": newRefreshToken,
})
}
Setiap kali /refresh dipanggil, response-nya sekarang membawa refresh_token yang baru — client (mobile app, frontend, dsb) wajib menyimpan token baru ini dan membuang yang lama, karena yang lama tidak akan bisa dipakai lagi.
Endpoint Logout
func (r *UserController) Logout(ctx http.Context) http.Response {
userID := helpers.AuthUserID(ctx)
if err := helpers.RevokeAllRefreshTokensForUser(userID); err != nil {
return helpers.Error(ctx, http.StatusInternalServerError, "Failed to logout", err.Error())
}
return helpers.Success(ctx, http.StatusOK, "Logout successful", nil)
}
Beda dengan rotasi (soft-revoke), Logout menghapus baris-nya sungguhan lewat RevokeAllRefreshTokensForUser — begitu user memilih logout, tidak ada gunanya menyimpan jejak token lama untuk deteksi reuse lagi. Method ini menghapus semua refresh token milik user yang bersangkutan (misalnya kalau user login dari beberapa device sekaligus, semuanya ikut ter-logout). Endpoint ini butuh autentikasi, jadi dibungkus middleware JWT.
Menambahkan Route
Endpoint /refresh kita satukan ke grup throttle login yang sudah dibuat di Part 20 — endpoint auth manapun yang menerima kredensial/token dari luar sebaiknya memang dibatasi rate-nya:
facades.Route().Middleware(frameworkmiddleware.Throttle("login")).Group(func(router route.Router) {
router.Post("/register", userController.Register)
router.Post("/login", userController.Login)
router.Post("/refresh", userController.Refresh)
})
facades.Route().Middleware(middleware.JwtMiddleware()).Group(func(router route.Router) {
router.Post("/logout", userController.Logout)
})
Testing
POST /registeratauPOST /login— simpantokendanrefresh_tokendari response.- Cek langsung ke database, kolom
tokendi tabelrefresh_tokensharus berupa hash 64 karakter (SHA-256 dalam hex), bukan nilai yang dikirim ke client. POST /refreshdenganrefresh_tokentadi — harus sukses, dan response-nya membawarefresh_tokenbaru.- Coba
POST /refreshlagi denganrefresh_tokenyang lama (yang barusan dipakai) — harus gagal401dengan pesan "Refresh token reuse detected, all sessions revoked". - Coba juga
refresh_tokenyang baru (hasil langkah 3) — sekarang ikut gagal juga, karena reuse yang terdeteksi di langkah 4 mencabut seluruh sesi user tersebut. POST /logoutdengan headerAuthorization: Bearer {token}, lalu coba/refreshlagi — harus gagal dengan401 Invalid refresh token.
Penutup
Dengan refresh token yang di-hash, rotasi otomatis, dan deteksi reuse, sistem autentikasi kita sekarang jauh lebih tahan terhadap skenario token dicuri — bukan cuma bisa dicabut manual lewat Logout. Di part berikutnya kita akan membahas Role & Permission, supaya ada perbedaan hak akses antara user biasa dan admin.
Bagian dari Series: Seri Tutorial Belajar Framework Goravel Rest API untuk Pemula
Overview Seri ini membahas cara membangun REST API menggunakan Goravel, framework web berbasis Golang yang terinspirasi dari Laravel. Materi mencakup...
Lihat Series Lengkap