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
+15
View File
@@ -111,6 +111,21 @@ func TestJWTValidateAccessTokenInvalid(t *testing.T) {
assert.Error(t, err)
}
func TestJWTValidationAcceptsPreviousSecretDuringRotation(t *testing.T) {
oldConfig := &config.JWTConfig{Secret: "4kM9sT2vX7qP1dR8nC5hL3wF6bJ0zYgU", ExpiryHours: 1, RefreshExpiryHours: 24}
user := &model.User{Base: model.Base{ID: 1}, Provider: "email"}
pair, err := NewJWTService(oldConfig).GenerateTokenPair(user, 10, "agent")
require.NoError(t, err)
rotated := NewJWTService(&config.JWTConfig{
Secret: "9pN2xR7mV4kD8sQ1cF6hT3wL5bJ0zYgU",
PreviousSecrets: []string{oldConfig.Secret},
})
claims, err := rotated.ValidateAccessToken(pair.AccessToken)
require.NoError(t, err)
assert.Equal(t, user.ID, claims.UserID)
}
func TestJWTValidateAccessTokenRefreshTokenRejected(t *testing.T) {
cfg := &config.JWTConfig{Secret: "testsecret", ExpiryHours: 1, RefreshExpiryHours: 24}
svc := NewJWTService(cfg)
+34 -42
View File
@@ -44,6 +44,12 @@ func NewJWTService(cfg *config.JWTConfig) *JWTService {
return &JWTService{cfg: cfg}
}
// InsecureHeaderAuthAllowed reports whether trusted dev/test callers may use
// X-User-ID instead of a signed token. Release config validation rejects this.
func (s *JWTService) InsecureHeaderAuthAllowed() bool {
return s != nil && s.cfg != nil && s.cfg.AllowInsecureHeaderAuth
}
// GenerateTokenPair generates an access token + refresh token pair.
// Access Token: 15min expiry with full Claims
// Refresh Token: 7 days expiry, only UserID + Provider
@@ -116,54 +122,40 @@ func (s *JWTService) GenerateTokenPairForClient(user *model.User, accountID uint
// ValidateAccessToken validates an access JWT token and returns claims.
func (s *JWTService) ValidateAccessToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.cfg.Secret), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
// Verify it's an access token (not a refresh token)
if claims.Subject != fmt.Sprintf("user_%d", claims.UserID) {
return nil, errors.New("invalid token type: expected access token")
}
return claims, nil
return s.validateToken(tokenString, "user", "access")
}
// ValidateRefreshToken validates a refresh JWT token and returns claims.
func (s *JWTService) ValidateRefreshToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
return s.validateToken(tokenString, "refresh", "refresh")
}
func (s *JWTService) validateToken(tokenString, subjectPrefix, kind string) (*Claims, error) {
if s == nil || s.cfg == nil {
return nil, errors.New("JWT service is not configured")
}
secrets := append([]string{s.cfg.Secret}, s.cfg.PreviousSecrets...)
var lastErr error
for _, secret := range secrets {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
lastErr = err
continue
}
return []byte(s.cfg.Secret), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
if !token.Valid {
lastErr = errors.New("invalid token claims")
continue
}
if claims.Subject != fmt.Sprintf("%s_%d", subjectPrefix, claims.UserID) {
return nil, fmt.Errorf("invalid token type: expected %s token", kind)
}
return claims, nil
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid refresh token claims")
}
// Verify it's a refresh token
if claims.Subject != fmt.Sprintf("refresh_%d", claims.UserID) {
return nil, errors.New("invalid token type: expected refresh token")
}
return claims, nil
return nil, fmt.Errorf("failed to parse %s token: %w", kind, lastErr)
}
// RefreshAccessToken generates a new access token from a valid refresh token.