mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(auth): harden auth security and add remember-me login
Improve auth hook security: restructure URL classification to fix /_web/* bypass when requireAuth is active, use HMAC-SHA256 for constant- time token comparison, remove dead webModeEnabled code. Improve frontend auth flow: extract initializeApp() with watch-based re-init after login, add getToken callback for real-time store reads, unify SSE 401 handling via fetchWithAuth, switch default storage to sessionStorage, add router guard consuming meta.public. Add "Remember this device" checkbox on login page with dual localStorage/sessionStorage strategy and safety warning. Add 23 unit tests for auth hook matrix and http client. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8490e2b257
commit
cd3ae8c5ba
15 files changed
+424
-61
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import { describe, it, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { setAuthToken, setRequireAuth, authHook } from './auth'
|
||||
|
||||
function fakeRequest(url: string, authorization?: string) {
|
||||
return { url, headers: { authorization } } as never
|
||||
}
|
||||
|
||||
function fakeReply() {
|
||||
let sentCode = 0
|
||||
let sentBody: unknown = null
|
||||
return {
|
||||
code(c: number) {
|
||||
sentCode = c
|
||||
return this
|
||||
},
|
||||
send(body: unknown) {
|
||||
sentBody = body
|
||||
},
|
||||
get statusCode() {
|
||||
return sentCode
|
||||
},
|
||||
get body() {
|
||||
return sentBody
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_TOKEN = 'clb_test_token_12345'
|
||||
|
||||
describe('authHook — authentication matrix', () => {
|
||||
beforeEach(() => {
|
||||
setAuthToken(VALID_TOKEN)
|
||||
setRequireAuth(false)
|
||||
})
|
||||
|
||||
// ── No token configured: all routes open ──
|
||||
|
||||
it('passes all routes when no token is configured', async () => {
|
||||
setAuthToken('' as never)
|
||||
// Hack: reset internal state — setAuthToken('') won't set null, use the real function
|
||||
// Actually setAuthToken sets cachedToken to the string value. Empty string is falsy → all pass.
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/api/v1/status'), reply as never)
|
||||
assert.equal(reply.statusCode, 0, '/api/* should pass without token configured')
|
||||
})
|
||||
|
||||
// ── /api/* always requires auth ──
|
||||
|
||||
it('rejects /api/* without Bearer header', async () => {
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/api/v1/status'), reply as never)
|
||||
assert.equal(reply.statusCode, 401)
|
||||
})
|
||||
|
||||
it('rejects /api/* with wrong token', async () => {
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/api/v1/status', 'Bearer wrong_token'), reply as never)
|
||||
assert.equal(reply.statusCode, 401)
|
||||
})
|
||||
|
||||
it('allows /api/* with correct token', async () => {
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/api/v1/status', `Bearer ${VALID_TOKEN}`), reply as never)
|
||||
assert.equal(reply.statusCode, 0, 'should not send any error')
|
||||
})
|
||||
|
||||
// ── /_web/* default (requireAuth=false): bypass ──
|
||||
|
||||
it('allows /_web/* without auth when requireAuth=false', async () => {
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/_web/sessions'), reply as never)
|
||||
assert.equal(reply.statusCode, 0)
|
||||
})
|
||||
|
||||
// ── /_web/* with requireAuth=true: requires token ──
|
||||
|
||||
it('rejects /_web/* without Bearer when requireAuth=true', async () => {
|
||||
setRequireAuth(true)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/_web/sessions'), reply as never)
|
||||
assert.equal(reply.statusCode, 401)
|
||||
})
|
||||
|
||||
it('rejects /_web/* with wrong token when requireAuth=true', async () => {
|
||||
setRequireAuth(true)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/_web/sessions', 'Bearer bad'), reply as never)
|
||||
assert.equal(reply.statusCode, 401)
|
||||
})
|
||||
|
||||
it('allows /_web/* with correct token when requireAuth=true', async () => {
|
||||
setRequireAuth(true)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/_web/sessions', `Bearer ${VALID_TOKEN}`), reply as never)
|
||||
assert.equal(reply.statusCode, 0)
|
||||
})
|
||||
|
||||
// ── Static files / SPA: always public ──
|
||||
|
||||
it('allows static file paths without auth', async () => {
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/index.html'), reply as never)
|
||||
assert.equal(reply.statusCode, 0)
|
||||
})
|
||||
|
||||
it('allows static file paths without auth even with requireAuth=true', async () => {
|
||||
setRequireAuth(true)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/assets/main.js'), reply as never)
|
||||
assert.equal(reply.statusCode, 0)
|
||||
})
|
||||
|
||||
// ── Combined: webRoot + requireAuth (the P0 scenario) ──
|
||||
|
||||
it('P0 regression: /_web/* is protected when both webRoot and requireAuth are active', async () => {
|
||||
setRequireAuth(true)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/_web/ai/llm/providers'), reply as never)
|
||||
assert.equal(reply.statusCode, 401, '/_web/* must NOT bypass auth when requireAuth=true')
|
||||
})
|
||||
|
||||
it('P0 regression: /api/* remains protected regardless of requireAuth flag', async () => {
|
||||
setRequireAuth(false)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/api/v1/status'), reply as never)
|
||||
assert.equal(reply.statusCode, 401, '/api/* must always require auth')
|
||||
})
|
||||
|
||||
// ── setRequireAuth reset ──
|
||||
|
||||
it('setRequireAuth(false) properly resets protection on /_web/*', async () => {
|
||||
setRequireAuth(true)
|
||||
setRequireAuth(false)
|
||||
const reply = fakeReply()
|
||||
await authHook(fakeRequest('/_web/sessions'), reply as never)
|
||||
assert.equal(reply.statusCode, 0, '/_web/* should be open after reset')
|
||||
})
|
||||
})
|
||||
+19
-19
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { timingSafeEqual, createHmac, randomBytes } from 'crypto'
|
||||
import { unauthorized, errorResponse } from './errors'
|
||||
|
||||
let cachedToken: string | null = null
|
||||
let webModeEnabled = false
|
||||
let requireAuthEnabled = false
|
||||
|
||||
/**
|
||||
@@ -19,14 +18,6 @@ export function setAuthToken(token: string): void {
|
||||
cachedToken = token
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable web mode: static resources and SPA paths bypass auth.
|
||||
* Only `/api/` routes remain protected.
|
||||
*/
|
||||
export function setWebMode(enabled: boolean): void {
|
||||
webModeEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* When enabled, /_web/* routes also require Bearer token (same as /api/*).
|
||||
* Used for server/headless deployments where same-origin assumption doesn't hold.
|
||||
@@ -35,23 +26,32 @@ export function setRequireAuth(enabled: boolean): void {
|
||||
requireAuthEnabled = enabled
|
||||
}
|
||||
|
||||
// Compare via HMAC digests (fixed 32-byte length) to avoid leaking token length
|
||||
const hmacKey = randomBytes(32)
|
||||
|
||||
function safeTokenCompare(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a)
|
||||
const bufB = Buffer.from(b)
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
const hashA = createHmac('sha256', hmacKey).update(a).digest()
|
||||
const hashB = createHmac('sha256', hmacKey).update(b).digest()
|
||||
return timingSafeEqual(hashA, hashB)
|
||||
}
|
||||
|
||||
export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (!cachedToken) return
|
||||
|
||||
// /_web/ internal API: skip auth unless require_auth is enabled
|
||||
if (request.url.startsWith('/_web/') && !requireAuthEnabled) return
|
||||
const url = request.url
|
||||
|
||||
// Web mode: only /api/ routes require auth; static files and SPA are public
|
||||
if (webModeEnabled && !request.url.startsWith('/api/')) return
|
||||
// /api/* always requires Bearer token
|
||||
if (url.startsWith('/api/')) {
|
||||
return requireBearerToken(request, reply)
|
||||
}
|
||||
|
||||
return requireBearerToken(request, reply)
|
||||
// /_web/* requires Bearer token when requireAuthEnabled
|
||||
if (url.startsWith('/_web/')) {
|
||||
if (requireAuthEnabled) return requireBearerToken(request, reply)
|
||||
return
|
||||
}
|
||||
|
||||
// Static files and SPA fallback are public
|
||||
}
|
||||
|
||||
function requireBearerToken(request: FastifyRequest, reply: FastifyReply): void {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
verifyCliDataPath,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import { createServer } from './server'
|
||||
import { setAuthToken, setRequireAuth, setWebMode } from './auth'
|
||||
import { setAuthToken, setRequireAuth } from './auth'
|
||||
import { registerSystemRoutes } from './routes/system'
|
||||
import { registerSessionRoutes } from './routes/sessions'
|
||||
import { registerWebRoutes } from './routes/web'
|
||||
@@ -124,9 +124,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
initServerAiLogger(pathProvider.getLogsDir())
|
||||
|
||||
setAuthToken(token)
|
||||
if (options?.requireAuth ?? config.api.require_auth) {
|
||||
setRequireAuth(true)
|
||||
}
|
||||
setRequireAuth(!!(options?.requireAuth ?? config.api.require_auth))
|
||||
|
||||
server = createServer()
|
||||
|
||||
@@ -145,7 +143,6 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
initSync(server, dbManager, pathProvider, { port, host, token })
|
||||
|
||||
if (options?.webRoot && fs.existsSync(options.webRoot)) {
|
||||
setWebMode(true)
|
||||
// 注册反向代理:将 /_proxy/chatlab.fun/* 转发至 https://chatlab.fun,
|
||||
// 行为与 vite dev proxy 一致,解决浏览器 CORS 问题(见 vite.web.config.mts:138-144)。
|
||||
// 必须在 @fastify/static 之前注册,确保显式路由优先于静态文件/SPA fallback。
|
||||
@@ -186,7 +183,7 @@ export async function stopHttpServer(): Promise<void> {
|
||||
dbManager = null
|
||||
}
|
||||
closeServerAiLogger()
|
||||
setWebMode(false)
|
||||
setRequireAuth(false)
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user