fix: close p0 auth and release gate gaps

This commit is contained in:
Your Name
2026-04-11 09:25:31 +08:00
parent b7b46dc827
commit 4adeee2e06
28 changed files with 3791 additions and 276 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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 {