mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 04:40:44 +08:00
fix(uds-auth): surface real cause of QR login network errors (0.3.16)
The QR poll showed a generic "network error" for any failure, and the server collapsed every upstream failure into an opaque 502 with no log. - Classify upstream failures (DNS, timeout, unreachable, TLS cert, HTTP status, bad response) and return host-specific localized messages. - Report why the post-scan user lookup failed instead of a generic invalid-credentials error. - Log the real errno/message server-side for every qr-proxy failure. - Show the server message in the QR poll UI; keep "network error" only for failed local fetches. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07cfbefb6c
commit
08a5520791
8 changed files with 182 additions and 15 deletions
|
|
@ -132,6 +132,16 @@ window.__ModuleLoader__.load({
|
|||
"err.rate_limited": "尝试过多,请稍后再试",
|
||||
"err.config_not_ready": "配置未初始化",
|
||||
"err.request_failed": "请求失败",
|
||||
"err.upstream_dns": "无法解析 {host}(DNS 失败),请确认本机已接入公司内网 / VPN",
|
||||
"err.upstream_timeout": "连接 {host} 超时,请确认本机已接入公司内网 / VPN,且未被防火墙拦截",
|
||||
"err.upstream_unreachable": "无法连接 {host}(连接被拒绝或中断),请检查网络、防火墙与代理",
|
||||
"err.upstream_cert": "与 {host} 的 TLS 证书校验失败:Node 不读取系统证书库,请设置 NODE_OPTIONS=--use-system-ca 或 NODE_EXTRA_CA_CERTS 后重启",
|
||||
"err.upstream_error": "访问 {host} 失败,详见服务端日志",
|
||||
"err.upstream_http_status": "{host} 返回 HTTP {status}",
|
||||
"err.invalid_upstream_response": "{host} 返回了无法识别的响应",
|
||||
"err.profile_http_status": "用户信息服务 {host} 返回 HTTP {status}(该服务需内网直连,不能经代理)",
|
||||
"err.profile_rejected": "扫码成功,但用户信息校验未通过({reason})",
|
||||
"err.login_finalize_failed": "扫码成功,但本机创建会话失败({detail}),详见服务端日志",
|
||||
"err.method_not_allowed": "方法不允许",
|
||||
"err.missing_qr_params": "缺少 qrCodeKey 或 qrCodeValue",
|
||||
"err.missing_emp_token": "缺少 empNo 或 token",
|
||||
|
|
@ -279,6 +289,16 @@ window.__ModuleLoader__.load({
|
|||
"err.rate_limited": "Too many attempts, try later",
|
||||
"err.config_not_ready": "Config not initialized",
|
||||
"err.request_failed": "Request failed",
|
||||
"err.upstream_dns": "Cannot resolve {host} (DNS failed). Make sure this machine is on the corporate network / VPN",
|
||||
"err.upstream_timeout": "Connection to {host} timed out. Make sure this machine is on the corporate network / VPN and not firewalled",
|
||||
"err.upstream_unreachable": "Cannot connect to {host} (refused or reset). Check network, firewall and proxy",
|
||||
"err.upstream_cert": "TLS certificate check for {host} failed. Node ignores the OS cert store: set NODE_OPTIONS=--use-system-ca or NODE_EXTRA_CA_CERTS and restart",
|
||||
"err.upstream_error": "Request to {host} failed; see server logs",
|
||||
"err.upstream_http_status": "{host} returned HTTP {status}",
|
||||
"err.invalid_upstream_response": "{host} returned an unrecognized response",
|
||||
"err.profile_http_status": "User lookup service {host} returned HTTP {status} (needs direct intranet access, no proxy)",
|
||||
"err.profile_rejected": "Scan succeeded but user lookup was rejected ({reason})",
|
||||
"err.login_finalize_failed": "Scan succeeded but creating the local session failed ({detail}); see server logs",
|
||||
"err.method_not_allowed": "Method not allowed",
|
||||
"err.missing_qr_params": "Missing qrCodeKey or qrCodeValue",
|
||||
"err.missing_emp_token": "Missing empNo or token",
|
||||
|
|
@ -1735,8 +1755,11 @@ function reloadAfterLogin() {
|
|||
setQrExpired(false)
|
||||
setQrFailed(false)
|
||||
setQrStatus(result.bo?.msg || boCode || t('ui.loginFailed'))
|
||||
} catch {
|
||||
if (qrRef.current.key === qrCodeKey) setQrStatus(t('ui.networkError'))
|
||||
} catch (err) {
|
||||
// A reply from the local server carries a classified, localized message
|
||||
// (DNS / timeout / TLS cert / lookup rejected); only a failed fetch is a real network error.
|
||||
const detail = err && err.data && err.data.message
|
||||
if (qrRef.current.key === qrCodeKey) setQrStatus(detail || t('ui.networkError'))
|
||||
} finally {
|
||||
qrRef.current.polling = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ export const MESSAGES = {
|
|||
'err.qr_expired': '二维码已过期',
|
||||
'err.qr_mismatch': '二维码校验失败',
|
||||
'err.upstream_too_large': '上游响应过大',
|
||||
'err.upstream_dns': '无法解析 {host}(DNS 失败),请确认本机已接入公司内网 / VPN',
|
||||
'err.upstream_timeout': '连接 {host} 超时,请确认本机已接入公司内网 / VPN,且未被防火墙拦截',
|
||||
'err.upstream_unreachable': '无法连接 {host}(连接被拒绝或中断),请检查网络、防火墙与代理',
|
||||
'err.upstream_cert': '与 {host} 的 TLS 证书校验失败:Node 不读取系统证书库,请设置 NODE_OPTIONS=--use-system-ca 或 NODE_EXTRA_CA_CERTS 后重启',
|
||||
'err.upstream_error': '访问 {host} 失败,详见服务端日志',
|
||||
'err.upstream_http_status': '{host} 返回 HTTP {status}',
|
||||
'err.invalid_upstream_response': '{host} 返回了无法识别的响应',
|
||||
'err.profile_http_status': '用户信息服务 {host} 返回 HTTP {status}(该服务需内网直连,不能经代理)',
|
||||
'err.profile_rejected': '扫码成功,但用户信息校验未通过({reason})',
|
||||
'err.login_finalize_failed': '扫码成功,但本机创建会话失败({detail}),详见服务端日志',
|
||||
'err.config_not_ready': '配置未初始化',
|
||||
'err.request_failed': '请求失败',
|
||||
'err.method_not_allowed': '方法不允许',
|
||||
|
|
@ -313,6 +323,16 @@ export const MESSAGES = {
|
|||
'err.qr_expired': 'QR challenge expired',
|
||||
'err.qr_mismatch': 'QR challenge mismatch',
|
||||
'err.upstream_too_large': 'Upstream response too large',
|
||||
'err.upstream_dns': 'Cannot resolve {host} (DNS failed). Make sure this machine is on the corporate network / VPN',
|
||||
'err.upstream_timeout': 'Connection to {host} timed out. Make sure this machine is on the corporate network / VPN and not firewalled',
|
||||
'err.upstream_unreachable': 'Cannot connect to {host} (refused or reset). Check network, firewall and proxy',
|
||||
'err.upstream_cert': 'TLS certificate check for {host} failed. Node ignores the OS cert store: set NODE_OPTIONS=--use-system-ca or NODE_EXTRA_CA_CERTS and restart',
|
||||
'err.upstream_error': 'Request to {host} failed; see server logs',
|
||||
'err.upstream_http_status': '{host} returned HTTP {status}',
|
||||
'err.invalid_upstream_response': '{host} returned an unrecognized response',
|
||||
'err.profile_http_status': 'User lookup service {host} returned HTTP {status} (needs direct intranet access, no proxy)',
|
||||
'err.profile_rejected': 'Scan succeeded but user lookup was rejected ({reason})',
|
||||
'err.login_finalize_failed': 'Scan succeeded but creating the local session failed ({detail}); see server logs',
|
||||
'err.config_not_ready': 'Config not initialized',
|
||||
'err.request_failed': 'Request failed',
|
||||
'err.method_not_allowed': 'Method not allowed',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
SESSION_COOKIE,
|
||||
} from './session/request-auth.js'
|
||||
import { checkRequestOrigin, requireJsonContentType } from './utils/origin-guard.js'
|
||||
import { classifyNetworkError, hostOfUrl } from './utils/net-error.js'
|
||||
import { requestIsLoopback } from './skill-credentials.js'
|
||||
import { atomicWriteJson } from './utils/atomic-write.js'
|
||||
import { readBodyLimited, MAX_QR_PROXY_BODY, MAX_QR_DATA_LEN, DEFAULT_MAX_BODY } from './utils/read-body.js'
|
||||
|
|
@ -302,6 +303,24 @@ function peekQrChallenge(key, value, binding) { return _qrChallenges.peek(key, v
|
|||
function consumeQrChallenge(key, value, binding) { return _qrChallenges.consume(key, value, binding) }
|
||||
function isQrLoginSuccessPayload(payload) { return !!qrLoginCredentials(payload) }
|
||||
|
||||
/** Log the real cause server-side; give the browser a classified, actionable code. */
|
||||
function sendUpstreamErr(res, req, err, host, stage) {
|
||||
_logger?.warn?.('[uds-auth] qr-proxy %s failed (host=%s): %s %s', stage, host,
|
||||
err?.code || '', err?.message || '')
|
||||
return sendErr(res, req, 502, classifyNetworkError(err), { host })
|
||||
}
|
||||
|
||||
/** Why the post-scan profile lookup failed -> [status, code, vars]. */
|
||||
function profileLookupFailure(out) {
|
||||
if (out?.reason === 'network') {
|
||||
return [502, classifyNetworkError({ code: out.code, message: out.error }), { host: out.host || '' }]
|
||||
}
|
||||
if (out?.reason === 'http_auth' || out?.reason === 'http_status') {
|
||||
return [502, 'profile_http_status', { host: out.host || '', status: out.statusCode || '' }]
|
||||
}
|
||||
return [401, 'profile_rejected', { reason: out?.reason || 'unknown' }]
|
||||
}
|
||||
|
||||
async function handleQrStart(req, res) {
|
||||
if (req.method !== 'POST' && req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||||
|
|
@ -404,10 +423,12 @@ async function handleQRProxy(req, res) {
|
|||
void (async () => {
|
||||
let payload
|
||||
try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
|
||||
return sendErr(res, req, 502, 'invalid_upstream_response')
|
||||
_logger?.warn?.('[uds-auth] qr-proxy upstream %s returned non-JSON (HTTP %s)', url.hostname, proxyRes.statusCode)
|
||||
return sendErr(res, req, 502, 'invalid_upstream_response', { host: url.hostname })
|
||||
}
|
||||
if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
|
||||
return sendErr(res, req, 502, 'upstream_error')
|
||||
_logger?.warn?.('[uds-auth] qr-proxy upstream %s returned HTTP %s', url.hostname, proxyRes.statusCode)
|
||||
return sendErr(res, req, 502, 'upstream_http_status', { host: url.hostname, status: proxyRes.statusCode })
|
||||
}
|
||||
const credentials = qrLoginCredentials(payload)
|
||||
if (!credentials) {
|
||||
|
|
@ -417,14 +438,20 @@ async function handleQRProxy(req, res) {
|
|||
if (top === '0000' && ['4002', '1002'].includes(boCode)) {
|
||||
return sendJSON(res, 200, { code: { code: top }, bo: { code: boCode } })
|
||||
}
|
||||
return sendErr(res, req, 502, 'invalid_upstream_response')
|
||||
_logger?.warn?.('[uds-auth] qr-proxy upstream %s unexpected result code=%s bo=%s', url.hostname, top, boCode)
|
||||
return sendErr(res, req, 502, 'invalid_upstream_response', { host: url.hostname })
|
||||
}
|
||||
const consumed = consumeQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue,
|
||||
req.headers['x-uds-qr-binding'])
|
||||
if (!consumed.ok) return sendErr(res, req, 409, consumed.reason)
|
||||
// Reserve once before asynchronous profile validation or session minting.
|
||||
const profile = await _authMiddleware.verifyEmpNoAndToken(credentials.empNo, credentials.token)
|
||||
if (!profile) return sendErr(res, req, 401, 'invalid_credentials')
|
||||
let lookupFailure = null
|
||||
const profile = await _authMiddleware.verifyEmpNoAndToken(credentials.empNo, credentials.token,
|
||||
{ onFailure: (out) => { lookupFailure = out } })
|
||||
if (!profile) {
|
||||
const [status, code, vars] = profileLookupFailure(lookupFailure)
|
||||
return sendErr(res, req, status, code, { host: hostOfUrl(_currentConfig.userSearchUrl), ...vars })
|
||||
}
|
||||
const id = profile.empNo || credentials.empNo
|
||||
const role = await _authMiddleware.resolveRoleOnLogin(id)
|
||||
const now = new Date().toISOString()
|
||||
|
|
@ -444,20 +471,21 @@ async function handleQRProxy(req, res) {
|
|||
auth: { success: true, empNo: id, role, bridge,
|
||||
userContext: { empNo: id, username: userContext.username, role, isAuthenticated: true } } })
|
||||
})().catch(err => {
|
||||
if (!res.headersSent) sendErr(res, req, 502, err.code || 'upstream_error')
|
||||
_logger?.warn?.('[uds-auth] qr-proxy post-scan login failed: %s %s', err?.code || '', err?.message || err)
|
||||
if (!res.headersSent) sendErr(res, req, 502, 'login_finalize_failed', { detail: err?.code || err?.message || '' })
|
||||
})
|
||||
})
|
||||
proxyRes.on('error', () => {
|
||||
if (!res.headersSent) sendErr(res, req, 502, 'upstream_error')
|
||||
proxyRes.on('error', (err) => {
|
||||
if (!res.headersSent) sendUpstreamErr(res, req, err, url.hostname, 'response')
|
||||
})
|
||||
})
|
||||
|
||||
proxyReq.on('timeout', () => {
|
||||
try { proxyReq.destroy() } catch { /* ignore */ }
|
||||
try { proxyReq.destroy(Object.assign(new Error('upstream timeout'), { code: 'ETIMEDOUT' })) } catch { /* ignore */ }
|
||||
})
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!res.headersSent) sendErr(res, req, 502, 'upstream_error')
|
||||
if (!res.headersSent) sendUpstreamErr(res, req, err, url.hostname, 'request')
|
||||
})
|
||||
|
||||
proxyReq.write(body)
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
|||
).trim()
|
||||
const initialAdminEmpNo = resolveInitialAdmin()
|
||||
|
||||
async function verifyEmpNoAndToken(empNo, token) {
|
||||
/** @param {{ onFailure?: (out: object) => void }} [opts] lets callers surface why the lookup failed */
|
||||
async function verifyEmpNoAndToken(empNo, token, opts = {}) {
|
||||
const out = await searchUserByEmpNoToken({
|
||||
userSearchUrl,
|
||||
empNo,
|
||||
|
|
@ -66,11 +67,13 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
|||
})
|
||||
if (!out.ok) {
|
||||
console.warn('[uds-auth] verifyEmpNoAndToken failed:', out)
|
||||
try { opts.onFailure?.(out) } catch { /* diagnostics only */ }
|
||||
return null
|
||||
}
|
||||
const subject = String(out.profile?.empNo || '').trim()
|
||||
if (!subject || subject !== String(empNo).trim()) {
|
||||
console.warn('[uds-auth] verifyEmpNoAndToken subject mismatch')
|
||||
try { opts.onFailure?.({ ok: false, reason: 'emp_mismatch' }) } catch { /* diagnostics only */ }
|
||||
return null
|
||||
}
|
||||
return out.profile
|
||||
|
|
|
|||
|
|
@ -122,7 +122,9 @@ export async function searchUserByEmpNoToken({
|
|||
try {
|
||||
res = await directRequest(userSearchUrl, { method: 'POST', headers, body, timeoutMs })
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'network', error: err.message }
|
||||
let host = ''
|
||||
try { host = new URL(userSearchUrl).hostname } catch { /* diagnostics only */ }
|
||||
return { ok: false, reason: 'network', error: err.message, code: err.code, host }
|
||||
}
|
||||
|
||||
// Only accept 2xx — do not treat HTTP 500 + biz success as ok (SEC-16).
|
||||
|
|
|
|||
40
uds-auth/lib/utils/net-error.js
Normal file
40
uds-auth/lib/utils/net-error.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Map a Node network/TLS error to a stable, user-actionable api error code
|
||||
* (see `err.upstream_*` in i18n.js). Keeps raw errno out of API responses.
|
||||
*/
|
||||
const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'EAI_FAIL', 'EAI_NODATA'])
|
||||
const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ECONNABORTED'])
|
||||
const UNREACHABLE_CODES = new Set([
|
||||
'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'EADDRNOTAVAIL',
|
||||
])
|
||||
const CERT_CODES = new Set([
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'UNABLE_TO_GET_ISSUER_CERT',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'CERT_NOT_YET_VALID',
|
||||
'CERT_UNTRUSTED',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'HOSTNAME_MISMATCH',
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {{ code?: string, message?: string } | null | undefined} err
|
||||
* @returns {'upstream_dns' | 'upstream_timeout' | 'upstream_unreachable' | 'upstream_cert' | 'upstream_error'}
|
||||
*/
|
||||
export function classifyNetworkError(err) {
|
||||
const code = String(err?.code || '')
|
||||
const msg = String(err?.message || '')
|
||||
if (CERT_CODES.has(code) || /certificate|self[- ]signed/i.test(msg)) return 'upstream_cert'
|
||||
if (DNS_CODES.has(code)) return 'upstream_dns'
|
||||
if (TIMEOUT_CODES.has(code) || /timeout|timed out/i.test(msg)) return 'upstream_timeout'
|
||||
if (UNREACHABLE_CODES.has(code) || /socket hang up/i.test(msg)) return 'upstream_unreachable'
|
||||
return 'upstream_error'
|
||||
}
|
||||
|
||||
/** Host for diagnostics only; never throws. */
|
||||
export function hostOfUrl(raw) {
|
||||
try { return new URL(String(raw)).hostname } catch { return '' }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "uds-auth",
|
||||
"version": "0.3.15",
|
||||
"version": "0.3.16",
|
||||
"description": "UDS authentication plugin for DeepSeek Harness — trusted single-instance access control (not strong multi-tenant isolation)",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
|
|
|
|||
51
uds-auth/test/net-error.test.js
Normal file
51
uds-auth/test/net-error.test.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { classifyNetworkError, hostOfUrl } from '../lib/utils/net-error.js'
|
||||
import { apiError, MESSAGES } from '../lib/i18n.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const CODES = [
|
||||
'upstream_dns', 'upstream_timeout', 'upstream_unreachable', 'upstream_cert', 'upstream_error',
|
||||
'upstream_http_status', 'invalid_upstream_response', 'profile_http_status', 'profile_rejected',
|
||||
'login_finalize_failed',
|
||||
]
|
||||
|
||||
describe('classifyNetworkError', () => {
|
||||
it('maps errno/TLS codes to actionable api codes', () => {
|
||||
assert.equal(classifyNetworkError({ code: 'ENOTFOUND' }), 'upstream_dns')
|
||||
assert.equal(classifyNetworkError({ code: 'EAI_AGAIN' }), 'upstream_dns')
|
||||
assert.equal(classifyNetworkError({ code: 'ETIMEDOUT' }), 'upstream_timeout')
|
||||
assert.equal(classifyNetworkError({ message: 'directRequest timeout after 3000ms: x' }), 'upstream_timeout')
|
||||
assert.equal(classifyNetworkError({ code: 'ECONNREFUSED' }), 'upstream_unreachable')
|
||||
assert.equal(classifyNetworkError({ message: 'socket hang up' }), 'upstream_unreachable')
|
||||
assert.equal(classifyNetworkError({ code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }), 'upstream_cert')
|
||||
assert.equal(classifyNetworkError({ code: 'SELF_SIGNED_CERT_IN_CHAIN' }), 'upstream_cert')
|
||||
assert.equal(classifyNetworkError({ message: 'unable to get local issuer certificate' }), 'upstream_cert')
|
||||
assert.equal(classifyNetworkError({ code: 'WHATEVER' }), 'upstream_error')
|
||||
assert.equal(classifyNetworkError(null), 'upstream_error')
|
||||
})
|
||||
|
||||
it('extracts host without throwing', () => {
|
||||
assert.equal(hostOfUrl('https://icenterapi.zte.com.cn/a/b'), 'icenterapi.zte.com.cn')
|
||||
assert.equal(hostOfUrl('not a url'), '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upstream error messages', () => {
|
||||
it('are defined in zh and en and interpolate host', () => {
|
||||
for (const code of CODES) {
|
||||
assert.ok(MESSAGES.zh['err.' + code], 'zh ' + code)
|
||||
assert.ok(MESSAGES.en['err.' + code], 'en ' + code)
|
||||
}
|
||||
const m = apiError('upstream_dns', 'zh', { host: 'uac.zte.com.cn' })
|
||||
assert.equal(m.error, 'upstream_dns')
|
||||
assert.match(m.message, /uac\.zte\.com\.cn/)
|
||||
})
|
||||
|
||||
it('are mirrored in the browser client dictionary', () => {
|
||||
const client = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
||||
for (const code of CODES) {
|
||||
assert.equal(client.split(`"err.${code}":`).length - 1, 2, code)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue