first commit
This commit is contained in:
69
internal/middleware/auth.go
Normal file
69
internal/middleware/auth.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Error(c, http.StatusUnauthorized, "token tidak ditemukan", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
response.Error(c, http.StatusUnauthorized, "format token tidak valid", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusUnauthorized, "token tidak valid atau kedaluwarsa", err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRoles(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
roleVal, exists := c.Get("role")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "akses ditolak: autentikasi diperlukan", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userRole, ok := roleVal.(string)
|
||||
if !ok {
|
||||
response.Error(c, http.StatusForbidden, "akses ditolak: role tidak valid", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
for _, role := range allowedRoles {
|
||||
if strings.EqualFold(userRole, role) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Error(c, http.StatusForbidden, "akses ditolak: anda tidak memiliki hak akses", nil)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
23
internal/middleware/cors.go
Normal file
23
internal/middleware/cors.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS mengizinkan request dari origin lain
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
78
internal/middleware/logger.go
Normal file
78
internal/middleware/logger.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Logger mencatat setiap request: method, path, status, durasi, IP, dan body request
|
||||
// (kalau JSON) supaya gampang di-debug. Field sensitif seperti password, token, dsb
|
||||
// OTOMATIS disensor oleh package logger (lihat internal/pkg/logger), jadi aman
|
||||
// dipasang bahkan di endpoint login/register.
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
bodyFields := readBodyForLog(c)
|
||||
|
||||
c.Next()
|
||||
|
||||
duration := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
fields := logger.Fields{
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"status": status,
|
||||
"duration": duration.String(),
|
||||
"ip": c.ClientIP(),
|
||||
}
|
||||
if bodyFields != nil {
|
||||
fields["body"] = bodyFields
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
fields["gin_errors"] = c.Errors.String()
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
logger.Error("request selesai", fields)
|
||||
case status >= 400:
|
||||
logger.Warn("request selesai", fields)
|
||||
default:
|
||||
logger.Info("request selesai", fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readBodyForLog membaca body request (kalau berupa JSON) untuk keperluan log,
|
||||
// lalu mengembalikan body itu ke request supaya handler asli tetap bisa membacanya
|
||||
// seperti biasa. Field sensitif di dalam body (misal "password") akan disensor
|
||||
// otomatis oleh package logger saat log ditulis, bukan di sini.
|
||||
func readBodyForLog(c *gin.Context) map[string]interface{} {
|
||||
if c.Request.Body == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Request.Method != "POST" && c.Request.Method != "PUT" && c.Request.Method != "PATCH" {
|
||||
return nil
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// kembalikan body supaya bisa dibaca lagi oleh ShouldBindJSON di handler
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &parsed); err != nil {
|
||||
return nil // bukan JSON (atau body kosong), tidak masalah, cukup skip
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
94
internal/middleware/rate_limitter.go
Normal file
94
internal/middleware/rate_limitter.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// visitor menyimpan limiter token bucket milik satu IP + waktu terakhir dia request
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// visitorStore menyimpan limiter per-IP secara thread-safe.
|
||||
// Setiap IP punya "kantong token" sendiri, jadi satu IP yang spam tidak
|
||||
// mempengaruhi jatah IP lain.
|
||||
type visitorStore struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
rps rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func newVisitorStore(requestsPerSecond float64, burst int) *visitorStore {
|
||||
vs := &visitorStore{
|
||||
visitors: make(map[string]*visitor),
|
||||
rps: rate.Limit(requestsPerSecond),
|
||||
burst: burst,
|
||||
}
|
||||
go vs.cleanupLoop()
|
||||
return vs
|
||||
}
|
||||
|
||||
func (vs *visitorStore) getLimiter(ip string) *rate.Limiter {
|
||||
vs.mu.Lock()
|
||||
defer vs.mu.Unlock()
|
||||
|
||||
v, exists := vs.visitors[ip]
|
||||
if !exists {
|
||||
limiter := rate.NewLimiter(vs.rps, vs.burst)
|
||||
vs.visitors[ip] = &visitor{limiter: limiter, lastSeen: time.Now()}
|
||||
return limiter
|
||||
}
|
||||
|
||||
v.lastSeen = time.Now()
|
||||
return v.limiter
|
||||
}
|
||||
|
||||
// cleanupLoop membuang data IP yang sudah tidak aktif > 3 menit supaya memori tidak terus membengkak
|
||||
func (vs *visitorStore) cleanupLoop() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
|
||||
vs.mu.Lock()
|
||||
for ip, v := range vs.visitors {
|
||||
if time.Since(v.lastSeen) > 3*time.Minute {
|
||||
delete(vs.visitors, ip)
|
||||
}
|
||||
}
|
||||
vs.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimiter membatasi jumlah request per-IP menggunakan algoritma token bucket.
|
||||
//
|
||||
// - requestsPerSecond: rata-rata request yang diizinkan per detik (token yang "diisi ulang" per detik)
|
||||
// - burst: jumlah request maksimum yang boleh "meledak" sekaligus (kapasitas kantong token)
|
||||
//
|
||||
// Contoh: RateLimiter(5, 10) artinya rata-rata 5 request/detik diizinkan,
|
||||
// tapi boleh burst sampai 10 request sekaligus selama tokennya masih ada.
|
||||
//
|
||||
// Response saat limit terlampaui: HTTP 429 Too Many Requests.
|
||||
func RateLimiter(requestsPerSecond float64, burst int) gin.HandlerFunc {
|
||||
store := newVisitorStore(requestsPerSecond, burst)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
limiter := store.getLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
response.Error(c, http.StatusTooManyRequests, "terlalu banyak request, coba lagi nanti", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
78
internal/middleware/rate_limitter_test.go
Normal file
78
internal/middleware/rate_limitter_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupRouterWithRateLimit(rps float64, burst int) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(RateLimiter(rps, burst))
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRateLimiter_MengizinkanRequestSelamaTokenTersedia(t *testing.T) {
|
||||
// burst 3 artinya 3 request pertama harus tetap lolos meski dikirim beruntun
|
||||
r := setupRouterWithRateLimit(1, 3)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request ke-%d: mau status 200, dapat %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_MenolakSetelahBurstHabis(t *testing.T) {
|
||||
// burst 2: request ke-3 yang dikirim beruntun (tanpa jeda) harus ditolak 429
|
||||
r := setupRouterWithRateLimit(1, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request ke-%d seharusnya lolos, dapat status %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request ke-3 seharusnya ditolak 429, dapat status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_IPBerbedaPunyaJatahTerpisah(t *testing.T) {
|
||||
// burst 1: IP A dan IP B masing-masing harus dapat 1 jatah request sendiri-sendiri
|
||||
r := setupRouterWithRateLimit(1, 1)
|
||||
|
||||
reqA := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
reqA.RemoteAddr = "1.1.1.1:1234"
|
||||
wA := httptest.NewRecorder()
|
||||
r.ServeHTTP(wA, reqA)
|
||||
|
||||
reqB := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
reqB.RemoteAddr = "2.2.2.2:5678"
|
||||
wB := httptest.NewRecorder()
|
||||
r.ServeHTTP(wB, reqB)
|
||||
|
||||
if wA.Code != http.StatusOK {
|
||||
t.Errorf("IP A request pertama seharusnya lolos, dapat status %d", wA.Code)
|
||||
}
|
||||
if wB.Code != http.StatusOK {
|
||||
t.Errorf("IP B request pertama seharusnya lolos meski IP A sudah pakai jatahnya, dapat status %d", wB.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user