mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
219 lines
7.7 KiB
JavaScript
219 lines
7.7 KiB
JavaScript
import { UdsClient } from '../uds/client.js'
|
||
import { UdsValidator } from '../uds/validator.js'
|
||
import { ROLES, computePermissions } from '../roles.js'
|
||
|
||
/**
|
||
* auth-middleware
|
||
*
|
||
* 正常登录:Cookie 中必须同时有 empNo + token,并用 userSearchUrl
|
||
* (带 X-Emp-No / X-Auth-Value)拉用户详情;两者都成功才建会话。
|
||
*
|
||
* 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话;
|
||
* 禁止仅靠伪造 UDS_FALLBACK_USER Cookie 提权。
|
||
*/
|
||
export function createAuthMiddleware(config, sessionStore, rolesStore) {
|
||
const udsClient = new UdsClient(config.udsAuth)
|
||
const validatorConfig = {
|
||
...config.udsAuth,
|
||
authMode: config.udsAuth?.authMode || 'token+profile',
|
||
}
|
||
const udsValidator = new UdsValidator(validatorConfig)
|
||
const { cookieMaxAge, slidingExpiration } = config.session
|
||
const userSearchUrl = config.userSearchUrl || config.udsAuth?.userSearchUrl
|
||
const uacBaseUrl = config.udsAuth?.baseUrl || ''
|
||
|
||
function extractEmpNo(cookieHeader) {
|
||
const udsMatch = cookieHeader?.match(/(?:PORTALSSOUser|ZTEDPGSSOUser)=([^;]+)/)
|
||
if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' }
|
||
const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/)
|
||
if (fbMatch) return { empNo: decodeURIComponent(fbMatch[1].trim()), kind: 'fallback' }
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* empNo + token → 调用户搜索接口;返回解析后的 profile 或 null
|
||
*/
|
||
async function verifyEmpNoAndToken(empNo, token) {
|
||
if (!userSearchUrl || !empNo || !token) {
|
||
console.warn('[uds-auth] verifyEmpNoAndToken skipped: missing', {
|
||
hasUrl: !!userSearchUrl, hasEmpNo: !!empNo, hasToken: !!token,
|
||
})
|
||
return null
|
||
}
|
||
try {
|
||
const targetUrl = new URL(userSearchUrl)
|
||
const body = JSON.stringify({
|
||
employeeShortId: empNo,
|
||
enableLabel: true,
|
||
keyword: empNo,
|
||
})
|
||
const headers = {
|
||
'Content-Type': 'application/json;charset=UTF-8',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
[config.udsAuth?.empNoHeader || 'X-Emp-No']: empNo,
|
||
[config.udsAuth?.authValueHeader || 'X-Auth-Value']: token,
|
||
}
|
||
if (uacBaseUrl) {
|
||
headers.Origin = uacBaseUrl
|
||
headers.Referer = uacBaseUrl.replace(/\/?$/, '/')
|
||
}
|
||
|
||
const isHttps = targetUrl.protocol === 'https:'
|
||
const mod = await import(isHttps ? 'node:https' : 'node:http')
|
||
const { statusCode, result } = await new Promise((resolve, reject) => {
|
||
const req = mod.request({
|
||
hostname: targetUrl.hostname,
|
||
port: targetUrl.port || (isHttps ? 443 : 80),
|
||
path: targetUrl.pathname + targetUrl.search,
|
||
method: 'POST',
|
||
headers,
|
||
timeout: 8000,
|
||
}, (res) => {
|
||
let data = ''
|
||
res.on('data', (c) => { data += c })
|
||
res.on('end', () => {
|
||
let parsed = null
|
||
try { parsed = JSON.parse(data) } catch { parsed = { raw: String(data).slice(0, 300) } }
|
||
resolve({ statusCode: res.statusCode, result: parsed })
|
||
})
|
||
})
|
||
req.on('error', reject)
|
||
req.on('timeout', () => { req.destroy(); reject(new Error('user-info timeout')) })
|
||
req.write(body)
|
||
req.end()
|
||
})
|
||
|
||
const code = result?.code?.code ?? result?.code
|
||
if (code !== '0000' && code !== 0 && code !== '0') {
|
||
console.warn('[uds-auth] userSearch failed:', {
|
||
empNo, statusCode, code, msg: result?.code?.msg || result?.msg || result?.raw,
|
||
})
|
||
return null
|
||
}
|
||
const list = Array.isArray(result?.bo) ? result.bo
|
||
: Array.isArray(result?.bo?.rows) ? result.bo.rows
|
||
: Array.isArray(result?.bo?.list) ? result.bo.list
|
||
: []
|
||
if (!list.length) {
|
||
console.warn('[uds-auth] userSearch empty bo:', { empNo, statusCode, keys: result && Object.keys(result) })
|
||
return null
|
||
}
|
||
const emp = list[0]
|
||
const resolvedEmpNo = String(
|
||
emp.employeeShortId || emp.employeeNO || emp.empUIID || emp.empNo || empNo,
|
||
).trim()
|
||
// 工号必须对得上(防 token 有效但查了别人)
|
||
if (resolvedEmpNo && resolvedEmpNo !== String(empNo).trim()
|
||
&& !String(empNo).includes(resolvedEmpNo)
|
||
&& !resolvedEmpNo.includes(String(empNo).trim())) {
|
||
// 宽松:短工号/长工号互含即通过;完全无关则拒绝
|
||
const a = String(empNo).replace(/\D/g, '')
|
||
const b = resolvedEmpNo.replace(/\D/g, '')
|
||
if (!a || !b || !(a.includes(b) || b.includes(a))) return null
|
||
}
|
||
return {
|
||
empNo: String(empNo).trim(),
|
||
username: emp.name || emp.empName || emp.userName || empNo,
|
||
department: emp.deptName || emp.deptShortName || emp.orgName || emp.department || '',
|
||
email: emp.email || emp.mail || '',
|
||
phone: emp.mobile || emp.phone || '',
|
||
token,
|
||
}
|
||
} catch (err) {
|
||
console.warn('[uds-auth] verifyEmpNoAndToken failed:', err.message)
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function authMiddleware(ctx, next) {
|
||
const { req } = ctx
|
||
const cookieHeader = req.headers.cookie || ''
|
||
const extracted = extractEmpNo(cookieHeader)
|
||
|
||
if (!extracted) return next()
|
||
|
||
// 1. 已有会话 → 滑动续期
|
||
let userContext = await sessionStore.get(extracted.empNo)
|
||
if (userContext) {
|
||
if (slidingExpiration) {
|
||
userContext.lastActiveAt = new Date().toISOString()
|
||
await sessionStore.setex(
|
||
extracted.empNo,
|
||
Math.floor(cookieMaxAge / 1000),
|
||
userContext,
|
||
)
|
||
}
|
||
const role = rolesStore.getRole(extracted.empNo)
|
||
ctx.userContext = userContext
|
||
ctx.empNo = extracted.empNo
|
||
ctx.role = role
|
||
ctx.permissions = computePermissions(role)
|
||
return next()
|
||
}
|
||
|
||
// 2. 无会话
|
||
// 兜底 Cookie:绝不在这里建会话(必须走密码登录)
|
||
if (extracted.kind === 'fallback') {
|
||
return next()
|
||
}
|
||
|
||
// UDS:必须 empNo + token,且用户详情接口校验通过
|
||
const credentials = udsValidator.extractCredentials(req)
|
||
if (!credentials || !udsValidator.validateCredentials(credentials)) {
|
||
return next()
|
||
}
|
||
if (credentials.empNo !== extracted.empNo) {
|
||
return next()
|
||
}
|
||
|
||
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
|
||
if (!profile) {
|
||
return next()
|
||
}
|
||
|
||
userContext = udsValidator.buildUserContext({
|
||
empNo: profile.empNo,
|
||
token: credentials.token,
|
||
lang: credentials.lang,
|
||
username: profile.username,
|
||
department: profile.department,
|
||
email: profile.email,
|
||
phone: profile.phone,
|
||
}, false)
|
||
userContext.username = profile.username
|
||
userContext.displayName = profile.username
|
||
userContext.department = profile.department
|
||
userContext.email = profile.email
|
||
userContext.phone = profile.phone
|
||
userContext.authenticatedAt = new Date().toISOString()
|
||
userContext.lastActiveAt = new Date().toISOString()
|
||
userContext.isAuthenticated = true
|
||
userContext.authMode = 'token+profile'
|
||
|
||
await sessionStore.setex(
|
||
profile.empNo,
|
||
Math.floor(cookieMaxAge / 1000),
|
||
userContext,
|
||
)
|
||
|
||
const role = rolesStore.getRole(profile.empNo)
|
||
ctx.userContext = userContext
|
||
ctx.empNo = profile.empNo
|
||
ctx.role = role
|
||
ctx.permissions = computePermissions(role)
|
||
return next()
|
||
}
|
||
|
||
authMiddleware.udsClient = udsClient
|
||
authMiddleware.udsValidator = udsValidator
|
||
authMiddleware.verifyEmpNoAndToken = verifyEmpNoAndToken
|
||
return authMiddleware
|
||
}
|
||
|
||
export function requirePermission(ctx, permission) {
|
||
if (!ctx?.permissions) return false
|
||
if (permission === 'super_admin') {
|
||
return ctx.role === ROLES.SUPER_ADMIN || ctx.role === ROLES.FALLBACK_ADMIN
|
||
}
|
||
return !!ctx.permissions[permission]
|
||
}
|