前言

由于每次登陆校园网都需要执行 “打开浏览器->输入用户名和密码->点击登录” 几个步骤, 显得繁琐且愚蠢, 因此想到了使用 Python 脚本来完成校园网登录流程. 阅读本篇文章可能需要你具有一定的 HTTP 基本知识.

其实这篇文章的内容我在很早之前就已经完成了, 由于种种原因 (懒), 一直拖到了今天. 在最开始我是计划使用 Python 和 PyQt 来完成一个带有 GUI 的应用来实现登录校园网 (项目链接, 由于代码过于丑陋, 随时可能删除). 也基本实现了这一目标, 但是在完成这个应用并使用一段时间之后, 由于种种原因 (坑爹的地方: 比如说 PyQt 的各种问题, Pyinstaller 的问题等等), 我发现还是命令行软件好用, 而恰好这段时间正在接触 Golang, Golang 的编译语言特性, 尤其是交叉编译特性对于跨平台应用来说非常有用 (比如说让树莓派也能自动登陆), 同时编译好的程序也不需要安装一个复杂的运行环境, 因此最终用 Golang 重写了整个脚本.

这篇文章主要介绍脚本核心部分的 Python 实现, 至于用 PyQt 实现 GUI 和 Golang 版本的实现, 只能说看我会不会因懒惰而死了…

核心部分的 Python 实现

web 登录界面概览

登陆界面

登陆界面

可以从图里面发现这是一个比较典型的用户登陆界面, 当用户输入 id 和密码之后点击登录, 系统便会提交登陆信息. 一般来说, 校园网的登陆不会用到特别复杂的技术, 往往都是向目标服务器提交一个 POST 请求就能实现登陆, 当然这也不是一拍脑袋就能确定的, 下面介绍如何分析网站登陆过程, 一般我们把这个过程叫做抓包 (packet capture).

抓包

抓包与分析过程

在最早我是使用的 fiddler (Wikiwand, 官网), fiddler 是抓包软件的一种, 可以截获你和服务器之间的通信内容, 也就是说通过抓包软件你就可以看到你究竟向服务器提交了什么内容, 是以什么格式提交的, 这样你只要用同样的方法提交同样的内容, 服务器就无法分辨你是提交的信息是由手工填写并点击按钮产生的, 还是由某个脚本所生成的.

在 Chrome 和 Firefox 还没有诞生并变得如此好用之前, 抓包软件是网络开发的重要工具. 然而现在你只需要有一个带有足够好用的开发工具的现代浏览器就可以了, 我使用的是 Chrome. 大部分情况下你用不到抓包工具的高级功能, 而 Chrome 的开发者工具已经足够好用, 最重要的是这样一来你就不需要安装额外的应用, 同时也能避免抓包软件要求修改你的代理配置的问题 (有时).

