2017.11.18 更新:前天才发这篇博客,今天就发现学校改了整个外网访问教务系统的方式,我把这部份的代码全部重写 Grader 才重新正常工作,所以以下的内容已经不完全适用于目前的教务系统身份验证了,但整体思路和原理基本没有变,建议有能力的读者可以直接去看源码。(由于是第一个 Node.js 项目,代码写的比较烂,见谅)

今年暑假初学 Node.js 所以想写个什么练练手,又鉴于学校教务系统查成绩每次都要登录,略显麻烦,所以主要利用 Express + request 这两个库写了一个代查询教务成绩的脚本,目前已经上线服务器了,这里是地址,使用的话要求输入学号和教务系统的密码,所有数据均用 aes-256-cbc 加密存放在本地 cookie 里面(但其实还是有安全隐患,我后文会讲到),为了方便,我设定了 30 天内无需重新登录,这样子就能爽快的查成绩了!虽说整个脚本并不难,但是作为一个初学者,期间还是遇到了许多有趣的坑,所以在此记录一下探索的过程,多亏了何哲宇菊苣的催更,这篇博客才得以在我的一拖再拖下顺利完成。

由于之前用 Python 写过一个自动登录校园网的脚本,所以着手的基本思路还是有的:

  • 分析教务系统的登录 URL 和所要传递的数据
  • 利用 node.js 的 request 模块向登录 URL 发起携带相应数据的 POST 请求
  • 查看 Response,确定是否登录成功

分析网站

利用现代浏览器的 Developer Tools 都可以完成这个步骤,以 Chrome 为例,打开 Developer Tools 以后访问北邮的教务界面,可以从 Element 一栏看到网页的源代码,Network 一栏中看到所有的网络请求。先用 Element 查看网页的源代码,可以看到教务系统登录表单的 HTML 如下。

<form method="post" name="loginForm" action="/jwLoginAction.do" onSubmit="return login();">

从而我们也拿到了教务处理登录请求的地址:https://jwxt.bupt.edu.cn/jwLoginAction.do

再点开 Network 查看访问主页的网络请求,看到 Cookie 一栏有这么一条内容:JSESSIONID=acbZT7XEZJhCZgMY48CCw。可以看出教务系统使用的是 Java 的 Tomcat 服务器搭配 JSESSIONID 进行身份验证。

这里讲一下通常的 Session 验证过程。客户端首先先向服务器发起登录请求,在验证帐号密码等信息正确后,服务端就会生成一个 SessionID,并且写在响应头中的 Set-Cookie 返回给客户端,也就是在教务系统的主页访问中我们发现的 Cookie 中的内容。在后续的访问中,只要客户端带上 Cookie 中的 SessionID,服务端就会知道客户端的身份,从而返回对应的信息。

然而教务系统却有些不同,SessionID 在一开始访问主页而并未进行登录操作时我们就已经拿到了,所以我初步推测我们在对教务进行登录的 POST 请求时需要将带有 SessionID 的 Cookie 一同传递,从而完成登录身份和 SeesionID 的绑定。

进行一次登录以后,Network 里也出现了登录的 POST 请求,我们来看看登录表单都传出了哪些数据。

可以看到学号,密码以及验证码以外,还有一个值为 ssotype。除此之外我还意外地发现教务系统居然在明文的传递密码,实在是太不安全也太不应该了,不过对此也只能说一句:信息黄埔,信息黄埔.jpg。不过不知道是不是因为我跟北邮人团队吐槽过这一点,在我写下这篇博客的时候,教务系统相关的网址都上了 SSL 证书,从 http 变成了 https。尽管如此,明文传输表单数据的做法还是依旧,所以只能说一句也许这是为了防君子而不妨小人?

第一次 POST 尝试

大致摸清整个登录过程后,我在 Node.js 中用如下代码做了第一次 POST 尝试:

//设定好请求头
var headers = {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'Accept-Encoding': 'gzip, deflate',
    'content-type': 'application/x-www-form-urlencoded',
    'Connection': 'keep-alive',
    'Cookie': `JSESSIONID=acbZT7XEZJhCZgMY48CCw`, //我们从浏览器中拿到的 SessionID
};

//要发送的表单数据
var form = {
    type: 'sso',
    zjh: '2017233233', //学号
    mm: 'password', //教务密码
    v_yzm: 'test', //验证码
};

