From e58ca15fd9f4916a2a9e50de85a07eb5ad5ee312 Mon Sep 17 00:00:00 2001 From: "PC-20230511ZTHH\\Administrator" Date: Mon, 27 Apr 2026 15:15:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D:=20?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E5=88=9D=E5=A7=8B=E5=8C=96=E3=80=81OAuth=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E9=89=B4=E6=9D=83=E3=80=81=E8=87=AA=E5=8A=A9?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=8E=E6=94=AF=E4=BB=98=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/setup.go | 10 +++++ controller/topup.go | 56 +++++++++++++++++++++++++ controller/topup_creem.go | 38 +++++++++++++++++ controller/topup_stripe.go | 33 +++++++++++++++ controller/topup_waffo.go | 34 +++++++++++++++ controller/user.go | 86 ++++++++++++++++++++++++++++++++++---- controller/wechat.go | 2 + router/api-router.go | 6 +-- 8 files changed, 255 insertions(+), 10 deletions(-) diff --git a/controller/setup.go b/controller/setup.go index 2f6a0c9beed1..b3a199a70ff6 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -5,6 +5,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" @@ -54,6 +55,7 @@ func GetSetup(c *gin.Context) { func PostSetup(c *gin.Context) { // Check if setup is already completed if constant.Setup { + logger.LogWarn(c.Request.Context(), "setup rejected reason=setup_completed client_ip="+c.ClientIP()) c.JSON(200, gin.H{ "success": false, "message": "系统已经初始化完成", @@ -63,6 +65,14 @@ func PostSetup(c *gin.Context) { // Check if root user already exists rootExists := model.RootUserExists() + if rootExists { + logger.LogWarn(c.Request.Context(), "setup rejected reason=root_exists client_ip="+c.ClientIP()) + c.JSON(200, gin.H{ + "success": false, + "message": "系统已经初始化完成", + }) + return + } var req SetupRequest err := c.ShouldBindJSON(&req) diff --git a/controller/topup.go b/controller/topup.go index a6445b40d68c..d923bc27ed57 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -1,10 +1,12 @@ package controller import ( + "context" "fmt" "net/http" "net/url" "strconv" + "strings" "sync" "time" @@ -177,6 +179,30 @@ func getMinTopup() int64 { return int64(minTopup) } +func normalizeMoneyDecimalFromFloat(amount float64) decimal.Decimal { + return decimal.NewFromFloat(amount).Round(2) +} + +func normalizeMoneyDecimalFromMinorUnits(amount int64) decimal.Decimal { + return decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)).Round(2) +} + +func normalizeMoneyDecimalFromString(amount string) (decimal.Decimal, error) { + trimmed := strings.TrimSpace(amount) + if trimmed == "" { + return decimal.Zero, fmt.Errorf("empty amount") + } + value, err := decimal.NewFromString(trimmed) + if err != nil { + return decimal.Zero, err + } + return value.Round(2), nil +} + +func logPaymentReject(ctx context.Context, provider string, tradeNo string, expectedAmount decimal.Decimal, actualAmount decimal.Decimal, reason string, clientIP string) { + logger.LogWarn(ctx, fmt.Sprintf("%s callback rejected provider=%s trade_no=%s expected_amount=%s actual_amount=%s reason=%s client_ip=%s", provider, provider, tradeNo, expectedAmount.StringFixed(2), actualAmount.StringFixed(2), reason, clientIP)) +} + func RequestEpay(c *gin.Context) { var req EpayRequest err := c.ShouldBindJSON(&req) @@ -344,6 +370,36 @@ func EpayNotify(c *gin.Context) { verifyInfo, err := client.Verify(params) if err == nil && verifyInfo.VerifyStatus { logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo))) + if verifyInfo.TradeStatus == epay.StatusTradeSuccess { + topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo) + if topUp == nil { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Epay callback rejected provider=%s trade_no=%s reason=order_not_found client_ip=%s", model.PaymentProviderEpay, verifyInfo.ServiceTradeNo, c.ClientIP())) + _, _ = c.Writer.Write([]byte("fail")) + return + } + if topUp.PaymentProvider != model.PaymentProviderEpay { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Epay callback rejected provider=%s trade_no=%s reason=provider_mismatch actual_provider=%s client_ip=%s", model.PaymentProviderEpay, verifyInfo.ServiceTradeNo, topUp.PaymentProvider, c.ClientIP())) + _, _ = c.Writer.Write([]byte("fail")) + return + } + expectedAmount := normalizeMoneyDecimalFromFloat(topUp.Money) + actualAmount, amountErr := normalizeMoneyDecimalFromString(verifyInfo.Money) + if amountErr != nil { + logPaymentReject(c.Request.Context(), model.PaymentProviderEpay, verifyInfo.ServiceTradeNo, expectedAmount, decimal.Zero, "invalid_callback_amount", c.ClientIP()) + _, _ = c.Writer.Write([]byte("fail")) + return + } + if !expectedAmount.Equal(actualAmount) { + logPaymentReject(c.Request.Context(), model.PaymentProviderEpay, verifyInfo.ServiceTradeNo, expectedAmount, actualAmount, "amount_mismatch", c.ClientIP()) + _, _ = c.Writer.Write([]byte("fail")) + return + } + if topUp.Status != common.TopUpStatusPending && topUp.Status != common.TopUpStatusSuccess { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Epay callback rejected provider=%s trade_no=%s reason=invalid_status status=%s client_ip=%s", model.PaymentProviderEpay, verifyInfo.ServiceTradeNo, topUp.Status, c.ClientIP())) + _, _ = c.Writer.Write([]byte("fail")) + return + } + } _, err := c.Writer.Write([]byte("success")) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error())) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 7472690e22fb..b4ebcc4449a9 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -15,9 +15,11 @@ import ( "github.com/QuantumNous/new-api/setting" "io" "net/http" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/thanhpk/randstr" ) @@ -337,6 +339,42 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) { // 处理充值,传入客户邮箱和姓名信息 customerEmail := event.Object.Customer.Email + if topUp.PaymentProvider != model.PaymentProviderCreem { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem callback rejected provider=%s trade_no=%s reason=provider_mismatch actual_provider=%s client_ip=%s", model.PaymentProviderCreem, referenceId, topUp.PaymentProvider, c.ClientIP())) + c.AbortWithStatus(http.StatusBadRequest) + return + } + expectedAmount := decimal.NewFromFloat(topUp.Money).Round(2) + actualAmount := decimal.NewFromInt(int64(event.Object.Order.AmountPaid)).Div(decimal.NewFromInt(100)).Round(2) + if !expectedAmount.Equal(actualAmount) { + logPaymentReject(c.Request.Context(), model.PaymentProviderCreem, referenceId, expectedAmount, actualAmount, "amount_mismatch", c.ClientIP()) + c.AbortWithStatus(http.StatusBadRequest) + return + } + var products []CreemProduct + if err := common.UnmarshalJsonStr(setting.CreemProducts, &products); err == nil { + var matchedProducts []CreemProduct + for _, product := range products { + if product.Quota == topUp.Amount && decimal.NewFromFloat(product.Price).Round(2).Equal(expectedAmount) { + matchedProducts = append(matchedProducts, product) + } + } + if len(matchedProducts) == 1 { + expectedProduct := matchedProducts[0] + if expectedProduct.ProductId != "" && event.Object.Product.Id != expectedProduct.ProductId { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem callback rejected provider=%s trade_no=%s reason=product_mismatch expected_product=%s actual_product=%s client_ip=%s", model.PaymentProviderCreem, referenceId, expectedProduct.ProductId, event.Object.Product.Id, c.ClientIP())) + c.AbortWithStatus(http.StatusBadRequest) + return + } + expectedCurrency := strings.ToUpper(expectedProduct.Currency) + actualCurrency := strings.ToUpper(event.Object.Order.Currency) + if expectedCurrency != "" && actualCurrency != expectedCurrency { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem callback rejected provider=%s trade_no=%s reason=currency_mismatch expected_currency=%s actual_currency=%s client_ip=%s", model.PaymentProviderCreem, referenceId, expectedCurrency, actualCurrency, c.ClientIP())) + c.AbortWithStatus(http.StatusBadRequest) + return + } + } + } customerName := event.Object.Customer.Name // 防护性检查,确保邮箱和姓名不为空字符串 diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index ceee8ecdd66c..7b1b1a8fb090 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -18,6 +18,7 @@ import ( "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81/checkout/session" "github.com/stripe/stripe-go/v81/webhook" @@ -279,6 +280,38 @@ func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, c return } + topUp := model.GetTopUpByTradeNo(referenceId) + if topUp == nil { + logger.LogWarn(ctx, fmt.Sprintf("Stripe callback rejected provider=%s trade_no=%s reason=order_not_found client_ip=%s", model.PaymentProviderStripe, referenceId, callerIp)) + return + } + if topUp.PaymentProvider != model.PaymentProviderStripe { + logger.LogWarn(ctx, fmt.Sprintf("Stripe callback rejected provider=%s trade_no=%s reason=provider_mismatch actual_provider=%s client_ip=%s", model.PaymentProviderStripe, referenceId, topUp.PaymentProvider, callerIp)) + return + } + if topUp.Status == common.TopUpStatusSuccess { + logger.LogInfo(ctx, fmt.Sprintf("Stripe callback idempotent success trade_no=%s client_ip=%s", referenceId, callerIp)) + return + } + if topUp.Status != common.TopUpStatusPending { + logger.LogWarn(ctx, fmt.Sprintf("Stripe callback rejected provider=%s trade_no=%s reason=invalid_status status=%s client_ip=%s", model.PaymentProviderStripe, referenceId, topUp.Status, callerIp)) + return + } + actualMinor, parseErr := strconv.ParseInt(event.GetObjectValue("amount_total"), 10, 64) + if parseErr != nil { + logger.LogWarn(ctx, fmt.Sprintf("Stripe callback rejected provider=%s trade_no=%s reason=invalid_amount_total client_ip=%s", model.PaymentProviderStripe, referenceId, callerIp)) + return + } + expectedAmount := decimal.NewFromFloat(topUp.Money).Round(2) + actualAmount := decimal.NewFromInt(actualMinor).Div(decimal.NewFromInt(100)).Round(2) + if !expectedAmount.Equal(actualAmount) { + logPaymentReject(ctx, model.PaymentProviderStripe, referenceId, expectedAmount, actualAmount, "amount_mismatch", callerIp) + return + } + if strings.ToUpper(event.GetObjectValue("currency")) != "USD" { + logger.LogWarn(ctx, fmt.Sprintf("Stripe callback rejected provider=%s trade_no=%s reason=currency_mismatch expected_currency=%s actual_currency=%s client_ip=%s", model.PaymentProviderStripe, referenceId, "USD", strings.ToUpper(event.GetObjectValue("currency")), callerIp)) + return + } err := model.Recharge(referenceId, customerId, callerIp) if err != nil { logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error())) diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index 1885c1ded9ec..ba97e0286e4e 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/thanhpk/randstr" waffo "github.com/waffo-com/waffo-go" "github.com/waffo-com/waffo-go/config" @@ -394,6 +395,39 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa LockOrder(merchantOrderId) defer UnlockOrder(merchantOrderId) + topUp := model.GetTopUpByTradeNo(merchantOrderId) + if topUp == nil { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo callback rejected provider=%s trade_no=%s reason=order_not_found client_ip=%s", model.PaymentProviderWaffo, merchantOrderId, c.ClientIP())) + sendWaffoWebhookResponse(c, wh, false, "order not found") + return + } + if topUp.PaymentProvider != model.PaymentProviderWaffo { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo callback rejected provider=%s trade_no=%s reason=provider_mismatch actual_provider=%s client_ip=%s", model.PaymentProviderWaffo, merchantOrderId, topUp.PaymentProvider, c.ClientIP())) + sendWaffoWebhookResponse(c, wh, false, "provider mismatch") + return + } + if topUp.Status == common.TopUpStatusSuccess { + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo callback idempotent success trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP())) + sendWaffoWebhookResponse(c, wh, true, "") + return + } + if topUp.Status != common.TopUpStatusPending { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo callback rejected provider=%s trade_no=%s reason=invalid_status status=%s client_ip=%s", model.PaymentProviderWaffo, merchantOrderId, topUp.Status, c.ClientIP())) + sendWaffoWebhookResponse(c, wh, false, "invalid status") + return + } + expectedAmount := decimal.NewFromFloat(topUp.Money).Round(2) + actualAmount, err := normalizeMoneyDecimalFromString(result.OrderAmount) + if err != nil { + logPaymentReject(c.Request.Context(), model.PaymentProviderWaffo, merchantOrderId, expectedAmount, decimal.Zero, "invalid_callback_amount", c.ClientIP()) + sendWaffoWebhookResponse(c, wh, false, "invalid amount") + return + } + if !expectedAmount.Equal(actualAmount) { + logPaymentReject(c.Request.Context(), model.PaymentProviderWaffo, merchantOrderId, expectedAmount, actualAmount, "amount_mismatch", c.ClientIP()) + sendWaffoWebhookResponse(c, wh, false, "amount mismatch") + return + } if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error())) diff --git a/controller/user.go b/controller/user.go index b5722668632d..8f93f547ebcb 100644 --- a/controller/user.go +++ b/controller/user.go @@ -630,9 +630,39 @@ func UpdateSelf(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } + sensitiveFields := []string{ + "quota", + "balance", + "role", + "group", + "status", + "aff_code", + "used_quota", + "request_count", + "inviter_id", + "stripe_customer", + "access_token", + "token", + "root", + "is_admin", + "permission", + "quota_setting", + "subscription", + } + for _, field := range sensitiveFields { + if _, exists := requestData[field]; exists { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("forbidden self update rejected user_id=%d field=%s reason=forbidden_self_update_field client_ip=%s", c.GetInt("id"), field, c.ClientIP())) + common.ApiErrorI18n(c, i18n.MsgInvalidInput) + return + } + } // 检查是否是用户设置更新请求 (sidebar_modules 或 language) if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists { + if len(requestData) != 1 { + common.ApiErrorI18n(c, i18n.MsgInvalidInput) + return + } userId := c.GetInt("id") user, err := model.GetUserById(userId, false) if err != nil { @@ -661,6 +691,10 @@ func UpdateSelf(c *gin.Context) { // 检查是否是语言偏好更新请求 if language, langExists := requestData["language"]; langExists { + if len(requestData) != 1 { + common.ApiErrorI18n(c, i18n.MsgInvalidInput) + return + } userId := c.GetInt("id") user, err := model.GetUserById(userId, false) if err != nil { @@ -688,17 +722,55 @@ func UpdateSelf(c *gin.Context) { } // 原有的用户信息更新逻辑 - var user model.User - requestDataBytes, err := json.Marshal(requestData) - if err != nil { - common.ApiErrorI18n(c, i18n.MsgInvalidParams) - return + allowedFields := map[string]struct{}{ + "username": {}, + "password": {}, + "display_name": {}, + "original_password": {}, + } + for field := range requestData { + if _, ok := allowedFields[field]; !ok { + common.ApiErrorI18n(c, i18n.MsgInvalidInput) + return + } } - err = json.Unmarshal(requestDataBytes, &user) - if err != nil { + if len(requestData) == 0 { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } + var user model.User + if username, ok := requestData["username"]; ok { + usernameStr, ok := username.(string) + if !ok { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + user.Username = usernameStr + } + if displayName, ok := requestData["display_name"]; ok { + displayNameStr, ok := displayName.(string) + if !ok { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + user.DisplayName = displayNameStr + } + if password, ok := requestData["password"]; ok { + passwordStr, ok := password.(string) + if !ok { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + user.Password = passwordStr + } + if originalPassword, ok := requestData["original_password"]; ok { + originalPasswordStr, ok := originalPassword.(string) + if !ok { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + user.OriginalPassword = originalPasswordStr + } if user.Password == "" { user.Password = "$I_LOVE_U" // make Validator happy :) diff --git a/controller/wechat.go b/controller/wechat.go index 8889daca77db..9e869fc97940 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -10,6 +10,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/gin-contrib/sessions" @@ -104,6 +105,7 @@ func WeChatAuth(c *gin.Context) { return } } else { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("wechat auto register rejected provider=wechat reason=register_disabled wechat_id=%s client_ip=%s", wechatId, c.ClientIP())) c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员关闭了新用户注册", diff --git a/router/api-router.go b/router/api-router.go index 83f5e4ae9d92..8bd300d56050 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -36,12 +36,12 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) // OAuth routes - specific routes must come before :provider wildcard apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) - apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) + apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.EmailBind) // Non-standard OAuth (WeChat, Telegram) - keep original routes apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) - apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind) + apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind) apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) - apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/oauth/telegram/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.TelegramBind) // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)