feat(auth): add opaque bearer sessions
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
71
web/src/utils/authSessionStorage.js
Normal file
71
web/src/utils/authSessionStorage.js
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
51
web/tests/auth-session-storage.test.mjs
Normal file
51
web/tests/auth-session-storage.test.mjs
Normal 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')
|
||||
35
web/tests/vite-setup-lock.test.mjs
Normal file
35
web/tests/vite-setup-lock.test.mjs
Normal 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')
|
||||
@@ -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 : '初始化写入失败。'
|
||||
|
||||
Reference in New Issue
Block a user