| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- /**
- * 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<string>}
- */
- 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
- }
|