fix(auth): invalidate access tokens on session revoke

This commit is contained in:
IanShaw027
2026-04-22 13:30:34 +08:00
parent 01a991f56f
commit 3d29f7c2fa
6 changed files with 90 additions and 19 deletions

View File

@@ -719,7 +719,7 @@ func (h *AuthHandler) RevokeAllSessions(c *gin.Context) {
return return
} }
if err := h.authService.RevokeAllUserSessions(c.Request.Context(), subject.UserID); err != nil { if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil {
slog.Error("failed to revoke all sessions", "user_id", subject.UserID, "error", err) slog.Error("failed to revoke all sessions", "user_id", subject.UserID, "error", err)
response.InternalError(c, "Failed to revoke sessions") response.InternalError(c, "Failed to revoke sessions")
return return

View File

@@ -0,0 +1,61 @@
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestAuthHandlerRevokeAllSessionsInvalidatesAccessTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &userHandlerRepoStub{
user: &service.User{
ID: 29,
Email: "session@example.com",
Username: "session-user",
Role: service.RoleUser,
Status: service.StatusActive,
TokenVersion: 7,
},
}
refreshTokenCache := &userHandlerRefreshTokenCacheStub{}
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret",
ExpireHour: 1,
},
}
authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil)
handler := &AuthHandler{authService: authService}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all-sessions", nil)
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 29})
handler.RevokeAllSessions(c)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []int64{29}, refreshTokenCache.revokedUserIDs)
require.Equal(t, int64(8), repo.user.TokenVersion)
var resp struct {
Code int `json:"code"`
Data struct {
Message string `json:"message"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Equal(t, "All sessions have been revoked. Please log in again.", resp.Data.Message)
}

View File

@@ -1346,18 +1346,6 @@ func newWeChatOAuthTestHandlerWithSettings(t *testing.T, invitationEnabled bool,
}, client }, client
} }
func assertOAuthRedirectError(t *testing.T, location string, errorCode string, errorMessage string) {
t.Helper()
parsed, err := url.Parse(location)
require.NoError(t, err)
fragment, err := url.ParseQuery(parsed.Fragment)
require.NoError(t, err)
require.Equal(t, errorCode, fragment.Get("error"))
require.Equal(t, errorMessage, fragment.Get("error_message"))
}
type wechatOAuthSettingRepoStub struct { type wechatOAuthSettingRepoStub struct {
values map[string]string values map[string]string
} }

View File

@@ -259,7 +259,7 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) {
return return
} }
if h.authService != nil { if h.authService != nil {
if err := h.authService.RevokeAllUserSessions(c.Request.Context(), subject.UserID); err != nil { if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
} }

View File

@@ -593,11 +593,12 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure
repo := &userHandlerRepoStub{ repo := &userHandlerRepoStub{
user: &service.User{ user: &service.User{
ID: 23, ID: 23,
Email: "identity@example.com", Email: "identity@example.com",
Username: "identity-user", Username: "identity-user",
Role: service.RoleUser, Role: service.RoleUser,
Status: service.StatusActive, Status: service.StatusActive,
TokenVersion: 4,
}, },
identities: []service.UserAuthIdentityRecord{ identities: []service.UserAuthIdentityRecord{
{ {
@@ -632,6 +633,7 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure
require.Equal(t, http.StatusOK, recorder.Code) require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs) require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs)
require.Equal(t, int64(5), repo.user.TokenVersion)
} }
func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) { func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {

View File

@@ -1467,6 +1467,26 @@ func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID int64) e
return s.refreshTokenCache.DeleteUserRefreshTokens(ctx, userID) return s.refreshTokenCache.DeleteUserRefreshTokens(ctx, userID)
} }
// RevokeAllUserTokens invalidates both stateless access tokens and refresh sessions.
// Access/refresh token verification both depend on TokenVersion, so bumping it provides
// immediate revocation even if refresh-token cache cleanup later fails.
func (s *AuthService) RevokeAllUserTokens(ctx context.Context, userID int64) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
user.TokenVersion++
if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("update user: %w", err)
}
if err := s.RevokeAllUserSessions(ctx, userID); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after token invalidation for user %d: %v", userID, err)
}
return nil
}
// hashToken 计算Token的SHA256哈希 // hashToken 计算Token的SHA256哈希
func hashToken(token string) string { func hashToken(token string) string {
hash := sha256.Sum256([]byte(token)) hash := sha256.Sum256([]byte(token))