request.post({url: 'https://jwxt.bupt.edu.cn/jwLoginAction.do', gzip: true, headers: headers, form: form}, function (error, response, body) {
    console.log(body);
});

PS:关于验证码的输入,我是直接把教务系统的验证码生成链接拉了过来,所以整个查成绩的过程唯一需要用户输入的内容只有验证码(学号密码只需在第一次登录的时候输入),以上的示例代码省略了这些内容,只关注核心功能的实现(但是你可以在 Grader 的 GitHub 项目页面 上看到完整的代码)。

一切调试妥当,开始运行后,我却发现每次 POST 请求得到的返回 body 都是教务系统登录失败的界面,在保证帐号密码以及验证码都输入正确的前提下,似乎出问题的点就只在 Session 验证这个过程中。我又多次修改 header 参数,尝试了不同的 POST 姿势,然而结果依旧不变,一直是登录失败,问题一度陷入了僵局。

第二次 POST 尝试

冷静进行一波分析后,我觉得应该是「利用访问教务系统主页得到的 SessionID 与身份验证进行绑定」这一步出现了问题。于是乎我又用 request 对我能想到的对象都发了一波 GET 请求,看看响应头里都有什么东西。果不其然,我最后在一个意想不到却又十分合理的地方发现了另一个 SessionID:验证码。

与主页的访问一样,每当客户端(浏览器)请求生成一次验证码时,服务端都回在响应的主体中返回一个随机生成的验证码图片,并在响应头中附带一个 SessionID。于是乎我把 POST HEADER 中的 Cookie 参数改成了访问验证码得到的 SessionID 后,奇迹发生了脚本便成功登入进了教务系统。

获得成绩表格

完成身份验证后的步骤就简单的多了,从浏览器的网络请求中可以看到北邮教务系统的成绩查询脚本主要来自这两个 URL:

  • https://jwxt.bupt.edu.cn/bxqcjcxAction.do 负责查询本学期的成绩
  • https://jwxt.bupt.edu.cn/gradeLnAllAction.do 负责查询所有学期的总成绩

所以只要区别用户的查询请求,分别向这两个 URL 发起 POST 请求(记得在 Header 中携带登录时验证过的 SessionID 用于确认查询者的身份),就可以得到查询者的 HTML 成绩表单了。不过拿到的成绩单内容并不是那么「干净」,许多冗余的信息会影响到我们后续的 GPA 筛选计算。

所以我用 Node.js 上很有名的 HTML 解析库 cheerio 对成绩的 HTML 表单进行「清洗」:

if (type === 'current')
            return $("table.displayTag").removeClass('displayTag').addClass('table').attr('cellpadding', null).attr('width', null).attr('cellspacing', null).attr('border', null).attr('id', null);
        if (type === 'all')
            return $("table.titleTop2").removeClass('titleTop2').addClass('table').attr('cellpadding', null).attr('width', null).attr('cellspacing', null).attr('border', null);

删除掉杂七杂八的样式设定和奇怪的 class 后,得到的表格就清爽多了。

一些后续处理

基本功能完成后我又加入了一些可有可无的功能,毕竟是练手项目,所以杂糅一点奇怪的东西也没什么。不过有一点需要说在前面,本项目纯属兴趣使然,Grader 只会做它该做的事情。由于教务系统验证的方式如此,所以要使用 Grader 必须输入自己的学号和密码,我已经尽可能保证所有用户数据的安全,并且不会在服务器端记录任何使用者的任何信息,如果还是不放心的话,可以选择不使用 Grader。

  • 本地 Cookie 加密。不过由于在数据传递的时候密码还是用的明文所以这一步显得有点多余
  • 好看点的 CSS 样式。教务系统上个世纪风格的审美还是挺不讨喜的,所以加了一点简单的现代风格前端样式,力争做到不丑
  • GPA 计算。挺简单一功能教务系统居然没有,所以只好自己动手丰衣足食了,完成表格清洗后对每一列进行遍历和筛选,排除任选课成绩,计算 GPA 功能也就完成了。

后来跟一学长聊天的时候他告诉我,验证码绑定 SessionID 的做法其实在这种 Java 后端的服务系统上挺常见的,我之所以会被卡住还是因为缺乏经验,这也是我写此文的一个目的了,积攒一些经验,分享出来,希望大家看了也能有一点收获。✌🏻