feat(auth): add opaque bearer sessions

This commit is contained in:
caoxiaozhu
2026-07-13 14:45:36 +08:00
parent 661990b27b
commit 653eda0596
59 changed files with 1408 additions and 408 deletions

View File

@@ -8,7 +8,10 @@ import {
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { fetchCurrentAuthUser, login as loginByAccount } from '../services/auth.js'
import {
fetchCurrentAuthUser,
login as loginByAccount
} from '../services/auth.js'
import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
@@ -16,15 +19,22 @@ import { useToast } from './useToast.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
import {
AUTH_SESSION_EXPIRED_EVENT,
clearAuthCredentials,
hasValidAuthSession,
persistAuthCredentials,
readAuthAccessToken,
readAuthExpiresAt
} from '../utils/authSessionStorage.js'
import {
clearAuthSessionMetrics,
finalizeAndRevokeAuthSession,
finalizeAuthSession,
incrementAuthActivityCount,
persistAuthSessionMetrics
} from '../utils/authSessionMetrics.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_USER_KEY = 'x-financial-auth-user'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const DEFAULT_USER_NAME = '系统管理员'
@@ -78,20 +88,8 @@ function readClientBootstrapState() {
}
}
function readAuthState() {
if (typeof window === 'undefined') {
return false
}
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
}
function readStoredUsername() {
if (typeof window === 'undefined') {
return ''
}
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
function readAuthState() {
return hasValidAuthSession()
}
function buildAnonymousUser() {
@@ -116,31 +114,6 @@ function buildAnonymousUser() {
}
}
function buildLegacyAdminUser(username = '') {
const normalized = String(username || '').trim()
const name = normalized || DEFAULT_USER_NAME
return {
username: normalized,
name,
role: DEFAULT_USER_ROLE,
department: '',
departmentName: '',
position: DEFAULT_USER_ROLE,
grade: '',
employeeNo: '',
managerName: '',
location: '',
costCenter: '',
financeOwnerName: '',
riskProfile: {},
roleCodes: ['manager'],
email: '',
avatar: name.slice(0, 1).toUpperCase(),
isAdmin: true
}
}
function resolvePlatformAdminFlag(payload, roleCodes = []) {
return resolveAuthUserAdminFlag(payload, roleCodes)
}
@@ -171,12 +144,11 @@ function readStoredUser() {
return normalizeStoredAuthUser(payload)
}
} catch {
return buildLegacyAdminUser(readStoredUsername())
}
}
const legacyUsername = readStoredUsername()
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
return buildAnonymousUser()
}
}
return buildAnonymousUser()
}
function readLastActivityAt() {
@@ -187,10 +159,14 @@ function readLastActivityAt() {
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function isSessionExpired(now = Date.now()) {
if (!readAuthState()) {
return false
}
function isSessionExpired(now = Date.now()) {
if (!readAuthAccessToken()) {
return false
}
if (!hasValidAuthSession(now)) {
return true
}
const lastActivityAt = readLastActivityAt()
@@ -201,24 +177,24 @@ function isSessionExpired(now = Date.now()) {
return now - lastActivityAt > authIdleTimeoutMs
}
function persistAuthState(value, user = null, sessionId = '') {
function persistAuthState(value, user = null, sessionId = '', accessToken = '', expiresAt = '') {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
if (value) {
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
persistAuthCredentials(accessToken, expiresAt)
persistAuthSessionMetrics(sessionId)
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_USER_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
window.sessionStorage.removeItem('x-financial-authenticated')
window.sessionStorage.removeItem('x-financial-auth-username')
clearAuthCredentials()
clearAuthSessionMetrics()
}
@@ -228,7 +204,6 @@ function persistAuthUserSnapshot(user = {}) {
}
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
}
@@ -269,7 +244,9 @@ function scheduleSessionTimeout() {
return
}
const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt)
const idleRemaining = authIdleTimeoutMs - (Date.now() - lastActivityAt)
const tokenRemaining = readAuthExpiresAt() - Date.now()
const remaining = Math.min(idleRemaining, tokenRemaining)
if (remaining <= 0) {
logout('timeout', { notify: true })
@@ -307,6 +284,13 @@ function handleSessionActivity(event) {
touchAuthActivity()
}
function handleAuthSessionExpired() {
if (!readAuthAccessToken()) {
return
}
logout('expired', { notify: true, revoke: false })
}
function handleSessionUnload(event) {
if (event?.type === 'pagehide' && event.persisted) {
return
@@ -323,6 +307,7 @@ function installSessionMonitoring() {
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, handleAuthSessionExpired)
window.addEventListener('pagehide', handleSessionUnload, { passive: true })
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
}
@@ -428,11 +413,11 @@ const runtimeTestMessage = ref('')
const databaseTestMessage = ref('')
const loginSubmitting = ref(false)
const loginError = ref('')
const loggedIn = ref(readAuthState() && !isSessionExpired())
const loggedIn = ref(readAuthState() && !isSessionExpired())
const currentUser = ref(readStoredUser())
if (!loggedIn.value && readAuthState()) {
persistAuthState(false)
if (!loggedIn.value && readAuthAccessToken()) {
persistAuthState(false)
}
const { toast } = useToast()
@@ -674,7 +659,13 @@ async function handleLogin(credentials) {
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
}
loggedIn.value = true
persistAuthState(true, user, response?.sessionId || '')
persistAuthState(
true,
user,
response?.sessionId || '',
response?.accessToken || '',
response?.expiresAt || ''
)
currentUser.value = user
touchAuthActivity(true)
return true
@@ -688,18 +679,27 @@ async function handleLogin(credentials) {
}
}
function logout(reason = 'manual', options = {}) {
function logout(reason = 'manual', options = {}) {
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
const revoke = options.revoke ?? (reason !== 'invalid' && reason !== 'expired')
finalizeAuthSession(reason)
if (revoke && readAuthAccessToken()) {
finalizeAndRevokeAuthSession(reason).catch((error) => {
console.warn('Failed to revoke auth session:', error)
})
}
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildAnonymousUser()
clearSessionTimeout()
if (notify) {
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。')
if (notify) {
toast(
reason === 'timeout' || reason === 'expired'
? '登录已失效,请重新登录。'
: '已退出登录。'
)
}
if (redirect) {

View File

@@ -1,7 +1,10 @@
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
import {
buildBearerHeaders,
notifyAuthSessionExpired,
readAuthAccessToken
} from '../utils/authSessionStorage.js'
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
function isHeaderValueSafe(value) {
const normalized = String(value || '').trim()
@@ -33,85 +36,6 @@ export function pickSafeHeaderValue(value, fallback = '') {
return ''
}
function readCurrentUserHeaders() {
if (typeof window === 'undefined') {
return {}
}
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
if (!raw) {
return {}
}
try {
const payload = JSON.parse(raw)
const user = normalizeAuthUserSnapshot(payload)
const username = user.username
const name = user.name || username
const roleCodes = user.roleCodes
const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes)
const department = user.department || user.departmentName
const costCenter = user.costCenter
const position = user.position
const grade = user.grade
const employeeNo = user.employeeNo
const managerName = user.managerName
const safeUsername = pickSafeHeaderValue(username)
const safeName = pickSafeHeaderValue(name)
const safeDepartment = pickSafeHeaderValue(department)
const safeCostCenter = pickSafeHeaderValue(costCenter)
const safePosition = pickSafeHeaderValue(position)
const safeGrade = pickSafeHeaderValue(grade)
const safeEmployeeNo = pickSafeHeaderValue(employeeNo)
const safeManagerName = pickSafeHeaderValue(managerName)
if (!safeUsername && !safeName) {
return {}
}
const headers = {
'x-auth-role-codes': roleCodes.join(','),
'x-auth-is-admin': String(isAdmin)
}
if (safeUsername) {
headers['x-auth-username'] = safeUsername
}
if (safeName) {
headers['x-auth-name'] = safeName
}
if (safeDepartment) {
headers['x-auth-department'] = safeDepartment
}
if (safeCostCenter) {
headers['x-auth-cost-center'] = safeCostCenter
}
if (safePosition) {
headers['x-auth-position'] = safePosition
}
if (safeGrade) {
headers['x-auth-grade'] = safeGrade
}
if (safeEmployeeNo) {
headers['x-auth-employee-no'] = safeEmployeeNo
}
if (safeManagerName) {
headers['x-auth-manager-name'] = safeManagerName
}
return headers
} catch {
return {}
}
}
function normalizeApiBaseUrl(value) {
return String(value || '/api/v1').replace(/\/$/, '')
}
@@ -260,8 +184,20 @@ function sanitizeHeaders(headers) {
return nextHeaders
}
function buildApiResponseError(response, payload, { auth, handleUnauthorized }) {
const error = new Error(resolveErrorMessage(payload))
error.status = response.status
if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) {
error.code = 'AUTH_SESSION_EXPIRED'
notifyAuthSessionExpired()
}
return error
}
export async function apiRequest(path, options = {}) {
const {
auth = true,
handleUnauthorized = true,
contentType = 'application/json',
responseType = 'json',
headers: customHeaders,
@@ -270,10 +206,15 @@ export async function apiRequest(path, options = {}) {
...fetchOptions
} = options
const headers = sanitizeHeaders({
...readCurrentUserHeaders(),
...(customHeaders || {})
})
const headers = sanitizeHeaders(customHeaders || {})
if (auth) {
Object.keys(headers).forEach((key) => {
if (key.toLowerCase() === 'authorization') {
delete headers[key]
}
})
Object.assign(headers, buildBearerHeaders())
}
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
headers['Content-Type'] = contentType
@@ -327,7 +268,7 @@ export async function apiRequest(path, options = {}) {
payload = null
}
throw new Error(resolveErrorMessage(payload))
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
}
return response.blob()
@@ -341,7 +282,7 @@ export async function apiRequest(path, options = {}) {
}
if (!response.ok) {
throw new Error(resolveErrorMessage(payload))
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
}
return payload

View File

@@ -1,7 +1,10 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
import { buildBearerHeaders } from '../utils/authSessionStorage.js'
export function login(payload) {
return apiRequest('/auth/login', {
auth: false,
handleUnauthorized: false,
method: 'POST',
body: JSON.stringify(payload)
})
@@ -11,6 +14,14 @@ export function fetchCurrentAuthUser() {
return apiRequest('/auth/me')
}
export function revokeCurrentAuthSession(payload) {
return apiRequest('/auth/logout', {
method: 'POST',
handleUnauthorized: false,
body: JSON.stringify(payload || {})
})
}
export function finishSession(sessionId, payload) {
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
method: 'POST',
@@ -29,13 +40,12 @@ export function finishSessionOnUnload(sessionId, payload) {
const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish`
const body = JSON.stringify(payload || {})
if (typeof window.navigator?.sendBeacon === 'function') {
return window.navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
}
window.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...buildBearerHeaders()
},
body,
keepalive: true
}).catch(() => {})

View File

@@ -1,4 +1,8 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
import {
buildBearerHeaders,
notifyAuthSessionExpired
} from '../utils/authSessionStorage.js'
export function fetchStewardPlan(payload, options = {}) {
return apiRequest('/steward/plans', {
@@ -63,7 +67,8 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...buildBearerHeaders()
},
body: JSON.stringify(payload),
signal: controller?.signal
@@ -78,6 +83,9 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
if (!response.ok) {
clearAbortTimer()
if (response.status === 401) {
notifyAuthSessionExpired()
}
throw new Error(await resolveStreamError(response))
}

View File

@@ -242,6 +242,10 @@ export function canAccessAppView(user, viewId) {
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
}
if (viewId === 'settings') {
return isPlatformAdminUser(user)
}
if (isManagerUser(user)) {
return true
}

View File

@@ -1,6 +1,7 @@
import {
finishSession,
finishSessionOnUnload
finishSessionOnUnload,
revokeCurrentAuthSession
} from '../services/auth.js'
const AUTH_SESSION_ID_KEY = 'x-financial-auth-session-id'
@@ -88,3 +89,11 @@ export function finalizeAuthSession(reason, options = {}) {
console.warn('Failed to finish auth session:', error)
})
}
export function finalizeAndRevokeAuthSession(reason) {
const sessionId = readStoredSessionId()
return revokeCurrentAuthSession({
...buildSessionFinishPayload(reason),
sessionId
})
}

View File

@@ -0,0 +1,71 @@
export const AUTH_ACCESS_TOKEN_KEY = 'x-financial-auth-access-token'
export const AUTH_EXPIRES_AT_KEY = 'x-financial-auth-expires-at'
export const AUTH_SESSION_EXPIRED_EVENT = 'x-financial:auth-expired'
function canUseSessionStorage() {
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'
}
export function readAuthAccessToken() {
if (!canUseSessionStorage()) {
return ''
}
return String(window.sessionStorage.getItem(AUTH_ACCESS_TOKEN_KEY) || '').trim()
}
export function readAuthExpiresAt() {
if (!canUseSessionStorage()) {
return 0
}
const raw = String(window.sessionStorage.getItem(AUTH_EXPIRES_AT_KEY) || '').trim()
if (!raw) {
return 0
}
const timestamp = Date.parse(raw)
return Number.isFinite(timestamp) ? timestamp : 0
}
export function hasValidAuthSession(now = Date.now()) {
const accessToken = readAuthAccessToken()
const expiresAt = readAuthExpiresAt()
return Boolean(accessToken && expiresAt && expiresAt > now)
}
export function persistAuthCredentials(accessToken, expiresAt) {
if (!canUseSessionStorage()) {
return
}
const normalizedToken = String(accessToken || '').trim()
const normalizedExpiresAt = String(expiresAt || '').trim()
if (!normalizedToken || !normalizedExpiresAt || !Number.isFinite(Date.parse(normalizedExpiresAt))) {
throw new Error('登录接口未返回有效的认证凭证。')
}
window.sessionStorage.setItem(AUTH_ACCESS_TOKEN_KEY, normalizedToken)
window.sessionStorage.setItem(AUTH_EXPIRES_AT_KEY, normalizedExpiresAt)
}
export function clearAuthCredentials() {
if (!canUseSessionStorage()) {
return
}
window.sessionStorage.removeItem(AUTH_ACCESS_TOKEN_KEY)
window.sessionStorage.removeItem(AUTH_EXPIRES_AT_KEY)
}
export function buildBearerHeaders() {
const accessToken = readAuthAccessToken()
if (!accessToken) {
return {}
}
return { Authorization: `Bearer ${accessToken}` }
}
export function notifyAuthSessionExpired() {
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
return
}
const event = typeof CustomEvent === 'function'
? new CustomEvent(AUTH_SESSION_EXPIRED_EVENT)
: { type: AUTH_SESSION_EXPIRED_EVENT }
window.dispatchEvent(event)
}

View File

@@ -108,6 +108,11 @@ test('budget center is visible to platform admin, budget monitor, and executive
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false)
})
test('system settings are visible to platform admin instead of business managers', () => {
assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'settings'), true)
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'settings'), false)
})
test('budget edit and department switching are limited to admin and senior finance', () => {
assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true)
assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true)

View File

@@ -45,9 +45,11 @@ async function testSupportsBlobResponses() {
assert.equal(payload, blob)
}
async function testInjectsAuthenticatedUserHeaders() {
const sessionStorage = new Map([
[
async function testInjectsBearerTokenWithoutUserControlledIdentityHeaders() {
const sessionStorage = new Map([
['x-financial-auth-access-token', 'opaque-access-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'],
[
'x-financial-auth-user',
JSON.stringify({
username: 'admin',
@@ -84,27 +86,16 @@ async function testInjectsAuthenticatedUserHeaders() {
await apiRequest('/knowledge/library')
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User')
assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager')
assert.equal(capturedOptions.headers['x-auth-grade'], 'M5')
assert.equal(capturedOptions.headers['x-auth-employee-no'], 'E-001')
assert.equal(capturedOptions.headers['x-auth-manager-name'], 'Approver User')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
assert.equal(capturedOptions.headers.Authorization, 'Bearer opaque-access-token')
assert.equal(capturedOptions.headers['x-auth-username'], undefined)
assert.equal(capturedOptions.headers['x-auth-role-codes'], undefined)
assert.equal(capturedOptions.headers['x-auth-is-admin'], undefined)
}
async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
async function testLoginCanDisableBearerInjection() {
const sessionStorage = new Map([
[
'x-financial-auth-user',
JSON.stringify({
username: 'superadmin',
name: 'superadmin',
roleCodes: ['manager'],
is_admin: true
})
]
['x-financial-auth-access-token', 'stale-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
])
global.window = {
@@ -127,11 +118,74 @@ async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
}
}
await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' })
await apiRequest('/auth/login', {
auth: false,
handleUnauthorized: false,
method: 'POST',
body: '{}'
})
assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
assert.equal(capturedOptions.headers.Authorization, undefined)
}
async function testRejectsCustomAuthorizationOverride() {
const sessionStorage = new Map([
['x-financial-auth-access-token', 'server-issued-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
])
global.window = {
sessionStorage: {
getItem(key) {
return sessionStorage.get(key) ?? null
}
}
}
let capturedOptions = null
global.fetch = async (_url, options) => {
capturedOptions = options
return { ok: true, async json() { return { ok: true } } }
}
await apiRequest('/knowledge/library', {
headers: { Authorization: 'Bearer attacker-token' }
})
assert.equal(capturedOptions.headers.Authorization, 'Bearer server-issued-token')
}
async function testUnauthorizedResponsePublishesSessionExpiredEvent() {
const sessionStorage = new Map([
['x-financial-auth-access-token', 'expired-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
])
const events = []
global.window = {
sessionStorage: {
getItem(key) {
return sessionStorage.get(key) ?? null
}
},
dispatchEvent(event) {
events.push(event.type)
}
}
global.fetch = async () => ({
ok: false,
status: 401,
async json() {
return { detail: '登录会话已失效。' }
}
})
await assert.rejects(
() => apiRequest('/auth/me'),
(error) => {
assert.equal(error.status, 401)
assert.equal(error.code, 'AUTH_SESSION_EXPIRED')
return true
}
)
assert.deepEqual(events, ['x-financial:auth-expired'])
}
async function testFormatsValidationErrors() {
@@ -192,8 +246,10 @@ async function testRejectsWithCustomTimeoutMessage() {
async function run() {
await testUsesCustomContentTypeHeader()
await testSupportsBlobResponses()
await testInjectsAuthenticatedUserHeaders()
await testInjectsLegacyAdminHeaderFromSnakeCaseFlag()
await testInjectsBearerTokenWithoutUserControlledIdentityHeaders()
await testLoginCanDisableBearerInjection()
await testRejectsCustomAuthorizationOverride()
await testUnauthorizedResponsePublishesSessionExpiredEvent()
await testFormatsValidationErrors()
await testRejectsWithCustomTimeoutMessage()
console.log('api-request tests passed')

View File

@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import {
AUTH_ACCESS_TOKEN_KEY,
AUTH_EXPIRES_AT_KEY,
buildBearerHeaders,
clearAuthCredentials,
hasValidAuthSession,
persistAuthCredentials,
readAuthAccessToken,
readAuthExpiresAt
} from '../src/utils/authSessionStorage.js'
const storage = new Map()
global.window = {
sessionStorage: {
getItem(key) {
return storage.get(key) ?? null
},
setItem(key, value) {
storage.set(key, String(value))
},
removeItem(key) {
storage.delete(key)
}
}
}
const expiresAt = '2099-01-01T00:00:00.000Z'
persistAuthCredentials('opaque-token', expiresAt)
assert.equal(readAuthAccessToken(), 'opaque-token')
assert.equal(readAuthExpiresAt(), Date.parse(expiresAt))
assert.equal(hasValidAuthSession(Date.parse('2098-01-01T00:00:00.000Z')), true)
assert.deepEqual(buildBearerHeaders(), { Authorization: 'Bearer opaque-token' })
storage.set(AUTH_EXPIRES_AT_KEY, '2020-01-01T00:00:00.000Z')
assert.equal(hasValidAuthSession(Date.parse('2021-01-01T00:00:00.000Z')), false)
storage.set(AUTH_ACCESS_TOKEN_KEY, '')
assert.deepEqual(buildBearerHeaders(), {})
assert.throws(
() => persistAuthCredentials('', expiresAt),
/未返回有效的认证凭证/
)
clearAuthCredentials()
assert.equal(storage.has(AUTH_ACCESS_TOKEN_KEY), false)
assert.equal(storage.has(AUTH_EXPIRES_AT_KEY), false)
console.log('auth session storage tests passed')

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { isSetupCompletedState, normalizeState } from '../vite.config.js'
const completedEnv = {
SETUP_COMPLETED: 'true',
POSTGRES_HOST: 'database.internal',
POSTGRES_PORT: '5432',
POSTGRES_DB: 'x_financial',
POSTGRES_USER: 'postgres-admin',
POSTGRES_PASSWORD: 'secret',
REDIS_URL: 'redis://redis.internal:6379/0'
}
assert.equal(isSetupCompletedState(completedEnv, true), true)
assert.equal(isSetupCompletedState(completedEnv, false), false)
assert.equal(isSetupCompletedState({ SETUP_COMPLETED: 'false' }, true), false)
const publicState = normalizeState(completedEnv, { adminConfigured: true })
assert.equal(publicState.initialized, true)
assert.equal(publicState.database.host, '')
assert.equal(publicState.database.username, '')
assert.equal(publicState.redis.url, '')
assert.equal(publicState.database.password_configured, true)
const setupState = normalizeState(
{ ...completedEnv, SETUP_COMPLETED: 'false' },
{ adminConfigured: false }
)
assert.equal(setupState.initialized, false)
assert.equal(setupState.database.host, 'database.internal')
assert.equal(setupState.database.username, 'postgres-admin')
assert.equal(setupState.redis.url, 'redis://redis.internal:6379/0')
console.log('vite setup lock tests passed')

View File

@@ -17,8 +17,9 @@ const adminSecretDir = path.join(rootDir, 'server', '.secrets')
const adminSecretFile = path.join(adminSecretDir, 'admin.json')
const adminScryptOptions = { N: 16384, r: 8, p: 1 }
const adminScryptKeyLength = 64
let backendStartPromise = null
let backendStartState = createBackendStartState()
let backendStartPromise = null
let backendStartState = createBackendStartState()
let backendStartAuthorized = false
function createBackendStartState() {
return {
@@ -400,11 +401,21 @@ function buildClientEnvUpdates(payload, apiBaseUrl) {
}
}
function normalizeState(env) {
const adminConfigured = Boolean(readAdminSecret())
return {
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured,
export function isSetupCompletedState(env, adminConfigured) {
return String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured
}
function isSetupCompleted() {
return isSetupCompletedState(readEnvState(), Boolean(readAdminSecret()))
}
export function normalizeState(env, options = {}) {
const adminConfigured = options.adminConfigured ?? Boolean(readAdminSecret())
const initialized = isSetupCompletedState(env, adminConfigured)
const redactInfrastructure = options.redactInfrastructure ?? initialized
return {
initialized,
company: {
name: env.COMPANY_NAME || '',
code: env.COMPANY_CODE || '',
@@ -423,18 +434,26 @@ function normalizeState(env) {
},
database: {
driver: 'postgresql',
host: env.POSTGRES_HOST || '127.0.0.1',
host: redactInfrastructure ? '' : env.POSTGRES_HOST || '127.0.0.1',
port: Number(env.POSTGRES_PORT || 5432),
name: env.POSTGRES_DB || 'x_financial',
username: env.POSTGRES_USER || 'postgres',
username: redactInfrastructure ? '' : env.POSTGRES_USER || 'postgres',
password_configured: Boolean(env.POSTGRES_PASSWORD)
},
redis: {
enabled: Boolean(env.REDIS_URL),
url: env.REDIS_URL || ''
url: redactInfrastructure ? '' : env.REDIS_URL || ''
}
}
}
}
}
function rejectCompletedSetup(res) {
if (!isSetupCompleted()) {
return false
}
sendJson(res, 403, { detail: '系统已完成初始化,本地初始化桥已锁定。' })
return true
}
async function readJsonBody(req) {
const chunks = []
@@ -827,9 +846,12 @@ function localSetupPlugin() {
server.watcher.unwatch(path.join(rootDir, 'server', 'storage'))
server.watcher.unwatch(path.join(rootDir, 'test-results'))
server.middlewares.use('/__setup/auth/login', async (req, res) => {
try {
if (req.method !== 'POST') {
server.middlewares.use('/__setup/auth/login', async (req, res) => {
try {
if (rejectCompletedSetup(res)) {
return
}
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
@@ -864,9 +886,12 @@ function localSetupPlugin() {
}
})
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
try {
if (req.method !== 'PUT') {
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
try {
if (rejectCompletedSetup(res)) {
return
}
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
@@ -895,9 +920,12 @@ function localSetupPlugin() {
}
})
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
try {
if (req.method !== 'PUT') {
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
try {
if (rejectCompletedSetup(res)) {
return
}
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
@@ -928,21 +956,29 @@ function localSetupPlugin() {
server.middlewares.use('/__setup/bootstrap/backend', async (req, res) => {
try {
if (req.method === 'GET') {
const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log')
backendStartState.logTail = readBackendLogTail(logFile)
sendJson(res, 200, cloneBackendStartState())
return
if (req.method === 'GET') {
const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log')
backendStartState.logTail = isSetupCompleted() ? '' : readBackendLogTail(logFile)
sendJson(res, 200, cloneBackendStartState())
return
}
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
try {
const result = await startBackendAndWait()
sendJson(res, 200, result)
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
if (isSetupCompleted() && !backendStartAuthorized) {
sendJson(res, 403, { detail: '系统已完成初始化,后端启动桥已锁定。' })
return
}
try {
const result = await startBackendAndWait()
if (result.completed) {
backendStartAuthorized = false
}
sendJson(res, 200, result)
} catch (error) {
sendJson(res, 500, {
ok: false,
@@ -963,12 +999,16 @@ function localSetupPlugin() {
return
}
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const currentEnv = readEnvState()
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
if (rejectCompletedSetup(res)) {
return
}
const currentEnv = readEnvState()
const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv)
const validationError = validateSetupPayload(payload)
@@ -991,7 +1031,7 @@ function localSetupPlugin() {
const apiBaseUrl = buildApiBaseUrl(payload, currentEnv)
updateEnvFile({
updateEnvFile({
SETUP_COMPLETED: 'true',
COMPANY_NAME: String(payload.company_name || '').trim(),
COMPANY_CODE: String(payload.company_code || '').trim(),
@@ -1009,10 +1049,11 @@ function localSetupPlugin() {
REDIS_URL: String(payload.redis_url || '').trim(),
CORS_ORIGINS: buildCorsOrigins(payload),
VITE_API_BASE_URL: apiBaseUrl,
...buildClientEnvUpdates(payload, apiBaseUrl)
})
sendJson(res, 201, normalizeState(readEnvState()))
...buildClientEnvUpdates(payload, apiBaseUrl)
})
backendStartAuthorized = true
sendJson(res, 201, normalizeState(readEnvState()))
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '初始化写入失败。'