diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 9f9e497b..a4b7a297 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -778,6 +778,14 @@ func TestExchangePendingOAuthCompletionExistingLoginWithSuggestedProfileSkipsAdo require.Equal(t, "https://cdn.example/existing-login.png", payload["suggested_avatar_url"]) require.NotContains(t, payload, "adoption_required") + accessToken, ok := payload["access_token"].(string) + require.True(t, ok) + claims, err := handler.authService.ValidateToken(accessToken) + require.NoError(t, err) + reloadedUser, err := handler.userService.GetByID(ctx, userEntity.ID) + require.NoError(t, err) + require.Equal(t, reloadedUser.TokenVersion, claims.TokenVersion) + decisionCount, err := client.IdentityAdoptionDecision.Query(). Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)). Count(ctx) @@ -2033,6 +2041,13 @@ func TestLogin2FACompletesPendingOAuthBindAndConsumesSession(t *testing.T) { payload := decodeJSONResponseData(t, recorder) require.NotEmpty(t, payload["access_token"]) require.NotEmpty(t, payload["refresh_token"]) + accessToken, ok := payload["access_token"].(string) + require.True(t, ok) + claims, err := handler.authService.ValidateToken(accessToken) + require.NoError(t, err) + reloadedUser, err := handler.userService.GetByID(ctx, existingUser.ID) + require.NoError(t, err) + require.Equal(t, reloadedUser.TokenVersion, claims.TokenVersion) identity, err := client.AuthIdentity.Query(). Where( diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 59442d1f..3bf9da3d 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -1500,6 +1500,9 @@ func resolvedTokenVersion(user *User) int64 { if user == nil { return 0 } + if user.TokenVersionResolved { + return user.TokenVersion + } material := strings.ToLower(strings.TrimSpace(user.Email)) + "\n" + user.PasswordHash sum := sha256.Sum256([]byte(material)) diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index fa04d95e..9dc13381 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -23,12 +23,15 @@ type User struct { Status string AllowedGroups []int64 TokenVersion int64 // Incremented on password change to invalidate existing tokens - SignupSource string - LastLoginAt *time.Time - LastActiveAt *time.Time - LastUsedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + // TokenVersionResolved indicates TokenVersion already contains the fingerprint-derived + // value expected in JWT claims and refresh-token state. + TokenVersionResolved bool + SignupSource string + LastLoginAt *time.Time + LastActiveAt *time.Time + LastUsedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time // GroupRates 用户专属分组倍率配置 // map[groupID]rateMultiplier diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index a211103f..7ba401e7 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -943,10 +943,11 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) { } func normalizeLoadedUserTokenVersion(user *User) { - if user == nil { + if user == nil || user.TokenVersionResolved { return } user.TokenVersion = resolvedTokenVersion(user) + user.TokenVersionResolved = true } // TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。