当你已经使用某种现代浏览器打开校园网登陆页面后 (在这里是 http://w.seu.edu.cn, 需要内网访问), 你就能看到上面那张登陆界面, 此时按下你的键盘上的 F12 (对于其他浏览器也许这一快捷键并不适用, 请搜索相关帮助信息, 或者到处乱按来找到 “开发者工具” 或其他类似选项), 此时你应当能看到类似于下图的界面:

开发者工具界面

开发者工具界面

此时我们关注右边弹出的界面, 请不要对突然出现的大量内容感到紧张, 其中大部分内容是你不需要理会的, 如果出现的不是这样的界面, 请检查最上面选中的选项卡是不是 “Network” 选项卡. 现在你可以注意下图中被方框框住的内容, 这些东西就是你和服务器之间通信的内容, 你可以把它们理解成 “资源文件”, 也可以把鼠标放在资源名称上查看资源的详细路径.

现在在左边的界面 (也就是你平时使用的那个界面) 输入你的用户名和密码并点击登陆, 这时候你会发现右侧资源列表新增了一项资源, 而且它的名字是 “login”, 这显然和你点击登陆按钮的操作是有联系的. 如果出现了其他的信息在这里可以不用理会 (尤其是当它们是图片或者是类似的一些明显和登陆没有关系的信息时).

现在点击右侧列表中的 “login”, 你会看到如下界面 (注意中间的选项卡位于 “Header”):

又是一大堆文字, 不要紧张, 你暂时只需要关注 “General” 中的 “Requset URL” 和 “Request Method”, 这部分信息显示当你按下登录按钮时提交的是一个 POST 请求, 并且也告诉了你请求的链接.

此外还需要关注 “Request Headers” 和 “Form Data”, 其中 “Form Data” 也就是表单数据就是你实际上提交的东西 (我们先讨论这个部分, 再来讨论 “Request Headers”), 很明显, 其中包括了用户名和密码, 以及一个值为 0 的 “enablemacauth”, 从字面意思可以看出, 这个选项是指的是否启用 MAC (物理地址) 验证, 既然它的值是 0, 并且用这个值能够正常登陆, 那么在你写脚本的时候用同样的数据就行了.

请注意在 Chrome 开发工具中显示的表单是解析后的样子, 如果你想看到你提交的数据的原本形式, 点击 “view source” 就可以了.

可以用类似的方法可以很容易查看其他数据.

password 字段编码

你可以观察一下你提交的表单, 你会很快发现这里的 “password” 字段并不是你实际上输入的密码, 这是哪里出错了吗? 并不是的, 这是因为程序在你提交密码之前先对密码进行了编码, 而不是直接上传你的密码的原文, 这种做法其实很常见, 因为这可以防止万一你在上传数据时被截取到了数据 (就是用类似于抓包的手法), 攻击者不能通过他抓取到的数据来反推到你的真实密码, 从而导致密码泄露 (原理你可以搜索 “Hash 算法”, 如果你对这方面的知识感兴趣的话你可以进一步搜索 “彩虹表”, “加盐” 等名词).

然而不幸的是校园网并不是对密码进行 Hash, 而是使用了一个可逆的函数, 所以并没有上文提到的防止密码泄露的作用, 而仅仅只能让密码看上去变得不一样 (大部分情况下是看上去更加复杂) 了. 冷漠.jpg

那么问题来了, 如果只是给自己用还好, 提交的数据就填他编码后的数据就好了, 但是如果想要让别的账户也能使用脚本呢? 难道要求每个人都自己抓一次包然后修改代码吗?

实际上由于编码过程都是在本地进行的, 所以你肯定能找到实际的编码方式, 然后在脚本中进行一次同样的编码就行了.

接下来的问题是, 这个例子中到底使用的是什么编码方式呢? 一个通用的办法就是读代码, 回想你登陆时的动作: 按下登陆按钮, 那么你就可以右键登陆按钮->审查元素, 找到按下按钮这一动作实际执行的代码 (在这里是某个 js 文件, 阅读代码需要你对 Javascript 足够了解), 然后逐步逐步找到对密码进行编码的部分, 在你自己的脚本中同样实现一遍就可以了.

上面的这个方法听起来就很麻烦, 所幸这个案例比较简单, 我们实际上已经得到了一些提示了, 仔细查看打开网页时加载的资源列表:

如果你具有一定的计算机知识的话, 你应当很快就能发现 “md5.js” 和 “base64.js” 这两个文件, 事实上 md5 是一种 hash 函数, 而 base64 是一种编码算法, 显然这个例子使用这两种方法中的某一种 (也有可能是先进行 md5 计算, 再进行 base64 编码, etc.) 对密码进行处理的可能性极大. 在这里并不会深入介绍技术细节, 如果有兴趣并想要知道其中的原理的话请搜索相关关键字.

那么很快你就会想到我上文说到的使用 hash 算法对密码进行加密, 而 md5 刚好是一种 hash 算法, 那么这里是不是的 md5 算法呢? 很不幸, 并不是的 (也许你已经从我前文的斜体字猜到了). 这里使用的是 base64 编码, 如果你想要验证一下的话, 你可以搜索 “在线 md5”, “在线 base64” , 得到你的密码处理结果之后与之前数据表单中的内容进行对比.

实际上, 如果你对编码足够熟悉, 应当很快就能看出这是 base64 编码, base64 编码有一个比较明显的特征: 编码后的字符串长度是 4 的倍数, 字符串最后如果有等号的话, 就说明编码后的原始字符串不是 4 的倍数, 需要用等号补齐, 也就是说等号最多不会多于三个.

Request Headers

HTTP Request Headers 是浏览器或客户端发送到服务器以请求 Web 服务器上特定页面或数据的网络数据包的组件。其中包含了客户端采用的协议, 使用的系统版本和浏览器内核 (用户代理字段), 目标主机信息以及 Cookie 信息. 这里不会对每一个字段的内容进行解释, 大部分时候除了 Cookie 以外, 其他内容都是相同的, 因此代码中直接用同样的内容就行了. 至于 Cookie, 在这个案例中, 实际上是不会影响登陆的, 但在 Python 实现中还是加入了 Cookie.

Response Preview

接下来选择 “Preview” 选项卡, 你会很容易发现这就是登陆成功之后在左侧界面中显示的信息, 同时你也能通过字段名称很猜出各个字段的意思, 这里就是服务器返回的信息, 格式是 json (avaScript Object Notation, JS 对象标记), 在 Python 中你可以使用 json 包把 json 格式解析为 dict (字典) 来进行操作.

如果登录失败…

有时因为种种原因登陆发生错误 (例如密码错误), 除了无法连接上服务器, 大部分时候服务器都会返回错误信息 (这也是你在左侧界面看到的错误提示的来源), 你可以同样在 “Preview” 选项卡中看到返回的内容提供的:

用户名或密码错误

用户名或密码错误

流量超配额

流量超配额

可以发现此时返回的 json 和登陆成功时返回的 json 不一样, 在接下来的编程中需要注意这一点, 否则如果登录失败, 在解析返回的 json 后, 操作解析结果字典时可能会发生索引不存在的情况 (KeyError).

有一点你可能已经注意到了, 当登陆成功时, “status” 字段值为 1, 否则为 0. 实际上当登出成功时这一字段值也是 1, 因此你可以用这个字段来判断期望操作是否成功.

(2018.10.08 更新: 当你的密码出现错误时, 似乎校园网并不会返回任何数据, 当你的代码在发送请求并等待一段时间后仍然接收不到服务器返回的数据, 你的程序就会报超时错误, 当你遇到这种情况时, 首先思考一下是不是密码出错了即可)

使用 Python 实现登录脚本的核心部分

在抓包完成后, 就可以进行实际的编程工作了. 另外在写这个项目时我看到了一句话:

任何大于两百行的项目都应该使用强类型语言.

我们知道 Python 是典型的弱类型语言, 不过在 Python 3 中你已经可以声明带有参数类型的函数了. 例如一个简单的两整数相加返回一个整数的函数:

1
2
def add(x: int, y: int) -> int:
return x + y

先不要急着说这简直蠢爆了, 毕竟 Python 的鸭子类型就是其灵活性的重要保证, 但是不可否认的是过于灵活反而容易导致问题, 因此我十分建议你在比较复杂的 (也就是不是用完一次就扔了) 项目中还是使用这种带有类型指定的函数声明方式, 尤其是在构建容易发生异常的项目时.

接下来详细介绍代码的各个部分, 对于一些比较简单的内容不会过多解释, 参考代码注释即可.

仔细分析, 我们登陆的过程是这样的: 首先打开登陆页面 (从服务器获取登陆页面和 Cookie), 然后提交用户名和密码请求 (POST), 所以为了模拟整个过程, 你首先需要进行一个 “打开登陆页面” 并获取 Cookie 的操作. (实际上这一步并非总是必要的, 你可以试一试去掉这一步是否会影响登陆)

为了能够让 Cookie 能够多次使用 (实际上 Cookie 的设计目的就是为了让服务器能认出你来, 所以完全可以保存在本地供下次使用). 需要用到 pickle 包中的 load()dump() 两个函数. 从字面上也能看出来, load() 就是加载本地保存的文件, dump() 就是把程序中的数据保存到本地. (你可以这样理解, 实际上要复杂一些, dump() 函数实际上是把某个 Python 对象转存到了本地, 而 load() 只能加载 dump() 转存的文件)

1
2
3
4
5
import pickle
test_dict = {1: "张三", 2: "李四", 3: "王五"}
filename = "test"
pickle.dump(test_dict, open(filename, "wb")) # 打开并保存对象
loaded_dict = pickle.load(open(cookies_file, 'rb')) #打开并读取

下面使用 requests 库来实现网络通信. 关于 requests 库的介绍, 你可以参考这两篇文章的相关部分: Python爬虫入坑; Python 爬虫中的一些知识.

为实现获取登陆页面, 使用 requests.get 函数即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from requests import get

from requests.exceptions import Timeout # requests 库中的超时异常

try: # 在容易发生异常的地方一定要记得使用 try
# 为了避免无法连接到服务器时脚本运行等待过长时间, 设置超时为 1 秒
r = get("http://w.seu.edu.cn/, timeout=1")
# 很显然访问网络有可能发生很多异常, 多次尝试之后发现
# 当无法连接到服务器时往往是这三种异常.
# 你也可以默认抛出所有异常, 但那不是一个很好的习惯.
except (TimeoutError, ConnectionError, Timeout):
raise ConnectionError # 交由上一层处置

# 到这里你已经可以判断网络没有发生异常, 接下来判断服务器是否正常返回信息
if r.status_code != 200: # 服务器返回 200 说明正常
raise ConnectionError

# 下面终于可以开始处理 cookie 了, 获取并保存 cookie
cookies = r.cookies
dump(cookies, open(cookies_file, 'wb'))

此时你就拿到 Cookie 了, 以下是这个部分的完整代码, import 了一些暂时用不到但下面马上就会用到的包.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pickle import load, dump
from requests import get, post
from requests.exceptions import Timeout

cookies_file = "cookies.dat"

def get_cookies():
try:
cookies = load(open(cookies_file, 'rb'))
except (FileNotFoundError, EOFError):
try:
r = get("http://w.seu.edu.cn/", timeout=1)
except (TimeoutError, ConnectionError, Timeout):
raise ConnectionError
if r.status_code != 200:
raise ConnectionError # if the error is not ConnectionError, may be you need to delete cookies
cookies = r.cookies
dump(cookies, open(cookies_file, 'wb'))
return cookies

Data Form 编码

还记得这张图吗, 这就是你在这部分需要得到的东西, 也是你实际上向服务器提交的主要信息.

首先我们要解决密码编码的问题, 我们假定使用一个名为 password_encode 的函数来对密码进行编码, 然后我们就能很容易地使用 Python 格式化字符串来产生这段内容了:

1
data_login = f'username={username}&password={password_encode(password)}&enablemacauth=0'

实际上为了实现保存编码后的密码到本地的功能, 密码的编码并不是在这里完成的.

密码编码

我们已经在前面的讨论中确定了密码的编码方式为 base64 编码, 作为一种比较常见的编码算法, Python 中自然有相关的实现, 首先导入 base64 包中的相关函数:

1
from base64 import b64encode   #解码: b64decode

接下来是这个案例的第一个细节点, 由于 b64encode() 函数只能接受字节码 (bytes, 也就是引号前面带有 b 的格式, 形如 b'test string') 作为输入, 我们首先要把输入的密码字符串转换为 bytes, 这里直接使用 str.encode() 函数转换即可. 然后再将转换后的 bytes 作为 b64encode() 的参数输入, 由于函数返回值仍是字节码, 所以需要将字节码变回 string, 同时不要忘记指定编码, 你可以尝试一下如果不指定编码会发生什么.

1
2
3
string_step_1 = input_string.encode()
string_step_2 = b64encode(string_step_1) # 得到的是字节码
output_string = str(string_step_2, encoding='utf-8') # 得到最终的字符串

把这几句代码写成一个函数:

1
2
3
from base64 import b64encode
def password_encode(inputstr: str) -> str:
return str(b64encode(inputstr.encode()), encoding='utf-8')

你也可以用类似的方式实现一个解码函数, 虽然在这里并没有什么作用, 但还是写出来好了:

1
2
3
from base64 import b64decode
def password_decode(inputstr: str) -> str:
return str(b64decode(inputstr.encode()), encoding='utf-8')

Request Headers 编码

在这里你只需要根据抓包时获取的内容用字典填写一遍就可以, 格式参考下面的代码即可. 需要注意的是, “Content-Length” 字段还是需要做一点变化的, 计算一下 “Data Form” 的长度, 然后把 len() 函数返回的数字转换为字符串即可 (否则会报错).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
headers_login = {
"Origin": "http://w.seu.edu.cn",
"Referer": "http://w.seu.edu.cn/",
"Accept-Language": "zh-CN",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Accept-Encoding": "gzip, deflate",
"Content-Length": str(len(data_login)),
"DNT": "1",
"Host": "w.seu.edu.cn",
"Connection": "Keep-Alive",
"Pragma": "no-cache",
}

接下来就到了正式发送登陆请求的时候了, 使用 requests.post() 函数 (在之前已经 import 过了), 使用 data, headercookies 参数来指定请求头, 数据表单和 cookie, 同时为了避免发生网络问题时等待过长时间, 指定超时为 5 秒.

1
2
3
4
5
6
7
8
response = post(url, data=data_login, headers=headers_login, timeout=5, cookies=cookies)

# 你也可以更新一下本地的 cookie 文件
cookies = response.cookies
dump(cookies, open(cookies_file, 'wb'))

# 对返回值进行解析, 得到一个字典
response_json = response.json()

最后的 response.json() 就是之前几张在 “Response Preview” 小节中的图片的内容. 也就是我们最终需要解析的返回内容.

返回数据解析

对于返回的数据, 由其在解析后成为了一个 Python 字典对象, 你可以直接用 print() 函数来输出它的内容, 也可以使用一些更加美观的处理方式, 下面给出代码参考, 由于我这部分内容实际上是与 GUI 的代码写在了一起, 因此下面是略微修改后的代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
if type(result_info) is dict:
if result_type == 0: # 成功登出时也会返回状态码 1
info_string = f'信息: {result_info["info"]}\n' \
f'登陆位置: {result_info["logout_domain"]} {result_info["logout_location"]}\n' \
f'IP: {result_info["logout_ip"]}\n' \
f'状态: {result_info["status"]}'
else:
info_string = f'信息: {result_info["info"]}\n' \
f'状态: {result_info["status"]}'
else: # type(result_info) is str
info_string = result_info

# return info_string

我们最终得到的 info_string 就是格式化后的返回数据, 这段代码能处理登陆成功, 密码错误, 登出成功等多种情况, 目前来说应当是够用了的.

核心部分完整参考代码

参考: seu-wlan-client/seu_wlan_core.py at master · zhzyX/seu-wlan-client

结语

本文原计划写作的内容还剩下 PyQt 部分和 Golang 部分, 然而由于我的懒惰, 暂时不想继续写下去了, 同时由于 Qt for Python 即将到来 (并且 PyQt 的槽点也太多了) 的原因, 我以后应该不会再使用 PyQt anymore 了. 所以这部分内容很有可能不会写了. (2018.10.08 更新: 但是我写了另一个基于 PyQt 的小工具: 基于 PyQt 的文件夹迁移工具 | 等等 这才不是情书呢 😺)

这里就说几个 PyQt 当时把我坑到了的地方吧, 首先是如果你使用形如 “on_pushButton_clicked” 的槽函数命名方式, PyQt 会自动帮你进行链接, 所以如果你自己又进行了一次手动链接的话, 那么当你按下按钮时就会触发两次函数操作, 而如果同时你没有使用 @QtCore.pyqtSlot() 装饰器装饰槽函数的话, 那么这个触发次数就会还变多一次, 这时候真的很难发现这个问题, 当时我一直搞不明白为什么会发送三次请求 (这时候抓包软件就派上用场了, 单独的程序是不经过浏览器的), 后来大概花了一周多的时间才找到问题所在, 当然这也和我对 PyQt 不是很了解有一定的关系, 同时网上 PyQt5 的教程偏少, 只能看 PyQt4 教程来进行学习参考, 也导致了在编程过程中的不太顺畅. (2018.10.08 更新: 上面说到的多次触发槽函数的问题似乎只会在部分 PyQt 版本中出现, 在 PyQt 5.6.0 中似乎没有这个问题)

在最初我计划编写一个跨平台项目, 不过后来我意识到登陆校园网这种需求根本不需要一个 GUI, 其实用一个命令行程序然后用另外一个脚本调用这个程序就能很好实现这个要求了, 而 Python 因为需要一个额外 (且巨大) 的运行环境, 不太方便把应用发布给其他人使用 (以及部署到树莓派), 所以后面才尝试使用 Golang 来重写, 重写的效果还不错.

这篇文章主要是为了记录一下自己在这个案例中的完整思路与操作, 不过写到后面发现内容太多一次写不完, 那就先这样吧. 以后有机会了再说, 哈哈.

其他内容

  • Golang 实现 (待续)
  • Windows 开机自动运行 (使用任务计划即可)
  • 部署至树莓派 (用 Golang 交叉编译, 然后 scp 传上去, 配置一下 rc.local 就行了)
  • 在 iOS 上使用 Pythonista 实现校园网一键登录 widget (这部分工作的难点不多, 参考 Pythonista 官方提供的几个 widget demo 就能很容易做出来. 另外查看 WiFi SSID 的功能的实现参考 这里. 参考代码下载)