Initial commit - Lost and Found Revisi
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
// internal/middleware/cors.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORSMiddleware handles CORS
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||
|
||||
if ctx.Request.Method == "OPTIONS" {
|
||||
ctx.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// internal/middleware/idempotency.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"lost-and-found/internal/utils"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ✅ KRITERIA BACKEND: Idempotency (5 Poin) - Advanced Feature
|
||||
|
||||
// IdempotencyStore menyimpan hasil request yang sudah diproses
|
||||
type IdempotencyStore struct {
|
||||
mu sync.RWMutex
|
||||
results map[string]*IdempotencyResult
|
||||
}
|
||||
|
||||
// IdempotencyResult menyimpan response dari request sebelumnya
|
||||
type IdempotencyResult struct {
|
||||
StatusCode int
|
||||
Body interface{}
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var idempotencyStore = &IdempotencyStore{
|
||||
results: make(map[string]*IdempotencyResult),
|
||||
}
|
||||
|
||||
// cleanupIdempotencyStore membersihkan hasil yang sudah lama (> 24 jam)
|
||||
func cleanupIdempotencyStore() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
idempotencyStore.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, result := range idempotencyStore.results {
|
||||
if now.Sub(result.Timestamp) > 24*time.Hour {
|
||||
delete(idempotencyStore.results, key)
|
||||
}
|
||||
}
|
||||
idempotencyStore.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func init() {
|
||||
cleanupIdempotencyStore()
|
||||
}
|
||||
|
||||
// IdempotencyMiddleware mencegah double-submit pada endpoint kritis
|
||||
// Endpoint kritis: Payment, Create Order, Transfer, Submit Claim
|
||||
func IdempotencyMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Hanya apply untuk POST/PUT/PATCH methods
|
||||
if ctx.Request.Method != http.MethodPost &&
|
||||
ctx.Request.Method != http.MethodPut &&
|
||||
ctx.Request.Method != http.MethodPatch {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Get idempotency key from header
|
||||
idempotencyKey := ctx.GetHeader("Idempotency-Key")
|
||||
|
||||
// Jika tidak ada idempotency key, skip (tidak wajib untuk semua request)
|
||||
if idempotencyKey == "" {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique key: idempotency-key + user-id + path + method
|
||||
userID, _ := ctx.Get("user_id")
|
||||
uniqueKey := generateUniqueKey(idempotencyKey, fmt.Sprintf("%v", userID), ctx.Request.URL.Path, ctx.Request.Method)
|
||||
|
||||
// Check if request sudah pernah diproses
|
||||
idempotencyStore.mu.RLock()
|
||||
result, exists := idempotencyStore.results[uniqueKey]
|
||||
idempotencyStore.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
// Request sudah pernah diproses, return hasil sebelumnya
|
||||
ctx.JSON(result.StatusCode, gin.H{
|
||||
"success": true,
|
||||
"message": "Request already processed (idempotent)",
|
||||
"data": result.Body,
|
||||
"idempotent": true,
|
||||
"original_at": result.Timestamp,
|
||||
})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Mark request sebagai "processing" untuk prevent concurrent duplicate
|
||||
processingLock := fmt.Sprintf("%s-processing", uniqueKey)
|
||||
idempotencyStore.mu.Lock()
|
||||
if _, processing := idempotencyStore.results[processingLock]; processing {
|
||||
idempotencyStore.mu.Unlock()
|
||||
utils.ErrorResponse(ctx, http.StatusConflict, "Request is being processed", "Duplicate request detected")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// Lock dengan timestamp sebagai marker
|
||||
idempotencyStore.results[processingLock] = &IdempotencyResult{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
idempotencyStore.mu.Unlock()
|
||||
|
||||
// Custom response writer untuk capture hasil
|
||||
blw := &bodyLogWriter{body: []byte{}, ResponseWriter: ctx.Writer}
|
||||
ctx.Writer = blw
|
||||
|
||||
// Process request
|
||||
ctx.Next()
|
||||
|
||||
// Remove processing lock
|
||||
idempotencyStore.mu.Lock()
|
||||
delete(idempotencyStore.results, processingLock)
|
||||
idempotencyStore.mu.Unlock()
|
||||
|
||||
// Simpan hasil hanya jika sukses (2xx status)
|
||||
if ctx.Writer.Status() >= 200 && ctx.Writer.Status() < 300 {
|
||||
idempotencyStore.mu.Lock()
|
||||
idempotencyStore.results[uniqueKey] = &IdempotencyResult{
|
||||
StatusCode: ctx.Writer.Status(),
|
||||
Body: blw.body,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
idempotencyStore.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLogWriter untuk capture response body
|
||||
type bodyLogWriter struct {
|
||||
gin.ResponseWriter
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (w *bodyLogWriter) Write(b []byte) (int, error) {
|
||||
w.body = append(w.body, b...)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// generateUniqueKey membuat unique key dari kombinasi parameter
|
||||
func generateUniqueKey(idempotencyKey, userID, path, method string) string {
|
||||
data := fmt.Sprintf("%s:%s:%s:%s", idempotencyKey, userID, path, method)
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ClearIdempotencyCache membersihkan cache (untuk testing/admin)
|
||||
func ClearIdempotencyCache() {
|
||||
idempotencyStore.mu.Lock()
|
||||
defer idempotencyStore.mu.Unlock()
|
||||
idempotencyStore.results = make(map[string]*IdempotencyResult)
|
||||
}
|
||||
|
||||
// GetIdempotencyCacheSize return ukuran cache (untuk monitoring)
|
||||
func GetIdempotencyCacheSize() int {
|
||||
idempotencyStore.mu.RLock()
|
||||
defer idempotencyStore.mu.RUnlock()
|
||||
return len(idempotencyStore.results)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// internal/middleware/jwt_middleware.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"lost-and-found/internal/config"
|
||||
"lost-and-found/internal/repositories"
|
||||
"lost-and-found/internal/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// JWTMiddleware validates JWT token
|
||||
func JWTMiddleware(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Get token from Authorization header
|
||||
authHeader := ctx.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authorization header required", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if Bearer token
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid authorization format", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// Validate token
|
||||
claims, err := config.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid or expired token", err.Error())
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
user, err := userRepo.FindByID(claims.UserID)
|
||||
if err != nil {
|
||||
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is blocked
|
||||
if user.IsBlocked() {
|
||||
utils.ErrorResponse(ctx, http.StatusForbidden, "Account is blocked", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Set user in context
|
||||
ctx.Set("user", user)
|
||||
ctx.Set("user_id", user.ID)
|
||||
ctx.Set("user_role", user.Role.Name)
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalJWTMiddleware validates JWT token if present (for public routes that can benefit from auth)
|
||||
func OptionalJWTMiddleware(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
authHeader := ctx.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
claims, err := config.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
user, err := userRepo.FindByID(claims.UserID)
|
||||
if err != nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsBlocked() {
|
||||
ctx.Set("user", user)
|
||||
ctx.Set("user_id", user.ID)
|
||||
ctx.Set("user_role", user.Role.Name)
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// internal/middleware/logger.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LoggerMiddleware logs HTTP requests
|
||||
func LoggerMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Start timer
|
||||
startTime := time.Now()
|
||||
|
||||
// Process request
|
||||
ctx.Next()
|
||||
|
||||
// Calculate latency
|
||||
latency := time.Since(startTime)
|
||||
|
||||
// Get request info
|
||||
method := ctx.Request.Method
|
||||
path := ctx.Request.URL.Path
|
||||
statusCode := ctx.Writer.Status()
|
||||
clientIP := ctx.ClientIP()
|
||||
|
||||
// Get user ID if authenticated
|
||||
userID := "guest"
|
||||
if id, exists := ctx.Get("user_id"); exists {
|
||||
userID = fmt.Sprintf("%v", id)
|
||||
}
|
||||
|
||||
// Log format
|
||||
fmt.Printf("[%s] %s | %3d | %13v | %15s | %s | User: %s\n",
|
||||
time.Now().Format("2006-01-02 15:04:05"),
|
||||
method,
|
||||
statusCode,
|
||||
latency,
|
||||
clientIP,
|
||||
path,
|
||||
userID,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// internal/middleware/rate_limiter.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"lost-and-found/internal/utils"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RateLimiter stores rate limit data
|
||||
type RateLimiter struct {
|
||||
visitors map[string]*Visitor
|
||||
mu sync.RWMutex
|
||||
rate int // requests per window
|
||||
window time.Duration // time window
|
||||
}
|
||||
|
||||
// Visitor represents a visitor's rate limit data
|
||||
type Visitor struct {
|
||||
lastSeen time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
var limiter *RateLimiter
|
||||
|
||||
// InitRateLimiter initializes the rate limiter
|
||||
func InitRateLimiter(rate int, window time.Duration) {
|
||||
limiter = &RateLimiter{
|
||||
visitors: make(map[string]*Visitor),
|
||||
rate: rate,
|
||||
window: window,
|
||||
}
|
||||
|
||||
// Cleanup old visitors every minute
|
||||
go limiter.cleanupVisitors()
|
||||
}
|
||||
|
||||
// cleanupVisitors removes old visitor entries
|
||||
func (rl *RateLimiter) cleanupVisitors() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
rl.mu.Lock()
|
||||
for ip, visitor := range rl.visitors {
|
||||
if time.Since(visitor.lastSeen) > rl.window {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// getVisitor retrieves or creates a visitor
|
||||
func (rl *RateLimiter) getVisitor(ip string) *Visitor {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
visitor, exists := rl.visitors[ip]
|
||||
if !exists {
|
||||
visitor = &Visitor{
|
||||
lastSeen: time.Now(),
|
||||
count: 0,
|
||||
}
|
||||
rl.visitors[ip] = visitor
|
||||
}
|
||||
|
||||
return visitor
|
||||
}
|
||||
|
||||
// isAllowed checks if request is allowed
|
||||
func (rl *RateLimiter) isAllowed(ip string) bool {
|
||||
visitor := rl.getVisitor(ip)
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
// Reset count if window has passed
|
||||
if time.Since(visitor.lastSeen) > rl.window {
|
||||
visitor.count = 0
|
||||
visitor.lastSeen = time.Now()
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if visitor.count >= rl.rate {
|
||||
return false
|
||||
}
|
||||
|
||||
visitor.count++
|
||||
visitor.lastSeen = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// RateLimiterMiddleware applies rate limiting
|
||||
func RateLimiterMiddleware() gin.HandlerFunc {
|
||||
// Initialize rate limiter (1000 requests per minute)
|
||||
if limiter == nil {
|
||||
InitRateLimiter(1000, time.Minute)
|
||||
}
|
||||
|
||||
return func(ctx *gin.Context) {
|
||||
ip := ctx.ClientIP()
|
||||
|
||||
if !limiter.isAllowed(ip) {
|
||||
utils.ErrorResponse(ctx, http.StatusTooManyRequests, "Rate limit exceeded", "Too many requests, please try again later")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// internal/middleware/role_middleware.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"lost-and-found/internal/models"
|
||||
"lost-and-found/internal/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RequirePermission(requiredPerm string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// 1. Ambil object user dari context (diset oleh JWTMiddleware)
|
||||
userObj, exists := ctx.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user := userObj.(*models.User)
|
||||
|
||||
// 2. Cek Permission menggunakan method helper di model User
|
||||
// Pastikan method HasPermission sudah ditambahkan di internal/models/user.go
|
||||
if !user.HasPermission(requiredPerm) {
|
||||
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "Missing permission: "+requiredPerm)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole checks if user has required role
|
||||
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
userObj, exists := ctx.Get("user")
|
||||
if !exists {
|
||||
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user := userObj.(*models.User)
|
||||
userRole := user.Role.Name
|
||||
|
||||
// Check if user has allowed role
|
||||
hasRole := false
|
||||
for _, role := range allowedRoles {
|
||||
if userRole == role {
|
||||
hasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRole {
|
||||
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin middleware (admin only)
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleAdmin)
|
||||
}
|
||||
|
||||
// RequireManager middleware (manager and admin)
|
||||
func RequireManager() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleAdmin, models.RoleManager)
|
||||
}
|
||||
|
||||
// RequireUser middleware (all authenticated users)
|
||||
func RequireUser() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleAdmin, models.RoleManager, models.RoleUser)
|
||||
}
|
||||
Reference in New Issue
Block a user