前些日子在写神秘小工具的时候,一开始设计的登录方式是用户自己从教务系统登录,然后将登录后的 cookie 复制下来给程序使用。这种方式虽然简单,但是不够优雅,而且对于初学者而言,可能会有一定的难度。和乃子哥讨论了一下决定写一个再写一个登录。
一开始的想法还是用教务系统的登录方式。教务系统的登录要求用户输入账号、密码和验证码,尽管验证码是那种比较好认的,但是对于 ocr 识别库比如 pytesseract 来说,还是有一定的难度。这类登录方式在 GitHub 有不少前人的实现,比如 Cherrison/ouc_jwc_spader、 ClodLingxi/OucWebAPI 和 donky16/OUC-JWGL。要获得比较好的体验基本上都是外接另一个百度云的 ocr 识别服务。
考虑到挺多人压根没用过直接从网址登录教务系统,而是直接从学校的统一认证登录,从来没改过他的教务系统密码。既然大家都只记得自己的统一认证密码,那干脆就用统一认证登录好了。还省了验证码的问题。
完整流程#
统一认证登录,SSO(Single Sign-On),是一种用户只需要登录一次就可以访问多个应用的登录方式。在这种登录方式下,用户登录后会得到一个 token,这个 token 可以用来访问其他应用。这个 token 会在用户退出登录或者一段时间后失效。
简单抓了一晚上包,发现海大的统一认证登录是基于 CAS(Central Authentication Service)协议的。CAS 是一种基于 HTTP 的认证协议,它的基本原理是用户登录后,服务端会返回一个 ticket,用户可以用这个 ticket 访问其他应用。我使用的是 ProxyPin 这个工具抓包。
统一认证登录信息门户#
先访问信息门户:
可以看到访问信息门户后抓到了一系列的信息门户页面的资源,但显然这时候我们没有进入信息门户,而是进入了统一认证登录页面:
为什么呢?继续往下看:
原来是 POST 了一个请求到 https://my/prod-api/casLogin 这里,返回了 401 Unauthorized 和一个登录页面的地址。这个地址就是统一认证登录页面的地址。
这个页面就大有门道了。查看响应内容可以知道这就是登录页面的 HTML 代码。
继续登录看看:
登录是 POST 到 https://id/baseLogin/postLogin 这里的。可以看到,请求体里带了学号和加密过的密码。这个密码显然是在前端加密过的,我们可以在前端代码里找到加密的方法。
搜索 encrypt
,在上面找到了公钥。在实现时,我们可以用这个公钥加密密码,然后再 POST 到登录接口。这部分代码可以参考 白佬的 BBHelper。
def encrypt(self, message):
rsa_file = os.path.join(os.getcwd(), 'utils/publicKey.rsa')
with open(rsa_file) as f:
key = f.read()
pub_key = RSA.importKey(str(key))
cipher = PKCS1_cipher.new(pub_key)
rsa_text = base64.b64encode(cipher.encrypt(bytes(message.encode("utf8"))))
return rsa_text.decode('utf-8')
成功登录后的返回就有了很多信息,这里其实可以直接说明,只需要响应体里的 identityId 和响应头里的 Set-Cookie 字段的 JSESSIONID 就可以了。identityId 用来标识用户,JSESSIONID 用来标识用户的会话。
接着往下看,统一登录之后要跳转到信息门户的 casLogin 页面,这里就很有意思了:
在请求体里,带了一个 execution、_eventId、geolocation、username、password 和 service。其中会变的只有 execution 和 username,其他的都是固定的。查了一下,execution 一般是一个随机字符串,用来防止 CSRF 攻击。这个字符串可以在登录页面的 HTML 代码里找到,也就是上面提到的很有门道的登录页面。
简单通过正则表达式就可以找到 execution。这里就不贴代码了。这样就可以在登录之后,直接 POST 到 casLogin 页面,然后就可以访问信息门户了。这个请求响应的 Set-Cookie 字段里的 COMSYSUIATGC 就是我们要的。
从信息门户访问教务系统#
接下来从信息门户访问教务系统,会发送一个 GET 请求到 https://id/sso/bridgeLogin?username={username}&service=http://jwgl/login 这里。这里的 username 就是我们的学号。
携带的 Cookie 是 COMSYSUIATGC、JSESSIONID 和 identifyId。注意这里的 identifyId 是登录成功后返回的 identityId,字母差了一个,怀疑是开发的时候写错了。
成功响应的状态码是 302,在响应的 Set-Cookie 里有一个 TGC,保存。
下一步登录教务系统,发送了一个 GET 请求到 http://jwgl/login。这个请求 Cookie 只带了 identifyId。
响应里带了一个 Location 字段,重定向到 SSO 登录页面。这里用的还是旧版的统一认证登录页面,旧版的登录页面随即就会重定向到新版的登录页面。
这一步的请求 Cookie 里带了 COMSYSUIATGC、TGC、JSESSIONID 和 identifyId。这里的 TGC 就是上一步的响应里的 TGC。然后,响应状态码如果是 302,那么就会重定向到 http://jwgl/login,参数里带了一个 ticket。
接着带着这个 ticket 发送一个 GET 请求到 http://jwgl/login,Cookie 只需要带上 identifyId。
返回的状态码是 302,重定向到 http://jwgl/login;jsessionid={JSESSIONID}。这里的 JSESSIONID 是一个新的 JSESSIONID,可以在响应头里的 Set-Cookie 字段找到。
最后,带着这个 JSESSIONID 发送一个 GET 请求到 http://jwgl/login;jsessionid={JSESSIONID},Cookie 带上 identifyId 和新的 JSESSIONID,就可以登录教务系统了。
这样就可以实现统一认证登录海大教务系统了。
流程图和代码#
简单让 Claude 画了一个流程图:
完整代码如下:
def Login(username, password):
session = requests.Session()
# Step 1: Get execution
login_url = 'https://id/sso/login?service=https://my/cas/login'
response = session.get(login_url)
execution = re.search(r'<input type="hidden" name="execution" value="(.*?)"', response.text).group(1)
# Step 2: Post login data
login_data = {
"account": username,
"password": encrypt(password),
"randomCode": "comsys-base-login",
"isRememberPwdOpen": 0,
"scale": 1
}
post_login_url = 'https://id/baseLogin/postLogin'
response = session.post(post_login_url, json=login_data)
# Step 3: Submit login form
form_data = {
'execution': execution,
'_eventId': 'submit',
'geolocation': '',
'username': username,
'password': "01110100",
'service': 'https://my/cas/login'
}
response = session.post(login_url, data=form_data)
# Step 4: Bridge login
bridge_url = f'https://id/sso/bridgeLogin?username={username}&service=http://jwgl/login'
response = session.get(bridge_url)
# Step 5: SSO login
sso_url = 'https://id/sso/login?service=http%3A%2F%2Fjwgl%2Flogin'
response = session.get(sso_url, allow_redirects=False)
# Step 6: Follow redirection
if response.status_code == 302:
ticket_url = response.headers['Location']
response = session.get(ticket_url)
# Step 7: Final login
# print(session.cookies)
# get JSESSIONID for jwgl
jsessionid = session.cookies.get('JSESSIONID', domain='jwgl')
final_url = f'http://jwgl/login;jsessionid={jsessionid}'
response = session.get(final_url)
# Make sure login is successful
main_frm_url = 'http://jwgl/MainFrm.html'
response = session.get(main_frm_url)
# print(response.status_code)
if response.status_code == 200:
return jsessionid
希望对你有所帮助!