first
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Package middleware provides HTTP middleware shared across modules,
|
||||
// notably the admin/customer auth guards, CORS and rate limiting.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxUserIDKey = "auth_user_id"
|
||||
ctxRoleKey = "auth_role"
|
||||
)
|
||||
|
||||
func extractBearerToken(c *gin.Context) (string, bool) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// requireAudience builds a guard that only accepts access tokens issued for
|
||||
// the given audience (admin or customer space) and role. Because the JWT
|
||||
// audience and the expected role are both checked, an admin-space token can
|
||||
// never authenticate a customer-only route, and vice versa.
|
||||
func requireAudience(secret string, aud security.Audience, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString, ok := extractBearerToken(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := security.ParseAccessToken(secret, tokenString, aud)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != role {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid subject claim"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxUserIDKey, userID)
|
||||
c.Set(ctxRoleKey, claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin protects admin-panel routes. Only tokens issued in the admin
|
||||
// audience with role "admin" pass.
|
||||
func RequireAdmin(secret string) gin.HandlerFunc {
|
||||
return requireAudience(secret, security.AudienceAdmin, "admin")
|
||||
}
|
||||
|
||||
// RequireCustomer protects storefront account routes. Only tokens issued in
|
||||
// the customer audience with role "customer" pass. Not mounted yet in this
|
||||
// phase (no customer-facing routes), but kept fully separate from
|
||||
// RequireAdmin so activating the customer module later never risks sharing
|
||||
// a session/cookie space with the admin panel.
|
||||
func RequireCustomer(secret string) gin.HandlerFunc {
|
||||
return requireAudience(secret, security.AudienceCustomer, "customer")
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user's ID set by RequireAdmin/RequireCustomer.
|
||||
func GetUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, exists := c.Get(ctxUserIDKey)
|
||||
if !exists {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
id, ok := v.(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// GetRole returns the authenticated user's role set by RequireAdmin/RequireCustomer.
|
||||
func GetRole(c *gin.Context) (string, bool) {
|
||||
v, exists := c.Get(ctxRoleKey)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
role, ok := v.(string)
|
||||
return role, ok
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS allows only the configured frontend origin, with credentials enabled
|
||||
// (required for the httpOnly refresh-token cookie to be sent cross-origin
|
||||
// between the Vite dev server and the API in development).
|
||||
func CORS(allowedOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && origin == allowedOrigin {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// PerIPRateLimiter limits requests per client IP, intended for sensitive
|
||||
// low-frequency endpoints like login (defense against credential stuffing
|
||||
// / brute force).
|
||||
type PerIPRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func NewPerIPRateLimiter(requestsPerMinute int, burst int) *PerIPRateLimiter {
|
||||
l := &PerIPRateLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
r: rate.Every(time.Minute / time.Duration(requestsPerMinute)),
|
||||
burst: burst,
|
||||
}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
limiter, exists := l.limiters[ip]
|
||||
if !exists {
|
||||
limiter = rate.NewLimiter(l.r, l.burst)
|
||||
l.limiters[ip] = limiter
|
||||
}
|
||||
return limiter
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) cleanupLoop() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
l.mu.Lock()
|
||||
for ip, limiter := range l.limiters {
|
||||
if limiter.Tokens() >= float64(l.burst) {
|
||||
delete(l.limiters, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !l.getLimiter(ip).Allow() {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user