From 19d0ee130d17e9ca5c4a21cc58f308b055599a59 Mon Sep 17 00:00:00 2001 From: Junming Chen Date: Mon, 29 Dec 2025 17:18:17 -0500 Subject: [PATCH 1/7] 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 23ef3da0f416db23b090b2f383c78381756eb6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=8C=BFMT?= <32916545+mt21625457@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:09:29 +0800 Subject: [PATCH 2/7] Remove redundant entry in Makefile --- backend/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Makefile b/backend/Makefile index 9a26afb9..9a3d23e7 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,4 +1,4 @@ -.PHONY: wire build build-embed test-unit test-integration test-e2e test-cover-integration clean-coverage +.PHONY: wire build build-embed test-unit test-integration test-e2e test-cover-integration wire: @echo "生成 Wire 代码..." @@ -38,4 +38,4 @@ clean-coverage: clean: clean-coverage @rm -rf bin/ - @echo "构建产物已清理" \ No newline at end of file + @echo "构建产物已清理" From 7b2185eb5f36a5612670b6ccbe937a41eb16079d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=8C=BFMT?= <32916545+mt21625457@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:13:42 +0800 Subject: [PATCH 3/7] Delete backend/Makefile --- backend/Makefile | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 backend/Makefile diff --git a/backend/Makefile b/backend/Makefile deleted file mode 100644 index 9a3d23e7..00000000 --- a/backend/Makefile +++ /dev/null @@ -1,41 +0,0 @@ -.PHONY: wire build build-embed test-unit test-integration test-e2e test-cover-integration - -wire: - @echo "生成 Wire 代码..." - @cd cmd/server && go generate - @echo "Wire 代码生成完成" - -build: - @echo "构建后端(不嵌入前端)..." - @go build -o bin/server ./cmd/server - @echo "构建完成: bin/server" - -build-embed: - @echo "构建后端(嵌入前端)..." - @go build -tags embed -o bin/server ./cmd/server - @echo "构建完成: bin/server (with embedded frontend)" - -test-unit: - @go test -tags unit ./... -count=1 - -test-integration: - @go test -tags integration ./... -count=1 -race -parallel=8 - -test-e2e: - @echo "运行 E2E 测试(需要本地服务器运行)..." - @go test -tags e2e ./internal/integration/... -count=1 -v - -test-cover-integration: - @echo "运行集成测试并生成覆盖率报告..." - @go test -tags=integration -cover -coverprofile=coverage.out -count=1 -race -parallel=8 ./... - @go tool cover -func=coverage.out | tail -1 - @go tool cover -html=coverage.out -o coverage.html - @echo "覆盖率报告已生成: coverage.html" - -clean-coverage: - @rm -f coverage.out coverage.html - @echo "覆盖率文件已清理" - -clean: clean-coverage - @rm -rf bin/ - @echo "构建产物已清理" From 52e3e44008ef14192aaad2867c08eb91be76e0b8 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 30 Dec 2025 10:29:26 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E8=BF=98=E5=8E=9F=E8=AF=AF?= =?UTF-8?q?=E5=88=A0=E7=9A=84makefile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/Makefile | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 deploy/Makefile diff --git a/deploy/Makefile b/deploy/Makefile new file mode 100644 index 00000000..2c6d5217 --- /dev/null +++ b/deploy/Makefile @@ -0,0 +1,41 @@ +.PHONY: wire build build-embed test-unit test-integration test-e2e test-cover-integration + +wire: + @echo "生成 Wire 代码..." + @cd cmd/server && go generate + @echo "Wire 代码生成完成" + +build: + @echo "构建后端(不嵌入前端)..." + @go build -o bin/server ./cmd/server + @echo "构建完成: bin/server" + +build-embed: + @echo "构建后端(嵌入前端)..." + @go build -tags embed -o bin/server ./cmd/server + @echo "构建完成: bin/server (with embedded frontend)" + +test-unit: + @go test -tags unit ./... -count=1 + +test-integration: + @go test -tags integration ./... -count=1 -race -parallel=8 + +test-e2e: + @echo "运行 E2E 测试(需要本地服务器运行)..." + @go test -tags e2e ./internal/integration/... -count=1 -v + +test-cover-integration: + @echo "运行集成测试并生成覆盖率报告..." + @go test -tags=integration -cover -coverprofile=coverage.out -count=1 -race -parallel=8 ./... + @go tool cover -func=coverage.out | tail -1 + @go tool cover -html=coverage.out -o coverage.html + @echo "覆盖率报告已生成: coverage.html" + +clean-coverage: + @rm -f coverage.out coverage.html + @echo "覆盖率文件已清理" + +clean: clean-coverage + @rm -rf bin/ + @echo "构建产物已清理" \ No newline at end of file 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 5/7] =?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 6/7] =?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 @@ From e5c314092da94901993182b74895cec05800e7a4 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Tue, 30 Dec 2025 14:08:48 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 51583259..1aa43786 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,6 @@ CLAUDE.md .claude scripts .code-review-state - +openspec/ +code-reviews/ +docs/ \ No newline at end of file