跳过正文

Python 实现统一认证登录海大教务系统

··2491 字·5 分钟· loading · loading ·
加绒
作者
加绒
融雪之前,牧神搭上春色的火车,而日光在我们之间。
目录

前些日子在写神秘小工具的时候,一开始设计的登录方式是用户自己从教务系统登录,然后将登录后的 cookie 复制下来给程序使用。这种方式虽然简单,但是不够优雅,而且对于初学者而言,可能会有一定的难度。和乃子哥讨论了一下决定写一个再写一个登录。

一开始的想法还是用教务系统的登录方式。教务系统的登录要求用户输入账号、密码和验证码,尽管验证码是那种比较好认的,但是对于 ocr 识别库比如 pytesseract 来说,还是有一定的难度。这类登录方式在 GitHub 有不少前人的实现,比如 Cherrison/ouc_jwc_spaderClodLingxi/OucWebAPIdonky16/OUC-JWGL。要获得比较好的体验基本上都是外接另一个百度云的 ocr 识别服务。

教务系统的登录方式

考虑到挺多人压根没用过直接从网址登录教务系统,而是直接从学校的统一认证登录,从来没改过他的教务系统密码。既然大家都只记得自己的统一认证密码,那干脆就用统一认证登录好了。还省了验证码的问题。

完整流程
#

统一认证登录,SSO(Single Sign-On),是一种用户只需要登录一次就可以访问多个应用的登录方式。在这种登录方式下,用户登录后会得到一个 token,这个 token 可以用来访问其他应用。这个 token 会在用户退出登录或者一段时间后失效。

简单抓了一晚上包,发现海大的统一认证登录是基于 CAS(Central Authentication Service)协议的。CAS 是一种基于 HTTP 的认证协议,它的基本原理是用户登录后,服务端会返回一个 ticket,用户可以用这个 ticket 访问其他应用。我使用的是 ProxyPin 这个工具抓包。

统一认证登录信息门户
#

先访问信息门户:

GET https://my/

可以看到访问信息门户后抓到了一系列的信息门户页面的资源,但显然这时候我们没有进入信息门户,而是进入了统一认证登录页面:

统一认证登录页面

为什么呢?继续往下看:

POST https://my/prod-api/casLogin

原来是 POST 了一个请求到 https://my/prod-api/casLogin 这里,返回了 401 Unauthorized 和一个登录页面的地址。这个地址就是统一认证登录页面的地址。

POST https://id/sso/login

这个页面就大有门道了。查看响应内容可以知道这就是登录页面的 HTML 代码。

继续登录看看:

POST https://id/baseLogin/postLogin

登录是 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 用来标识用户的会话。

POST https://id/baseLogin/postLogin

POST https://id/baseLogin/postLogin

接着往下看,统一登录之后要跳转到信息门户的 casLogin 页面,这里就很有意思了:

POST https://id/sso/login

POST https://id/sso/login

在请求体里,带了一个 execution、_eventId、geolocation、username、password 和 service。其中会变的只有 execution 和 username,其他的都是固定的。查了一下,execution 一般是一个随机字符串,用来防止 CSRF 攻击。这个字符串可以在登录页面的 HTML 代码里找到,也就是上面提到的很有门道的登录页面。

execution

简单通过正则表达式就可以找到 execution。这里就不贴代码了。这样就可以在登录之后,直接 POST 到 casLogin 页面,然后就可以访问信息门户了。这个请求响应的 Set-Cookie 字段里的 COMSYSUIATGC 就是我们要的。

POST https://id/sso/login

从信息门户访问教务系统
#

接下来从信息门户访问教务系统,会发送一个 GET 请求到 https://id/sso/bridgeLogin?username={username}&service=http://jwgl/login 这里。这里的 username 就是我们的学号。

GET https://id/sso/bridgeLogin?username={username}&service=http://jwgl/login

携带的 Cookie 是 COMSYSUIATGC、JSESSIONID 和 identifyId。注意这里的 identifyId 是登录成功后返回的 identityId,字母差了一个,怀疑是开发的时候写错了。

成功响应的状态码是 302,在响应的 Set-Cookie 里有一个 TGC,保存。

GET https://id/sso/bridgeLogin?username={username}&service=http://jwgl/login

GET https://id/sso/bridgeLogin?username={username}&service=http://jwgl/login

下一步登录教务系统,发送了一个 GET 请求到 http://jwgl/login。这个请求 Cookie 只带了 identifyId。

GET http://jwgl/login

响应里带了一个 Location 字段,重定向到 SSO 登录页面。这里用的还是旧版的统一认证登录页面,旧版的登录页面随即就会重定向到新版的登录页面。

GET http://jwgl/login

GET https://id/sso/login?service=http%3A%2F%2Fjwgl%2Flogin

这一步的请求 Cookie 里带了 COMSYSUIATGC、TGC、JSESSIONID 和 identifyId。这里的 TGC 就是上一步的响应里的 TGC。然后,响应状态码如果是 302,那么就会重定向到 http://jwgl/login,参数里带了一个 ticket。

GET https://id/sso/login?service=http%3A%2F%2Fjwgl%2Flogin

接着带着这个 ticket 发送一个 GET 请求到 http://jwgl/login,Cookie 只需要带上 identifyId。

GET http://jwgl/login?ticket={ticket}

返回的状态码是 302,重定向到 http://jwgl/login;jsessionid={JSESSIONID}。这里的 JSESSIONID 是一个新的 JSESSIONID,可以在响应头里的 Set-Cookie 字段找到。

最后,带着这个 JSESSIONID 发送一个 GET 请求到 http://jwgl/login;jsessionid={JSESSIONID},Cookie 带上 identifyId 和新的 JSESSIONID,就可以登录教务系统了。

GET http://jwgl/login;jsessionid={JSESSIONID}

这样就可以实现统一认证登录海大教务系统了。

流程图和代码
#

简单让 Claude 画了一个流程图:

sequenceDiagram participant User participant Browser participant Portal as 信息门户 (my) participant IdP as 身份提供商 (id) participant JW as 教务系统 (jwgl) User->>Browser: 访问信息门户(my) Browser->>Portal: 请求访问 Portal->>Browser: 重定向到IdP登录页面 Browser->>IdP: 请求登录页面 IdP->>Browser: 返回登录页面(包含execution) User->>Browser: 输入用户名和密码 Browser->>IdP: 提交加密的登录信息 IdP->>IdP: 验证凭据 IdP->>Browser: 返回identityId Browser->>IdP: 提交登录表单 IdP->>Browser: 设置SSO会话(COMSYSUIATGC, TGC) IdP->>Browser: 重定向回信息门户 Browser->>Portal: 带SSO凭证访问 Portal->>Browser: 显示信息门户首页 User->>Browser: 选择进入教务系统 Browser->>JW: 请求访问教务系统 JW->>Browser: 重定向到IdP进行认证 Browser->>IdP: 请求SSO认证(已有会话) IdP->>Browser: 生成Ticket并重定向回教务系统 Browser->>JW: 带Ticket请求访问 JW->>IdP: 验证Ticket IdP->>JW: 确认Ticket有效 JW->>Browser: 设置教务系统会话(JSESSIONID) JW->>Browser: 返回教务系统首页 Browser->>User: 显示教务系统页面

完整代码如下:

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

希望对你有所帮助!