webvpnCookie.js 10 KB

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