/** * WebVPN 会话 Cookie:调用 ic-ctbu-backend-py /webvpnlogin,并可 Redis 缓存。 */ const crypto = require('crypto') const axios = require('axios') const config = require('../../config.json') const db = require('../../plugin/DataBase/db') const Redis = require('../../plugin/DataBase/Redis') const CACHE_PREFIX = 'webvpn_cookie:' const CACHE_TTL_SEC = 10 * 60 class WebVpnLoginError extends Error { constructor(message, { pyCode, pyMsg } = {}) { super(message) this.name = 'WebVpnLoginError' this.pyCode = pyCode this.pyMsg = pyMsg } } function webvpnLoginBaseUrl() { const u = config.webvpnLoginBaseUrl || config.url3 return (u && String(u).replace(/\/$/, '')) || '' } function isProbablyVpnLoginHtml(body) { if (typeof body !== 'string') return false return ( body.includes('lyuapServer') || body.includes('ivpn') || body.includes('统一身份认证') || body.includes('rump_frontend/login') ) } /** * @param {string} createUserUuid * @param {string} lepaoStudentNum * @returns {Promise} */ async function resolveUserAgentFromLepaoAccount(createUserUuid, lepaoStudentNum) { if (!createUserUuid || !lepaoStudentNum) return '' const rows = await db.query( 'SELECT userAgent FROM lepao_account WHERE create_user = ? AND student_num = ? LIMIT 1', [createUserUuid, lepaoStudentNum] ) const ua = rows?.[0]?.userAgent return ua && String(ua).trim() ? String(ua).trim() : '' } /** * @param {string} ua * @returns {string} 拼在 Redis key 后的后缀(含前导冒号) */ function cacheKeySuffixForUa(ua) { if (!ua) return ':default' const h = crypto.createHash('sha256').update(ua, 'utf8').digest('hex').slice(0, 16) return `:${h}` } /** * @param {unknown} err * @returns {boolean} 是否应将乐跑账号标为 state=3(统一认证凭证类失败) */ function isWebVpnUnifiedAuthCredentialFailure(err) { const code = err && typeof err.pyCode === 'number' ? err.pyCode : null const msg = String((err && err.pyMsg) || (err && err.message) || '') if (code === 401) return true if (code === 500) { const patterns = ['密码错误', '用户名或密码错误', '用户不存在', '账号或密码', '用户名错误', '帐号或密码'] return patterns.some((p) => msg.includes(p)) } return false } /** * @param {string} studentNum * @param {string} [createUserUuid] * @param {string} [jwUsername] */ async function markLepaoUnifiedAuthFailed(studentNum, createUserUuid, jwUsername) { const t = Date.now() await db.query('UPDATE lepao_account SET state = 3, update_time = ? WHERE student_num = ?', [ t, studentNum ]) if (createUserUuid && jwUsername) { try { await db.query( 'UPDATE jw_account SET state = 0, update_time = ? WHERE create_user = ? AND username = ?', [t, createUserUuid, jwUsername] ) } catch (_) { /* ignore */ } try { await invalidateWebVpnCookie(createUserUuid, jwUsername) } catch (_) { /* ignore */ } } } /** * WebVPN 凭证校验成功:将教务绑定标为已启用(与教务「激活」同属 state=1)。 * @param {string} createUserUuid * @param {string} jwUsername 学号(教务登录名) */ async function markJwUnifiedAuthSucceeded(createUserUuid, jwUsername) { const t = Date.now() await db.query( 'UPDATE jw_account SET state = 1, update_time = ? WHERE create_user = ? AND username = ?', [t, createUserUuid, jwUsername] ) } /** * @param {string} username * @param {string} password * @param {{ userAgent?: string, createUserUuid?: string, lepaoStudentNum?: string }} [opts] */ async function fetchWebVpnCookieFromPy(username, password, opts = {}) { const base = webvpnLoginBaseUrl() if (!base) { throw new Error('未配置 webvpnLoginBaseUrl') } let ua = '' if (opts.userAgent && String(opts.userAgent).trim()) { ua = String(opts.userAgent).trim() } else if (opts.createUserUuid && opts.lepaoStudentNum) { ua = await resolveUserAgentFromLepaoAccount(opts.createUserUuid, opts.lepaoStudentNum) } const url = `${base}/webvpnlogin` const body = { username, password } if (ua) body.user_agent = ua const res = await axios.post(url, body, { timeout: 120000, proxy: false, validateStatus: () => true }) const data = res.data if (!data || data.code !== 0 || !data.webvpn_cookie) { const pyMsg = data?.msg || '统一身份认证登录失败' const pyCode = typeof data?.code === 'number' ? data.code : undefined throw new WebVpnLoginError(pyMsg, { pyCode, pyMsg }) } return String(data.webvpn_cookie) } /** * @returns {Promise<{ error: string|null, password?: string }>} */ async function resolveJwCredentialForLepao(uuid, jwUsername) { const rows = await db.query( 'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?', [uuid, jwUsername] ) if (!rows || rows.length !== 1 || !rows[0].password) { return { error: '未绑定统一认证账号,请先在乐跑绑定中填写统一认证密码' } } if (Number(rows[0].state) === 2) { return { error: '统一认证账号已标记为失效,请重新绑定教务密码' } } return { error: null, password: rows[0].password } } /** * WebVPN 登录用:仅需已存密码;state 可为 0(待同步)或 1,排除 2(失效)。 */ async function getJwPasswordForWebVpn(uuid, jwUsername) { const rows = await db.query( 'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?', [uuid, jwUsername] ) if (!rows || rows.length !== 1 || !rows[0].password) { throw new Error('未绑定统一认证账号,请先在乐跑绑定中填写统一认证密码') } if (Number(rows[0].state) === 2) { throw new Error('统一认证账号已标记为失效,请重新绑定教务密码') } return rows[0].password } async function assertLepaoJwBindingOrThrow(uuid, jwUsername) { const r = await resolveJwCredentialForLepao(uuid, jwUsername) if (r.error) throw new Error(r.error) } async function getJwPasswordForUser(uuid, jwUsername) { const r = await resolveJwCredentialForLepao(uuid, jwUsername) if (r.error) throw new Error(r.error) return r.password } const { syncLepaoStateViaBeforeRun } = require('./lepaoBeforeRunStateSync') /** * @param {string} createUserUuid * @param {string} jwUsername 教务登录名 * @param {{ skipCache?: boolean, skipPostWebVpnLepaoSync?: boolean, logger?: object }} [opts] */ async function getWebVpnCookieHeader(createUserUuid, jwUsername, opts = {}) { const skipCache = opts.skipCache === true const skipPost = opts.skipPostWebVpnLepaoSync === true const ua = await resolveUserAgentFromLepaoAccount(createUserUuid, jwUsername) const suffix = cacheKeySuffixForUa(ua) const key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}${suffix}` if (!skipCache) { const cached = await Redis.get(key) if (cached) return cached } const pwd = await getJwPasswordForWebVpn(createUserUuid, jwUsername) const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd, ua ? { userAgent: ua } : {}) await Redis.set(key, cookie, { EX: CACHE_TTL_SEC }) await markJwUnifiedAuthSucceeded(createUserUuid, jwUsername) if (!skipPost) { const accRows = await db.query( 'SELECT uid, token, school_id, userAgent FROM lepao_account WHERE student_num = ? AND create_user = ? LIMIT 1', [jwUsername, createUserUuid] ) const conditionSql = 'student_num = ? AND create_user = ?' const queryParams = [jwUsername, createUserUuid] try { await syncLepaoStateViaBeforeRun({ studentNum: jwUsername, ownerUuid: createUserUuid, webvpnCookie: cookie, account: accRows?.[0], conditionSql, queryParams, invalidateWebVpn: () => invalidateWebVpnCookie(createUserUuid, jwUsername), refreshWebVpnCookie: async () => { try { return await getWebVpnCookieHeader(createUserUuid, jwUsername, { skipCache: true, skipPostWebVpnLepaoSync: true, logger: opts.logger }) } catch (e) { if (isWebVpnUnifiedAuthCredentialFailure(e)) { await markLepaoUnifiedAuthFailed(jwUsername, createUserUuid, jwUsername) } throw e } }, logger: opts.logger }) } catch (syncErr) { opts.logger?.warn?.(`[WebVPN] 乐跑 token 同步异常 ${jwUsername}: ${syncErr.stack || syncErr}`) } } return cookie } /** * 清除该用户下该教务账号的 WebVPN Cookie 缓存(含历史无 UA 后缀的 key 与当前 UA 派生 key)。 * @param {string} createUserUuid * @param {string} jwUsername */ async function invalidateWebVpnCookie(createUserUuid, jwUsername) { const legacyKey = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}` await Redis.del(legacyKey) const ua = await resolveUserAgentFromLepaoAccount(createUserUuid, jwUsername) const suffix = cacheKeySuffixForUa(ua) await Redis.del(`${CACHE_PREFIX}${createUserUuid}:${jwUsername}${suffix}`) await Redis.del(`${CACHE_PREFIX}${createUserUuid}:${jwUsername}:default`) } module.exports = { webvpnLoginBaseUrl, isProbablyVpnLoginHtml, WebVpnLoginError, fetchWebVpnCookieFromPy, resolveJwCredentialForLepao, assertLepaoJwBindingOrThrow, getJwPasswordForUser, getJwPasswordForWebVpn, getWebVpnCookieHeader, invalidateWebVpnCookie, isWebVpnUnifiedAuthCredentialFailure, markLepaoUnifiedAuthFailed, markJwUnifiedAuthSucceeded, resolveUserAgentFromLepaoAccount, CACHE_TTL_SEC }