mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 15:53:31 +08:00
Add parallel skill credential path to uds-auth for agent/cron use.
UI session behavior stays unchanged; login writes a separate skill cache, with optional retain-on-logout and loopback agent-credentials/outbound APIs plus helpers/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
564d6ac189
commit
64761c8300
13 changed files with 798 additions and 10 deletions
189
uds-auth/lib/agent-auth.js
Normal file
189
uds-auth/lib/agent-auth.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Loopback agent APIs: credentials + outbound proxy.
|
||||
*/
|
||||
import { directRequest } from './uds/user-search.js'
|
||||
import { requestIsLoopback } from './skill-credentials.js'
|
||||
|
||||
const DEFAULT_OUTBOUND_HOSTS = [
|
||||
'icenterapi.zte.com.cn',
|
||||
'icentermsg.dt.zte.com.cn',
|
||||
]
|
||||
|
||||
function sendJSON(res, code, data) {
|
||||
res.statusCode = code >= 200 && code < 300 ? 200 : code
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.end(JSON.stringify(data))
|
||||
}
|
||||
|
||||
function readSessionId(req, body) {
|
||||
const h = req.headers || {}
|
||||
return (
|
||||
h['x-dsh-session-id']
|
||||
|| h['X-DSH-Session-Id']
|
||||
|| body?.sessionId
|
||||
|| body?.dshSessionId
|
||||
|| null
|
||||
)
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
let raw = ''
|
||||
for await (const chunk of req) raw += chunk
|
||||
if (!raw) return {}
|
||||
try { return JSON.parse(raw) } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>,
|
||||
* resolveCredentialsForEmpNo: (empNo: string) => Promise<{empNo:string,token:string}|null>,
|
||||
* empNoHeader?: string,
|
||||
* authValueHeader?: string,
|
||||
* outboundHosts?: string[],
|
||||
* }} deps
|
||||
*/
|
||||
export function createAgentAuthHandlers(deps) {
|
||||
const empNoHeader = deps.empNoHeader || 'X-Emp-No'
|
||||
const authValueHeader = deps.authValueHeader || 'X-Auth-Value'
|
||||
|
||||
function allowedHosts() {
|
||||
const list = typeof deps.outboundHosts === 'function'
|
||||
? deps.outboundHosts()
|
||||
: deps.outboundHosts
|
||||
if (Array.isArray(list) && list.length) return list.map(String)
|
||||
return DEFAULT_OUTBOUND_HOSTS.slice()
|
||||
}
|
||||
|
||||
function hostAllowed(hostname) {
|
||||
const host = String(hostname || '').toLowerCase()
|
||||
return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase()))
|
||||
}
|
||||
|
||||
async function resolveFromRequest(req, body = {}) {
|
||||
const sessionId = readSessionId(req, body)
|
||||
if (sessionId) {
|
||||
const creds = await deps.resolveCredentialsForSession(String(sessionId))
|
||||
if (creds) return { creds, sessionId: String(sessionId) }
|
||||
}
|
||||
const empNo = body.empNo || req.headers['x-uds-emp-no']
|
||||
if (empNo) {
|
||||
const creds = await deps.resolveCredentialsForEmpNo(String(empNo))
|
||||
if (creds) return { creds, sessionId: sessionId ? String(sessionId) : null }
|
||||
}
|
||||
return { creds: null, sessionId: sessionId ? String(sessionId) : null }
|
||||
}
|
||||
|
||||
async function handleAgentCredentials(req, res) {
|
||||
if (!requestIsLoopback(req)) {
|
||||
return sendJSON(res, 403, { error: 'agent-credentials is loopback-only' })
|
||||
}
|
||||
const method = (req.method || 'GET').toUpperCase()
|
||||
if (method !== 'GET' && method !== 'POST') {
|
||||
return sendJSON(res, 405, { error: 'Method not allowed' })
|
||||
}
|
||||
const body = method === 'POST' ? await readJsonBody(req) : {}
|
||||
const { creds, sessionId } = await resolveFromRequest(req, body)
|
||||
if (!creds) {
|
||||
return sendJSON(res, 401, {
|
||||
error: 'no_skill_credentials',
|
||||
message: '请先完成 UDS 扫码登录',
|
||||
sessionId: sessionId || null,
|
||||
})
|
||||
}
|
||||
return sendJSON(res, 200, {
|
||||
empNo: creds.empNo,
|
||||
token: creds.token,
|
||||
updatedAt: creds.updatedAt || null,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOutbound(req, res) {
|
||||
if (!requestIsLoopback(req)) {
|
||||
return sendJSON(res, 403, { error: 'outbound is loopback-only' })
|
||||
}
|
||||
const method = (req.method || 'POST').toUpperCase()
|
||||
if (method !== 'POST') {
|
||||
return sendJSON(res, 405, { error: 'Method not allowed' })
|
||||
}
|
||||
const body = await readJsonBody(req)
|
||||
const { creds } = await resolveFromRequest(req, body)
|
||||
if (!creds) {
|
||||
return sendJSON(res, 401, {
|
||||
error: 'no_skill_credentials',
|
||||
message: '请先完成 UDS 扫码登录',
|
||||
})
|
||||
}
|
||||
|
||||
const targetUrl = body.url
|
||||
if (!targetUrl || typeof targetUrl !== 'string') {
|
||||
return sendJSON(res, 400, { error: 'url required' })
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(targetUrl)
|
||||
} catch {
|
||||
return sendJSON(res, 400, { error: 'invalid url' })
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return sendJSON(res, 400, { error: 'unsupported protocol' })
|
||||
}
|
||||
if (!hostAllowed(parsed.hostname)) {
|
||||
return sendJSON(res, 403, { error: 'host_not_allowed', host: parsed.hostname })
|
||||
}
|
||||
|
||||
const upstreamMethod = String(body.method || 'POST').toUpperCase()
|
||||
const headers = { ...(body.headers && typeof body.headers === 'object' ? body.headers : {}) }
|
||||
for (const k of Object.keys(headers)) {
|
||||
const lower = k.toLowerCase()
|
||||
if (lower === 'x-auth-value' || lower === 'x-emp-no' || lower === 'host' || lower === 'content-length') {
|
||||
delete headers[k]
|
||||
}
|
||||
}
|
||||
headers[empNoHeader] = creds.empNo
|
||||
headers[authValueHeader] = creds.token
|
||||
if (!headers['Content-Type'] && !headers['content-type'] && body.body != null) {
|
||||
headers['Content-Type'] = 'application/json;charset=UTF-8'
|
||||
}
|
||||
|
||||
let upstreamBody = body.body
|
||||
if (upstreamBody != null && typeof upstreamBody !== 'string' && !Buffer.isBuffer(upstreamBody)) {
|
||||
upstreamBody = JSON.stringify(upstreamBody)
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await directRequest(parsed, {
|
||||
method: upstreamMethod,
|
||||
headers,
|
||||
body: upstreamBody,
|
||||
timeoutMs: Number(body.timeoutMs) || 30000,
|
||||
})
|
||||
res.statusCode = out.statusCode || 502
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('X-Uds-Outbound-Status', String(out.statusCode || 0))
|
||||
// Wrap so skill gets status + body without leaking injected auth headers
|
||||
res.end(JSON.stringify({
|
||||
statusCode: out.statusCode,
|
||||
headers: sanitizeUpstreamHeaders(out.headers),
|
||||
body: out.body,
|
||||
json: out.json,
|
||||
}))
|
||||
} catch (err) {
|
||||
return sendJSON(res, 502, { error: 'upstream_failed', message: err.message || String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS }
|
||||
}
|
||||
|
||||
function sanitizeUpstreamHeaders(headers) {
|
||||
if (!headers || typeof headers !== 'object') return {}
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
const lower = k.toLowerCase()
|
||||
if (lower === 'x-auth-value' || lower.includes('token') || lower.includes('cookie')) continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -14,7 +14,13 @@ import { requirePermission } from './middleware/auth-middleware.js'
|
|||
* POST /fallback/password → super_admin only(设置兜底密码)
|
||||
* POST /fallback/clear → super_admin only(清除兜底密码)
|
||||
*/
|
||||
export function createApiHandlers(config, sessionStore, rolesStore) {
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {import('./session/store.js').SessionStore} sessionStore
|
||||
* @param {*} rolesStore
|
||||
* @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra]
|
||||
*/
|
||||
export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) {
|
||||
async function sendRes(res, code, data) {
|
||||
res.statusCode = code >= 200 && code < 300 ? 200 : code
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
|
|
@ -24,6 +30,13 @@ export function createApiHandlers(config, sessionStore, rolesStore) {
|
|||
async function logout(ctx) {
|
||||
const empNo = ctx.empNo
|
||||
if (empNo) await sessionStore.delete(empNo)
|
||||
// Skill 凭证轨:默认保留;retainSkillCredentialsOnLogout=false 时清除
|
||||
const retain = extra.retainSkillCredentialsOnLogout
|
||||
? extra.retainSkillCredentialsOnLogout() !== false
|
||||
: true
|
||||
if (empNo && !retain) {
|
||||
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
|
||||
}
|
||||
// Cookie clear attrs must match login (Secure + HttpOnly), or browsers keep the old cookie.
|
||||
const clear = []
|
||||
for (const name of [
|
||||
|
|
@ -122,6 +135,7 @@ export function createApiHandlers(config, sessionStore, rolesStore) {
|
|||
try {
|
||||
await rolesStore.removeUser(empNo, ctx.role)
|
||||
await sessionStore.delete(empNo)
|
||||
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
|
||||
await sendRes(ctx.res, 200, { message: `${empNo} 已删除` })
|
||||
} catch (err) {
|
||||
await sendRes(ctx.res, 400, { error: err.message })
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const DEFAULT_CONFIG = {
|
|||
userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search',
|
||||
loginSystemCode: '100000455558',
|
||||
originSystemCode: '',
|
||||
retainSkillCredentialsOnLogout: true,
|
||||
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
|
||||
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
|
||||
}
|
||||
|
||||
// 内部常量(不暴露)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ const CONFIG_DEFAULTS = {
|
|||
loginSystemCode: '100000455558',
|
||||
originSystemCode: '',
|
||||
workspaceRoot: '',
|
||||
/** UI 退出时是否保留 skill 凭证缓存(默认保留,供 cron/skill) */
|
||||
retainSkillCredentialsOnLogout: true,
|
||||
/** skill 凭证 TTL(秒),默认 7 天 */
|
||||
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
|
||||
/** outbound 白名单 host(逗号分隔或数组) */
|
||||
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
|
||||
}
|
||||
|
||||
// 内部常量(不暴露给用户,UDS 固定协议)
|
||||
|
|
@ -97,6 +103,9 @@ function loadConfigSchemaAndSettings(ctx) {
|
|||
loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode),
|
||||
originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode),
|
||||
workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot),
|
||||
retainSkillCredentialsOnLogout: z.boolean().default(CONFIG_DEFAULTS.retainSkillCredentialsOnLogout),
|
||||
skillCredentialTtlSeconds: z.number().default(CONFIG_DEFAULTS.skillCredentialTtlSeconds),
|
||||
outboundAllowedHosts: z.string().default(CONFIG_DEFAULTS.outboundAllowedHosts),
|
||||
})
|
||||
_DshSettings = require('@deepseek-ai/dsh-settings')
|
||||
ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded')
|
||||
|
|
@ -122,6 +131,8 @@ let _sessionAcl = null
|
|||
let _userWorkspaces = null
|
||||
let _pluginCtx = null
|
||||
let _logger = console
|
||||
let _skillCredentials = null
|
||||
let _agentAuthHandlers = null
|
||||
|
||||
function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
|
||||
if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath
|
||||
|
|
@ -312,8 +323,11 @@ async function handleConfigGet(req, res) {
|
|||
userSearchUrl: c.userSearchUrl,
|
||||
loginSystemCode: c.loginSystemCode,
|
||||
originSystemCode: c.originSystemCode,
|
||||
workspaceRoot: c.workspaceRoot,
|
||||
},
|
||||
workspaceRoot: c.workspaceRoot,
|
||||
retainSkillCredentialsOnLogout: c.retainSkillCredentialsOnLogout !== false,
|
||||
skillCredentialTtlSeconds: c.skillCredentialTtlSeconds,
|
||||
outboundAllowedHosts: c.outboundAllowedHosts,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -494,10 +508,25 @@ const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
|
|||
async function saveRuntimeConfig(partial) {
|
||||
if (!_currentConfig) throw new Error('配置未初始化')
|
||||
// 只允许修改 4 个可配置字段
|
||||
const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot']
|
||||
const allowed = [
|
||||
'uacBaseUrl',
|
||||
'userSearchUrl',
|
||||
'loginSystemCode',
|
||||
'originSystemCode',
|
||||
'workspaceRoot',
|
||||
'retainSkillCredentialsOnLogout',
|
||||
'skillCredentialTtlSeconds',
|
||||
'outboundAllowedHosts',
|
||||
]
|
||||
for (const k of allowed) {
|
||||
if (partial[k] !== undefined) {
|
||||
_currentConfig[k] = String(partial[k])
|
||||
if (k === 'retainSkillCredentialsOnLogout') {
|
||||
_currentConfig[k] = partial[k] === true || partial[k] === 'true'
|
||||
} else if (k === 'skillCredentialTtlSeconds') {
|
||||
_currentConfig[k] = Number(partial[k]) || CONFIG_DEFAULTS.skillCredentialTtlSeconds
|
||||
} else {
|
||||
_currentConfig[k] = typeof partial[k] === 'string' ? partial[k] : String(partial[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
// 写运行时配置文件(不覆盖原始 config.default.yaml)
|
||||
|
|
@ -550,6 +579,26 @@ async function handleAllRoutes(req, res) {
|
|||
return await handleUserInfo(req, res)
|
||||
}
|
||||
|
||||
// Skill / agent:本机凭证与出站代理(loopback-only)
|
||||
if (pathname === '/uds-auth/agent-credentials' && (method === 'GET' || method === 'POST')) {
|
||||
if (!_agentAuthHandlers) {
|
||||
res.statusCode = 503
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ error: 'skill credentials not ready' }))
|
||||
return
|
||||
}
|
||||
return await _agentAuthHandlers.handleAgentCredentials(req, res)
|
||||
}
|
||||
if (pathname === '/uds-auth/outbound' && method === 'POST') {
|
||||
if (!_agentAuthHandlers) {
|
||||
res.statusCode = 503
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ error: 'outbound not ready' }))
|
||||
return
|
||||
}
|
||||
return await _agentAuthHandlers.handleOutbound(req, res)
|
||||
}
|
||||
|
||||
// 客户端配置(RPC config.get 在 web 端走 HTTP)
|
||||
if (pathname === '/uds-auth/config.get' && (method === 'GET' || method === 'POST')) {
|
||||
return await handleConfigGet(req, res)
|
||||
|
|
@ -805,6 +854,8 @@ async function initServices(ctx, config) {
|
|||
const { SessionAclStore } = await import('./session-acl.js')
|
||||
const { UserWorkspaceStore } = await import('./workspace-provision.js')
|
||||
const { patchWebServerWithIdentity, resolveIdentityFromRequest, resolveIdentityFromRequestSync, installDshAcl } = await import('./dsh-acl.js')
|
||||
const { SkillCredentialCache } = await import('./skill-credentials.js')
|
||||
const { createAgentAuthHandlers } = await import('./agent-auth.js')
|
||||
|
||||
_pluginCtx = ctx
|
||||
_currentConfig = config
|
||||
|
|
@ -819,8 +870,31 @@ async function initServices(ctx, config) {
|
|||
}
|
||||
} catch { /* runtime config optional */ }
|
||||
|
||||
// Normalize skill-related config after merge
|
||||
if (_currentConfig.retainSkillCredentialsOnLogout === undefined) {
|
||||
_currentConfig.retainSkillCredentialsOnLogout = CONFIG_DEFAULTS.retainSkillCredentialsOnLogout
|
||||
} else {
|
||||
_currentConfig.retainSkillCredentialsOnLogout =
|
||||
_currentConfig.retainSkillCredentialsOnLogout === true
|
||||
|| _currentConfig.retainSkillCredentialsOnLogout === 'true'
|
||||
}
|
||||
if (!_currentConfig.skillCredentialTtlSeconds) {
|
||||
_currentConfig.skillCredentialTtlSeconds = CONFIG_DEFAULTS.skillCredentialTtlSeconds
|
||||
}
|
||||
if (!_currentConfig.outboundAllowedHosts) {
|
||||
_currentConfig.outboundAllowedHosts = CONFIG_DEFAULTS.outboundAllowedHosts
|
||||
}
|
||||
|
||||
_sessionStore = await createSessionStore(INTERNAL.session)
|
||||
|
||||
const ttlMs = Math.max(60, Number(_currentConfig.skillCredentialTtlSeconds) || CONFIG_DEFAULTS.skillCredentialTtlSeconds) * 1000
|
||||
_skillCredentials = new SkillCredentialCache({
|
||||
file: resolve(__dirname, '..', 'skill-credentials.json'),
|
||||
ttlMs,
|
||||
logger: ctx.logger || console,
|
||||
})
|
||||
await _skillCredentials.init()
|
||||
|
||||
const rolesFile = resolve(__dirname, '..', 'roles.json')
|
||||
_rolesStore = new RolesStore({ rolesFile })
|
||||
await _rolesStore.init()
|
||||
|
|
@ -831,6 +905,10 @@ async function initServices(ctx, config) {
|
|||
_userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(__dirname, '..', 'user-workspaces.json') })
|
||||
await _userWorkspaces.init()
|
||||
|
||||
const rememberSkillCreds = (empNo, token) => {
|
||||
try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_authMiddleware = createAuthMiddleware({
|
||||
userSearchUrl: _currentConfig.userSearchUrl,
|
||||
udsAuth: {
|
||||
|
|
@ -841,8 +919,48 @@ async function initServices(ctx, config) {
|
|||
authValueHeader: INTERNAL.authValueHeader,
|
||||
},
|
||||
session: INTERNAL.session,
|
||||
}, _sessionStore, _rolesStore)
|
||||
_apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore)
|
||||
}, _sessionStore, _rolesStore, { onSkillCredentials: rememberSkillCreds })
|
||||
|
||||
_apiHandlers = createApiHandlers(
|
||||
{ session: INTERNAL.session },
|
||||
_sessionStore,
|
||||
_rolesStore,
|
||||
{
|
||||
skillCredentials: _skillCredentials,
|
||||
retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false,
|
||||
},
|
||||
)
|
||||
|
||||
function parseOutboundHosts() {
|
||||
const raw = _currentConfig?.outboundAllowedHosts
|
||||
if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
|
||||
return String(raw || CONFIG_DEFAULTS.outboundAllowedHosts)
|
||||
.split(/[,;\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function resolveCredentialsForEmpNo(empNo) {
|
||||
if (!empNo) return null
|
||||
const row = _skillCredentials?.get(empNo)
|
||||
if (!row?.token) return null
|
||||
return { empNo: row.empNo, token: row.token, updatedAt: row.updatedAt }
|
||||
}
|
||||
|
||||
async function resolveCredentialsForSession(sessionId) {
|
||||
if (!sessionId) return null
|
||||
const empNo = _sessionAcl?.getOwner(sessionId) || null
|
||||
if (!empNo) return null
|
||||
return resolveCredentialsForEmpNo(empNo)
|
||||
}
|
||||
|
||||
_agentAuthHandlers = createAgentAuthHandlers({
|
||||
resolveCredentialsForSession,
|
||||
resolveCredentialsForEmpNo,
|
||||
empNoHeader: INTERNAL.empNoHeader,
|
||||
authValueHeader: INTERNAL.authValueHeader,
|
||||
outboundHosts: () => parseOutboundHosts(),
|
||||
})
|
||||
|
||||
const identityDeps = {
|
||||
sessionStore: _sessionStore,
|
||||
|
|
@ -938,10 +1056,15 @@ async function initServices(ctx, config) {
|
|||
getSessionOwner(sessionId) {
|
||||
return _sessionAcl?.getOwner(sessionId) || null
|
||||
},
|
||||
resolveCredentialsForSession,
|
||||
resolveCredentialsForEmpNo,
|
||||
rememberSkillCredentials(empNo, token) {
|
||||
rememberSkillCreds(empNo, token)
|
||||
},
|
||||
}
|
||||
|
||||
ctx.provide('udsAuth', udsAuth)
|
||||
ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + udsAuth service)')
|
||||
ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + skill credentials + udsAuth service)')
|
||||
} catch (err) {
|
||||
ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,13 @@ import { searchUserByEmpNoToken } from '../uds/user-search.js'
|
|||
* 出站请求绕过 HTTP(S)_PROXY。
|
||||
*
|
||||
* 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。
|
||||
*
|
||||
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
|
||||
*/
|
||||
export function createAuthMiddleware(config, sessionStore, rolesStore) {
|
||||
export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {}) {
|
||||
const onSkillCredentials = typeof hooks.onSkillCredentials === 'function'
|
||||
? hooks.onSkillCredentials
|
||||
: null
|
||||
const udsClient = new UdsClient(config.udsAuth)
|
||||
const validatorConfig = {
|
||||
...config.udsAuth,
|
||||
|
|
@ -107,6 +112,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
|
|||
Math.floor(cookieMaxAge / 1000),
|
||||
userContext,
|
||||
)
|
||||
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
|
||||
} else {
|
||||
// 查不到资料则作废 trust 会话,避免“假登录”
|
||||
await sessionStore.delete(extracted.empNo)
|
||||
|
|
@ -127,6 +133,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
|
|||
userContext,
|
||||
)
|
||||
}
|
||||
if (userContext.token) {
|
||||
try { onSkillCredentials?.(extracted.empNo, userContext.token) } catch { /* ignore */ }
|
||||
}
|
||||
const role = await resolveRole(extracted.empNo, extracted.kind)
|
||||
ctx.userContext = userContext
|
||||
ctx.empNo = extracted.empNo
|
||||
|
|
@ -172,6 +181,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore) {
|
|||
Math.floor(cookieMaxAge / 1000),
|
||||
userContext,
|
||||
)
|
||||
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
|
||||
|
||||
const role = await resolveRole(profile.empNo, 'uds')
|
||||
ctx.userContext = userContext
|
||||
|
|
|
|||
132
uds-auth/lib/skill-credentials.js
Normal file
132
uds-auth/lib/skill-credentials.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Skill credential cache — separate from UI sessionStore.
|
||||
* empNo → { token, updatedAt }; long TTL for cron/skills after UI logout.
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export class SkillCredentialCache {
|
||||
/**
|
||||
* @param {{ file?: string, ttlMs?: number, logger?: Console }} opts
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this._file = opts.file || null
|
||||
this._ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
|
||||
this._logger = opts.logger || console
|
||||
/** @type {Map<string, { token: string, updatedAt: string, expiresAt: number }>} */
|
||||
this._map = new Map()
|
||||
this._dirty = false
|
||||
this._flushTimer = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this._file) return
|
||||
try {
|
||||
const raw = await readFile(this._file, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
const entries = data?.entries && typeof data.entries === 'object' ? data.entries : data
|
||||
if (!entries || typeof entries !== 'object') return
|
||||
const now = Date.now()
|
||||
for (const [empNo, row] of Object.entries(entries)) {
|
||||
if (!row?.token) continue
|
||||
const expiresAt = Number(row.expiresAt) || (now + this._ttlMs)
|
||||
if (expiresAt <= now) continue
|
||||
this._map.set(String(empNo), {
|
||||
token: String(row.token),
|
||||
updatedAt: row.updatedAt || new Date().toISOString(),
|
||||
expiresAt,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* optional file */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} empNo
|
||||
* @param {string} token
|
||||
*/
|
||||
set(empNo, token) {
|
||||
if (!empNo || !token) return
|
||||
if (String(empNo).startsWith('__')) return
|
||||
if (empNo === 'administrator') return
|
||||
const now = Date.now()
|
||||
this._map.set(String(empNo), {
|
||||
token: String(token),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: now + this._ttlMs,
|
||||
})
|
||||
this._scheduleFlush()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} empNo
|
||||
* @returns {{ empNo: string, token: string, updatedAt: string } | null}
|
||||
*/
|
||||
get(empNo) {
|
||||
if (!empNo) return null
|
||||
const key = String(empNo)
|
||||
const row = this._map.get(key)
|
||||
if (!row) return null
|
||||
if (row.expiresAt && Date.now() > row.expiresAt) {
|
||||
this._map.delete(key)
|
||||
this._scheduleFlush()
|
||||
return null
|
||||
}
|
||||
return { empNo: key, token: row.token, updatedAt: row.updatedAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} empNo
|
||||
*/
|
||||
delete(empNo) {
|
||||
if (!empNo) return
|
||||
if (this._map.delete(String(empNo))) this._scheduleFlush()
|
||||
}
|
||||
|
||||
_scheduleFlush() {
|
||||
if (!this._file) return
|
||||
this._dirty = true
|
||||
if (this._flushTimer) return
|
||||
this._flushTimer = setTimeout(() => {
|
||||
this._flushTimer = null
|
||||
void this._flush()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
async _flush() {
|
||||
if (!this._file || !this._dirty) return
|
||||
this._dirty = false
|
||||
const entries = {}
|
||||
for (const [empNo, row] of this._map.entries()) {
|
||||
entries[empNo] = {
|
||||
token: row.token,
|
||||
updatedAt: row.updatedAt,
|
||||
expiresAt: row.expiresAt,
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mkdir(dirname(this._file), { recursive: true })
|
||||
await writeFile(this._file, JSON.stringify({ entries }, null, 2), 'utf-8')
|
||||
} catch (err) {
|
||||
this._logger?.warn?.('[uds-auth] skillCredentialCache flush failed:', err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackAddress(addr) {
|
||||
if (!addr) return false
|
||||
const a = String(addr).replace(/^::ffff:/i, '')
|
||||
return a === '127.0.0.1' || a === '::1' || a === 'localhost'
|
||||
}
|
||||
|
||||
export function requestIsLoopback(req) {
|
||||
const ra = req?.socket?.remoteAddress
|
||||
if (isLoopbackAddress(ra)) return true
|
||||
const xf = String(req?.headers?.['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
if (xf && isLoopbackAddress(xf)) return true
|
||||
return false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue