Adds GET and PUT /outlets/:id/loyalty-settings (docs/prd-point-coin.md F1, PC-201) on top of the typed settings processor. The response shows every setting with its default when unset, the organization's point value, and the effective EnakPoint cashback (earn_value × point_value / earn_per_amount), so an owner cannot misread the scale. PUT applies the body on top of the current settings: fields left out keep their value, null clears an optional limit, and unknown fields are refused so a typo cannot be ignored silently. The read-only fields of the GET response are accepted and ignored, so a client can send back what it received. It returns the keys that changed. Values outside the F1 bounds answer 400, and an outlet of another organization 404. RequireAdminOrManager also lets the purchasing role through, so loyalty settings and the manual wallet adjustment from PC-107 now use a stricter RequireLoyaltyManager (superadmin, admin, manager, owner). Adds a test that registers every route, since gin panics at startup when two routes name the same path parameter differently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"apskel-pos-be/internal/appcontext"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/contract"
|
|
"apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type AuthMiddleware struct {
|
|
authService service.AuthService
|
|
}
|
|
|
|
func NewAuthMiddleware(authService service.AuthService) *AuthMiddleware {
|
|
return &AuthMiddleware{
|
|
authService: authService,
|
|
}
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := m.extractTokenFromHeader(c)
|
|
if token == "" {
|
|
logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireAuth -> Missing authorization token")
|
|
m.sendErrorResponse(c, "Authorization token is required", http.StatusUnauthorized)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
userResponse, err := m.authService.ValidateToken(token)
|
|
if err != nil {
|
|
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthMiddleware::RequireAuth -> Invalid token")
|
|
m.sendErrorResponse(c, "Invalid or expired token", http.StatusUnauthorized)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
setKeyInContext(c, appcontext.UserRoleKey, userResponse.Role)
|
|
setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String())
|
|
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
|
|
|
|
// Always override OutletID from token to prevent header injection.
|
|
// Set empty string if user has no outlet, so PopulateContext header value is ignored.
|
|
outletIDStr := ""
|
|
if userResponse.OutletID != nil && *userResponse.OutletID != uuid.Nil {
|
|
outletIDStr = userResponse.OutletID.String()
|
|
}
|
|
setKeyInContext(c, appcontext.OutletIDKey, outletIDStr)
|
|
|
|
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
appCtx := appcontext.FromGinContext(c.Request.Context())
|
|
|
|
hasRequiredRole := false
|
|
for _, role := range allowedRoles {
|
|
if appCtx.UserRole == role {
|
|
hasRequiredRole = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasRequiredRole {
|
|
m.sendErrorResponse(c, "Insufficient permissions", http.StatusForbidden)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
|
}
|
|
|
|
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
|
// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only
|
|
// admins and managers may do these; unlike RequireAdminOrManager it does not let the
|
|
// purchasing role through.
|
|
func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc {
|
|
return m.RequireRole("superadmin", "admin", "manager", "owner")
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
|
return m.RequireRole("admin")
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireSuperAdmin() gin.HandlerFunc {
|
|
return m.RequireRole("superadmin")
|
|
}
|
|
|
|
func (m *AuthMiddleware) RequireActiveUser() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
userResponse, exists := c.Get("user")
|
|
if !exists {
|
|
logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireActiveUser -> User not authenticated")
|
|
m.sendErrorResponse(c, "Authentication required", http.StatusUnauthorized)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
user, ok := userResponse.(*contract.UserResponse)
|
|
if !ok {
|
|
logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireActiveUser -> Invalid user context")
|
|
m.sendErrorResponse(c, "Invalid user context", http.StatusInternalServerError)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
if !user.IsActive {
|
|
logger.FromContext(c.Request.Context()).Errorf("AuthMiddleware::RequireActiveUser -> User account is deactivated: %s", user.Email)
|
|
m.sendErrorResponse(c, "User account is deactivated", http.StatusForbidden)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireActiveUser -> Active user check passed: %s", user.Email)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (m *AuthMiddleware) extractTokenFromHeader(c *gin.Context) string {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
return ""
|
|
}
|
|
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
return ""
|
|
}
|
|
|
|
return parts[1]
|
|
}
|
|
|
|
func (m *AuthMiddleware) sendErrorResponse(c *gin.Context, message string, statusCode int) {
|
|
errorResponse := &contract.ErrorResponse{
|
|
Error: "auth_error",
|
|
Message: message,
|
|
Code: statusCode,
|
|
Details: map[string]interface{}{
|
|
"entity": constants.AuthHandlerEntity,
|
|
},
|
|
}
|
|
c.JSON(statusCode, errorResponse)
|
|
}
|