wujinjun / XJTLU UIM 自动登录 (请求用户名+密码+OTP)

// ==UserScript==
// @name        XJTLU UIM 自动登录 (请求用户名+密码+OTP)
// @namespace   http://tampermonkey.net/
// @version     2.0
// @description XJTLU UIM 自动登录增强版,自动完成辅助验证,无需手动输入验证码/OTP -- 2.0已部分重构,提升成功率和安全性
// @author      wujinjun
// @license     MIT
// @match       *://example.com
// @match       https://uim.xjtlu.edu.cn/esc-sso/login/page
// @match       https://uim.xjtlu.edu.cn/login/mfaLogin.html
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_deleteValue
// @grant       GM_registerMenuCommand
// @require     https://cdn.jsdelivr.net/npm/jsencrypt@3.5.4/bin/jsencrypt.min.js
// @require     https://update.greasyfork.org/scripts/511697/1471164/TOTP%20Generator.js
// @run-at      document-start
// ==/UserScript==

(function() {
    'use strict';

    // **********************************
    // *********** 配置区 *************
    // **********************************
    const CONFIG_KEY = "xjtlu_uim_autologin_config_v2";
    const ENABLED_KEY = "xjtlu_uim_autologin_enabled";
    const REFERER_KEY = 'xjtlu_uim_original_referer';

    const DEFAULT_CONFIG = {
        enabled: true,
        username: "username",
        password: "password",
        secret: "OTP",
        forceOtpAfterSuccess: false,
        maxRetries1: 3,
        retryDelayMs1: 2000,
        maxRetries2: 3,
        retryDelayMs2: 2000
    };

    function loadConfig() {
        const saved = GM_getValue(CONFIG_KEY, {});
        return {
            ...DEFAULT_CONFIG,
            ...(saved && typeof saved === "object" ? saved : {})
        };
    }

    function saveConfig(nextConfig) {
        const merged = { ...loadConfig(), ...nextConfig };
        GM_setValue(CONFIG_KEY, merged);
        GM_setValue(ENABLED_KEY, !!merged.enabled);
        return merged;
    }

    function setScriptEnabled(enabled) {
        const nextEnabled = !!enabled;
        GM_setValue(ENABLED_KEY, nextEnabled);
        config.enabled = nextEnabled;
        GM_setValue(CONFIG_KEY, config);
        return nextEnabled;
    }

    function promptConfigValue(key, label, currentValue) {
        const displayed = String(currentValue ?? "");
        const answer = prompt(`${label}\n当前值: ${displayed || "(空)"}\n请输入新值,取消则不修改:`, `${displayed === "[hidden]" ? "" : displayed}`);
        if (answer === null) {
            return null;
        }
        const trimmed = answer.trim();
        if (!trimmed) {
            return null;
        }
        config = saveConfig({ [key]: trimmed });
        return trimmed;
    }

    function promptBooleanConfigValue(key, label, currentValue) {
        const current = !!currentValue;
        const answer = prompt(`${label}\n当前值: ${current ? "true" : "false"}\n请输入 true / false,取消则不修改:`, current ? "true" : "false");
        if (answer === null) {
            return null;
        }
        const normalized = String(answer).trim().toLowerCase();
        if (normalized !== "true" && normalized !== "false") {
            alert("请输入 true 或 false");
            return null;
        }
        const value = normalized === "true";
        config = saveConfig({ [key]: value });
        return value;
    }

    function promptNumberConfigValue(key, label, currentValue, minValue = 0) {
        const displayed = String(currentValue ?? "");
        const answer = prompt(`${label}\n当前值: ${displayed}\n请输入整数值,取消则不修改:`, displayed);
        if (answer === null) {
            return null;
        }
        const trimmed = String(answer).trim();
        if (!/^\d+$/.test(trimmed)) {
            alert("请输入一个有效的非负整数");
            return null;
        }
        const value = Number(trimmed);
        if (value < minValue) {
            alert(`值不能小于 ${minValue}`);
            return null;
        }
        config = saveConfig({ [key]: value });
        return value;
    }

    function registerConfigMenu() {
        if (typeof GM_registerMenuCommand !== "function") {
            return;
        }

        GM_registerMenuCommand(`XJTLU UIM: 自动登录 ${scriptEnabled ? '✅':'❌'}`, () => {
            const nextEnabled = !scriptEnabled;
            setScriptEnabled(nextEnabled);
            alert(nextEnabled ? "已开启 XJTLU UIM 自动登录" : "已关闭 XJTLU UIM 自动登录,可在菜单中重新打开");
            console.log(`脚本已${nextEnabled ? "开启" : "关闭"}。`);
            window.location.reload();
        });

        GM_registerMenuCommand("XJTLU UIM: 设置用户名", () => {
            const value = promptConfigValue("username", "设置用户名", config.username);
            if (value !== null) {
                alert(`用户名已更新为: ${value}`);
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置密码", () => {
            const value = promptConfigValue("password", "设置明文密码", "[hidden]");
            if (value !== null) {
                alert("密码已更新");
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置OTP密钥", () => {
            const value = promptConfigValue("secret", "设置 TOTP 密钥", config.secret);
            if (value !== null) {
                alert("OTP 密钥已更新");
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置强制OTP", () => {
            const value = promptBooleanConfigValue("forceOtpAfterSuccess", "强制在首次登录成功后执行 OTP(true/false)", config.forceOtpAfterSuccess);
            if (value !== null) {
                alert(`已更新 forceOtpAfterSuccess = ${value}`);
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置阶段1最大重试", () => {
            const value = promptNumberConfigValue("maxRetries1", "设置阶段1最大重试次数", config.maxRetries1, 0);
            if (value !== null) {
                alert(`已更新 maxRetries1 = ${value}`);
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置阶段1重试延迟(ms)", () => {
            const value = promptNumberConfigValue("retryDelayMs1", "设置阶段1重试延迟(毫秒)", config.retryDelayMs1, 0);
            if (value !== null) {
                alert(`已更新 retryDelayMs1 = ${value}`);
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置阶段2最大重试", () => {
            const value = promptNumberConfigValue("maxRetries2", "设置阶段2最大重试次数", config.maxRetries2, 0);
            if (value !== null) {
                alert(`已更新 maxRetries2 = ${value}`);
            }
        });

        GM_registerMenuCommand("XJTLU UIM: 设置阶段2重试延迟(ms)", () => {
            const value = promptNumberConfigValue("retryDelayMs2", "设置阶段2重试延迟(毫秒)", config.retryDelayMs2, 0);
            if (value !== null) {
                alert(`已更新 retryDelayMs2 = ${value}`);
            }
        });
    }

    let config = loadConfig();
    let scriptEnabled = !!GM_getValue(ENABLED_KEY, config.enabled);
    config.enabled = scriptEnabled;

    const username = config.username;
    const password = config.password;
    const secret = config.secret;
    const FORCE_OTP_AFTER_SUCCESS = !!config.forceOtpAfterSuccess;
    const MAX_RETRIES_1 = Number(config.maxRetries1) || 3;
    const RETRY_DELAY_MS_1 = Number(config.retryDelayMs1) || 2000;
    const MAX_RETRIES_2 = Number(config.maxRetries2) || 3;
    const RETRY_DELAY_MS_2 = Number(config.retryDelayMs2) || 2000;
    const isLoginPage = window.location.href.includes("/esc-sso/login/page");
    const isMfaPage = window.location.href.includes("/login/mfaLogin.html");
    const isSettingDummy = window.location.href.includes("example.com");
    const nowtimestamp = Date.now();

    registerConfigMenu();

    if (!scriptEnabled) {
        console.warn("XJTLU UIM 自动登录已关闭,脚本不执行任何请求。可在 Tampermonkey 菜单中重新打开。");
        return;
    }


    // =========================================================================
    // Referer 捕获与重定向逻辑 (使用 Tampermonkey 存储跨页面持久化 Referer)
    // =========================================================================

    if (isLoginPage) {
        // 处于 /esc-sso/login/page 时,捕获并存储原始 Referer
        if (document.referrer && !document.referrer.includes("xjtlu.edu.cn")) {
            // 只有当 Referer 不是来自本域时才存储,避免循环
            GM_setValue(REFERER_KEY, document.referrer);
            console.log("已捕获并存储原始 Referer:", document.referrer);
        } else {
            // 如果是直接访问或来自本域,清除旧的 Referer
            GM_deleteValue(REFERER_KEY);
        }
    } else if (isMfaPage) {
        // 处于 /login/mfaLogin.html 时,尝试跳转回原始 Referer
        const originalReferer = GM_getValue(REFERER_KEY, null);

        if (originalReferer) {
            console.warn(`检测到被重定向到MFA页面,且找到原始 Referer。正在跳转回: ${originalReferer}`);
            // 清除存储的 Referer,防止后续意外跳转
            GM_deleteValue(REFERER_KEY);
            // 执行跳转,替换当前历史记录
            window.location.replace(originalReferer);
            // 停止脚本后续执行,因为页面已跳转
            return;
        } else {
            // 报告未找到 Referer,但允许脚本继续执行阶段2登录请求
            console.log("处于MFA页面,未找到原始 Referer,脚本将尝试执行阶段2登录请求。");
        }
    } else if (isSettingDummy) {
        // 处于 `*://example.com` 时,视为"离线模式"配置脚本功能,立即退出
        return;
    }

    // 如果页面已被跳转 (或在 MFA 页面上需要回跳),则停止执行后续的登录请求逻辑
    if (isMfaPage && GM_getValue(REFERER_KEY, null)) {
         return;
    }

    // 预处理 (在 Referer 逻辑后重新定义,确保变量初始化)
    let publicKey = null;
    let publicKeyId = null;

    // RSA 加密函数
    function rsaEncrypt(text, pubKey) {
        try {
            const encrypt = new JSEncrypt();
            encrypt.setPublicKey(pubKey);
            return encrypt.encrypt(text);
        } catch (e) {
            console.error("RSA加密失败:", e);
            return null;
        }
    }

    // --- 登录请求逻辑 ---

    async function fetchJson(url, options = {}) {
        const response = await fetch(url, {
            credentials: "include",
            mode: "cors",
            cache: "no-store",
            headers: {
                "Accept": "application/json, text/plain, */*",
                "X-Requested-With": "XMLHttpRequest",
                "Origin": "https://uim.xjtlu.edu.cn",
                "Referer": window.location.href
            },
            ...options // 这是Javascript object spread,不是错误。 Ref: https://stackoverflow.com/questions/171251/how-can-i-merge-properties-of-two-javascript-objects
        });

        const responseText = await response.text();
        let parsed = null;
        if (responseText) {
            try {
                parsed = JSON.parse(responseText);
            } catch (e) {
                console.warn("非JSON响应:", responseText);
            }
        }

        return {
            ok: response.ok,
            status: response.status,
            statusText: response.statusText,
            responseText,
            data: parsed,
            headers: response.headers
        };
    }

    // 步骤1: 获取公钥和公钥ID
    fetchJson(`https://uim.xjtlu.edu.cn/esc-sso/api/v3/auth/policy?_=${nowtimestamp}`)
        .then(({ data, ok, status, statusText }) => {
            if (!ok || !data || !data.data || !data.data.param) {
                console.error("获取公钥请求失败!", status, statusText, data);
                return;
            }

            try {
                publicKey = data.data.param.publicKey;
                publicKeyId = data.data.param.publicKeyId;

                if (publicKey && publicKeyId) {
                    console.log("成功获取公钥和公钥ID。");
                    const encryptedPassword = rsaEncrypt(password, publicKey);
                    sendLoginRequest1(encryptedPassword, publicKeyId, 1);
                } else {
                    console.error("未能获取公钥或公钥ID。", data);
                }
            } catch (e) {
                console.error("解析阶段1的响应数据失败:", e, data);
            }
        })
        .catch(error => {
            console.error("获取公钥请求失败!", error);
        });

    // 步骤2: 发送第一个登录请求(使用密码)
    function sendLoginRequest1(encryptedPassword, publicKeyId, attempt = 1) {
        const requestData = {
            "authType": "webLocalAuth",
            "dataField": {
                "username": username,
                "password": encryptedPassword,
                "publicKeyId": publicKeyId
            }
        };

        fetchJson(`https://uim.xjtlu.edu.cn/esc-sso/api/v3/auth/doLogin?_=${nowtimestamp}`, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify(requestData)
        }).then(({ data, ok, status, statusText, responseText }) => {
            console.log(`阶段1第 ${attempt} 次登录请求(密码)发送成功。`);
            console.log("原始响应数据:", responseText);

            try {
                const data_code = data && data.code;
                const data_msg = data && data.msg;
                console.log(`解析响应数据: 响应码(code): ${data_code}; 消息(msg): ${data_msg}`);

                // 【检查访问过快 (429)】
                if (data_code === "429") {
                    if (attempt < MAX_RETRIES_1) {
                        const nextAttempt = attempt + 1;
                        console.warn(`[重试 ${attempt}/${MAX_RETRIES_1}] 检测到访问过快 (429)。将在 ${RETRY_DELAY_MS_1 / 1000} 秒后重试...`);
                        setTimeout(() => {
                            sendLoginRequest1(encryptedPassword, publicKeyId, nextAttempt);
                        }, RETRY_DELAY_MS_1);
                        return;
                    } else {
                        console.error(`访问过快 (429) 错误:已达到最大重试次数 (${MAX_RETRIES_1}),停止尝试。`);
                        return;
                    }
                }

                // 【检查会话过期】如果过期,强制立即执行第二次登录(OTP)。
                if (data_msg && data_msg.includes("当前会话已过期")) {
                    console.warn("检测到会话过期,强制立即执行阶段2登录请求(OTP)。", data_msg);
                    sendLoginRequest2();
                    return;
                }

                // 【滑动验证码】退出,用户自行操作
                if (data_code === "SSO10094" || data_msg && (data_msg.includes("slider verification") || data_msg.includes("滑块验证码"))) {
                    console.error("检测到滑块验证码,请自行完成操作,稍后若遇到OTP,可刷新以重新触发此脚本。 " + data_msg);
                    alert("[XJTLU UIM 自动登录] 检测到滑块验证码,请自行完成操作,稍后若遇到OTP,可刷新以重新触发此脚本。 " + data_msg);
                    return;
                }

                // 【用户名或密码错误】立即退出并永久关闭此脚本功能,随后在 Tampermonkey 菜单中重新开启
                if (data_code === "SSO10002" || (data_msg && (data_msg.includes("Incorrect Username or Password") || data_msg.includes("用户名或密码错误")))) {
                    console.error("❌用户名或密码错误,脚本立即关闭并停用,可在 Tampermonkey 菜单中重新打开 ", data_msg);
                    alert("[XJTLU UIM 自动登录] ❌用户名或密码错误,脚本立即关闭并停用,可在 Tampermonkey 菜单中重新打开 " + data_msg);
                    setScriptEnabled(false);
                    return;
                }

                // 【其他错误(非0)】
                if (data_code !== "0") {
                    console.error("阶段1 其他错误(返回值非0),请检查用户名/密码是否正确");
                    alert("[XJTLU UIM 自动登录] 阶段1 其他错误(返回值非0),请检查用户名/密码是否正确");
                    return;
                }

                // 检查是否需要OTP(标准流程:返回mfaLogin.html重定向)
                const isMfaRequired = data && data.data && data.data.redirect && data.data.redirect.includes("/mfaLogin.html");

                if (isMfaRequired) {
                    console.log("检测到MFA登录。");
                    console.log("继续执行阶段2登录请求(OTP)。");
                    sendLoginRequest2();
                } else if (FORCE_OTP_AFTER_SUCCESS) {
                    // 【强制执行逻辑】如果配置了强制执行
                    console.log("登录成功,但配置开启了FORCE_OTP_AFTER_SUCCESS。");
                    console.log("强制执行阶段2登录请求(OTP)。");
                    sendLoginRequest2();
                } else {
                    // 登录直接成功(标准流程)
                    console.log("登录直接成功或无需MFA。");
                }
            } catch (e) {
                console.error("解析阶段1登录请求的响应数据失败。请检查原始数据是否为有效JSON。", e);
            }
        }).catch(error => {
            console.error("阶段1登录请求失败!", status, statusText, error);
        });
    }

    async function postJsonWithBrowserSession(url, payload) {
        const response = await fetch(url, {
            method: "POST",
            credentials: "include",
            mode: "cors",
            cache: "no-store",
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json, text/plain, */*",
                "X-Requested-With": "XMLHttpRequest",
                "Origin": "https://uim.xjtlu.edu.cn",
                "Referer": window.location.href
            },
            body: JSON.stringify(payload)
        });

        const responseText = await response.text();
        let parsed = null;
        if (responseText) {
            try {
                parsed = JSON.parse(responseText);
            } catch (e) {
                console.warn("非JSON响应:", responseText);
            }
        }

        return {
            ok: response.ok,
            status: response.status,
            statusText: response.statusText,
            responseText,
            data: parsed,
            headers: response.headers
        };
    }

    // 步骤3: 发送第二个登录请求 (使用OTP)
    function sendLoginRequest2(attempt = 1) {
        const nowtimestamp2 = Date.now();
        const authType2 = "webOtpAuth";

        // 使用外部库提供的 generateTOTP(secret)
        generateTOTP(secret)
            .then(async otp => {
                console.log("已生成OTP:", otp);

                const requestData2 = {
                    "authType": authType2,
                    "dataField": {
                        "username": username,
                        "otp": otp
                    },
                    "redirectUri": ""
                };

                const url = `https://uim.xjtlu.edu.cn/esc-sso/api/v3/auth/doLogin?_=${nowtimestamp2}`;

                try {
                    const response = await postJsonWithBrowserSession(url, requestData2);
                    console.log("阶段2登录请求(OTP)发送成功。");
                    console.log("原始响应数据:", response.responseText);

                    const data = response.data || {};
                    const data_code = data.code;
                    const data_msg = data && data.msg;
                    console.log(`解析响应数据: 响应码(code): ${data_code}; 消息(msg): ${data_msg}`);

                    // 【检查访问过快 (429)】
                    if (data_code === "429") {
                        if (attempt < MAX_RETRIES_2) {
                            const nextAttempt = attempt + 1;
                            console.warn(`[重试 ${attempt}/${MAX_RETRIES_2}] 检测到访问过快 (429)。将在 ${RETRY_DELAY_MS_2 / 1000} 秒后重试...`);
                            setTimeout(() => {
                                sendLoginRequest2(nextAttempt);
                            }, RETRY_DELAY_MS_2);
                            return;
                        } else {
                            console.error(`访问过快 (429) 错误:已达到最大重试次数 (${MAX_RETRIES_2}),停止尝试。`);
                            return;
                        }
                    }

                    // 【滑动验证码】退出,用户自行操作
                    if (data_code === "SSO10094" || (data_msg && (data_msg.includes("slider verification") || data_msg.includes("滑块验证码")))) {
                        console.error("检测到滑块验证码,请自行完成操作,稍后若遇到OTP,可刷新以重新触发此脚本。 " + data_msg);
                        alert("[XJTLU UIM 自动登录] 检测到滑块验证码,请自行完成操作,稍后若遇到OTP,可刷新以重新触发此脚本。 " + data_msg);
                        return;
                    }

                    // 【其他错误(非0)】
                    if (data_code !== "0") {
                        console.error("阶段2 其他错误(返回值非0),请检查用户名/TOTP密钥是否正确");
                        alert("[XJTLU UIM 自动登录] 阶段2 其他错误(返回值非0),请检查用户名/TOTP密钥是否正确");
                        return;
                    }

                    // 检查服务器是否返回了最终的重定向链接 (无论是 data.data.redirect 还是顶层 redirect)
                    let finalRedirectUrl = (data.data && data.data.redirect) || data.redirect;

                    if (finalRedirectUrl) {
                        // 1. 服务器返回了链接,使用服务器的链接进行跳转
                        console.warn(`登录成功。使用服务器返回的链接进行跳转: ${finalRedirectUrl}`);
                        GM_deleteValue(REFERER_KEY);
                        window.location.replace(finalRedirectUrl);
                    } else {
                        // 2. 服务器未返回链接,尝试使用存储的 Referer 进行回跳
                        const originalReferer = GM_getValue(REFERER_KEY, null);

                        if (originalReferer) {
                            console.warn(`服务器未返回最终跳转链接,尝试使用存储的原始 Referer 进行回跳: ${originalReferer}`);
                            GM_deleteValue(REFERER_KEY);
                            window.location.replace(originalReferer);
                        } else {
                            console.error("阶段2登录请求成功,但未找到最终跳转链接(服务器响应或存储的Referer)。用户可能停留在当前页面。");
                            GM_deleteValue(REFERER_KEY); // 即使失败也清除,避免干扰下次登录
                        }
                    }
                } catch (error) {
                    console.error("解析阶段2登录请求的响应数据失败。尝试使用存储的Referer进行回跳。", error);

                    const originalReferer = GM_getValue(REFERER_KEY, null);
                    if (originalReferer) {
                        console.warn(`OTP POST 失败,但发现存储的 Referer。正在手动跳转到: ${originalReferer}`);
                        GM_deleteValue(REFERER_KEY);
                        window.location.replace(originalReferer);
                    }
                }
            })
            .catch(error => {
                console.error("生成OTP失败:", error);
            });
    }

})();