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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user