HH-437: harden production auth and tenant authorization (#84)

* HH-437 harden auth and account authorization

* HH-437 reject revoked platform access

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-21 19:13:10 +08:00
committed by GitHub
co-authored by rogee
parent 04c1654086
commit cf263d10b4
22 changed files with 442 additions and 90 deletions
@@ -76,6 +76,9 @@ func AccountScope() gin.HandlerFunc {
}
}
}
if isSuperAdminContext(c) {
roleStr = "super_admin"
}
// Step 5: Get custom_role_id from JWT claims
customRoleID := uint(0)
@@ -150,6 +153,13 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc {
}
c.Set("account_id", accountID)
if isSuperAdminContext(c) {
c.Set("role", "super_admin")
c.Set("custom_role_id", uint(0))
c.Set("policy_context", auth.NewPolicyContext(userID.(uint), accountID, "super_admin", 0, nil))
c.Next()
return
}
// Look up AccountUser to get role and CustomRoleID
role, customRoleID, err := lookup.GetAccountUserRole(userID.(uint), accountID)
@@ -201,6 +211,9 @@ func resolveScopedAccountID(c *gin.Context) (uint, bool) {
if !hasRouteAccountID {
return contextAccountID, true
}
if isSuperAdminContext(c) {
return routeAccountID, true
}
if contextAccountID == 0 {
return routeAccountID, true
}
@@ -259,6 +272,11 @@ func getAccountID(c *gin.Context) uint {
return 0
}
func isSuperAdminContext(c *gin.Context) bool {
userType, exists := c.Get("user_type")
return exists && isSuperAdminType(userType)
}
// --- RBACLookup Interface ---
// Defines what the middleware needs from the service layer.
// The service package implements this interface, avoiding circular imports.
@@ -207,3 +207,21 @@ func TestAccountScopeWithService_AllowsVerifiedAccountSwitch(t *testing.T) {
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/3", nil))
assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
}
func TestAccountScopeWithService_AllowsSuperAdminAcrossAccounts(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("user_id", uint(1))
c.Set("account_id", uint(1))
c.Set("user_type", "super_admin")
c.Next()
})
r.Use(AccountScopeWithService(accountScopeLookup{accountID: 999}))
r.PATCH("/api/v1/accounts/:account_id", SuperAdminOrAdministrator(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPatch, "/api/v1/accounts/2", nil))
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
+4 -3
View File
@@ -21,7 +21,7 @@ import (
//
// Auth flow:
// 1. Extract JWT from Authorization Bearer header → set user_id/account_id from claims
// 2. If no Authorization header, fall back to X-User-ID / X-Account-ID headers (dev/test mode)
// 2. If explicitly enabled, fall back to X-User-ID / X-Account-ID headers (dev/test only)
// 3. Abort with 401 if neither source provides a valid identity
func AuthMiddleware(cfg *config.JWTConfig) gin.HandlerFunc {
jwtSvc := auth.NewJWTService(cfg)
@@ -68,11 +68,12 @@ func AuthMiddlewareWithServiceAndDB(jwtSvc *auth.JWTService, db *gorm.DB) gin.Ha
return
}
// Fallback: X-User-ID / X-Account-ID headers for development and testing.
// Fallback: X-User-ID / X-Account-ID headers for explicitly enabled
// development and test environments only.
// This allows integration tests and dev environments to bypass JWT while
// still exercising the same middleware → handler pipeline.
headerUserID := c.GetHeader("X-User-ID")
if headerUserID != "" {
if headerUserID != "" && jwtSvc.InsecureHeaderAuthAllowed() {
userID, err := strconv.ParseUint(headerUserID, 10, 32)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid X-User-ID header"})
+16 -1
View File
@@ -196,9 +196,10 @@ func TestAuthMiddleware_AllowsPlatformAdminThroughSuperAdminGuard(t *testing.T)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuthMiddleware_FallbackHeaders(t *testing.T) {
func TestAuthMiddleware_FallbackHeadersRequireExplicitOptIn(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := makeJWTConfig()
cfg.AllowInsecureHeaderAuth = true
r := gin.New()
r.Use(AuthMiddleware(cfg))
r.GET("/test", func(c *gin.Context) {
@@ -215,9 +216,23 @@ func TestAuthMiddleware_FallbackHeaders(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func TestAuthMiddleware_RejectsFallbackHeadersByDefault(t *testing.T) {
r := gin.New()
r.Use(AuthMiddleware(makeJWTConfig()))
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("X-User-ID", "5")
req.Header.Set("X-Account-ID", "10")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthMiddleware_FallbackInvalidUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := makeJWTConfig()
cfg.AllowInsecureHeaderAuth = true
r := gin.New()
r.Use(AuthMiddleware(cfg))
r.GET("/test", func(c *gin.Context) {
@@ -1343,7 +1343,7 @@ func TestAuthMiddleware_AccessTokenHeader_Cov7(t *testing.T) {
}
func TestAuthMiddleware_XUserID_Cov7(t *testing.T) {
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1})
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1, AllowInsecureHeaderAuth: true})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
@@ -1355,7 +1355,7 @@ func TestAuthMiddleware_XUserID_Cov7(t *testing.T) {
}
func TestAuthMiddleware_InvalidXUserID_Cov7(t *testing.T) {
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1})
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1, AllowInsecureHeaderAuth: true})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
@@ -1365,7 +1365,7 @@ func TestAuthMiddleware_InvalidXUserID_Cov7(t *testing.T) {
}
func TestAuthMiddleware_XUserIDWithAccount_Cov7(t *testing.T) {
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1})
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1, AllowInsecureHeaderAuth: true})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
@@ -16,6 +16,7 @@ import (
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -67,6 +68,11 @@ func PlatformAppAuth(db *gorm.DB) gin.HandlerFunc {
"Failed to verify access token")
return
}
if accessToken.ExpiresAt != nil && !accessToken.ExpiresAt.After(time.Now()) {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
"Invalid access_token")
return
}
// Step 4: Load the PlatformApp with its Permissibles
var platformApp model.PlatformApp
@@ -75,6 +81,11 @@ func PlatformAppAuth(db *gorm.DB) gin.HandlerFunc {
"Invalid access_token")
return
}
if !platformApp.IsActive() {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
"Invalid access_token")
return
}
// Step 5: Set platform_app in Gin context for downstream handlers
c.Set("platform_app", platformApp)
@@ -83,4 +94,4 @@ func PlatformAppAuth(db *gorm.DB) gin.HandlerFunc {
c.Next()
}
}
}
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -96,6 +97,71 @@ func TestPlatformAppAuth_InvalidToken(t *testing.T) {
assert.Equal(t, 401, w.Code)
}
func TestPlatformAppAuth_InactiveOrDisabledAppRejected(t *testing.T) {
for _, tc := range []struct {
name string
active *bool
status string
}{
{name: "inactive", active: ptrBool(false), status: "active"},
{name: "disabled", active: ptrBool(true), status: "disabled"},
} {
t.Run(tc.name, func(t *testing.T) {
db := newPlatformTestDB(t)
pa := model.PlatformApp{Name: tc.name, Active: tc.active, Status: tc.status}
require.NoError(t, db.Create(&pa).Error)
rawToken := "inactive-platform-token"
hash := sha256.Sum256([]byte(rawToken))
require.NoError(t, db.Create(&model.AccessToken{
OwnerType: model.AccessTokenOwnerTypePlatformApp,
OwnerID: pa.ID,
Token: hex.EncodeToString(hash[:]),
TokenPrefix: rawToken[:8],
}).Error)
router := gin.New()
router.Use(PlatformAppAuth(db))
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("api_access_token", rawToken)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
}
func TestPlatformAppAuth_ExpiredTokenRejected(t *testing.T) {
db := newPlatformTestDB(t)
pa := model.PlatformApp{Name: "ExpiredTokenApp", Active: ptrBool(true), Status: "active"}
require.NoError(t, db.Create(&pa).Error)
rawToken := "expired-platform-token"
hash := sha256.Sum256([]byte(rawToken))
expiresAt := time.Now().Add(-time.Minute)
require.NoError(t, db.Create(&model.AccessToken{
OwnerType: model.AccessTokenOwnerTypePlatformApp,
OwnerID: pa.ID,
Token: hex.EncodeToString(hash[:]),
TokenPrefix: rawToken[:8],
ExpiresAt: &expiresAt,
}).Error)
router := gin.New()
router.Use(PlatformAppAuth(db))
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("api_access_token", rawToken)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestPlatformAppAuth_UserTokenRejected(t *testing.T) {
db := newPlatformTestDB(t)
router := gin.New()
@@ -205,4 +271,4 @@ func TestPlatformAppAuth_ContextValues(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func ptrBool(b bool) *bool { return &b }
func ptrBool(b bool) *bool { return &b }