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)
}