This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
@@ -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()
}
}