webvpnCookie.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. /**
  2. * WebVPN 会话 Cookie:调用 ic-ctbu-backend-py /webvpnlogin,并可 Redis 缓存。
  3. */
  4. const crypto = require('crypto')
  5. const axios = require('axios')
  6. const config = require('../../config.json')
  7. const db = require('../../plugin/DataBase/db')
  8. const Redis = require('../../plugin/DataBase/Redis')
  9. const { buildWebVpnAttemptList, isPyOutboundRetryable, persistAssignedProxy, stepLabel } = require('./lepaoOutboundProxy')
  10. const CACHE_PREFIX = 'webvpn_cookie:'
  11. const CACHE_TTL_SEC = 10 * 60
  12. class WebVpnLoginError extends Error {
  13. constructor(message, { pyCode, pyMsg } = {}) {
  14. super(message)
  15. this.name = 'WebVpnLoginError'
  16. this.pyCode = pyCode
  17. this.pyMsg = pyMsg
  18. }
  19. }
  20. function webvpnLoginBaseUrl() {
  21. const u = config.webvpnLoginBaseUrl || config.url3
  22. return (u && String(u).replace(/\/$/, '')) || ''
  23. }
  24. function isProbablyVpnLoginHtml(body) {
  25. if (typeof body !== 'string') return false
  26. return (
  27. body.includes('lyuapServer') ||
  28. body.includes('ivpn') ||
  29. body.includes('统一身份认证') ||
  30. body.includes('rump_frontend/login')
  31. )
  32. }
  33. /**
  34. * @param {string} createUserUuid
  35. * @param {string} lepaoStudentNum
  36. * @returns {Promise<string>}
  37. */
  38. async function resolveUserAgentFromLepaoAccount(createUserUuid, lepaoStudentNum) {
  39. if (!createUserUuid || !lepaoStudentNum) return ''
  40. const rows = await db.query(
  41. 'SELECT userAgent FROM lepao_account WHERE create_user = ? AND student_num = ? LIMIT 1',
  42. [createUserUuid, lepaoStudentNum]
  43. )
  44. const ua = rows?.[0]?.userAgent
  45. return ua && String(ua).trim() ? String(ua).trim() : ''
  46. }
  47. /**
  48. * @param {string} ua
  49. * @returns {string} 拼在 Redis key 后的后缀(含前导冒号)
  50. */
  51. function cacheKeySuffixForUa(ua) {
  52. if (!ua) return ':default'
  53. const h = crypto.createHash('sha256').update(ua, 'utf8').digest('hex').slice(0, 16)
  54. return `:${h}`
  55. }
  56. /**
  57. * @param {unknown} err
  58. * @returns {boolean} 是否应将乐跑账号标为 state=3(统一认证凭证类失败)
  59. */
  60. function isWebVpnUnifiedAuthCredentialFailure(err) {
  61. const code = err && typeof err.pyCode === 'number' ? err.pyCode : null
  62. const msg = String((err && err.pyMsg) || (err && err.message) || '')
  63. if (code === 401) return true
  64. if (code === 500) {
  65. const patterns = ['密码错误', '用户名或密码错误', '用户不存在', '账号或密码', '用户名错误', '帐号或密码']
  66. return patterns.some((p) => msg.includes(p))
  67. }
  68. return false
  69. }
  70. /**
  71. * @param {string} studentNum
  72. * @param {string} [createUserUuid]
  73. * @param {string} [jwUsername]
  74. */
  75. async function markLepaoUnifiedAuthFailed(studentNum, createUserUuid, jwUsername) {
  76. const t = Date.now()
  77. await db.query('UPDATE lepao_account SET state = 3, update_time = ? WHERE student_num = ?', [
  78. t,
  79. studentNum
  80. ])
  81. if (createUserUuid && jwUsername) {
  82. try {
  83. await db.query(
  84. 'UPDATE jw_account SET state = 0, update_time = ? WHERE create_user = ? AND username = ?',
  85. [t, createUserUuid, jwUsername]
  86. )
  87. } catch (_) {
  88. /* ignore */
  89. }
  90. try {
  91. await invalidateWebVpnCookie(createUserUuid, jwUsername)
  92. } catch (_) {
  93. /* ignore */
  94. }
  95. }
  96. }
  97. /**
  98. * WebVPN 凭证校验成功:将教务绑定标为已启用(与教务「激活」同属 state=1)。
  99. * @param {string} createUserUuid
  100. * @param {string} jwUsername 学号(教务登录名)
  101. */
  102. async function markJwUnifiedAuthSucceeded(createUserUuid, jwUsername) {
  103. const t = Date.now()
  104. await db.query(
  105. 'UPDATE jw_account SET state = 1, update_time = ? WHERE create_user = ? AND username = ?',
  106. [t, createUserUuid, jwUsername]
  107. )
  108. }
  109. /**
  110. * @param {string} username
  111. * @param {string} password
  112. * @param {{ userAgent?: string, createUserUuid?: string, lepaoStudentNum?: string }} [opts]
  113. */
  114. async function fetchWebVpnCookieFromPy(username, password, opts = {}) {
  115. const base = webvpnLoginBaseUrl()
  116. if (!base) {
  117. throw new Error('未配置 webvpnLoginBaseUrl')
  118. }
  119. let ua = ''
  120. if (opts.userAgent && String(opts.userAgent).trim()) {
  121. ua = String(opts.userAgent).trim()
  122. } else if (opts.createUserUuid && opts.lepaoStudentNum) {
  123. ua = await resolveUserAgentFromLepaoAccount(opts.createUserUuid, opts.lepaoStudentNum)
  124. }
  125. const url = `${base}/webvpnlogin`
  126. const createUserUuid = opts.createUserUuid || null
  127. const lepaoStudentNum = opts.lepaoStudentNum || null
  128. const logger = opts.logger
  129. const attempts = await buildWebVpnAttemptList(createUserUuid, lepaoStudentNum)
  130. let lastErr = null
  131. for (const att of attempts) {
  132. logger?.info?.(
  133. `[WebVPN] 登录请求 username=${username} via=${att.direct ? 'DIRECT' : (att.proxyUrl || stepLabel(att))}`
  134. )
  135. const body = { username, password }
  136. if (ua) body.user_agent = ua
  137. if (att.proxyUrl) body.proxy_url = att.proxyUrl
  138. const res = await axios.post(url, body, { timeout: 120000, proxy: false, validateStatus: () => true })
  139. const data = res.data
  140. if (data && data.code === 0 && data.webvpn_cookie) {
  141. if (createUserUuid && lepaoStudentNum) {
  142. if (att.direct) {
  143. await persistAssignedProxy(createUserUuid, lepaoStudentNum, null)
  144. } else if (att.proxyId != null) {
  145. await persistAssignedProxy(createUserUuid, lepaoStudentNum, att.proxyId)
  146. }
  147. }
  148. logger?.info?.(
  149. `[WebVPN] 登录成功 username=${username} via=${att.direct ? 'DIRECT' : (att.proxyUrl || stepLabel(att))}`
  150. )
  151. return String(data.webvpn_cookie)
  152. }
  153. const pyMsg = data?.msg || '统一身份认证登录失败'
  154. const pyCode = typeof data?.code === 'number' ? data.code : undefined
  155. const err = new WebVpnLoginError(pyMsg, { pyCode, pyMsg })
  156. lastErr = err
  157. if (isPyOutboundRetryable(pyCode, pyMsg)) {
  158. logger?.warn?.(
  159. `[WebVPN] 网络类失败,切换出口 username=${username} via=${att.direct ? 'DIRECT' : (att.proxyUrl || stepLabel(att))}: ${pyMsg}`
  160. )
  161. continue
  162. }
  163. throw err
  164. }
  165. throw lastErr || new WebVpnLoginError('统一身份认证登录失败', { pyMsg: '' })
  166. }
  167. /**
  168. * @returns {Promise<{ error: string|null, password?: string }>}
  169. */
  170. async function resolveJwCredentialForLepao(uuid, jwUsername) {
  171. const rows = await db.query(
  172. 'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?',
  173. [uuid, jwUsername]
  174. )
  175. if (!rows || rows.length !== 1 || !rows[0].password) {
  176. return {
  177. error: '未绑定统一认证账号,请先在乐跑绑定中填写统一认证密码'
  178. }
  179. }
  180. if (Number(rows[0].state) === 2) {
  181. return {
  182. error: '统一认证账号已标记为失效,请重新绑定教务密码'
  183. }
  184. }
  185. return { error: null, password: rows[0].password }
  186. }
  187. /**
  188. * WebVPN 登录用:仅需已存密码;state 可为 0(待同步)或 1,排除 2(失效)。
  189. */
  190. async function getJwPasswordForWebVpn(uuid, jwUsername) {
  191. const rows = await db.query(
  192. 'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?',
  193. [uuid, jwUsername]
  194. )
  195. if (!rows || rows.length !== 1 || !rows[0].password) {
  196. throw new Error('未绑定统一认证账号,请先在乐跑绑定中填写统一认证密码')
  197. }
  198. if (Number(rows[0].state) === 2) {
  199. throw new Error('统一认证账号已标记为失效,请重新绑定教务密码')
  200. }
  201. return rows[0].password
  202. }
  203. async function assertLepaoJwBindingOrThrow(uuid, jwUsername) {
  204. const r = await resolveJwCredentialForLepao(uuid, jwUsername)
  205. if (r.error) throw new Error(r.error)
  206. }
  207. async function getJwPasswordForUser(uuid, jwUsername) {
  208. const r = await resolveJwCredentialForLepao(uuid, jwUsername)
  209. if (r.error) throw new Error(r.error)
  210. return r.password
  211. }
  212. const { syncLepaoStateViaBeforeRun } = require('./lepaoBeforeRunStateSync')
  213. /**
  214. * @param {string} createUserUuid
  215. * @param {string} jwUsername 教务登录名
  216. * @param {{ skipCache?: boolean, skipPostWebVpnLepaoSync?: boolean, logger?: object }} [opts]
  217. */
  218. async function getWebVpnCookieHeader(createUserUuid, jwUsername, opts = {}) {
  219. const skipCache = opts.skipCache === true
  220. const skipPost = opts.skipPostWebVpnLepaoSync === true
  221. const ua = await resolveUserAgentFromLepaoAccount(createUserUuid, jwUsername)
  222. const suffix = cacheKeySuffixForUa(ua)
  223. const key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}${suffix}`
  224. if (!skipCache) {
  225. const cached = await Redis.get(key)
  226. if (cached) return cached
  227. }
  228. const pwd = await getJwPasswordForWebVpn(createUserUuid, jwUsername)
  229. const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd, {
  230. userAgent: ua,
  231. createUserUuid,
  232. lepaoStudentNum: jwUsername,
  233. logger: opts.logger
  234. })
  235. await Redis.set(key, cookie, { EX: CACHE_TTL_SEC })
  236. await markJwUnifiedAuthSucceeded(createUserUuid, jwUsername)
  237. if (!skipPost) {
  238. const accRows = await db.query(
  239. 'SELECT uid, token, school_id, userAgent FROM lepao_account WHERE student_num = ? AND create_user = ? LIMIT 1',
  240. [jwUsername, createUserUuid]
  241. )
  242. const conditionSql = 'student_num = ? AND create_user = ?'
  243. const queryParams = [jwUsername, createUserUuid]
  244. try {
  245. await syncLepaoStateViaBeforeRun({
  246. studentNum: jwUsername,
  247. ownerUuid: createUserUuid,
  248. webvpnCookie: cookie,
  249. account: accRows?.[0],
  250. conditionSql,
  251. queryParams,
  252. invalidateWebVpn: () => invalidateWebVpnCookie(createUserUuid, jwUsername),
  253. refreshWebVpnCookie: async () => {
  254. try {
  255. return await getWebVpnCookieHeader(createUserUuid, jwUsername, {
  256. skipCache: true,
  257. skipPostWebVpnLepaoSync: true,
  258. logger: opts.logger
  259. })
  260. } catch (e) {
  261. if (isWebVpnUnifiedAuthCredentialFailure(e)) {
  262. await markLepaoUnifiedAuthFailed(jwUsername, createUserUuid, jwUsername)
  263. }
  264. throw e
  265. }
  266. },
  267. logger: opts.logger
  268. })
  269. } catch (syncErr) {
  270. opts.logger?.warn?.(`[WebVPN] 乐跑 token 同步异常 ${jwUsername}: ${syncErr.stack || syncErr}`)
  271. }
  272. }
  273. return cookie
  274. }
  275. /**
  276. * 清除该用户下该教务账号的 WebVPN Cookie 缓存(含历史无 UA 后缀的 key 与当前 UA 派生 key)。
  277. * @param {string} createUserUuid
  278. * @param {string} jwUsername
  279. */
  280. async function invalidateWebVpnCookie(createUserUuid, jwUsername) {
  281. const legacyKey = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}`
  282. await Redis.del(legacyKey)
  283. const ua = await resolveUserAgentFromLepaoAccount(createUserUuid, jwUsername)
  284. const suffix = cacheKeySuffixForUa(ua)
  285. await Redis.del(`${CACHE_PREFIX}${createUserUuid}:${jwUsername}${suffix}`)
  286. await Redis.del(`${CACHE_PREFIX}${createUserUuid}:${jwUsername}:default`)
  287. }
  288. module.exports = {
  289. webvpnLoginBaseUrl,
  290. isProbablyVpnLoginHtml,
  291. WebVpnLoginError,
  292. fetchWebVpnCookieFromPy,
  293. resolveJwCredentialForLepao,
  294. assertLepaoJwBindingOrThrow,
  295. getJwPasswordForUser,
  296. getJwPasswordForWebVpn,
  297. getWebVpnCookieHeader,
  298. invalidateWebVpnCookie,
  299. isWebVpnUnifiedAuthCredentialFailure,
  300. markLepaoUnifiedAuthFailed,
  301. markJwUnifiedAuthSucceeded,
  302. resolveUserAgentFromLepaoAccount,
  303. CACHE_TTL_SEC
  304. }