Restore uds-auth in oclaw: DSH plugin with token+profile auth, settings UI, and pagination.

This commit is contained in:
oliver 2026-09-08 01:46:17 +08:00
parent 8ca42dd973
commit 505f9dea3f
202 changed files with 1427 additions and 46459 deletions

View file

@ -3,76 +3,186 @@ import { UdsValidator } from '../uds/validator.js'
import { ROLES, computePermissions } from '../roles.js'
/**
* auth-middleware: 从 PORTALSSOUser (empNo) 取用户,注入 role/permissions
*
* 三条路径:
* 1. 有 empNo + 有 session → 续期,注入
* 2. 有 empNo + 无 session → 创建 session + bootstrap role
* 3. 无 empNo → 未登录,交给下游
* 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 || 'trust',
authMode: config.udsAuth?.authMode || 'token+profile',
}
const udsValidator = new UdsValidator(validatorConfig)
const { cookieMaxAge, slidingExpiration, slidingInterval } = config.session
const { cookieMaxAge, slidingExpiration } = config.session
const userSearchUrl = config.userSearchUrl || config.udsAuth?.userSearchUrl
const uacBaseUrl = config.udsAuth?.baseUrl || ''
function extractEmpNo(cookieHeader) {
// 优先取 UDS 的 empNo,其次取 fallback administrator
const udsMatch = cookieHeader?.match(/(?:PORTALSSOUser|ZTEDPGSSOUser)=([^;]+)/)
if (udsMatch) return decodeURIComponent(udsMatch[1].trim())
if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' }
const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/)
if (fbMatch) return decodeURIComponent(fbMatch[1].trim())
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) 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 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', () => {
try { resolve(JSON.parse(data)) } catch { resolve(null) }
})
})
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') return null
const list = Array.isArray(result?.bo) ? result.bo
: Array.isArray(result?.bo?.rows) ? result.bo.rows
: []
if (!list.length) 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 empNo = extractEmpNo(cookieHeader)
const extracted = extractEmpNo(cookieHeader)
if (!empNo) return next()
if (!extracted) return next()
// 1. 有 session → 续期
let userContext = await sessionStore.get(empNo)
// 1. 已有会话 → 滑动续期
let userContext = await sessionStore.get(extracted.empNo)
if (userContext) {
if (slidingExpiration && userContext.lastActiveAt) {
const lastActive = new Date(userContext.lastActiveAt).getTime()
if (Date.now() - lastActive >= slidingInterval) {
userContext.lastActiveAt = new Date().toISOString()
await sessionStore.setex(empNo, Math.floor(cookieMaxAge / 1000), userContext)
}
}
} else {
// 2. 无 session → 创建
// 信任模式下,empNo 来自我们自己的 QR 登录流程设置的 cookie,
// 即使没有完整的 header 凭据也应建立会话。
const credentials = udsValidator.extractCredentials(req)
if (udsValidator.authMode === 'trust') {
// trust 模式:只要有 empNo 就建立会话;token 可选
userContext = udsValidator.buildUserContext(
credentials || { empNo, token: '', lang: 'zh-CN' },
false,
if (slidingExpiration) {
userContext.lastActiveAt = new Date().toISOString()
await sessionStore.setex(
extracted.empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
} else {
// verify 模式:必须有有效凭据
if (!credentials || !udsValidator.validateCredentials(credentials)) {
return next()
}
userContext = udsValidator.buildUserContext(credentials, false)
}
userContext.authenticatedAt = new Date().toISOString()
userContext.lastActiveAt = new Date().toISOString()
userContext.isAuthenticated = true
await sessionStore.setex(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()
}
// 注入 role + permissions
const role = rolesStore.getRole(empNo)
// 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 = empNo
ctx.empNo = profile.empNo
ctx.role = role
ctx.permissions = computePermissions(role)
return next()
@ -80,15 +190,14 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
authMiddleware.udsClient = udsClient
authMiddleware.udsValidator = udsValidator
authMiddleware.verifyEmpNoAndToken = verifyEmpNoAndToken
return authMiddleware
}
/**
* 权限守卫 — 拦截非 super_admin 的管理 API
* 用法: 在路由里 call guard(req, res, ctx, 'canManageUsers') → true 表示通过
*/
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
if (permission === 'super_admin') {
return ctx.role === ROLES.SUPER_ADMIN || ctx.role === ROLES.FALLBACK_ADMIN
}
return !!ctx.permissions[permission]
}