diff --git a/uds-auth/config.default.yaml b/uds-auth/config.default.yaml index 6f0a309a..3f2b2d76 100644 --- a/uds-auth/config.default.yaml +++ b/uds-auth/config.default.yaml @@ -4,7 +4,7 @@ # UAC 基础 URL (生产: https://uac.zte.com.cn, 测试: http://uactest.zte.com.cn:8080) uacBaseUrl: https://uac.zte.com.cn -# icenterapi 用户搜索接口 (完整 URL) +# icenterapi 用户搜索接口 (完整 URL;须内网直连,禁止走 HTTP(S)_PROXY) userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search # 登录时传给 UAC 的业务系统 Code diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index 48aaf15e..16e56fd3 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -355,7 +355,7 @@ async function handleVerifyCode(req, res) { })) } -// User info proxy - bypasses CORS by server-side fetching icenterapi +// User info proxy — intranet direct (no HTTP_PROXY), empNo+token headers async function handleUserInfo(req, res) { if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }) @@ -367,53 +367,35 @@ async function handleUserInfo(req, res) { const empNo = url.searchParams.get('empNo') const token = url.searchParams.get('token') - if (!empNo) { + if (!empNo || !token) { res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Missing empNo' })) + res.end(JSON.stringify({ error: 'Missing empNo or token' })) return } try { - const https = await import('node:https') - const targetUrl = new URL(_currentConfig.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), - [INTERNAL.empNoHeader]: empNo, - 'Origin': _currentConfig.uacBaseUrl, - 'Referer': _currentConfig.uacBaseUrl + '/' - } - if (!token) { - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Missing token' })) + const { searchUserByEmpNoToken } = await import('./uds/user-search.js') + const out = await searchUserByEmpNoToken({ + userSearchUrl: _currentConfig.userSearchUrl, + empNo, + token, + empNoHeader: INTERNAL.empNoHeader, + authValueHeader: INTERNAL.authValueHeader, + origin: _currentConfig.uacBaseUrl, + }) + if (!out.ok) { + const status = out.statusCode === 401 || out.statusCode === 403 ? out.statusCode : 502 + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + error: 'user search failed', + reason: out.reason, + hint: out.hint || 'Ensure userSearchUrl is intranet-reachable and Host does not force HTTP(S)_PROXY for *.zte.com.cn', + detail: { code: out.code, msg: out.msg, statusCode: out.statusCode }, + })) return } - headers[INTERNAL.authValueHeader] = token - - const result = await new Promise((resolve, reject) => { - const proxyReq = https.request({ - hostname: targetUrl.hostname, - port: targetUrl.port || 443, - path: targetUrl.pathname + targetUrl.search, - method: 'POST', - headers, - timeout: 8000, - }, (proxyRes) => { - let data = '' - proxyRes.on('data', chunk => data += chunk) - proxyRes.on('end', () => { - try { resolve(JSON.parse(data)) } catch (e) { resolve({ raw: data }) } - }) - }) - proxyReq.on('error', reject) - proxyReq.write(body) - proxyReq.end() - }) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(result)) + res.end(JSON.stringify(out.result)) } catch (err) { res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index dad3ca02..580ae3b4 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -1,15 +1,16 @@ import { UdsClient } from '../uds/client.js' import { UdsValidator } from '../uds/validator.js' import { ROLES, computePermissions } from '../roles.js' +import { searchUserByEmpNoToken } from '../uds/user-search.js' /** * auth-middleware * * 正常登录:Cookie 中必须同时有 empNo + token,并用 userSearchUrl - * (带 X-Emp-No / X-Auth-Value)拉用户详情;两者都成功才建会话。 + * (带 X-Emp-No / X-Auth-Value)直连内网拉用户详情;成功才建会话。 + * 出站请求绕过 HTTP(S)_PROXY。 * - * 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话; - * 禁止仅靠伪造 UDS_FALLBACK_USER Cookie 提权。 + * 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。 */ export function createAuthMiddleware(config, sessionStore, rolesStore) { const udsClient = new UdsClient(config.udsAuth) @@ -30,99 +31,46 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { return null } - /** - * empNo + token → 调用户搜索接口;返回解析后的 profile 或 null - */ + function applyProfile(userContext, profile, credentials) { + userContext.userId = profile.empNo + userContext.empNo = profile.empNo + userContext.username = profile.username + userContext.displayName = profile.username + userContext.department = profile.department + userContext.organization = profile.organization || profile.department || '' + userContext.email = profile.email + userContext.phone = profile.phone + userContext.token = credentials.token + userContext.lang = credentials.lang || userContext.lang || 'zh-CN' + userContext.isAuthenticated = true + userContext.authMode = 'token+profile' + userContext.lastActiveAt = new Date().toISOString() + return userContext + } + async function verifyEmpNoAndToken(empNo, token) { - if (!userSearchUrl || !empNo || !token) { - console.warn('[uds-auth] verifyEmpNoAndToken skipped: missing', { - hasUrl: !!userSearchUrl, hasEmpNo: !!empNo, hasToken: !!token, - }) + const out = await searchUserByEmpNoToken({ + userSearchUrl, + empNo, + token, + empNoHeader: config.udsAuth?.empNoHeader || 'X-Emp-No', + authValueHeader: config.udsAuth?.authValueHeader || 'X-Auth-Value', + origin: uacBaseUrl || undefined, + }) + if (!out.ok) { + console.warn('[uds-auth] verifyEmpNoAndToken failed:', out) 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(/\/?$/, '/') - } + return out.profile + } - 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 - } + function needsProfileUpgrade(userContext) { + if (!userContext) return true + if (userContext.authMode === 'trust') return true + if (userContext.authMode !== 'token+profile') return true + const name = userContext.displayName || userContext.username || '' + if (!name || name === userContext.empNo) return true + return false } async function authMiddleware(ctx, next) { @@ -132,8 +80,31 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { if (!extracted) return next() - // 1. 已有会话 → 滑动续期 let userContext = await sessionStore.get(extracted.empNo) + + // 旧 trust 会话 / 无姓名部门:强制用 token 重查用户信息 + if (userContext && extracted.kind === 'uds' && needsProfileUpgrade(userContext)) { + const credentials = udsValidator.extractCredentials(req) + if (credentials && credentials.empNo === extracted.empNo && udsValidator.validateCredentials(credentials)) { + const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token) + if (profile) { + userContext = applyProfile(userContext, profile, credentials) + await sessionStore.setex( + profile.empNo, + Math.floor(cookieMaxAge / 1000), + userContext, + ) + } else { + // 查不到资料则作废 trust 会话,避免“假登录” + await sessionStore.delete(extracted.empNo) + userContext = null + } + } else if (userContext.authMode === 'trust') { + await sessionStore.delete(extracted.empNo) + userContext = null + } + } + if (userContext) { if (slidingExpiration) { userContext.lastActiveAt = new Date().toISOString() @@ -151,13 +122,10 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { 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() @@ -176,19 +144,15 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) { token: credentials.token, lang: credentials.lang, username: profile.username, + displayName: profile.username, department: profile.department, + organization: profile.organization, 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 + applyProfile(userContext, profile, credentials) userContext.authenticatedAt = new Date().toISOString() - userContext.lastActiveAt = new Date().toISOString() - userContext.isAuthenticated = true - userContext.authMode = 'token+profile' + userContext.sessionCreatedAt = new Date().toISOString() await sessionStore.setex( profile.empNo, diff --git a/uds-auth/lib/uds/user-search.js b/uds-auth/lib/uds/user-search.js new file mode 100644 index 00000000..6f62fce5 --- /dev/null +++ b/uds-auth/lib/uds/user-search.js @@ -0,0 +1,163 @@ +/** + * Outbound HTTP(S) that must NOT use HTTP_PROXY / HTTPS_PROXY. + * ZTE icenter / UAC user APIs are intranet-only; corporate forward proxies + * typically return 401/403 and never reach the real service. + */ +import http from 'node:http' +import https from 'node:https' + +/** + * @param {string|URL} url + * @param {{ method?: string, headers?: Record, body?: string|Buffer, timeoutMs?: number }} opts + * @returns {Promise<{ statusCode: number, headers: object, body: string, json: any }>} + */ +export function directRequest(url, opts = {}) { + const target = typeof url === 'string' ? new URL(url) : url + const isHttps = target.protocol === 'https:' + const lib = isHttps ? https : http + const method = (opts.method || 'GET').toUpperCase() + const body = opts.body == null ? null : Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(String(opts.body)) + const headers = { ...(opts.headers || {}) } + if (body && headers['Content-Length'] == null && headers['content-length'] == null) { + headers['Content-Length'] = body.length + } + const timeoutMs = opts.timeoutMs ?? 8000 + + // Fresh Agent — never inherit globalAgent (often patched by proxy bootstraps). + const agent = new lib.Agent({ keepAlive: false }) + + return new Promise((resolve, reject) => { + const req = lib.request({ + protocol: target.protocol, + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + path: target.pathname + target.search, + method, + headers, + agent, + timeout: timeoutMs, + }, (res) => { + const chunks = [] + res.on('data', (c) => chunks.push(c)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + let json = null + try { json = JSON.parse(text) } catch { json = null } + resolve({ + statusCode: res.statusCode || 0, + headers: res.headers, + body: text, + json, + }) + }) + }) + req.on('error', reject) + req.on('timeout', () => { + req.destroy() + reject(new Error(`directRequest timeout after ${timeoutMs}ms: ${target.host}`)) + }) + if (body) req.write(body) + req.end() + }) +} + +/** + * POST userSearchUrl with X-Emp-No + X-Auth-Value (intranet, no proxy). + */ +export async function searchUserByEmpNoToken({ + userSearchUrl, + empNo, + token, + empNoHeader = 'X-Emp-No', + authValueHeader = 'X-Auth-Value', + origin, + timeoutMs = 8000, +}) { + if (!userSearchUrl || !empNo || !token) { + return { ok: false, reason: 'missing_args' } + } + const body = JSON.stringify({ + employeeShortId: empNo, + enableLabel: true, + keyword: empNo, + }) + const headers = { + 'Content-Type': 'application/json;charset=UTF-8', + [empNoHeader]: empNo, + [authValueHeader]: token, + } + if (origin) { + headers.Origin = origin + headers.Referer = String(origin).replace(/\/?$/, '/') + } + + let res + try { + res = await directRequest(userSearchUrl, { method: 'POST', headers, body, timeoutMs }) + } catch (err) { + return { ok: false, reason: 'network', error: err.message } + } + + if (res.statusCode === 401 || res.statusCode === 403) { + return { + ok: false, + reason: 'http_auth', + statusCode: res.statusCode, + msg: res.json?.code?.msg || res.json?.msg || res.body.slice(0, 200), + hint: 'userSearchUrl must be reachable on intranet WITHOUT HTTP(S)_PROXY', + } + } + + const result = res.json + if (!result) { + return { ok: false, reason: 'bad_json', statusCode: res.statusCode, raw: res.body.slice(0, 300) } + } + + const code = result?.code?.code ?? result?.code + if (code !== '0000' && code !== 0 && code !== '0') { + return { + ok: false, + reason: 'biz_code', + statusCode: res.statusCode, + code, + msg: result?.code?.msg || result?.msg, + } + } + + 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) { + return { ok: false, reason: 'empty', statusCode: res.statusCode, result } + } + + const emp = list[0] + const resolvedEmpNo = String( + emp.employeeShortId || emp.employeeNO || emp.empUIID || emp.empNo || empNo, + ).trim() + 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 { ok: false, reason: 'emp_mismatch', resolvedEmpNo } + } + } + + return { + ok: true, + profile: { + empNo: String(empNo).trim(), + username: emp.name || emp.empName || emp.userName || empNo, + department: emp.deptFullName || emp.deptName || emp.deptShortName || emp.orgNamePath || emp.orgName || emp.department || '', + organization: emp.orgNamePath || emp.orgName || '', + email: emp.email || emp.mail || '', + phone: emp.mobile || emp.phone || '', + raw: emp, + token, + }, + result, + } +} diff --git a/uds-auth/lib/uds/validator.js b/uds-auth/lib/uds/validator.js index cf87fc8e..77b26156 100644 --- a/uds-auth/lib/uds/validator.js +++ b/uds-auth/lib/uds/validator.js @@ -13,7 +13,7 @@ export class UdsValidator { this.empNoHeader = (config.empNoHeader || 'X-Emp-No').toLowerCase() this.authValueHeader = (config.authValueHeader || 'X-Auth-Value').toLowerCase() this.langIdHeader = (config.langIdHeader || 'X-Lang-Id').toLowerCase() - this.authMode = config.authMode || 'trust' + this.authMode = config.authMode || 'token+profile' this.hrApiUrl = config.hrApiUrl || 'https://icosg.dt.zte.com.cn/ihol/usercenter/pginfo/usercenter/plain/queryPersonGeneralInfo' } @@ -149,7 +149,12 @@ export class UdsValidator { } else { userData = { userId: credentialsOrResponse.empNo, - username: credentialsOrResponse.empNo, + username: credentialsOrResponse.username || credentialsOrResponse.empNo, + displayName: credentialsOrResponse.displayName || credentialsOrResponse.username || credentialsOrResponse.empNo, + department: credentialsOrResponse.department || '', + organization: credentialsOrResponse.organization || '', + email: credentialsOrResponse.email || '', + phone: credentialsOrResponse.phone || '', empNo: credentialsOrResponse.empNo, token: credentialsOrResponse.token, lang: credentialsOrResponse.lang, @@ -173,7 +178,7 @@ export class UdsValidator { lastActiveAt: now, sessionCreatedAt: now, isAuthenticated: true, - authMode: this.authMode, + authMode: this.authMode || 'token+profile', } }