fix(auth): preserve sessions during temporary service failures

This commit is contained in:
chihao.ou
2026-09-12 13:41:16 +08:00
parent 4726bdd08b
commit 781a02aea7
5 changed files with 141 additions and 4 deletions
@@ -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
}
@@ -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
}
@@ -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{
+57
View File
@@ -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')
+12 -1
View File
@@ -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')