Pada API internal, kebutuhan autentikasi dan otorisasi sering kali berbeda dari aplikasi publik. Fokusnya bukan hanya memastikan pengguna berhasil login, tetapi juga membatasi siapa yang boleh mengakses endpoint sensitif seperti import data, monitoring job worker, atau fitur administratif. Di artikel ini kita akan membangun pendekatan yang cukup aman dan praktis menggunakan JWT untuk autentikasi dan RBAC (Role-Based Access Control) untuk otorisasi di Go Fiber v3.
Pendekatan ini cocok untuk API internal antar service, dashboard operasional, atau tool backoffice. Kita akan membahas alur login, pembuatan access token dan refresh token, middleware verifikasi token, proteksi route berdasarkan role atau permission, pola error response yang aman, dan beberapa catatan keamanan yang sering terlewat seperti rotasi secret, masa berlaku token, serta audit log dasar.
Catatan: Untuk API internal, JWT bukan selalu satu-satunya pilihan. Jika semua akses berasal dari service-to-service di jaringan tertutup, mTLS atau signed service token bisa lebih tepat. Namun untuk dashboard internal dengan pengguna manusia dan kebutuhan role yang jelas, JWT masih sangat umum dan efektif.
Arsitektur Singkat: JWT untuk Auth, RBAC untuk Otorisasi
Kita pisahkan dua tanggung jawab berikut:
- Autentikasi: memastikan identitas pemanggil valid. Di sini kita gunakan JWT sebagai bukti login.
- Otorisasi: menentukan apakah identitas tersebut memiliki hak untuk mengakses resource tertentu. Di sini kita gunakan role dan permission.
Struktur sederhana yang umum dipakai:
- Access token: masa berlaku pendek, misalnya 15 menit. Dipakai di header
Authorization: Bearer .... - Refresh token: masa berlaku lebih panjang, misalnya 7 hari. Dipakai untuk meminta access token baru tanpa login ulang.
- Role: misalnya
admin,operator,viewer. - Permission: misalnya
import:run,worker:read,admin:access.
Role memudahkan pengelolaan user, sedangkan permission memberi fleksibilitas saat kebutuhan makin kompleks. Praktiknya, token dapat menyimpan role dan permission agar pengecekan cepat, tetapi tetap perlu strategi saat hak akses user berubah sebelum token kedaluwarsa.
Desain Claim JWT yang Masuk Akal
Jangan isi token terlalu banyak. Claim JWT sebaiknya cukup untuk identitas dan keputusan otorisasi dasar. Contoh claim yang umum:
sub: ID useremail: email userroles: daftar rolepermissions: daftar permissionexp: waktu kedaluwarsaiat: waktu diterbitkanjti: ID token unik, berguna untuk audit atau revocation
Contoh struct claim di Go:
type TokenClaims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
TokenType string `json:"token_type"`
jwt.RegisteredClaims
}Kenapa ada TokenType? Supaya access token dan refresh token tidak tertukar. Kesalahan umum adalah menerima refresh token di middleware auth normal, padahal seharusnya hanya access token yang boleh mengakses resource API.
Struktur Proyek dan Dependensi
Struktur minimal yang rapi:
internal/
auth/
handler.go
middleware.go
token.go
password.go
rbac/
policy.go
middleware.go
user/
repository.go
audit/
logger.go
cmd/
api/
main.goPaket yang umum dipakai:
- Fiber v3 untuk HTTP framework
- Paket JWT Go yang stabil dan umum dipakai
- Driver database sesuai kebutuhan, misalnya PostgreSQL
- Paket hashing password seperti bcrypt atau argon2
Untuk password, jangan pernah simpan plain text. Gunakan bcrypt atau argon2. Jika belum punya alasan kuat, bcrypt sudah cukup baik dan sederhana untuk mayoritas sistem internal.
Implementasi Login: Verifikasi User dan Terbitkan Token
Alur login
- User kirim email dan password.
- Aplikasi mencari user di database.
- Password diverifikasi terhadap hash.
- Jika valid, sistem memuat role dan permission user.
- Sistem menerbitkan access token dan refresh token.
- Event login dicatat ke audit log.
Contoh handler login yang disederhanakan:
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
func (h *Handler) Login(c fiber.Ctx) error {
var req LoginRequest
if err := c.Bind().Body(&req); err != nil {
return c.Status(400).JSON(fiber.Map{
"error": "invalid_request",
"message": "Payload tidak valid",
})
}
user, err := h.UserRepo.FindByEmail(c.Context(), req.Email)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"error": "invalid_credentials",
"message": "Email atau password salah",
})
}
if !CheckPasswordHash(req.Password, user.PasswordHash) {
return c.Status(401).JSON(fiber.Map{
"error": "invalid_credentials",
"message": "Email atau password salah",
})
}
accessToken, accessExp, err := h.TokenSvc.GenerateAccessToken(user)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal_error"})
}
refreshToken, err := h.TokenSvc.GenerateRefreshToken(user)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal_error"})
}
h.Audit.Log("auth.login.success", user.ID, c.IP())
return c.JSON(LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: accessExp.Unix(),
})
}Penting untuk diperhatikan: response error login sebaiknya tidak membocorkan apakah email tidak ditemukan atau password salah. Gunakan pesan umum seperti invalid_credentials agar tidak membantu enumerasi akun.
Menerbitkan token
func (s *TokenService) GenerateAccessToken(user User) (string, time.Time, error) {
exp := time.Now().Add(15 * time.Minute)
claims := TokenClaims{
UserID: user.ID,
Email: user.Email,
Roles: user.Roles,
Permissions: user.Permissions,
TokenType: "access",
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
Subject: user.ID,
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(exp),
Issuer: "internal-api",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(s.AccessSecret))
return signed, exp, err
}Untuk refresh token, gunakan secret terpisah atau minimal claim yang berbeda jelas. Bahkan lebih baik lagi jika refresh token disimpan server-side dalam tabel sesi atau token store agar bisa direvoke individual.
Refresh Token: Kapan Perlu dan Bagaimana Cara Aman
Untuk dashboard internal yang digunakan manusia, refresh token biasanya berguna agar sesi tidak terlalu sering memaksa login ulang. Namun untuk service-to-service, refresh token sering tidak diperlukan; gunakan token singkat dan rotasi otomatis dari sistem identity provider jika ada.
Pola yang relatif aman:
- Access token pendek, misalnya 10-15 menit.
- Refresh token lebih panjang, misalnya 7-30 hari tergantung risiko.
- Simpan refresh token di database dengan status aktif atau revoked.
- Lakukan refresh token rotation: saat refresh dipakai, keluarkan token baru dan nonaktifkan token lama.
Contoh endpoint refresh:
func (h *Handler) Refresh(c fiber.Ctx) error {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.Bind().Body(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid_request"})
}
claims, err := h.TokenSvc.ParseRefreshToken(req.RefreshToken)
if err != nil || claims.TokenType != "refresh" {
return c.Status(401).JSON(fiber.Map{"error": "invalid_token"})
}
if revoked := h.SessionRepo.IsRevoked(c.Context(), claims.ID); revoked {
return c.Status(401).JSON(fiber.Map{"error": "token_revoked"})
}
user, err := h.UserRepo.FindByID(c.Context(), claims.UserID)
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "invalid_token"})
}
accessToken, accessExp, err := h.TokenSvc.GenerateAccessToken(user)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal_error"})
}
return c.JSON(fiber.Map{
"access_token": accessToken,
"expires_in": accessExp.Unix(),
})
}Jika tidak ingin kompleksitas penyimpanan refresh token, Anda bisa menghilangkan fitur ini dan memaksa login ulang saat access token habis. Untuk API internal yang digunakan sedikit user, ini kadang justru lebih sederhana dan lebih aman.
Middleware Auth di Fiber v3
Middleware auth bertugas mengambil bearer token dari header, memverifikasi tanda tangan, memeriksa masa berlaku, lalu menyimpan identitas user ke context request.
func AuthMiddleware(tokenSvc *TokenService) fiber.Handler {
return func(c fiber.Ctx) error {
authz := c.Get("Authorization")
if authz == "" || !strings.HasPrefix(authz, "Bearer ") {
return c.Status(401).JSON(fiber.Map{
"error": "missing_token",
"message": "Authorization bearer token diperlukan",
})
}
rawToken := strings.TrimPrefix(authz, "Bearer ")
claims, err := tokenSvc.ParseAccessToken(rawToken)
if err != nil || claims.TokenType != "access" {
return c.Status(401).JSON(fiber.Map{
"error": "invalid_token",
"message": "Token tidak valid atau kedaluwarsa",
})
}
c.Locals("user_id", claims.UserID)
c.Locals("roles", claims.Roles)
c.Locals("permissions", claims.Permissions)
return c.Next()
}
}Kenapa data disimpan di Locals? Karena route handler berikutnya bisa mengakses hasil verifikasi tanpa perlu parse token ulang. Ini juga menjaga boundary yang jelas antara autentikasi dan business logic.
RBAC: Cek Role dan Permission pada Route
Jangan campurkan semua aturan akses ke satu middleware besar. Lebih baik buat middleware kecil dan spesifik, misalnya RequireRoles dan RequirePermissions.
func RequireRoles(allowed ...string) fiber.Handler {
allowSet := make(map[string]struct{}, len(allowed))
for _, r := range allowed {
allowSet[r] = struct{}{}
}
return func(c fiber.Ctx) error {
roles, _ := c.Locals("roles").([]string)
for _, role := range roles {
if _, ok := allowSet[role]; ok {
return c.Next()
}
}
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
"message": "Akses ditolak",
})
}
}
func RequirePermissions(required ...string) fiber.Handler {
need := make(map[string]struct{}, len(required))
for _, p := range required {
need[p] = struct{}{}
}
return func(c fiber.Ctx) error {
permissions, _ := c.Locals("permissions").([]string)
have := make(map[string]struct{}, len(permissions))
for _, p := range permissions {
have[p] = struct{}{}
}
for p := range need {
if _, ok := have[p]; !ok {
return c.Status(403).JSON(fiber.Map{
"error": "forbidden",
"message": "Permission tidak mencukupi",
})
}
}
return c.Next()
}
}Dengan pola ini, route menjadi jelas dan mudah dibaca:
app.Post("/auth/login", authHandler.Login)
app.Post("/auth/refresh", authHandler.Refresh)
api := app.Group("/api", AuthMiddleware(tokenSvc))
api.Post("/imports", RequirePermissions("import:run"), importHandler.Run)
api.Get("/workers/jobs", RequirePermissions("worker:read"), workerHandler.ListJobs)
api.Get("/admin/users", RequireRoles("admin"), adminHandler.ListUsers)
api.Post("/admin/users/:id/disable", RequireRoles("admin"), adminHandler.DisableUser)Use Case Nyata: Import, Monitoring Worker, dan Endpoint Admin
1. Endpoint import data
Import data biasanya berdampak besar: menulis banyak record, memicu validasi, atau menjalankan background job. Endpoint seperti ini sebaiknya tidak cukup dilindungi hanya oleh role umum seperti operator. Tambahkan permission spesifik import:run. Jika perlu, pisahkan lagi menjadi import:preview dan import:commit.
2. Monitoring job worker
Monitoring worker sering dibutuhkan oleh tim operasional yang tidak boleh mengubah konfigurasi sistem. Maka endpoint baca status job dapat memakai permission worker:read, sedangkan aksi seperti retry, cancel, atau purge queue memakai permission terpisah seperti worker:retry atau worker:manage.
3. Endpoint admin
Fitur admin biasanya mencakup manajemen user, assignment role, dan melihat audit log. Untuk route seperti ini, role admin masuk akal karena cakupannya luas. Namun untuk operasi sangat sensitif, pertimbangkan kombinasi role dan permission, atau bahkan langkah tambahan seperti re-authentication.
Error Response yang Aman dan Konsisten
Jangan kirim stack trace, detail query database, atau pesan error internal ke klien. Buat format error yang stabil dan aman:
{
"error": "forbidden",
"message": "Akses ditolak"
}Pedoman sederhana:
- 400 untuk request tidak valid
- 401 untuk belum login atau token tidak valid
- 403 untuk token valid tetapi tidak punya hak akses
- 404 bila resource tidak ditemukan
- 500 untuk kegagalan internal tanpa detail sensitif
Untuk debugging, simpan detail teknis ke log server, bukan ke response. Ini penting terutama pada API internal yang sering dianggap “aman” hanya karena berada di jaringan perusahaan.
Catatan Keamanan yang Sering Terlewat
Rotasi secret
Jika memakai JWT dengan HMAC secret, siapkan mekanisme rotasi. Minimal, dukung dua secret aktif sementara: secret lama untuk verifikasi token lama dan secret baru untuk menerbitkan token baru. Jika ingin lebih rapi, gunakan header kid pada JWT agar verifier tahu key mana yang dipakai.
Masa berlaku token
Access token sebaiknya pendek. Semakin lama umur token, semakin lama dampak jika token bocor. Untuk API internal, 10-15 menit biasanya masuk akal. Refresh token harus lebih dijaga karena umurnya lebih panjang.
Revocation
JWT stateless punya kelemahan: token yang sudah terbit tetap valid sampai expired, kecuali Anda punya mekanisme revoke. Untuk sistem internal, revoke penting jika user dinonaktifkan atau permission berubah drastis. Solusi praktis:
- simpan versi token user di database lalu cocokkan pada setiap request tertentu, atau
- simpan daftar session/refresh token aktif, atau
- gunakan access token pendek agar dampaknya terbatas.
Audit logging dasar
Catat event penting seperti login sukses, login gagal, refresh token, akses endpoint sensitif, perubahan role, dan aksi admin. Minimal simpan:
- timestamp
- user ID
- IP atau source
- aksi
- resource terkait
- status sukses atau gagal
Audit log tidak harus kompleks sejak awal, tetapi sangat membantu saat investigasi insiden atau troubleshooting izin akses.
Trade-off dan Kesalahan Umum
- Menyimpan terlalu banyak data di token: token menjadi besar dan sulit dikontrol saat data user berubah.
- Tidak membedakan access dan refresh token: berisiko membuat refresh token bisa dipakai ke endpoint biasa.
- Role terlalu kasar: semua operator punya akses terlalu luas. Tambahkan permission granular.
- Tanpa revoke strategy: token tetap valid meski user dinonaktifkan.
- Error terlalu detail: mempermudah attacker memahami sistem.
Jika aturan akses makin kompleks, pertimbangkan naik dari RBAC murni ke kombinasi RBAC dan policy-based authorization. Namun untuk mayoritas API internal, role ditambah permission sudah cukup efektif dan mudah dikelola.
Tips Debugging Saat Auth atau RBAC Bermasalah
- Pastikan header
Authorizationbenar-benar berformatBearer <token>. - Periksa apakah secret signing dan verifying sama.
- Verifikasi waktu server sinkron; perbedaan jam bisa membuat token dianggap expired.
- Cek apakah claim
rolesdanpermissionsbenar-benar terisi saat token dibuat. - Bedakan masalah 401 dan 403; ini sangat membantu mencari sumber bug.
- Log
jtiatau subject token untuk korelasi antara request dan audit log.
Penutup
Implementasi JWT dan RBAC di Go Fiber v3 tidak perlu rumit, tetapi harus dirancang dengan disiplin. Gunakan access token berumur pendek, refresh token hanya jika memang diperlukan, pisahkan autentikasi dari otorisasi, dan lindungi route dengan middleware kecil yang jelas tanggung jawabnya. Untuk API internal, perhatian pada detail seperti error response yang aman, rotasi secret, revoke strategy, dan audit logging sering jauh lebih penting daripada sekadar membuat login berhasil.
Jika Anda membangun endpoint sensitif seperti import data, monitoring job worker, dan fitur admin, jangan bergantung hanya pada satu role global. Kombinasikan role dan permission agar akses lebih presisi, mudah diaudit, dan lebih aman saat sistem berkembang.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!