Kaynağa Gözat

修复上游认证异常和迟到请求导致反复登录

Samien 1 ay önce
ebeveyn
işleme
6e7e3bbb30

+ 2 - 0
src/client/api/client.ts

@@ -201,7 +201,9 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
           continue
         }
         if (response.status === 401
+          && generation === securityGeneration
           && options.notifyUnauthorized !== false) {
+          // A response from before logout/account switching cannot expire a new login.
           for (const listener of unauthorizedListeners) listener()
         }
         throw new ApiError(

+ 5 - 0
src/server/upstream.ts

@@ -187,6 +187,11 @@ export class UpstreamClient {
         // a whole route handler (it may have performed several writes); repair this one 401 only.
         if (JSON.stringify([jar.header, jar.sessionToken]) === sentCredentials) await renew()
         response = await this.fetchResponse(path, jar, options)
+        // This jar belongs to the Hermes service, not the signed-in Yaoyao user.
+        // A failed renewal must not send the client back to its account login.
+        if (response.status === 401) {
+          throw new HttpError(502, 'Hermes 服务认证暂时不可用,请稍后重试', 'upstream_auth_unavailable')
+        }
       }
       return response
     }

+ 35 - 0
tests/client/authProtocol.test.ts

@@ -109,6 +109,41 @@ describe('authentication and CSRF protocol', () => {
     }
   })
 
+  it.each(['user-1', 'user-2'])('ignores a late 401 after a fresh login as %s', async accountId => {
+    setApiCsrfToken('old-token', 'user-1')
+    let finishOldRequest!: (value: Response) => void
+    vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(resolve => { finishOldRequest = resolve })))
+    const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
+    try {
+      const oldRequest = apiRequest('/api/app/profiles')
+      clearApiSecurityContext()
+      setApiCsrfToken('new-token', accountId)
+      finishOldRequest(Response.json({ code: 'authentication_required' }, { status: 401 }))
+      await expect(oldRequest).rejects.toMatchObject({ status: 401 })
+      expect(expired).not.toHaveBeenCalled()
+    } finally { unsubscribe() }
+  })
+
+  it('still expires the current session when its own request requires login', async () => {
+    setApiCsrfToken('current-token', 'user-1')
+    vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 'authentication_required' }, { status: 401 })))
+    const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
+    try {
+      await expect(apiRequest('/api/app/profiles')).rejects.toMatchObject({ status: 401 })
+      expect(expired).toHaveBeenCalledOnce()
+    } finally { unsubscribe() }
+  })
+
+  it('preserves login when the upstream service cannot authenticate', async () => {
+    setApiCsrfToken('current-token', 'user-1')
+    vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 'upstream_auth_unavailable' }, { status: 502 })))
+    const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
+    try {
+      await expect(apiRequest('/api/app/models')).rejects.toMatchObject({ status: 502, code: 'upstream_auth_unavailable' })
+      expect(expired).not.toHaveBeenCalled()
+    } finally { unsubscribe() }
+  })
+
   it('surfaces the first structured upstream validation error', async () => {
     setApiCsrfToken('csrf-validation')
     vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({

+ 46 - 0
tests/server/localAuthRoutes.test.ts

@@ -31,6 +31,52 @@ function cookies(response: request.Response): string {
 }
 
 describe('15300 local authentication routes', () => {
+  it('keeps desktop and native account sessions when Hermes rejects its service session', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-upstream-auth-')); roots.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: true,
+      superviseDashboard: true, upstreamUsername: 'service', upstreamPassword: 'fixture-password',
+    }
+    let rejected = true, modelRequests = 0
+    const runtime = createApplication({ config, fetchImpl: async (input, init) => {
+      const path = new URL(String(input)).pathname
+      if (path === '/api/status') return Response.json({ auth_required: true })
+      if (path === '/api/auth/me') return new Headers(init?.headers).get('cookie')
+        ? Response.json({ user_id: 'service' }) : Response.json({}, { status: 401 })
+      if (path === '/api/auth/providers') return Response.json({ providers: [] })
+      if (path === '/auth/password-login') return Response.json({ ok: true }, {
+        headers: { 'set-cookie': 'hermes_service=fixture; Path=/; HttpOnly' },
+      })
+      if (path === '/api/model/options') {
+        modelRequests += 1
+        return rejected ? Response.json({ error: 'service login required' }, { status: 401 })
+          : Response.json({ models: [] })
+      }
+      return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+    } }); runtimes.push(runtime)
+    const server = await listen(runtime), agent = request.agent(server)
+    const host = '127.0.0.1:15300'
+    const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+    const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', `http://${host}`)
+      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
+
+    for (const path of ['/api/app/models', '/api/model/options']) {
+      const failed = await agent.get(path).set('Host', host).expect(502)
+      expect(failed.body.code).toBe('upstream_auth_unavailable')
+      expect(failed.headers['set-cookie']).toBeUndefined()
+    }
+    expect(modelRequests).toBe(4)
+    expect((await agent.get('/api/app/bootstrap?inspectOnly=1').set('Host', host).expect(200)).body)
+      .toMatchObject({ authenticated: true, user: { id: setup.body.user.id } })
+    expect((await agent.get('/api/auth/me').set('Host', host).expect(200)).body.user_id).toBe(setup.body.user.id)
+
+    rejected = false
+    await agent.get('/api/app/models').set('Host', host).expect(200)
+    await request(server).get('/api/app/models').set('Host', host).expect(401)
+  })
+
   it('installs without upstream credentials while keeping local login and CSRF mandatory', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-token-')); roots.push(home)
     const token = 'fixture_local_only_session_token_123456'

+ 23 - 0
tests/server/upstreamServiceSession.test.ts

@@ -58,6 +58,29 @@ describe('upstream service validation lease', () => {
     expect((await f.session.request('/api/sessions/s', { method: 'POST', body: { text: 'once' } })).status).toBe(500)
     expect(f.fetch.mock.calls.length - before).toBe(1)
   })
+  it.each(['GET', 'POST'])('reports a persistent service 401 as an upstream failure for %s requests', async method => {
+    const f = fixture()
+    await f.session.ensure()
+    const fetch = f.fetch.getMockImplementation()!
+    f.fetch.mockImplementation(async (input, init) => new URL(String(input)).pathname === '/api/sessions/rejected'
+      ? Response.json({ error: 'service session rejected' }, { status: 401 }) : fetch(input, init))
+    try {
+      await expect(f.client.request('/api/sessions/rejected', f.session.jar, { method })).rejects.toMatchObject({
+        status: 502, code: 'upstream_auth_unavailable',
+      })
+      expect(f.fetch.mock.calls.filter(([input]) => new URL(String(input)).pathname === '/api/sessions/rejected')).toHaveLength(2)
+      expect(f.paths.filter(path => path === '/auth/password-login')).toHaveLength(1)
+    } finally { f.client.close() }
+  })
+
+  it('preserves auth-endpoint 401s for service credential renewal', async () => {
+    const f = fixture()
+    await f.session.ensure()
+    f.fetch.mockResolvedValue(Response.json({ error: 'expired' }, { status: 401 }))
+    try {
+      expect((await f.session.request('/api/auth/ws-ticket', { method: 'POST' })).status).toBe(401)
+    } finally { f.client.close() }
+  })
   it('invalidates validation and cached data on configured credential changes', async () => {
     const f = fixture()
     let password = 'first'