diff --git a/backend/internal/server/middleware/admin_auth.go b/backend/internal/server/middleware/admin_auth.go index 2075691335..741cbcf6c0 100644 --- a/backend/internal/server/middleware/admin_auth.go +++ b/backend/internal/server/middleware/admin_auth.go @@ -176,7 +176,11 @@ func validateJWTForAdmin( // 从数据库获取用户 user, err := userService.GetByID(c.Request.Context(), claims.UserID) if err != nil { - AbortWithError(c, 401, "USER_NOT_FOUND", "User not found") + if errors.Is(err, service.ErrUserNotFound) { + AbortWithError(c, 401, "USER_NOT_FOUND", "User not found") + } else { + AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") + } return false } diff --git a/backend/internal/server/middleware/jwt_auth.go b/backend/internal/server/middleware/jwt_auth.go index 3ed0c36c9a..07e941bf30 100644 --- a/backend/internal/server/middleware/jwt_auth.go +++ b/backend/internal/server/middleware/jwt_auth.go @@ -71,7 +71,11 @@ func jwtAuth( // 从数据库获取最新的用户信息 user, err := userService.GetByID(c.Request.Context(), claims.UserID) if err != nil { - AbortWithError(c, 401, "USER_NOT_FOUND", "User not found") + if errors.Is(err, service.ErrUserNotFound) { + AbortWithError(c, 401, "USER_NOT_FOUND", "User not found") + } else { + AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") + } return } diff --git a/backend/internal/server/middleware/jwt_auth_test.go b/backend/internal/server/middleware/jwt_auth_test.go index ac2b867d1c..6ff8ffdf4c 100644 --- a/backend/internal/server/middleware/jwt_auth_test.go +++ b/backend/internal/server/middleware/jwt_auth_test.go @@ -26,7 +26,7 @@ type stubJWTUserRepo struct { func (r *stubJWTUserRepo) GetByID(_ context.Context, id int64) (*service.User, error) { u, ok := r.users[id] if !ok { - return nil, errors.New("user not found") + return nil, service.ErrUserNotFound } return u, nil } @@ -232,6 +232,67 @@ func TestJWTAuth_TamperedToken(t *testing.T) { require.Equal(t, "INVALID_TOKEN", body.Code) } +func TestJWTAuth_UserLookupErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{JWT: config.JWTConfig{Secret: "test-secret", ExpireHour: 1}} + authSvc := service.NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil) + token, err := authSvc.GenerateToken(context.Background(), &service.User{ID: 1, Role: service.RoleAdmin}) + require.NoError(t, err) + + for _, tc := range []struct { + name string + err error + status int + code string + }{ + {"missing user", service.ErrUserNotFound, http.StatusUnauthorized, "USER_NOT_FOUND"}, + {"database timeout", context.DeadlineExceeded, http.StatusInternalServerError, "INTERNAL_ERROR"}, + {"database failure", errors.New("database unavailable"), http.StatusInternalServerError, "INTERNAL_ERROR"}, + } { + t.Run(tc.name, func(t *testing.T) { + userRepo := &stubUserRepo{getByID: func(context.Context, int64) (*service.User, error) { + return nil, tc.err + }} + userSvc := service.NewUserService(userRepo, nil, nil, nil) + for _, route := range []struct { + name string + handler gin.HandlerFunc + websocket bool + }{ + {"user", gin.HandlerFunc(NewJWTAuthMiddleware(authSvc, userSvc, nil, nil)), false}, + {"admin", gin.HandlerFunc(NewAdminAuthMiddleware(authSvc, userSvc, nil, nil)), false}, + {"admin websocket", gin.HandlerFunc(NewAdminAuthMiddleware(authSvc, userSvc, nil, nil)), true}, + } { + t.Run(route.name, func(t *testing.T) { + called := false + router := gin.New() + router.Use(route.handler) + router.GET("/protected", func(c *gin.Context) { + called = true + c.Status(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + if route.websocket { + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Sec-WebSocket-Protocol", "sub2api-admin, jwt."+token) + } else { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, tc.status, w.Code) + var body ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, tc.code, body.Code) + require.False(t, called) + }) + } + }) + } +} + func TestJWTAuth_UserNotFound(t *testing.T) { // 使用 user ID=1 的 token,但 repo 中没有该用户 fakeUser := &service.User{ diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index b095c7f375..f0c70e9291 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -12,6 +12,7 @@ describe('API Client', () => { beforeEach(async () => { localStorage.clear() + sessionStorage.clear() window.history.replaceState({}, '', '/') // 每次测试重新导入以获取干净的模块状态 vi.resetModules() @@ -392,6 +393,62 @@ describe('API Client', () => { expect(adapter.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer new-token') }) + it.each([429, 500, 503, 0])('刷新暂时失败(%s)时保留会话并返回实际状态', async (status) => { + localStorage.setItem('auth_token', 'expired-token') + localStorage.setItem('refresh_token', 'refresh-token') + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + localStorage.setItem('token_expires_at', '123') + const refreshError = new axios.AxiosError('Refresh unavailable', 'ERR_NETWORK') + if (status) { + refreshError.response = { + status, data: { message: 'Please try again later' }, statusText: '', + headers: {}, config: { headers: new axios.AxiosHeaders() }, + } + } + const refresh = await import('@/api/tokenRefresh') + vi.spyOn(refresh, 'refreshAuthTokens').mockRejectedValueOnce(refreshError) + const adapter = vi.fn().mockRejectedValueOnce({ + response: { status: 401, data: { code: 'TOKEN_EXPIRED' } }, + config: { url: '/test', headers: { Authorization: 'Bearer expired-token' } }, + }) + apiClient.defaults.adapter = adapter + + await expect(apiClient.get('/test')).rejects.toMatchObject({ + status, code: 'TOKEN_REFRESH_UNAVAILABLE', + message: status ? 'Please try again later' : 'Refresh unavailable', + }) + expect(adapter).toHaveBeenCalledTimes(1) + expect(localStorage.getItem('auth_token')).toBe('expired-token') + expect(localStorage.getItem('refresh_token')).toBe('refresh-token') + expect(localStorage.getItem('auth_user')).toBe(JSON.stringify({ id: 7 })) + expect(localStorage.getItem('token_expires_at')).toBe('123') + expect(sessionStorage.getItem('auth_expired')).toBeNull() + expect(window.location.pathname).toBe('/') + }) + + it.each([401, 403, null])('刷新被拒绝(%s)时仍清除失效会话', async (status) => { + window.history.replaceState({}, '', '/login') + localStorage.setItem('auth_token', 'expired-token') + localStorage.setItem('refresh_token', 'refresh-token') + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + localStorage.setItem('token_expires_at', '123') + const refreshError = status + ? Object.assign(new axios.AxiosError('Refresh rejected'), { response: { status } }) + : new Error('Invalid refresh response') + const refresh = await import('@/api/tokenRefresh') + vi.spyOn(refresh, 'refreshAuthTokens').mockRejectedValueOnce(refreshError) + apiClient.defaults.adapter = vi.fn().mockRejectedValueOnce({ + response: { status: 401, data: { code: 'TOKEN_EXPIRED' } }, + config: { url: '/test', headers: { Authorization: 'Bearer expired-token' } }, + }) + + await expect(apiClient.get('/test')).rejects.toMatchObject({ status: 401, code: 'TOKEN_REFRESH_FAILED' }) + for (const key of ['auth_token', 'refresh_token', 'auth_user', 'token_expires_at']) { + expect(localStorage.getItem(key)).toBeNull() + } + expect(sessionStorage.getItem('auth_expired')).toBe('1') + }) + it('刷新期间换号时旧请求不会清除新会话', async () => { localStorage.setItem('auth_token', 'user-a-access') localStorage.setItem('refresh_token', 'user-a-refresh') diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1f4e4d6134..04d893d26f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -186,7 +186,7 @@ apiClient.interceptors.response.use( originalRequest.headers.Authorization = `Bearer ${tokens.access_token}` } return apiClient(originalRequest) - } catch { + } catch (refreshError) { // A stale request must never destroy a session that was logged out or replaced while // its refresh was in flight (for example, when another tab signs in as another user). const sessionChanged = @@ -200,6 +200,17 @@ apiClient.interceptors.response.use( }) } + if (axios.isAxiosError(refreshError)) { + const refreshStatus = refreshError.response?.status ?? 0 + if (refreshStatus === 0 || refreshStatus === 429 || refreshStatus >= 500) { + return Promise.reject({ + status: refreshStatus, + code: 'TOKEN_REFRESH_UNAVAILABLE', + message: refreshError.response?.data?.message || refreshError.message + }) + } + } + // Clear tokens and redirect to login localStorage.removeItem('auth_token') localStorage.removeItem('refresh_token')