mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 11:25:03 +00:00
fix(auth): preserve sessions during temporary service failures
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user