fix: close p0 auth and release gate gaps
This commit is contained in:
@@ -2,9 +2,12 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -30,21 +33,23 @@ type TokenClaims struct {
|
||||
|
||||
// AuthConfig 鉴权中间件配置
|
||||
type AuthConfig struct {
|
||||
SecretKey string
|
||||
Issuer string
|
||||
CacheTTL time.Duration // token状态缓存TTL
|
||||
Enabled bool // 是否启用鉴权
|
||||
TrustedProxies []string // 可信代理IP列表CIDR,如 "10.0.0.0/8"
|
||||
SecretKey string
|
||||
PublicKey string
|
||||
Algorithm string
|
||||
Issuer string
|
||||
CacheTTL time.Duration // token状态缓存TTL
|
||||
Enabled bool // 是否启用鉴权
|
||||
TrustedProxies []string // 可信代理IP列表CIDR,如 "10.0.0.0/8"
|
||||
}
|
||||
|
||||
// AuthMiddleware 鉴权中间件
|
||||
type AuthMiddleware struct {
|
||||
config AuthConfig
|
||||
tokenCache *TokenCache
|
||||
tokenBackend TokenStatusBackend
|
||||
auditEmitter AuditEmitter
|
||||
bruteForce *BruteForceProtection // 暴力破解保护
|
||||
trustedProxies []string // 可信代理列表
|
||||
config AuthConfig
|
||||
tokenCache *TokenCache
|
||||
tokenBackend TokenStatusBackend
|
||||
auditEmitter AuditEmitter
|
||||
bruteForce *BruteForceProtection // 暴力破解保护
|
||||
trustedProxies []string // 可信代理列表
|
||||
}
|
||||
|
||||
// TokenStatusBackend Token状态后端查询接口
|
||||
@@ -200,6 +205,11 @@ func (b *BruteForceProtection) Len() int {
|
||||
// 对应M-016指标
|
||||
func (m *AuthMiddleware) QueryKeyRejectMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if shouldBypassAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查query string中的可疑参数
|
||||
queryParams := r.URL.Query()
|
||||
|
||||
@@ -257,6 +267,11 @@ func (m *AuthMiddleware) QueryKeyRejectMiddleware(next http.Handler) http.Handle
|
||||
// BearerExtractMiddleware 提取Bearer Token
|
||||
func (m *AuthMiddleware) BearerExtractMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if shouldBypassAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
||||
if authHeader == "" {
|
||||
@@ -299,6 +314,11 @@ func (m *AuthMiddleware) BearerExtractMiddleware(next http.Handler) http.Handler
|
||||
// MED-12: 添加暴力破解保护
|
||||
func (m *AuthMiddleware) TokenVerifyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if shouldBypassAuth(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果鉴权被禁用(仅用于开发环境),直接跳过验证
|
||||
if !m.config.Enabled {
|
||||
// 在开发模式下,虽然跳过JWT验证,但仍记录警告日志
|
||||
@@ -356,7 +376,25 @@ func (m *AuthMiddleware) TokenVerifyMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// 检查token状态(是否被吊销)
|
||||
status, err := m.checkTokenStatus(r.Context(), claims.ID)
|
||||
if err == nil && status != "active" {
|
||||
if err != nil {
|
||||
if m.auditEmitter != nil {
|
||||
m.auditEmitter.Emit(r.Context(), AuditEvent{
|
||||
EventName: "token.authn.fail",
|
||||
RequestID: getRequestID(r),
|
||||
TokenID: claims.ID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "AUTH_TOKEN_STATUS_UNAVAILABLE",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
writeAuthError(w, http.StatusUnauthorized, "AUTH_TOKEN_STATUS_UNAVAILABLE",
|
||||
"token status backend is unavailable")
|
||||
return
|
||||
}
|
||||
if status != "active" {
|
||||
if m.auditEmitter != nil {
|
||||
m.auditEmitter.Emit(r.Context(), AuditEvent{
|
||||
EventName: "token.authn.fail",
|
||||
@@ -435,10 +473,10 @@ func (m *AuthMiddleware) ScopeRoleAuthzMiddleware(requiredScope string) func(htt
|
||||
// viewer: level 10, operator: level 30, org_admin: level 50
|
||||
routeRoles := map[string]string{
|
||||
"/api/v1/supply/accounts": "org_admin",
|
||||
"/api/v1/supply/packages": "org_admin",
|
||||
"/api/v1/supply/settlements": "org_admin",
|
||||
"/api/v1/supply/billing": "viewer",
|
||||
"/api/v1/supplier/billing": "viewer",
|
||||
"/api/v1/supply/packages": "org_admin",
|
||||
"/api/v1/supply/settlements": "org_admin",
|
||||
"/api/v1/supply/billing": "viewer",
|
||||
"/api/v1/supplier/billing": "viewer",
|
||||
}
|
||||
|
||||
for path, requiredRole := range routeRoles {
|
||||
@@ -458,12 +496,16 @@ func (m *AuthMiddleware) ScopeRoleAuthzMiddleware(requiredScope string) func(htt
|
||||
|
||||
// verifyToken 校验JWT token
|
||||
func (m *AuthMiddleware) verifyToken(tokenString string) (*TokenClaims, error) {
|
||||
expectedAlgorithm := strings.ToUpper(strings.TrimSpace(m.config.Algorithm))
|
||||
if expectedAlgorithm == "" {
|
||||
expectedAlgorithm = jwt.SigningMethodHS256.Alg()
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// 严格验证算法:只接受HS256
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
if token.Method.Alg() != expectedAlgorithm {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(m.config.SecretKey), nil
|
||||
return m.signingKey(expectedAlgorithm)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -492,6 +534,53 @@ func (m *AuthMiddleware) verifyToken(tokenString string) (*TokenClaims, error) {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) signingKey(algorithm string) (interface{}, error) {
|
||||
switch algorithm {
|
||||
case jwt.SigningMethodHS256.Alg(), jwt.SigningMethodHS384.Alg(), jwt.SigningMethodHS512.Alg():
|
||||
if strings.TrimSpace(m.config.SecretKey) == "" {
|
||||
return nil, errors.New("missing token secret key")
|
||||
}
|
||||
return []byte(m.config.SecretKey), nil
|
||||
case jwt.SigningMethodRS256.Alg(), jwt.SigningMethodRS384.Alg(), jwt.SigningMethodRS512.Alg():
|
||||
return parseRSAPublicKey(m.config.PublicKey)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported signing method: %s", algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRSAPublicKey(publicKeyPEM string) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(publicKeyPEM)))
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid RSA public key: PEM decode failed")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err == nil {
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid RSA public key type")
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
cert, certErr := x509.ParseCertificate(block.Bytes)
|
||||
if certErr == nil {
|
||||
rsaPub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid RSA certificate public key type")
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid RSA public key: %w", err)
|
||||
}
|
||||
|
||||
func shouldBypassAuth(path string) bool {
|
||||
return path == "/actuator/health" ||
|
||||
path == "/actuator/health/live" ||
|
||||
path == "/actuator/health/ready"
|
||||
}
|
||||
|
||||
// checkTokenStatus 检查token状态(从缓存或数据库)
|
||||
func (m *AuthMiddleware) checkTokenStatus(ctx context.Context, tokenID string) (string, error) {
|
||||
if m.tokenCache != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -13,6 +14,15 @@ import (
|
||||
"lijiaoqiao/supply-api/internal/iam/model"
|
||||
)
|
||||
|
||||
type stubTokenStatusBackend struct {
|
||||
status string
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *stubTokenStatusBackend) CheckTokenStatus(ctx context.Context, tokenID string) (string, error) {
|
||||
return b.status, b.err
|
||||
}
|
||||
|
||||
func TestTokenVerify(t *testing.T) {
|
||||
secretKey := "test-secret-key-12345678901234567890"
|
||||
issuer := "test-issuer"
|
||||
@@ -431,6 +441,38 @@ func TestMED02_TokenCacheMiss_ShouldNotAssumeActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenVerifyMiddleware_BackendErrorShouldReject(t *testing.T) {
|
||||
secretKey := "test-secret-key-12345678901234567890"
|
||||
issuer := "test-issuer"
|
||||
|
||||
authMiddleware := NewAuthMiddleware(AuthConfig{
|
||||
SecretKey: secretKey,
|
||||
Issuer: issuer,
|
||||
Enabled: true,
|
||||
}, NewTokenCache(), &stubTokenStatusBackend{err: errors.New("database unavailable")}, nil)
|
||||
|
||||
nextCalled := false
|
||||
handler := authMiddleware.TokenVerifyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/supply/accounts", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), bearerTokenKey, createTestToken(secretKey, issuer, "subject:1", "org_admin", time.Now().Add(time.Hour))))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if nextCalled {
|
||||
t.Fatal("expected request to be rejected when token backend is unavailable")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "AUTH_TOKEN_STATUS_UNAVAILABLE") {
|
||||
t.Fatalf("expected response to contain AUTH_TOKEN_STATUS_UNAVAILABLE, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestToken(secretKey, issuer, subject, role string, expiresAt time.Time) string {
|
||||
|
||||
@@ -34,9 +34,9 @@ type TokenCacheBackend interface {
|
||||
// DBTokenStatusBackend DB-backed Token状态后端(P0-03修复)
|
||||
// 同时实现 TokenStatusBackend 和 TokenRevocationBackend 接口
|
||||
type DBTokenStatusBackend struct {
|
||||
repo TokenRepository
|
||||
redisCache TokenCacheBackend
|
||||
cacheTTL time.Duration
|
||||
repo TokenRepository
|
||||
redisCache TokenCacheBackend
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// NewDBTokenStatusBackend 创建DB-backed Token状态后端
|
||||
@@ -119,6 +119,17 @@ func (b *DBTokenStatusBackend) GetTokenStatus(ctx context.Context, tokenID strin
|
||||
|
||||
// RevokeBySubjectID 根据SubjectID吊销所有Token
|
||||
func (b *DBTokenStatusBackend) RevokeBySubjectID(ctx context.Context, subjectID int64, reason string) error {
|
||||
var tokenIDs []string
|
||||
if b.redisCache != nil {
|
||||
records, err := b.repo.ListActiveBySubjectID(ctx, subjectID)
|
||||
if err == nil {
|
||||
tokenIDs = make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
tokenIDs = append(tokenIDs, record.TokenID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 批量更新数据库
|
||||
count, err := b.repo.RevokeBySubjectID(ctx, subjectID, reason)
|
||||
if err != nil {
|
||||
@@ -132,13 +143,8 @@ func (b *DBTokenStatusBackend) RevokeBySubjectID(ctx context.Context, subjectID
|
||||
// 2. 失效所有相关缓存(这里需要查询后逐个失效)
|
||||
// 注意:生产环境建议使用Redis的pattern删除或发布事件通知
|
||||
if b.redisCache != nil {
|
||||
// 查询所有活跃token并失效
|
||||
records, err := b.repo.ListActiveBySubjectID(ctx, subjectID)
|
||||
if err != nil {
|
||||
return nil // 不影响主流程
|
||||
}
|
||||
for _, record := range records {
|
||||
_ = b.redisCache.InvalidateToken(ctx, record.TokenID)
|
||||
for _, tokenID := range tokenIDs {
|
||||
_ = b.redisCache.InvalidateToken(ctx, tokenID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
|
||||
// MockTokenStatusRepository mock Token状态仓储
|
||||
type MockTokenStatusRepository struct {
|
||||
mu sync.RWMutex
|
||||
tokenStatuses map[string]string
|
||||
tokenReasons map[string]string
|
||||
verificationCounts map[string]int
|
||||
subjectTokens map[int64][]string
|
||||
mu sync.RWMutex
|
||||
tokenStatuses map[string]string
|
||||
tokenReasons map[string]string
|
||||
verificationCounts map[string]int
|
||||
subjectTokens map[int64][]string
|
||||
}
|
||||
|
||||
func NewMockTokenStatusRepository() *MockTokenStatusRepository {
|
||||
@@ -84,9 +84,9 @@ func (m *MockTokenStatusRepository) ListActiveBySubjectID(ctx context.Context, s
|
||||
|
||||
// MockRedisCache mock Redis缓存
|
||||
type MockRedisCache struct {
|
||||
mu sync.RWMutex
|
||||
tokenCache map[string]*cache.TokenStatus
|
||||
subscribers []func(event *cache.TokenRevokedCacheEvent)
|
||||
mu sync.RWMutex
|
||||
tokenCache map[string]*cache.TokenStatus
|
||||
subscribers []func(event *cache.TokenRevokedCacheEvent)
|
||||
}
|
||||
|
||||
func NewMockRedisCache() *MockRedisCache {
|
||||
@@ -136,7 +136,7 @@ func (m *MockRedisCache) PublishRevocation(tokenID string, reason string) {
|
||||
for _, handler := range handlers {
|
||||
handler(&cache.TokenRevokedCacheEvent{
|
||||
TokenID: tokenID,
|
||||
Reason: reason,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -165,9 +165,9 @@ type TokenStatusRepositoryInterface interface {
|
||||
|
||||
// DBTokenStatusBackendForTest 用于测试的DBTokenStatusBackend
|
||||
type DBTokenStatusBackendForTest struct {
|
||||
repo TokenStatusRepositoryInterface
|
||||
redisCache *MockRedisCache
|
||||
cacheTTL time.Duration
|
||||
repo TokenStatusRepositoryInterface
|
||||
redisCache *MockRedisCache
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewDBTokenStatusBackendForTest(repo TokenStatusRepositoryInterface, redisCache *MockRedisCache, cacheTTL time.Duration) *DBTokenStatusBackendForTest {
|
||||
@@ -373,6 +373,31 @@ func TestDBTokenStatusBackend_RevokeBySubjectID(t *testing.T) {
|
||||
repo.mu.RUnlock()
|
||||
}
|
||||
|
||||
func TestDBTokenStatusBackend_RevokeBySubjectID_InvalidatesCachedTokens(t *testing.T) {
|
||||
repo := NewMockTokenStatusRepository()
|
||||
redisCache := NewMockRedisCache()
|
||||
|
||||
repo.subjectTokens[123] = []string{"token1", "token2"}
|
||||
redisCache.tokenCache["token1"] = &cache.TokenStatus{TokenID: "token1", Status: "active"}
|
||||
redisCache.tokenCache["token2"] = &cache.TokenStatus{TokenID: "token2", Status: "active"}
|
||||
|
||||
backend := NewDBTokenStatusBackend(repo, redisCache, 10*time.Second)
|
||||
|
||||
err := backend.RevokeBySubjectID(context.Background(), 123, "bulk revocation")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
redisCache.mu.RLock()
|
||||
defer redisCache.mu.RUnlock()
|
||||
if _, ok := redisCache.tokenCache["token1"]; ok {
|
||||
t.Fatal("expected token1 cache to be invalidated")
|
||||
}
|
||||
if _, ok := redisCache.tokenCache["token2"]; ok {
|
||||
t.Fatal("expected token2 cache to be invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBTokenStatusBackend_RevokeBySubjectID_NoTokens(t *testing.T) {
|
||||
repo := NewMockTokenStatusRepository()
|
||||
redisCache := NewMockRedisCache()
|
||||
@@ -412,10 +437,10 @@ func TestDBTokenStatusBackend_InterfaceCompliance(t *testing.T) {
|
||||
|
||||
// 测试各种状态转换
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenID string
|
||||
initialStatus string
|
||||
action func() error
|
||||
name string
|
||||
tokenID string
|
||||
initialStatus string
|
||||
action func() error
|
||||
expectedStatus string
|
||||
}{
|
||||
{
|
||||
@@ -780,7 +805,7 @@ func TestDBTokenStatusBackend_StartRevocationSubscriber_NoRedisCache(t *testing.
|
||||
|
||||
// MockTokenRevocationBackend mock TokenRevocationBackend
|
||||
type MockTokenRevocationBackend struct {
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
revokedTokens map[string]string
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,11 @@ func getTenantID(ctx context.Context) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetTenantID 公开函数,从context获取租户ID
|
||||
func GetTenantID(ctx context.Context) int64 {
|
||||
return getTenantID(ctx)
|
||||
}
|
||||
|
||||
func getOperatorID(ctx context.Context) int64 {
|
||||
if v := ctx.Value(operatorIDKey); v != nil {
|
||||
if id, ok := v.(int64); ok {
|
||||
|
||||
Reference in New Issue
Block a user