mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
765 lines
25 KiB
JavaScript
765 lines
25 KiB
JavaScript
/**
|
||
* uds-auth - UDS Authentication Plugin for DeepSeek Harness
|
||
*/
|
||
|
||
import { requirePermission } from './middleware/auth-middleware.js'
|
||
import { computePermissions } from './roles.js'
|
||
import { readFile, writeFile } from 'node:fs/promises'
|
||
import { resolve, dirname } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { createRequire } from 'node:module'
|
||
import { createHash, randomBytes } from 'node:crypto'
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||
const require = createRequire(import.meta.url)
|
||
|
||
// DSH plugin exports
|
||
export const name = 'uds-auth'
|
||
export const inject = []
|
||
export const NS = 'uds-auth'
|
||
|
||
function tryGet(ctx, name) {
|
||
try { return ctx.get(name) } catch { return undefined }
|
||
}
|
||
|
||
/** Register /uds-auth once; caller must wrap in ctx.effect so reload disposes. */
|
||
function registerWebRoute(ctx) {
|
||
const register = (server) => {
|
||
const dispose = server.register({
|
||
kind: 'prefix',
|
||
path: '/uds-auth',
|
||
handler: handleAllRoutes,
|
||
})
|
||
ctx.logger?.info?.('[uds-auth] Route registered: prefix /uds-auth')
|
||
return () => dispose?.()
|
||
}
|
||
const present = tryGet(ctx, 'webServer') ?? ctx.webServer
|
||
if (present !== undefined) return register(present)
|
||
const disposers = []
|
||
let registered = false
|
||
const off = ctx.on('internal/service', (serviceName, value) => {
|
||
if (serviceName !== 'webServer' || registered) return
|
||
registered = true
|
||
disposers.push(register(value))
|
||
})
|
||
return () => {
|
||
off()
|
||
for (const d of disposers) d()
|
||
}
|
||
}
|
||
|
||
const UDS_AUTH_SETTINGS_NAMESPACE = 'uds-auth'
|
||
const UDS_AUTH_RPC_CHANNEL = '/uds-auth'
|
||
|
||
// API prefix for routes
|
||
const API_PREFIX = '/uds-auth'
|
||
|
||
// Config 默认值 — 不依赖 schemastery,纯 JS 对象
|
||
// 可配置字段(部署相关),其余内部常量在 INTERNAL 里
|
||
const CONFIG_DEFAULTS = {
|
||
uacBaseUrl: 'https://uac.zte.com.cn',
|
||
userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search',
|
||
loginSystemCode: '100000455558',
|
||
originSystemCode: '',
|
||
}
|
||
|
||
// 内部常量(不暴露给用户,UDS 固定协议)
|
||
const INTERNAL = {
|
||
uacQrVerifyPath: '/uacqr/auth/qrcode/verify.serv',
|
||
cookieEmpNo: 'PORTALSSOUser',
|
||
cookieAuthValue: 'PORTALSSOCookie',
|
||
altCookieEmpNo: 'ZTEDPGSSOUser',
|
||
altCookieAuthValue: 'ZTEDPGSSOCookie',
|
||
empNoHeader: 'X-Emp-No',
|
||
authValueHeader: 'X-Auth-Value',
|
||
// Session: 按 empNo 做 key,不需要额外 cookie
|
||
session: {
|
||
storeType: 'memory', // 单实例足够;多实例/重启用 redis
|
||
redisUrl: 'redis://localhost:6379',
|
||
cookieMaxAge: 1800000, // 30min
|
||
slidingExpiration: true,
|
||
slidingInterval: 300000,
|
||
},
|
||
}
|
||
|
||
// 懒加载 schemastery Config schema(只在需要注册 DSH 设置面板时用)
|
||
// 用 createRequire 因为 schemastery 由 DSH host 运行时提供,不在我们的 package 里
|
||
let _ConfigSchema = null
|
||
let _DshSettings = null
|
||
function loadConfigSchemaAndSettings(ctx) {
|
||
if (_ConfigSchema && _DshSettings) return { schema: _ConfigSchema, settings: _DshSettings }
|
||
try {
|
||
const z = require('@deepseek-ai/schemastery')
|
||
_ConfigSchema = z.object({
|
||
uacBaseUrl: z.string().default(CONFIG_DEFAULTS.uacBaseUrl),
|
||
userSearchUrl: z.string().default(CONFIG_DEFAULTS.userSearchUrl),
|
||
loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode),
|
||
originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode),
|
||
})
|
||
_DshSettings = require('@deepseek-ai/dsh-settings')
|
||
ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded')
|
||
return { schema: _ConfigSchema, settings: _DshSettings }
|
||
} catch (err) {
|
||
ctx?.logger?.warn?.('[uds-auth] schemastery/dsh-settings not available: %s', err.message)
|
||
return { schema: null, settings: null }
|
||
}
|
||
}
|
||
|
||
function mergeConfig(custom = {}) {
|
||
return { ...CONFIG_DEFAULTS, ...custom }
|
||
}
|
||
|
||
// Store references
|
||
let _sessionStore = null
|
||
let _authMiddleware = null
|
||
let _apiHandlers = null
|
||
let _rolesStore = null
|
||
let _currentConfig = null
|
||
let _qrcodeLib = null
|
||
let _logger = console
|
||
|
||
function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
|
||
if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath
|
||
return (uacBaseUrl.replace(/\/$/, '') + '/' + uacQrVerifyPath.replace(/^\//, ''))
|
||
}
|
||
|
||
// Prefer npm `qrcode`; fall back to vendored copy if present.
|
||
async function loadQRCodeLib() {
|
||
if (_qrcodeLib) return _qrcodeLib
|
||
try {
|
||
_qrcodeLib = require('qrcode')
|
||
return _qrcodeLib
|
||
} catch {
|
||
try {
|
||
const libPath = resolve(__dirname, 'qrcode-lib', 'lib', 'browser.js')
|
||
_qrcodeLib = require(libPath)
|
||
return _qrcodeLib
|
||
} catch (e) {
|
||
_logger.error?.('[uds-auth] Failed to load qrcode library:', e)
|
||
return null
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Start a UAC TwoDIMAuth QR challenge (no browser jQuery plugins required). */
|
||
function createQrChallenge(config = _currentConfig || {}) {
|
||
const loginSystemCode = String(config.loginSystemCode || CONFIG_DEFAULTS.loginSystemCode)
|
||
const originSystemCode = String(config.originSystemCode || '')
|
||
const qrCodeKey = randomBytes(16).toString('hex')
|
||
const qrCodeValue = randomBytes(16).toString('hex')
|
||
const qrCodeStr = `TwoDIMAuth:${qrCodeKey}:${qrCodeValue}`
|
||
return { qrCodeStr, qrCodeKey, qrCodeValue, loginSystemCode, originSystemCode }
|
||
}
|
||
|
||
async function handleQrStart(req, res) {
|
||
if (req.method !== 'POST' && req.method !== 'GET') {
|
||
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
||
return
|
||
}
|
||
const challenge = createQrChallenge(_currentConfig)
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store',
|
||
})
|
||
res.end(JSON.stringify(challenge))
|
||
}
|
||
|
||
// CORS proxy for UAC API
|
||
async function handleQRProxy(req, res) {
|
||
if (req.method !== 'POST') {
|
||
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
||
return
|
||
}
|
||
|
||
let body = ''
|
||
for await (const chunk of req) {
|
||
body += chunk
|
||
}
|
||
|
||
try {
|
||
// 不替换 body 中的 loginClientIp(前端已用 127.0.0.1 计算 verifyCode)
|
||
// 但 originSystemCode 如果配置了则替换进 body
|
||
if (_currentConfig.originSystemCode) {
|
||
try {
|
||
const parsed = JSON.parse(body)
|
||
if (!parsed.originSystemCode) parsed.originSystemCode = _currentConfig.originSystemCode
|
||
body = JSON.stringify(parsed)
|
||
} catch { /* body 不是 JSON 就跳过 */ }
|
||
}
|
||
|
||
const https = await import('node:https')
|
||
const url = new URL(buildVerifyUrl(_currentConfig.uacBaseUrl, INTERNAL.uacQrVerifyPath))
|
||
|
||
const options = {
|
||
hostname: url.hostname,
|
||
port: url.port || 443,
|
||
path: url.pathname,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
'Origin': _currentConfig.uacBaseUrl,
|
||
'Referer': _currentConfig.uacBaseUrl + '/',
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
}
|
||
}
|
||
|
||
const proxyReq = https.request(options, (proxyRes) => {
|
||
let data = ''
|
||
proxyRes.on('data', chunk => data += chunk)
|
||
proxyRes.on('end', () => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json',
|
||
'Access-Control-Allow-Origin': '*'
|
||
})
|
||
res.end(data)
|
||
})
|
||
})
|
||
|
||
proxyReq.on('error', (err) => {
|
||
res.writeHead(502, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: err.message }))
|
||
})
|
||
|
||
proxyReq.write(body)
|
||
proxyReq.end()
|
||
} catch (err) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: err.message }))
|
||
}
|
||
}
|
||
|
||
// QR code endpoint
|
||
// 静态资源服务:/uds-auth/lib/* → lib/qrcode-lib/*
|
||
// /uds-auth/vendor/* → lib/vendor/*
|
||
const MIME = { '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', '.css': 'text/css', '.html': 'text/html' }
|
||
function makeStaticHandler(baseDir) {
|
||
return async function handleStatic(req, res) {
|
||
try {
|
||
const url = new URL(req.url, 'http://localhost')
|
||
let rel = url.pathname.replace(/^\/uds-auth\/(lib|vendor)\//, '')
|
||
rel = rel.replace(/\.\.\//g, '').replace(/\.\./g, '')
|
||
const filePath = resolve(baseDir, rel)
|
||
const data = await readFile(filePath)
|
||
const ext = filePath.substring(filePath.lastIndexOf('.'))
|
||
res.writeHead(200, {
|
||
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||
'Cache-Control': 'max-age=3600'
|
||
})
|
||
res.end(data)
|
||
} catch (e) {
|
||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||
res.end('Not found')
|
||
}
|
||
}
|
||
}
|
||
const handleQrcodeStatic = makeStaticHandler(resolve(__dirname, 'qrcode-lib'))
|
||
const handleVendorStatic = makeStaticHandler(resolve(__dirname, 'vendor'))
|
||
|
||
async function handleQRCode(req, res) {
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const data = url.searchParams.get('data')
|
||
|
||
if (!data) {
|
||
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||
res.end('Missing data parameter')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const qr = await loadQRCodeLib()
|
||
if (!qr || typeof qr.toString !== 'function') {
|
||
throw new Error('qrcode library not available')
|
||
}
|
||
const svg = await new Promise((resolve, reject) => {
|
||
qr.toString(data, { type: 'svg', width: 200, margin: 2 }, (err, str) => {
|
||
if (err) reject(err)
|
||
else resolve(str)
|
||
})
|
||
})
|
||
res.writeHead(200, {
|
||
'Content-Type': 'image/svg+xml',
|
||
'Cache-Control': 'no-cache'
|
||
})
|
||
res.end(svg)
|
||
} catch (err) {
|
||
// 不再生成无纠错码/掩码的无效二维码(会显示成扫不出来的"乱码")
|
||
console.error('[uds-auth] QR generation error:', err)
|
||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
|
||
res.end('QR generation failed')
|
||
}
|
||
}
|
||
|
||
// 客户端 config.get —— RPC 在 web 端以 HTTP 方式请求 /uds-auth/config.get
|
||
async function handleConfigGet(req, res) {
|
||
const c = _currentConfig || {}
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Cache-Control': 'no-cache'
|
||
})
|
||
res.end(JSON.stringify({
|
||
ok: true,
|
||
value: {
|
||
uacBaseUrl: c.uacBaseUrl,
|
||
userSearchUrl: c.userSearchUrl,
|
||
loginSystemCode: c.loginSystemCode,
|
||
originSystemCode: c.originSystemCode,
|
||
},
|
||
}))
|
||
}
|
||
|
||
// Calculate verifyCode using Node.js crypto (reliable)
|
||
function calculateVerifyCode(qrCodeKey, qrCodeValue, loginClientIp, loginSystemCode, originSystemCode) {
|
||
const source = qrCodeKey + qrCodeValue + loginClientIp + loginSystemCode + originSystemCode
|
||
return createHash('md5').update(source).digest('hex')
|
||
}
|
||
|
||
// VerifyCode calculation endpoint
|
||
async function handleVerifyCode(req, res) {
|
||
if (req.method !== 'GET') {
|
||
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
||
return
|
||
}
|
||
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const qrCodeKey = url.searchParams.get('qrCodeKey')
|
||
const qrCodeValue = url.searchParams.get('qrCodeValue')
|
||
const loginClientIp = url.searchParams.get('loginClientIp') || '127.0.0.1'
|
||
const loginSystemCode = url.searchParams.get('loginSystemCode') || '100000455558'
|
||
const originSystemCode = url.searchParams.get('originSystemCode') || ''
|
||
|
||
if (!qrCodeKey || !qrCodeValue) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: 'Missing qrCodeKey or qrCodeValue' }))
|
||
return
|
||
}
|
||
|
||
const verifyCode = calculateVerifyCode(qrCodeKey, qrCodeValue, loginClientIp, loginSystemCode, originSystemCode)
|
||
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json',
|
||
'Access-Control-Allow-Origin': '*'
|
||
})
|
||
res.end(JSON.stringify({
|
||
qrCodeKey,
|
||
qrCodeValue,
|
||
verifyCode,
|
||
loginClientIp,
|
||
loginSystemCode,
|
||
originSystemCode,
|
||
source: qrCodeKey + qrCodeValue + loginClientIp + loginSystemCode + originSystemCode
|
||
}))
|
||
}
|
||
|
||
// User info proxy - bypasses CORS by server-side fetching icenterapi
|
||
async function handleUserInfo(req, res) {
|
||
if (req.method !== 'GET') {
|
||
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
||
return
|
||
}
|
||
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const empNo = url.searchParams.get('empNo')
|
||
const token = url.searchParams.get('token')
|
||
|
||
if (!empNo) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: 'Missing empNo' }))
|
||
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' }))
|
||
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))
|
||
} catch (err) {
|
||
res.writeHead(502, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ error: err.message }))
|
||
}
|
||
}
|
||
|
||
function sendJSON(res, code, data) {
|
||
res.statusCode = code >= 200 && code < 300 ? 200 : code
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||
res.end(JSON.stringify(data))
|
||
}
|
||
|
||
async function handleFallbackLogin(req, res) {
|
||
let body = ''
|
||
for await (const chunk of req) body += chunk
|
||
let parsed
|
||
try { parsed = JSON.parse(body) } catch { parsed = {} }
|
||
|
||
const { username, password } = parsed
|
||
const ip = req.socket?.remoteAddress || 'unknown'
|
||
|
||
if (!username || !password) {
|
||
return sendJSON(res, 400, { error: '用户名和密码必填' })
|
||
}
|
||
if (username !== 'administrator') {
|
||
// 不泄露"administrator"是唯一用户名
|
||
return sendJSON(res, 401, { error: '用户名或密码错误' })
|
||
}
|
||
|
||
if (!_rolesStore.verifyFallback(password, ip)) {
|
||
return sendJSON(res, 401, { error: '用户名或密码错误' })
|
||
}
|
||
|
||
// 登录成功:创建 session,角色 = fallback_admin (等同 super_admin)
|
||
const empNo = 'administrator'
|
||
const userContext = {
|
||
empNo,
|
||
username: 'Fallback Administrator',
|
||
isAuthenticated: true,
|
||
role: 'fallback_admin',
|
||
authenticatedAt: new Date().toISOString(),
|
||
lastActiveAt: new Date().toISOString(),
|
||
}
|
||
await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext)
|
||
|
||
// 给浏览器设 cookie,让后续请求 auth-middleware 能识别
|
||
res.setHeader('Set-Cookie', [
|
||
'UDS_FALLBACK_USER=administrator',
|
||
`Max-Age=${Math.floor(INTERNAL.session.cookieMaxAge / 1000)}`,
|
||
'Path=/',
|
||
'Secure',
|
||
'HttpOnly',
|
||
'SameSite=Lax',
|
||
].join('; '))
|
||
|
||
sendJSON(res, 200, {
|
||
success: true,
|
||
empNo,
|
||
role: 'fallback_admin',
|
||
message: '兜底管理员登录成功',
|
||
})
|
||
}
|
||
|
||
/** 运行时配置文件路径 — 持久化 _currentConfig 让重启后不丢 */
|
||
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']
|
||
for (const k of allowed) {
|
||
if (partial[k] !== undefined) {
|
||
_currentConfig[k] = String(partial[k])
|
||
}
|
||
}
|
||
// 写运行时配置文件(不覆盖原始 config.default.yaml)
|
||
try {
|
||
await writeFile(RUNTIME_CONFIG_FILE, JSON.stringify(_currentConfig, null, 2), 'utf-8')
|
||
} catch (err) {
|
||
// 文件写失败不影响内存配置(服务端仍然生效)
|
||
console.warn('[uds-auth] Failed to write runtime config:', err.message)
|
||
}
|
||
}
|
||
|
||
// 统一入口:单个 prefix 路由 handler,内部自行分发
|
||
async function handleAllRoutes(req, res) {
|
||
try {
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
||
const method = (req.method || 'GET').toUpperCase()
|
||
|
||
// 静态资源:/uds-auth/vendor/*
|
||
if (pathname.startsWith('/uds-auth/vendor/')) {
|
||
return await handleVendorStatic(req, res)
|
||
}
|
||
// 静态资源:/uds-auth/lib/*
|
||
if (pathname.startsWith('/uds-auth/lib/')) {
|
||
return await handleQrcodeStatic(req, res)
|
||
}
|
||
|
||
// QR 生成
|
||
if (pathname === '/uds-auth/qr' && method === 'GET') {
|
||
return await handleQRCode(req, res)
|
||
}
|
||
|
||
// QR 挑战(扫码登录入口)
|
||
if (pathname === '/uds-auth/qr-start' && (method === 'GET' || method === 'POST')) {
|
||
return await handleQrStart(req, res)
|
||
}
|
||
|
||
// QR 代理
|
||
if (pathname === '/uds-auth/qr-proxy' && method === 'POST') {
|
||
return await handleQRProxy(req, res)
|
||
}
|
||
|
||
// verifyCode 计算
|
||
if (pathname === '/uds-auth/verify-code' && method === 'GET') {
|
||
return await handleVerifyCode(req, res)
|
||
}
|
||
|
||
// 用户信息代理
|
||
if (pathname === '/uds-auth/user-info' && method === 'GET') {
|
||
return await handleUserInfo(req, res)
|
||
}
|
||
|
||
// 客户端配置(RPC config.get 在 web 端走 HTTP)
|
||
if (pathname === '/uds-auth/config.get' && (method === 'GET' || method === 'POST')) {
|
||
return await handleConfigGet(req, res)
|
||
}
|
||
|
||
// 下面都是 /uds-auth/api/* — 走 handleRequest(含鉴权)
|
||
if (pathname.startsWith('/uds-auth/api/')) {
|
||
return await handleRequest(req, res)
|
||
}
|
||
|
||
// 未知路径
|
||
res.statusCode = 404
|
||
res.setHeader('Content-Type', 'application/json')
|
||
res.end(JSON.stringify({ error: 'Not found', path: pathname }))
|
||
} catch (err) {
|
||
console.error('[uds-auth] handleAllRoutes error:', err)
|
||
res.statusCode = 500
|
||
res.setHeader('Content-Type', 'application/json')
|
||
res.end(JSON.stringify({ error: err.message || 'Internal error' }))
|
||
}
|
||
}
|
||
|
||
function handleRequest(req, res) {
|
||
const ctx2 = { req, res }
|
||
_authMiddleware(ctx2, async () => {
|
||
// 首次部署 bootstrap: 第一个有 UDS 凭证的用户 = super_admin
|
||
if (ctx2.empNo && !ctx2.role) {
|
||
const { role, bootstrapped } = await _rolesStore.bootstrapFirstUser(ctx2.empNo)
|
||
ctx2.role = role
|
||
ctx2.permissions = computePermissions(role)
|
||
if (bootstrapped) {
|
||
ctx.logger?.info?.(`[uds-auth] BOOTSTRAP: ${ctx2.empNo} is now super_admin`)
|
||
}
|
||
}
|
||
|
||
const url = new URL(req.url, 'http://localhost').pathname.replace(API_PREFIX, '') || '/'
|
||
const method = req.method
|
||
|
||
// 公开端点(不需要登录态也能访问)
|
||
if (url === '/api/fallback/login' && method === 'POST') {
|
||
await handleFallbackLogin(req, res)
|
||
return
|
||
}
|
||
if (url === '/api/fallback/status' && method === 'GET') {
|
||
return sendJSON(res, 200, { enabled: _rolesStore.isFallbackEnabled() })
|
||
}
|
||
|
||
// 以下都需要登录态
|
||
if (!ctx2.empNo) {
|
||
return sendJSON(res, 401, { error: '未登录' })
|
||
}
|
||
|
||
// 用户管理 (super_admin only)
|
||
if (url === '/api/users' && method === 'GET') {
|
||
await _apiHandlers.listUsers(ctx2); return
|
||
}
|
||
if (url === '/api/users/role' && method === 'POST') {
|
||
await _apiHandlers.setUserRole(ctx2); return
|
||
}
|
||
if (url === '/api/users' && method === 'POST') {
|
||
await _apiHandlers.addUser(ctx2); return
|
||
}
|
||
if (url === '/api/users/delete' && method === 'POST') {
|
||
await _apiHandlers.removeUser(ctx2); return
|
||
}
|
||
|
||
// Fallback admin 管理 (super_admin only)
|
||
if (url === '/api/fallback/password' && method === 'POST') {
|
||
await _apiHandlers.setFallbackPassword(ctx2); return
|
||
}
|
||
if (url === '/api/fallback/clear' && method === 'POST') {
|
||
await _apiHandlers.clearFallbackPassword(ctx2); return
|
||
}
|
||
|
||
// 配置端点 (super_admin only)
|
||
if (url === '/api/config' && method === 'GET') {
|
||
if (!requirePermission(ctx2, 'super_admin')) {
|
||
return sendJSON(res, 403, { error: '只有超级管理员可以查看配置' })
|
||
}
|
||
sendJSON(res, 200, { config: _currentConfig })
|
||
return
|
||
}
|
||
if (url === '/api/config' && method === 'POST') {
|
||
if (!requirePermission(ctx2, 'super_admin')) {
|
||
return sendJSON(res, 403, { error: '只有超级管理员可以修改配置' })
|
||
}
|
||
let body = ''
|
||
for await (const chunk of req) body += chunk
|
||
let parsed
|
||
try { parsed = JSON.parse(body) } catch { parsed = {} }
|
||
try {
|
||
await saveRuntimeConfig(parsed)
|
||
sendJSON(res, 200, { message: '配置已保存' })
|
||
} catch(err) {
|
||
sendJSON(res, 400, { error: err.message })
|
||
}
|
||
return
|
||
}
|
||
|
||
// 基础端点
|
||
if (url === '/api/me' && method === 'GET') {
|
||
await _apiHandlers.getCurrentUser(ctx2); return
|
||
}
|
||
if (url === '/api/logout' && method === 'POST') {
|
||
await _apiHandlers.logout(ctx2); return
|
||
}
|
||
|
||
sendJSON(res, 200, {
|
||
success: true,
|
||
user: ctx2.userContext,
|
||
empNo: ctx2.empNo,
|
||
role: ctx2.role,
|
||
permissions: ctx2.permissions,
|
||
})
|
||
}).catch(err => {
|
||
sendJSON(res, 500, { error: err.message })
|
||
})
|
||
}
|
||
|
||
function installSettingsSection(ctx, entry, hooks) {
|
||
const { schema: ConfigSchema, settings: DshSettings } = loadConfigSchemaAndSettings(ctx)
|
||
if (!ConfigSchema || !DshSettings) {
|
||
ctx.logger?.warn?.('[uds-auth] cannot register settings panel — schemastery/dsh-settings unavailable')
|
||
return
|
||
}
|
||
|
||
const hooksArg = {
|
||
setSource: (current) => {
|
||
hooks.setSource?.(current)
|
||
_currentConfig = current
|
||
},
|
||
onChange: () => {
|
||
hooks.onChange?.()
|
||
}
|
||
}
|
||
|
||
// 和 netxops 完全对齐的注册方式
|
||
const legacy = DshSettings.installSettingsSection
|
||
if (typeof legacy === 'function') {
|
||
ctx.logger?.info?.('[uds-auth] registering settings panel (legacy API)')
|
||
legacy(ctx, UDS_AUTH_SETTINGS_NAMESPACE, ConfigSchema, entry, hooksArg)
|
||
return
|
||
}
|
||
ctx.inject(['settings'], (settingsCtx) => {
|
||
ctx.logger?.info?.('[uds-auth] registering settings panel (new API)')
|
||
settingsCtx.settings.installSection(ctx, UDS_AUTH_SETTINGS_NAMESPACE, ConfigSchema, entry, hooksArg)
|
||
})
|
||
}
|
||
|
||
export async function apply(ctx, config = {}) {
|
||
let source = () => mergeConfig(config)
|
||
_currentConfig = source()
|
||
_logger = ctx.logger || console
|
||
|
||
ctx.logger?.info?.('[uds-auth] Loading...')
|
||
|
||
installSettingsSection(ctx, source(), {
|
||
setSource: (current) => {
|
||
source = typeof current === 'function' ? current : () => current
|
||
_currentConfig = source()
|
||
},
|
||
onChange: () => {
|
||
_currentConfig = source()
|
||
ctx.logger?.info?.('[uds-auth] settings updated')
|
||
}
|
||
})
|
||
|
||
// Do NOT rpc.handle('/uds-auth') — same path as webServer prefix → duplicate route / 415.
|
||
// Client uses HTTP /uds-auth/config.get instead.
|
||
await initServices(ctx, source())
|
||
|
||
// Register prefix once; wrap in effect so reload disposes cleanly.
|
||
ctx.inject(['webServer'], (wctx) => {
|
||
wctx.effect(() => registerWebRoute(wctx), 'uds-auth: web route')
|
||
})
|
||
|
||
ctx.logger?.info?.('[uds-auth] Host ready')
|
||
}
|
||
|
||
async function initServices(ctx, config) {
|
||
try {
|
||
const { createSessionStore } = await import('./session/factory.js')
|
||
const { createAuthMiddleware } = await import('./middleware/auth-middleware.js')
|
||
const { createApiHandlers } = await import('./api.js')
|
||
const { RolesStore } = await import('./roles.js')
|
||
|
||
_currentConfig = config
|
||
|
||
// 加载运行时配置覆盖(如果存在)
|
||
try {
|
||
const raw = await readFile(RUNTIME_CONFIG_FILE, 'utf-8')
|
||
const rt = JSON.parse(raw)
|
||
if (rt && typeof rt === 'object') {
|
||
for (const k of Object.keys(rt)) {
|
||
_currentConfig[k] = rt[k]
|
||
}
|
||
}
|
||
} catch { /* runtime config 不存在是正常的 */ }
|
||
|
||
_sessionStore = await createSessionStore(INTERNAL.session)
|
||
|
||
// RolesStore — 持久化到 roles.json
|
||
const rolesFile = resolve(__dirname, '..', 'roles.json')
|
||
_rolesStore = new RolesStore({ rolesFile })
|
||
await _rolesStore.init()
|
||
|
||
_authMiddleware = createAuthMiddleware({
|
||
userSearchUrl: config.userSearchUrl,
|
||
udsAuth: {
|
||
baseUrl: config.uacBaseUrl,
|
||
systemCode: config.loginSystemCode,
|
||
userSearchUrl: config.userSearchUrl,
|
||
empNoHeader: INTERNAL.empNoHeader,
|
||
authValueHeader: INTERNAL.authValueHeader,
|
||
},
|
||
session: INTERNAL.session,
|
||
}, _sessionStore, _rolesStore)
|
||
_apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore)
|
||
|
||
ctx.logger?.info?.('[uds-auth] Initialized')
|
||
} catch (err) {
|
||
ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err))
|
||
}
|
||
}
|