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:
diguaandCursor committed 2026-06-02 15:41:21 +08:00
1 parent 8490e2b257
commit cd3ae8c5ba
15 files changed
+424 -61

No files matched your search

+139
View File
@@ -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
View File
@@ -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 {
+3 -6
View File
@@ -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
}
}