From 19d0ee130d17e9ca5c4a21cc58f308b055599a59 Mon Sep 17 00:00:00 2001 From: Junming Chen Date: Mon, 29 Dec 2025 17:18:17 -0500 Subject: [PATCH 1/3] fix: implement token invalidation on password change --- backend/internal/repository/user_repo.go | 4 ++++ .../internal/server/middleware/jwt_auth.go | 7 +++++++ backend/internal/service/auth_service.go | 21 +++++++++++++------ backend/internal/service/user.go | 1 + backend/internal/service/user_service.go | 5 +++++ 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 37e1e173..6a2c9764 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -198,6 +198,7 @@ type userModel struct { Concurrency int `gorm:"default:5;not null"` Status string `gorm:"size:20;default:active;not null"` AllowedGroups pq.Int64Array `gorm:"type:bigint[]"` + TokenVersion int64 `gorm:"default:0;not null"` // Incremented on password change CreatedAt time.Time `gorm:"not null"` UpdatedAt time.Time `gorm:"not null"` DeletedAt gorm.DeletedAt `gorm:"index"` @@ -221,6 +222,7 @@ func userModelToService(m *userModel) *service.User { Concurrency: m.Concurrency, Status: m.Status, AllowedGroups: []int64(m.AllowedGroups), + TokenVersion: m.TokenVersion, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, } @@ -242,6 +244,7 @@ func userModelFromService(u *service.User) *userModel { Concurrency: u.Concurrency, Status: u.Status, AllowedGroups: pq.Int64Array(u.AllowedGroups), + TokenVersion: u.TokenVersion, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } @@ -252,6 +255,7 @@ func applyUserModelToService(dst *service.User, src *userModel) { return } dst.ID = src.ID + dst.TokenVersion = src.TokenVersion dst.CreatedAt = src.CreatedAt dst.UpdatedAt = src.UpdatedAt } diff --git a/backend/internal/server/middleware/jwt_auth.go b/backend/internal/server/middleware/jwt_auth.go index 09239d0c..9a89aab7 100644 --- a/backend/internal/server/middleware/jwt_auth.go +++ b/backend/internal/server/middleware/jwt_auth.go @@ -61,6 +61,13 @@ func jwtAuth(authService *service.AuthService, userService *service.UserService) return } + // Security: Validate TokenVersion to ensure token hasn't been invalidated + // This check ensures tokens issued before a password change are rejected + if claims.TokenVersion != user.TokenVersion { + AbortWithError(c, 401, "TOKEN_REVOKED", "Token has been revoked (password changed)") + return + } + c.Set(string(ContextKeyUser), AuthSubject{ UserID: user.ID, Concurrency: user.Concurrency, diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index e2d08f2c..54bbfa5c 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -20,6 +20,7 @@ var ( ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") + ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") @@ -27,9 +28,10 @@ var ( // JWTClaims JWT载荷数据 type JWTClaims struct { - UserID int64 `json:"user_id"` - Email string `json:"email"` - Role string `json:"role"` + UserID int64 `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + TokenVersion int64 `json:"token_version"` // Used to invalidate tokens on password change jwt.RegisteredClaims } @@ -311,9 +313,10 @@ func (s *AuthService) GenerateToken(user *User) (string, error) { expiresAt := now.Add(time.Duration(s.cfg.JWT.ExpireHour) * time.Hour) claims := &JWTClaims{ - UserID: user.ID, - Email: user.Email, - Role: user.Role, + UserID: user.ID, + Email: user.Email, + Role: user.Role, + TokenVersion: user.TokenVersion, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(now), @@ -368,6 +371,12 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) ( return "", ErrUserNotActive } + // Security: Check TokenVersion to prevent refreshing revoked tokens + // This ensures tokens issued before a password change cannot be refreshed + if claims.TokenVersion != user.TokenVersion { + return "", ErrTokenRevoked + } + // 生成新token return s.GenerateToken(user) } diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 70995b5d..fe670202 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -18,6 +18,7 @@ type User struct { Concurrency int Status string AllowedGroups []int64 + TokenVersion int64 // Incremented on password change to invalidate existing tokens CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 6b190cf3..c17588c6 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -116,6 +116,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat } // ChangePassword 修改密码 +// Security: Increments TokenVersion to invalidate all existing JWT tokens func (s *UserService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -131,6 +132,10 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, req Chan return fmt.Errorf("set password: %w", err) } + // Increment TokenVersion to invalidate all existing tokens + // This ensures that any tokens issued before the password change become invalid + user.TokenVersion++ + if err := s.userRepo.Update(ctx, user); err != nil { return fmt.Errorf("update user: %w", err) } From 0026e871f0daf1a336b812a5ae0898b2778ec915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=80=E5=88=80?= Date: Tue, 30 Dec 2025 10:48:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?CC=20Stream=20=E5=93=8D=E5=BA=94=E6=B5=81?= =?UTF-8?q?=E4=B8=AD=E5=87=BA=E7=8E=B0=20error=20=E6=97=B6,=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=BF=94=E5=9B=9E=E9=87=8D=E8=AF=95=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 响应流中出现 error, 返回重试 * 响应流中出现 error, 返回重试 --- backend/internal/handler/gateway_handler.go | 2 +- backend/internal/service/gateway_service.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 59ab429c..bf179ea1 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -216,7 +216,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } } - const maxAccountSwitches = 3 + const maxAccountSwitches = 10 switchCount := 0 failedAccountIDs := make(map[int64]struct{}) lastFailoverStatus := 0 diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index ea6c89aa..50bfd161 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -695,6 +695,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if req.Stream { streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, req.Model) if err != nil { + if err.Error() == "have error in stream" { + return nil, &UpstreamFailoverError{ + StatusCode: 403, + } + } return nil, err } usage = streamResult.usage @@ -969,6 +974,9 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http for scanner.Scan() { line := scanner.Text() + if line == "event: error" { + return nil, errors.New("have error in stream") + } // Extract data from SSE line (supports both "data: " and "data:" formats) if sseDataRe.MatchString(line) { From 64b8219245a9c6bdebd815334893a0ea9be81f73 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 30 Dec 2025 11:43:26 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E5=88=86=E9=85=8D=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7=E6=90=9C=E7=B4=A2=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/admin/SubscriptionsView.vue | 149 +++++++++++++++--- 1 file changed, 130 insertions(+), 19 deletions(-) diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index f9c72ac1..bd6a17eb 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -335,12 +335,59 @@ >
- + + +
+
+ {{ t('common.loading') }} +
+
+ {{ t('common.noOptionsFound') }} +
+ +
+
@@ -462,11 +509,12 @@