Files
team-api/internal/middleware/open_auth.go
T
22ee0aa112 修复安全问题 (#9)
* fix:修复权限问题

* fix:修复项目key类型错误问题

* fix:修复rbac权限问题

* fix:修复api密钥未及时失效的问题

* improve:优化渠道调度稳定性

* improve:优化路由

* fix:修复项目预算控制缺陷

* fix:加强限流等安全措施

* feat:添加限流相关字段的前端展示及接口字段

* improve:优化注册时密码要求,只需要字母和数字即可

* feat(setting):添加注册限流设置

* improve:优化前端数字类型转换

* chore:rbac权限放行

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
Co-authored-by: qian <wuchengqian5@163.com>
2026-06-23 22:47:54 +08:00

296 lines
8.5 KiB
Go

package middleware
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/model/entity"
"github.com/qianfree/team-api/internal/response"
"github.com/qianfree/team-api/internal/utility/crypto"
lcommon "github.com/qianfree/team-api/internal/logic/common"
)
// openContextKey is a private type for context keys to prevent collisions.
type openContextKey string
const (
openPlatformHeaderSignature = "X-Signature"
openPlatformHeaderTimestamp = "X-Timestamp"
openPlatformHeaderNonce = "X-Nonce"
openPlatformHeaderAppID = "X-App-Id"
openPlatformMaxSkew = 5 * time.Minute
ctxKeyOpenAppID openContextKey = "openAppId"
ctxKeyOpenTenantID openContextKey = "openTenantId"
ctxKeyOpenPermissions openContextKey = "openPermissions"
ctxKeyOpenIsSandbox openContextKey = "openIsSandbox"
)
var openAppCache = lcommon.NewCache("open_app", 60*time.Second)
// InvalidateOpenAppCache removes the cached app metadata used by OpenPlatformAuth.
func InvalidateOpenAppCache(ctx context.Context, appID string) {
if appID != "" {
openAppCache.Delete(ctx, appID)
}
}
// OpenPlatformAuth is the HMAC-SHA256 authentication middleware for /open/* endpoints.
func OpenPlatformAuth(r *ghttp.Request) {
appID := r.GetHeader(openPlatformHeaderAppID)
signature := r.GetHeader(openPlatformHeaderSignature)
timestampStr := r.GetHeader(openPlatformHeaderTimestamp)
nonce := r.GetHeader(openPlatformHeaderNonce)
if appID == "" || signature == "" || timestampStr == "" || nonce == "" {
response.Error(r, consts.ErrUnauthorized)
return
}
// Validate timestamp skew
ts, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
response.Error(r, consts.ErrUnauthorized)
return
}
if time.Since(time.Unix(ts, 0)).Abs() > openPlatformMaxSkew {
response.Error(r, consts.ErrUnauthorized)
return
}
// Lookup app by app_id (cache-first)
var app entity.OpnApps
cacheKey := appID
if !openAppCache.GetJSON(r.Context(), cacheKey, &app) || app.Id == 0 {
err = dao.OpnApps.Ctx(r.Context()).Where("app_id", appID).Scan(&app)
if err != nil || app.Id == 0 {
response.Error(r, consts.ErrUnauthorized)
return
}
openAppCache.Set(r.Context(), cacheKey, &app)
}
if app.Status != "active" {
response.Error(r, consts.ErrUnauthorized)
return
}
// Check IP whitelist
if err := checkOpenAppIPWhitelist(r, app); err != nil {
response.Error(r, err)
return
}
// Per-app rate limiting (fixed window, 1 minute)
if app.RateLimit > 0 {
if !checkOpenAppRateLimit(r, app.Id, app.RateLimit) {
return
}
}
// Get app secret from Redis
secret, err := getOpenAppSecret(r.Context(), app.Id)
if err != nil || secret == "" {
response.Error(r, consts.ErrUnauthorized)
return
}
// Read request body for signature verification
// Use r.GetBody() instead of io.ReadAll(r.Body) to preserve body for downstream handlers
bodyBytes := r.GetBody()
// Verify HMAC signature (includes body hash for request integrity)
expectedSig := computeHMACSignature(secret, timestampStr, nonce, r.Method, r.URL.Path, bodyBytes)
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
response.Error(r, consts.ErrUnauthorized)
return
}
// Inject context variables using typed keys
ctx := r.Context()
ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id)
ctx = context.WithValue(ctx, ctxKeyOpenTenantID, app.TenantId)
ctx = context.WithValue(ctx, ctxKeyOpenPermissions, app.Permissions)
ctx = context.WithValue(ctx, ctxKeyOpenIsSandbox, app.IsSandbox)
r.SetCtx(ctx)
r.Middleware.Next()
}
func computeHMACSignature(secret, timestamp, nonce, method, path string, body []byte) string {
bodyHash := sha256.Sum256(body)
bodyHashHex := hex.EncodeToString(bodyHash[:])
message := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", timestamp, nonce, method, path, bodyHashHex)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
return hex.EncodeToString(mac.Sum(nil))
}
func checkOpenAppIPWhitelist(r *ghttp.Request, app entity.OpnApps) error {
if app.IpWhitelist == "" || app.IpWhitelist == "[]" {
return nil
}
var whitelist []string
_ = json.Unmarshal([]byte(app.IpWhitelist), &whitelist)
if len(whitelist) == 0 {
return nil
}
clientIP := r.GetClientIp()
for _, allowed := range whitelist {
if clientIP == allowed {
return nil
}
if strings.HasSuffix(allowed, ".*") {
prefix := strings.TrimSuffix(allowed, ".*")
if strings.HasPrefix(clientIP, prefix) {
return nil
}
}
}
return consts.ErrIpRestricted
}
func getOpenAppSecret(ctx context.Context, appID int64) (string, error) {
val, err := g.Redis().Do(ctx, "GET", fmt.Sprintf("open:secret:%d", appID))
encKey := getEncKey(ctx)
if err == nil && !val.IsNil() && !val.IsEmpty() {
return crypto.DecryptString(encKey, val.String())
}
if err != nil {
g.Log().Warningf(ctx, "[OpenAuth] Redis secret lookup failed app=%d: %v", appID, err)
}
type secretRow struct {
EncryptedSecret string `json:"encrypted_secret"`
}
var row *secretRow
err = dao.OpnApps.Ctx(ctx).
Where("id", appID).
Fields("encrypted_secret").
Scan(&row)
if err != nil {
return "", err
}
if row == nil || row.EncryptedSecret == "" {
return "", fmt.Errorf("secret not persisted; reset app secret required")
}
secret, err := crypto.DecryptString(encKey, row.EncryptedSecret)
if err != nil {
return "", err
}
_, _ = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", appID), row.EncryptedSecret, "EX", 30*86400)
return secret, nil
}
func getEncKey(ctx context.Context) []byte {
hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String()
if hexKey == "" {
panic("crypto.encryptionKey is not configured — refusing to start with weak key")
}
return crypto.MustGetEncryptionKey(hexKey)
}
// GetOpenTenantID extracts the tenant ID injected by OpenPlatformAuth middleware.
func GetOpenTenantID(ctx context.Context) int64 {
val := ctx.Value(ctxKeyOpenTenantID)
if val != nil {
if id, ok := val.(int64); ok {
return id
}
}
return 0
}
// GetOpenAppID extracts the app ID injected by OpenPlatformAuth middleware.
func GetOpenAppID(ctx context.Context) int64 {
val := ctx.Value(ctxKeyOpenAppID)
if val != nil {
if id, ok := val.(int64); ok {
return id
}
}
return 0
}
// checkOpenAppRateLimit enforces per-app rate limiting using a Redis fixed-window counter.
// Returns true if the request is allowed, false if rate limited (429 response already written).
func checkOpenAppRateLimit(r *ghttp.Request, appDBID int64, rateLimit int) bool {
ctx := r.Context()
now := time.Now()
minuteTimestamp := now.Unix() / 60
key := fmt.Sprintf("ratelimit:open:%d:%d", appDBID, minuteTimestamp)
count, err := g.Redis().Do(ctx, "INCR", key)
if err != nil {
g.Log().Warningf(ctx, "[OpenRateLimit] INCR failed app=%d: %v", appDBID, err)
// Fail open on Redis error
return true
}
currentCount := count.Int64()
if currentCount == 1 {
// First request in this window, set TTL
_, _ = g.Redis().Do(ctx, "EXPIRE", key, 60)
}
remaining := rateLimit - int(currentCount)
if remaining < 0 {
remaining = 0
}
r.Response.Header().Set("X-RateLimit-Limit", strconv.Itoa(rateLimit))
r.Response.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
if int(currentCount) > rateLimit {
response.ErrorWithCode(r, 429, consts.CodeRateLimitExceeded, consts.MsgRateLimitExceeded)
return false
}
return true
}
// GetOpenPermissions extracts the permissions string (JSON) injected by OpenPlatformAuth.
func GetOpenPermissions(ctx context.Context) string {
val := ctx.Value(ctxKeyOpenPermissions)
if val != nil {
if s, ok := val.(string); ok {
return s
}
}
return ""
}
// CheckOpenPermission checks if the authenticated app has the given permission.
// Returns error if permission is missing.
func CheckOpenPermission(ctx context.Context, permission string) error {
perms := GetOpenPermissions(ctx)
if perms == "" {
return gerror.NewCode(gcode.New(consts.CodeForbidden, "无权限", nil), "应用无此操作权限")
}
var list []string
if err := json.Unmarshal([]byte(perms), &list); err != nil {
return gerror.NewCode(gcode.New(consts.CodeForbidden, "无权限", nil), "应用无此操作权限")
}
for _, p := range list {
if p == permission {
return nil
}
}
return gerror.NewCode(gcode.New(consts.CodeForbidden, "无权限", nil), "应用无此操作权限")
}