oclaw/uds-auth/lib/middleware/auth-middleware.js
2026-09-08 02:37:08 +08:00

219 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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