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