|
|
@@ -1,13 +1,23 @@
|
|
|
/**
|
|
|
* 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 = 25 * 60
|
|
|
+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
|
|
|
@@ -19,68 +29,261 @@ function isProbablyVpnLoginHtml(body) {
|
|
|
return (
|
|
|
body.includes('lyuapServer') ||
|
|
|
body.includes('ivpn') ||
|
|
|
- body.includes('统一认证') ||
|
|
|
+ body.includes('统一身份认证') ||
|
|
|
body.includes('rump_frontend/login')
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-async function fetchWebVpnCookieFromPy(username, password) {
|
|
|
+/**
|
|
|
+ * @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 res = await axios.post(
|
|
|
- url,
|
|
|
- { username, password },
|
|
|
- { timeout: 120000, proxy: false, validateStatus: () => true }
|
|
|
- )
|
|
|
+ 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) {
|
|
|
- throw new Error(data?.msg || 'WebVPN 登录失败')
|
|
|
+ const pyMsg = data?.msg || '统一身份认证登录失败'
|
|
|
+ const pyCode = typeof data?.code === 'number' ? data.code : undefined
|
|
|
+ throw new WebVpnLoginError(pyMsg, { pyCode, pyMsg })
|
|
|
}
|
|
|
return String(data.webvpn_cookie)
|
|
|
}
|
|
|
|
|
|
-async function getJwPasswordForUser(uuid, jwUsername) {
|
|
|
+/**
|
|
|
+ * @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 FROM jw_account WHERE create_user = ? AND username = ? AND state IN (0, 1)',
|
|
|
+ 'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?',
|
|
|
[uuid, jwUsername]
|
|
|
)
|
|
|
if (!rows || rows.length !== 1 || !rows[0].password) {
|
|
|
- throw new Error('未绑定教务账号或账号不可用,请先绑定教务账号')
|
|
|
+ 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 }} [opts]
|
|
|
+ * @param {{ skipCache?: boolean, skipPostWebVpnLepaoSync?: boolean, logger?: object }} [opts]
|
|
|
*/
|
|
|
async function getWebVpnCookieHeader(createUserUuid, jwUsername, opts = {}) {
|
|
|
const skipCache = opts.skipCache === true
|
|
|
- const key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}`
|
|
|
+ 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 getJwPasswordForUser(createUserUuid, jwUsername)
|
|
|
- const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd)
|
|
|
+ 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 key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}`
|
|
|
- await Redis.del(key)
|
|
|
+ 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
|
|
|
}
|