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

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