深大校园网 Linux 自动认证:Srun 协议解析与 Python 实现
协议分析
Srun Portal 的所有接口都是 JSONP 风格的 GET 请求:参数放在 query string,附加 callback=jsonp,响应体为 jsonp({...}),截取括号内 JSON 解析即可。
认证流程:查在线 → 取真实 ac_id → 取挑战值 → 登录。另外还有一个独立的注销接口。
第 0 步:检查在线状态
GET /cgi-bin/rad_user_info?ip=<本机IP>&callback=jsonp在线时返回:
{
"error": "ok",
"user_name": "2023001",
"online_ip": "10.20.30.40",
"products_name": "学生区城市热点",
"user_balance": 0,
"online_device_total": 1
}error == "ok" 即在线,直接退出,无需后续流程。
第 1 步:获取真实的 ac_id(关键坑)
Portal 页面 URL 里的 ac_id(如 ?ac_id=1)只是入口主题 ID,并不是认证用的 acid。真正的 acid 藏在 Portal 页面内嵌的 JS 配置里:
GET /srun_portal_pc?ac_id=1&theme=proyx返回的 HTML 中有一段:
var CONFIG = {
page : 'account',
acid : "12", // ← 这才是认证用的 acid
ip : "10.20.30.40",
...
portal : {"AuthIP":"", "ServiceIP":"https://net.szu.edu.cn:8800", ...}
};用正则 acid\s*:\s*"(\d+)" 提取即可。用 URL 里的 ac_id 直接认证会报 Unknow ac-type: 错误——只有一个例外:已在线时重复登录会提前短路返回 ip_already_online_error,掩盖这个错误,容易误以为 ac_id 没问题。
第 2 步:获取挑战值 token
GET /cgi-bin/get_challenge?username=<账号>&ip=<本机IP>&callback=jsonp返回:
{
"error": "ok",
"challenge": "a1b2c3d4..." // 40 位 hex
}这个 challenge(下称 token)是后续所有加密的密钥,每次请求都会变化,因此无法重放。
第 3 步:构造登录请求
核心步骤,需要构造三个加密字段。
① password 字段——HMAC-MD5
password = "{MD5}" + HMAC_MD5(key=token, msg=明文密码).hexdigest()注意是直接以 token 为密钥对明文密码做 HMAC,不是先 MD5 再 HMAC。
② info 字段——xEncode + 自定义 Base64
先构造一个 JSON(字段顺序固定):
{"username":"2023001","password":"明文密码","ip":"10.20.30.40","acid":"12","enc_ver":"srun_bx1"}然后以 token 为密钥做 xEncode(XXTEA 变体,delta 为 0x9E3779B9),再做一次字母表置换过的 Base64:
标准字母表: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
置换后: LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA最终:
info = "{SRBX1}" + customBase64(xEncode(jsonString, token))xEncode 伪代码:
s(str, withLen): 每 4 字节按小端打包成 uint32 数组;withLen 时末尾追加原始长度
k = s(token, false),不足 4 个 word 补 0
v = s(plain, true); n = len(v)-1; z = v[n]
q = 6 + 52 // (n+1); d = 0
重复 q 次:
d = (d + 0x9E3779B9) & 0xFFFFFFFF
e = (d >>> 2) & 3
for p in 0..n-1:
y = v[p+1]
m = (z>>>5 ^ y<<2) + ((y>>>3 ^ z<<4) ^ (d^y)) + (k[(p&3)^e] ^ z)
z = v[p] = (v[p]+m) & 0xFFFFFFFF
y = v[0]
m = (z>>>5 ^ y<<2) + ((y>>>3 ^ z<<4) ^ (d^y)) + (k[(n&3)^e] ^ z)
z = v[n] = (v[n]+m) & 0xFFFFFFFF
输出: 每个 word 小端拆 4 字节拼接③ chksum 字段——SHA1 拼接签名
按固定顺序把 7 段 token+字段 拼接后计算 SHA1:
chksum = SHA1(
token+username +
token+hmacPassword +
token+acid +
token+ip +
token+"200" + // n=200
token+"0" + // type=0(桌面客户端)
token+info // 上面构造的完整 info 字符串(含 {SRBX1} 前缀)
).hexdigest()第 4 步:发起登录
GET /cgi-bin/srun_portal?action=login&username=<账号>&password={MD5}...&os=Linux&name=linux&double_stack=0&chksum=<sha1>&info={SRBX1}...&ac_id=12&ip=<本机IP>&n=200&type=0&callback=jsonp成功返回:
{
"error": "ok",
"ecode": 0,
"suc_msg": "login_ok",
"ploy_msg": "E0000: Login is successful.",
"access_token": "e479d16c..."
}注销(踢下线)
sign = SHA1(time + username + ip + unbind + time)
GET /cgi-bin/rad_user_dm?ip=<本机IP>&username=<账号>&time=<unix秒>&unbind=1&sign=<sha1>&callback=jsonperror == "ok" 即注销成功,本机会话立即下线。已在线时重复调登录接口,服务器返回 error:"ok", suc_msg:"ip_already_online_error"——有些官方客户端此时会自动调注销接口踢掉旧会话再重登,本文脚本默认不这么做(在线即退出),注销需要显式加 --logout。
任何加密字段构造错误,服务器都会返回 sign/decrypt 类错误而非 ok,所以 error:"ok" 本身就是协议正确性的强校验。
完整代码
纯 Python 3 标准库,零依赖。保存为 srun_auth.py:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Srun (深澜) campus portal auth client.
Protocol:
0. GET /srun_portal_pc?ac_id=1&theme=proyx -> parse real acid from CONFIG
1. GET /cgi-bin/rad_user_info -> error == "ok" means online
2. GET /cgi-bin/get_challenge -> token = res["challenge"]
3. password = "{MD5}" + HMAC-MD5(key=token, msg=password).hexdigest()
4. info = "{SRBX1}" + base64_custom(xEncode(json, token))
5. chksum = SHA1(token+username + token+hmacpwd + token+acid + token+ip
+ token+"200" + token+"0" + token+info).hexdigest()
6. GET /cgi-bin/srun_portal?action=login&...
Logout: GET /cgi-bin/rad_user_dm with sign=SHA1(time+username+ip+unbind+time)
All requests are JSONP style: ?callback=jsonp, body = "jsonp({...})".
Only Python standard library is used.
"""
import argparse
import base64
import hashlib
import hmac as hmac_mod
import json
import platform
import re
import socket
import ssl
import sys
import time
import urllib.parse
import urllib.request
# Custom base64 alphabet used by the portal.
B64_ALPHA = "LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA"
_B64_TRANS = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", B64_ALPHA
)
# xEncode constants: XXTEA delta and 32-bit mask.
_DELTA = 0x9E3779B9
_MASK = 0xFFFFFFFF
def _u32(x):
return x & _MASK
def _str_to_words(data, include_len):
"""Pack 4 bytes per uint32 (little-endian), optionally append length."""
v = []
for i in range(0, len(data), 4):
v.append(int.from_bytes(data[i:i + 4].ljust(4, b"\x00"), "little"))
if include_len:
v.append(len(data))
return v
def xencode(data, key):
"""XXTEA-style xEncode. Bytes in, bytes out."""
if not data:
return b""
v = _str_to_words(data, True)
k = _str_to_words(key, False)
while len(k) < 4:
k.append(0)
n = len(v) - 1
z = v[n]
d = 0
q = 6 + 52 // (n + 1)
while q > 0:
q -= 1
d = _u32(d + _DELTA)
e = (d >> 2) & 3
for p in range(n):
y = v[p + 1]
m = (z >> 5) ^ _u32(y << 2)
m = _u32(m + (((y >> 3) ^ _u32(z << 4)) ^ (d ^ y)))
m = _u32(m + (k[(p & 3) ^ e] ^ z))
v[p] = z = _u32(v[p] + m)
y = v[0]
m = (z >> 5) ^ _u32(y << 2)
m = _u32(m + (((y >> 3) ^ _u32(z << 4)) ^ (d ^ y)))
m = _u32(m + (k[(n & 3) ^ e] ^ z))
v[n] = z = _u32(v[n] + m)
return b"".join(w.to_bytes(4, "little") for w in v)
def encode_user_info(info, token):
"""'{SRBX1}' + customBase64(xEncode(JSON.stringify(info), token))"""
# Compact JSON, no spaces; key order = insertion order.
payload = json.dumps(info, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
enc = xencode(payload, token.encode("utf-8"))
b64 = base64.b64encode(enc).decode("ascii").translate(_B64_TRANS)
return "{SRBX1}" + b64
def hmac_md5_hex(msg, key):
return hmac_mod.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.md5).hexdigest()
def sha1_hex(s):
return hashlib.sha1(s.encode("utf-8")).hexdigest()
class SrunPortal:
def __init__(self, host, ac_id, username, password, ip=None, timeout=10):
self.host = host.rstrip("/")
self.ac_id = int(ac_id)
self.username = username
self.password = password
self.ip = ip or self._local_ip()
self.timeout = timeout
# The campus portal cert is not always chain-valid on minimal
# systems, so don't verify TLS.
self._ssl_ctx = ssl.create_default_context()
self._ssl_ctx.check_hostname = False
self._ssl_ctx.verify_mode = ssl.CERT_NONE
def _local_ip(self):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((urllib.parse.urlparse(self.host).hostname, 80))
return s.getsockname()[0]
finally:
s.close()
def _jsonp(self, path, params=None):
q = dict(params or {})
q["callback"] = "jsonp"
url = self.host + path + "?" + urllib.parse.urlencode(q)
req = urllib.request.Request(url, headers={"User-Agent": "srun-portal-cli/1.0"})
with urllib.request.urlopen(req, timeout=self.timeout, context=self._ssl_ctx) as r:
text = r.read().decode("utf-8", "replace")
# unwrap jsonp({...}) (tolerate plain JSON too)
start = text.find("(")
end = text.rfind(")")
if start != -1 and end > start:
text = text[start + 1:end]
return json.loads(text)
def check_online(self):
"""Return (online: bool, info: dict)."""
res = self._jsonp("/cgi-bin/rad_user_info", {"ip": self.ip})
return res.get("error") == "ok", res
def get_real_acid(self):
"""Fetch the portal page and parse the real acid from its embedded CONFIG.
The ac_id in the portal URL (e.g. 1) is only an entry theme id; the
server embeds the actual auth acid in a JS CONFIG block, e.g.
`acid : "12"`. Auth with the wrong acid fails with
'Unknow ac-type:'. Falls back to --ac-id if parsing fails.
"""
url = "%s/srun_portal_pc?ac_id=%s&theme=proyx" % (self.host, self.ac_id)
try:
req = urllib.request.Request(url, headers={"User-Agent": "srun-portal-cli/1.0"})
with urllib.request.urlopen(req, timeout=self.timeout, context=self._ssl_ctx) as r:
html = r.read().decode("utf-8", "replace")
m = re.search(r'acid\s*:\s*"(\d+)"', html)
if m:
return int(m.group(1))
except Exception:
pass
return self.ac_id
def get_token(self):
res = self._jsonp("/cgi-bin/get_challenge",
{"username": self.username, "ip": self.ip})
if res.get("error") != "ok":
raise RuntimeError("get_challenge failed: %s" % json.dumps(res, ensure_ascii=False))
return res["challenge"]
def login(self):
acid = self.get_real_acid()
token = self.get_token()
hmac_password = hmac_md5_hex(self.password, token)
n, type_ = 200, 0 # n=200, type=0 (desktop client)
info_json = {
"username": self.username,
"password": self.password,
"ip": self.ip,
"acid": str(acid),
"enc_ver": "srun_bx1",
}
i = encode_user_info(info_json, token)
# chksum concatenation order is fixed by the protocol:
s = token + self.username
s += token + hmac_password
s += token + str(acid)
s += token + self.ip
s += token + str(n)
s += token + str(type_)
s += token + i
params = {
"action": "login",
"username": self.username,
"password": "{MD5}" + hmac_password,
"os": platform.system(), # e.g. "Linux"
"name": sys.platform, # e.g. "linux"
"double_stack": 0,
"chksum": sha1_hex(s),
"info": i,
"ac_id": acid,
"ip": self.ip,
"n": n,
"type": type_,
}
return self._jsonp("/cgi-bin/srun_portal", params)
def logout(self):
"""Kick the current session offline via /cgi-bin/rad_user_dm."""
unbind = 1
ts = int(time.time())
params = {
"ip": self.ip,
"username": self.username,
"time": ts,
"unbind": unbind,
"sign": sha1_hex("%s%s%s%s%s" % (ts, self.username, self.ip, unbind, ts)),
}
return self._jsonp("/cgi-bin/rad_user_dm", params)
def main():
ap = argparse.ArgumentParser(description="Srun campus portal auth (SZU)")
ap.add_argument("--host", default="https://net.szu.edu.cn")
ap.add_argument("--ac-id", default="1")
ap.add_argument("--username", required=True)
ap.add_argument("--password", required=True)
ap.add_argument("--ip", default=None, help="override local IP sent to the portal")
ap.add_argument("--logout", action="store_true",
help="kick the current session offline instead of logging in")
args = ap.parse_args()
portal = SrunPortal(args.host, args.ac_id, args.username, args.password, ip=args.ip)
print("[*] portal=%s ac_id=%s user=%s ip=%s" % (args.host, args.ac_id, args.username, portal.ip))
online, info = portal.check_online()
if online:
print("[+] already online: user=%s ip=%s product=%s balance=%s" % (
info.get("user_name"), info.get("online_ip"),
info.get("products_name"), info.get("user_balance")))
if not args.logout:
return 0
if args.logout:
if not online:
print("[*] not online (rad_user_info error=%r), nothing to kick" % info.get("error"))
return 0
print("[*] kicking session offline via rad_user_dm...")
res = portal.logout()
print("[*] rad_user_dm response: %s" % json.dumps(res, ensure_ascii=False))
if res.get("error") == "ok":
print("[+] logout success")
return 0
print("[-] logout failed: error=%s error_msg=%s" % (res.get("error"), res.get("error_msg")))
return 1
print("[*] not online (rad_user_info error=%r), authenticating..." % info.get("error"))
res = portal.login()
print("[*] srun_portal response: %s" % json.dumps(res, ensure_ascii=False))
if res.get("error") == "ok":
# suc_msg may be "login_ok" or "ip_already_online_error"; both mean the
# session is up. We deliberately do NOT auto-kick the other session.
print("[+] auth success (suc_msg=%s)" % res.get("suc_msg"))
return 0
if "already_online" in str(res.get("error", "")) or "already_online" in str(res.get("error_msg", "")):
print("[+] server says already online -> protocol accepted, treating as success")
return 0
print("[-] auth failed: error=%s error_msg=%s" % (res.get("error"), res.get("error_msg")))
return 1
if __name__ == "__main__":
sys.exit(main())使用方法
登录(脚本先检查在线状态,在线则直接退出):
python3 srun_auth.py --username <学号/账号> --password <密码>输出示例:
[*] portal=https://net.szu.edu.cn ac_id=1 user=2023001 ip=10.20.30.40
[*] not online (rad_user_info error='not_online_error'), authenticating...
[*] srun_portal response: {"error": "ok", "suc_msg": "login_ok", ...}
[+] auth success (suc_msg=login_ok)注销 / 踢下线:
python3 srun_auth.py --username <账号> --password <密码> --logout可选参数:
--host:Portal 服务器地址,默认https://net.szu.edu.cn(其他使用深澜系统的学校改成自己的 Portal 地址即可)--ac-id:Portal 页面 URL 中的入口 ac_id,默认1;认证用的真实 acid 脚本会自动从页面 CONFIG 中解析--ip:手动指定上报给 Portal 的本机 IP(一般不需要,脚本会自动探测出口 IP)
搭配 cron 实现掉线自动重连(每 5 分钟检查一次):
*/5 * * * * /usr/bin/python3 /home/user/srun_auth.py --username <账号> --password <密码> >> /tmp/srun_auth.log 2>&1脚本在线时不做任何写操作、也不踢会话,放 cron 里长期跑是安全的。