├── test.py ├── LICENSE ├── .gitignore ├── README.md ├── cli.py └── tunet.py /test.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | import tunet 3 | 4 | 5 | username = 'username' 6 | password = 'password' 7 | 8 | if __name__ == '__main__': 9 | pprint.pprint(tunet.auth4.checklogin()) 10 | pprint.pprint(tunet.auth4.login(username, password, net=True)) 11 | pprint.pprint(tunet.net.checklogin()) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tailing Yuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tunet-python 2 | 3 | TUNet 2018 认证协议的纯 python 实现,含 auth4 / auth6 / net 认证。适用于服务器在无人交互时自动认证。 4 | 5 | ## API 6 | API 共 3 * 3 项功能,对于 `https://{auth4,auth6,net}.tsinghua.edu.cn/` 分别有 login、logout、checklogin 三项功能。 7 | 8 | 用法示例: 9 | 10 | ```py 11 | >>> import tunet 12 | >>> print(tunet.auth4.login(username, password, net=True)) 13 | >>> print(tunet.net.checklogin()) 14 | ``` 15 | 16 | 在需要认证的网络环境下,`tunet.auth4.login` 的参数 `net=True` 用于同时完成准入认证和连接外网,相当于在 auth4 网页端勾选“访问校外网络”。 17 | 若无 `net=True`,则只登录校内准入认证,无法访问校外网络。 18 | 19 | 行为定义: 20 | 21 | | | 无需认证时 | 需认证但未认证时 | 已认证时 | 22 | | :--------------- | :--------- | :--------------- | :------- | 23 | | auth4.login | 即时返回 | 即时返回 | 即时返回 | 24 | | auth4.logout | 即时返回 | 即时返回 | 即时返回 | 25 | | auth4.checklogin | 即时返回 | 即时返回 | 即时返回 | 26 | | auth6.login | 即时返回 | 即时返回 | 即时返回 | 27 | | auth6.logout | 即时返回 | 即时返回 | 即时返回 | 28 | | auth6.checklogin | 即时返回 | 即时返回 | 即时返回 | 29 | | net.login | 即时返回 | 即时异常退出 | 即时返回 | 30 | | net.logout | 即时返回 | 即时异常退出 | 即时返回 | 31 | | net.checklogin | 即时返回 | 即时异常退出 | 即时返回 | 32 | 33 | 特殊地, 34 | 35 | - 如果无 IPv6 网络环境,则无法访问 auth6,此时 auth6 的三项功能都会即时异常退出。 36 | 37 | API 总是提供原生的结果,如果不希望异常退出,或需要更友好的提示语,可自行包装一层。 38 | 39 | ## 命令行 40 | 提供简单的命令行包装,用法示例: 41 | 42 | ```sh 43 | $ python cli.py auth4 checklogin 44 | $ cat password.txt | python cli.py auth4 login --net -u username 45 | $ python cli.py net checklogin 46 | ``` 47 | 48 | 进程返回 0 的语义约定: 49 | 50 | | | 进程返回 0 的情况 | 进程返回非 0 的情况 | 51 | | :--------------- | :--------------------- | :------------------- | 52 | | auth4 login | 成功登录,或此前已登录 | 连接错误或帐号错误 | 53 | | auth4 logout | 成功登出,或此前已登出 | 连接错误 | 54 | | auth4 checklogin | 确认处于登录状态 | 连接错误或非登录状态 | 55 | | auth6 login | 成功登录,或此前已登录 | 连接错误或帐号错误 | 56 | | auth6 logout | 成功登出,或此前已登出 | 连接错误 | 57 | | auth6 checklogin | 确认处于登录状态 | 连接错误或非登录状态 | 58 | | net login | 成功登录,或此前已登录 | 连接错误或帐号错误 | 59 | | net logout | 成功登出,或此前已登出 | 连接错误 | 60 | | net checklogin | 确认处于登录状态 | 连接错误或非登录状态 | 61 | 62 | login 的密码输入方式:如果标准输入流是 tty,则使用 getpass 读取,无回显;否则,从标准输入读取一行。 63 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | from __future__ import unicode_literals 7 | 8 | import argparse 9 | import getpass 10 | import sys 11 | import tunet 12 | 13 | if sys.version_info[0] == 2: 14 | from urllib2 import URLError 15 | else: 16 | from urllib.error import URLError 17 | 18 | 19 | if __name__ == '__main__': 20 | parser = argparse.ArgumentParser( 21 | description='TUNet Command-Line Interface') 22 | parser.add_argument('target', 23 | help='Select a target: auth4 / auth6 / net') 24 | parser.add_argument('action', 25 | help='Select an action: login / logout / checklogin') 26 | parser.add_argument('-u', '--user', '--username', 27 | help='username to login', required=False) 28 | parser.add_argument('-n', '--net', action='store_true', 29 | help='access to the Internet', required=False) 30 | args = parser.parse_args() 31 | 32 | def error(s): 33 | print(s) 34 | exit(1) 35 | 36 | if args.target not in ('auth4', 'auth6', 'net'): 37 | error('tunet: no such target') 38 | if args.action not in ('login', 'logout', 'checklogin'): 39 | error('tunet: no such action') 40 | target = getattr(tunet, args.target) 41 | action = getattr(target, args.action) 42 | if args.action == 'login': 43 | if not args.user: 44 | error('login: username required') 45 | if sys.stdin.isatty(): 46 | password = getpass.getpass() 47 | else: 48 | password = sys.stdin.readline().rstrip('\n') 49 | try: 50 | if args.target == 'net': 51 | res = action(args.user, password) 52 | else: 53 | res = action(args.user, password, bool(args.net)) 54 | except URLError as e: 55 | error('URLError: {:s}'.format(e)) 56 | else: 57 | try: 58 | res = action() 59 | except URLError as e: 60 | error('URLError: {:s}'.format(e)) 61 | 62 | if args.target == 'net': 63 | if args.action == 'checklogin': 64 | if not res.get('username'): 65 | print('not login') 66 | exit(1) 67 | else: 68 | print('Username:', res['username']) 69 | print('Time online:', res['time_query'] - res['time_login']) 70 | print('Session traffic incoming:', res['session_incoming']) 71 | print('Session traffic outgoing:', res['session_outgoing']) 72 | print('Cumulative traffic:', res['cumulative_incoming']) 73 | print('Cumulative online time', res['cumulative_time']) 74 | print('IPv4 address:', res['ipv4_address']) 75 | print('Balance:', res['balance']) 76 | exit(0) 77 | else: 78 | print('message:', res['msg']) 79 | if 'is successful' in res['msg'] or \ 80 | 'has been online' in res['msg'] or \ 81 | 'are not online' in res['msg']: 82 | exit(0) 83 | else: 84 | exit(1) 85 | else: 86 | if args.action == 'checklogin': 87 | if not res.get('username'): 88 | print('not login') 89 | exit(1) 90 | else: 91 | print('username:', res['username']) 92 | exit(0) 93 | else: 94 | print('return:', res.get('error')) 95 | print('result:', res.get('res')) 96 | print('message:', res.get('error_msg')) 97 | if res.get('error') == 'ok' or \ 98 | res.get('error') == 'ip_already_online_error' or \ 99 | (args.action == 'logout' and 100 | res.get('error') == 'login_error'): 101 | exit(0) 102 | else: 103 | exit(1) 104 | -------------------------------------------------------------------------------- /tunet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | from __future__ import unicode_literals 7 | 8 | 9 | def closure(): 10 | import base64 11 | import functools 12 | import hashlib 13 | import hmac 14 | import json 15 | import sys 16 | import time 17 | 18 | if sys.version_info[0] == 2: 19 | from urllib import urlencode 20 | from urllib2 import ProxyHandler, Request, build_opener 21 | from urlparse import parse_qs, urlparse 22 | int2byte = chr 23 | else: 24 | import struct 25 | from urllib.parse import parse_qs, urlencode, urlparse 26 | from urllib.request import ProxyHandler, Request, build_opener 27 | int2byte = struct.Struct(">B").pack 28 | 29 | urlopen = build_opener(ProxyHandler({})).open 30 | 31 | _URL_SRUN_PORTAL = 'https://auth{:d}.tsinghua.edu.cn/cgi-bin/srun_portal' 32 | _URL_GET_CHALLENGE = _URL_SRUN_PORTAL.replace( 33 | 'srun_portal', 'get_challenge') 34 | _URL_QUERY_AC_ID = 'https://usereg.tsinghua.edu.cn/ip_login_import.php' 35 | _URL_AC_DETECT = 'https://auth{:d}.tsinghua.edu.cn/ac_detect.php?ac_id=1' 36 | _URL_NET_LOGIN = 'https://net.tsinghua.edu.cn/do_login.php' 37 | _URL_USER_INFO = 'https://{:s}.tsinghua.edu.cn/rad_user_info.php' 38 | _SHORT_TIMEOUT = 5 39 | 40 | _JSONP_FUNCNAME = 'callback' 41 | 42 | def current_timestamp(): 43 | return int(time.time() * 1000) 44 | 45 | def xEncode(str, key): 46 | def s(a, b): 47 | c = len(a) 48 | v = [] 49 | for i in range(0, c, 4): 50 | v.append( 51 | ord(a[i]) | 52 | lshift(0 if i + 1 >= len(a) else ord(a[i + 1]), 8) | 53 | lshift(0 if i + 2 >= len(a) else ord(a[i + 2]), 16) | 54 | lshift(0 if i + 3 >= len(a) else ord(a[i + 3]), 24) 55 | ) 56 | if b: 57 | v.append(c) 58 | return v 59 | 60 | def l(a, b): 61 | d = len(a) 62 | c = lshift(d - 1, 2) 63 | if b: 64 | m = a[d - 1] 65 | if m < c - 3 or m > c: 66 | return None 67 | c = m 68 | for i in range(d): 69 | a[i] = int2byte(a[i] & 0xff) \ 70 | + int2byte(rshift(a[i], 8) & 0xff) \ 71 | + int2byte(rshift(a[i], 16) & 0xff) \ 72 | + int2byte(rshift(a[i], 24) & 0xff) 73 | if b: 74 | return b''.join(a)[:c] 75 | else: 76 | return b''.join(a) 77 | 78 | def rshift(x, n): 79 | return x >> n 80 | 81 | def lshift(x, n): 82 | return (x << n) & ((1 << 32) - 1) 83 | 84 | if str == '': 85 | return '' 86 | v = s(str, True) 87 | k = s(key, False) 88 | while len(k) < 4: 89 | k.append(None) 90 | n = len(v) - 1 91 | z = v[n] 92 | c = 0x86014019 | 0x183639A0 93 | q = 6 + 52 // (n + 1) 94 | d = 0 95 | while 0 < q: 96 | q -= 1 97 | d = d + c & (0x8CE0D9BF | 0x731F2640) 98 | e = rshift(d, 2) & 3 99 | for p in range(n): 100 | y = v[p + 1] 101 | m = rshift(z, 5) ^ lshift(y, 2) 102 | m += rshift(y, 3) ^ lshift(z, 4) ^ (d ^ y) 103 | m += k[(p & 3) ^ e] ^ z 104 | z = v[p] = v[p] + m & (0xEFB8D130 | 0x10472ECF) 105 | p = n 106 | y = v[0] 107 | m = rshift(z, 5) ^ lshift(y, 2) 108 | m += rshift(y, 3) ^ lshift(z, 4) ^ (d ^ y) 109 | m += k[(p & 3) ^ e] ^ z 110 | z = v[n] = v[n] + m & (0xBB390742 | 0x44C6F8BD) 111 | return l(v, False) 112 | 113 | def base64_encode(s): 114 | a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 115 | b = 'LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA' 116 | s = base64.b64encode(s) 117 | return s.decode().translate( 118 | {ord(x): y for (x, y) in zip(a, b)}).encode() 119 | 120 | def get_ac_id(ip): 121 | url = _URL_QUERY_AC_ID 122 | data = {'actionType': 'searchNasId', 'ip': ip} 123 | req = Request(url, data=urlencode(data).encode()) 124 | res = urlopen(req, timeout=_SHORT_TIMEOUT) 125 | assert 200 == res.getcode() 126 | text = res.read().decode('utf-8').strip() 127 | if text == 'fail': 128 | return 1 129 | else: 130 | return int(text) 131 | 132 | def read_callback(res): 133 | assert 200 == res.getcode() 134 | page = res.read().decode('utf-8').strip() 135 | assert page.startswith(_JSONP_FUNCNAME + '({') and page.endswith('})') 136 | page = page[len(_JSONP_FUNCNAME) + 1:-1] 137 | args = json.loads(page) 138 | return args 139 | 140 | def get_challenge(ipv, username, ip): 141 | url = _URL_GET_CHALLENGE.format(ipv) 142 | data = { 143 | 'callback': _JSONP_FUNCNAME, 144 | 'username': username, 145 | 'ip': ip, 146 | 'double_stack': '1', 147 | '_': current_timestamp(), 148 | } 149 | req = Request(url + '?' + urlencode(data)) 150 | res = urlopen(req, timeout=_SHORT_TIMEOUT) 151 | challenge = read_callback(res) 152 | assert challenge.get('res') == 'ok', challenge.get('error') 153 | return challenge 154 | 155 | def _auth_login(ipv, username, password, net=False, ip=''): 156 | if not net: 157 | username = '{:s}@tsinghua'.format(username)\ 158 | 159 | url = _URL_SRUN_PORTAL.format(ipv) 160 | challenge = get_challenge(ipv, username, ip) 161 | ip = ip or challenge['online_ip'] 162 | ac_id = get_ac_id(ip) 163 | n = 200 164 | type = 1 165 | token = challenge['challenge'] 166 | hmd5 = hmac.new(token.encode(), None, hashlib.md5).hexdigest() 167 | info = '{SRBX1}' + base64_encode(xEncode(json.dumps({ 168 | 'username': username, 169 | 'password': password, 170 | 'ip': ip, 171 | 'acid': ac_id, 172 | 'enc_ver': 'srun_bx1', 173 | }), token)).decode() 174 | chksum = hashlib.sha1(( 175 | token + username + 176 | token + hmd5 + 177 | token + '{:d}'.format(ac_id) + 178 | token + ip + 179 | token + '{:d}'.format(n) + 180 | token + '{:d}'.format(type) + 181 | token + info 182 | ).encode()).hexdigest() 183 | 184 | data = { 185 | 'callback': _JSONP_FUNCNAME, 186 | 'action': 'login', 187 | 'username': username, 188 | 'password': '{MD5}' + hmd5, 189 | 'ac_id': ac_id, 190 | 'ip': ip, 191 | 'double_stack': '1', 192 | 'info': info, 193 | 'chksum': chksum, 194 | 'n': n, 195 | 'type': type, 196 | '_': current_timestamp(), 197 | } 198 | req = Request(url + '?' + urlencode(data)) 199 | res = urlopen(req, timeout=_SHORT_TIMEOUT) 200 | return read_callback(res) 201 | 202 | def _auth_checklogin(ipv): 203 | url = _URL_AC_DETECT.format(ipv) 204 | req = Request(url) 205 | res = urlopen(req) 206 | assert 200 == res.getcode() 207 | url = res.geturl() 208 | username = parse_qs(urlparse(url).query).get('username') 209 | if not username: 210 | return {} 211 | else: 212 | return { 213 | 'username': username[0], 214 | } 215 | 216 | def _auth_logout(ipv, ip=''): 217 | url = _URL_SRUN_PORTAL.format(ipv) 218 | username = 'placeholder' 219 | challenge = get_challenge(ipv, username, ip) 220 | 221 | ac_id = 1 222 | n = 200 223 | type = 1 224 | token = challenge['challenge'] 225 | info = '{SRBX1}' + base64_encode(xEncode(json.dumps({ 226 | 'username': username, 227 | 'ip': ip, 228 | 'acid': ac_id, 229 | 'enc_ver': 'srun_bx1', 230 | }), token)).decode() 231 | chksum = hashlib.sha1(( 232 | token + username + 233 | token + '{:d}'.format(ac_id) + 234 | token + ip + 235 | token + '{:d}'.format(n) + 236 | token + '{:d}'.format(type) + 237 | token + info 238 | ).encode()).hexdigest() 239 | data = { 240 | 'callback': _JSONP_FUNCNAME, 241 | 'action': 'logout', 242 | 'username': username, 243 | 'ac_id': 1, 244 | 'ip': ip, 245 | 'double_stack': '1', 246 | 'info': info, 247 | 'chksum': chksum, 248 | 'n': n, 249 | 'type': type, 250 | '_': current_timestamp(), 251 | } 252 | req = Request(url + '?' + urlencode(data)) 253 | res = urlopen(req, timeout=_SHORT_TIMEOUT) 254 | return read_callback(res) 255 | 256 | def _subdomain_info(subdomain): 257 | url = _URL_USER_INFO.format(subdomain) 258 | req = Request(url) 259 | res = urlopen(req) 260 | assert 200 == res.getcode() 261 | line = res.read().decode('utf-8').strip() 262 | if not line: 263 | return {} 264 | else: 265 | words = [s.strip() for s in line.split(',')] 266 | return { 267 | 'username': words[0], 268 | 'time_login': int(words[1]), 269 | 'time_query': int(words[2]), 270 | 'session_incoming': int(words[3]), 271 | 'session_outgoing': int(words[4]), 272 | 'cumulative_incoming': int(words[6]), 273 | 'cumulative_time': int(words[7]), 274 | 'ipv4_address': words[8], 275 | 'balance': words[11], 276 | } 277 | 278 | def _net_login(username, password): 279 | url = _URL_NET_LOGIN 280 | data = { 281 | 'action': 'login', 282 | 'username': username, 283 | 'password': '{MD5_HEX}' + hashlib.md5( 284 | password.encode('latin1')).hexdigest(), 285 | 'ac_id': '1', 286 | } 287 | req = Request(url, data=urlencode(data).encode()) 288 | res = urlopen(req) 289 | assert 200 == res.getcode() 290 | return {'msg': res.read().decode('utf-8')} 291 | 292 | def _net_logout(): 293 | url = _URL_NET_LOGIN 294 | data = {'action': 'logout'} 295 | req = Request(url, data=urlencode(data).encode()) 296 | res = urlopen(req) 297 | assert 200 == res.getcode() 298 | return {'msg': res.read().decode('utf-8')} 299 | 300 | class Tunet(object): 301 | pass 302 | 303 | auth4 = Tunet() 304 | auth4.login = functools.partial(_auth_login, 4) 305 | auth4.logout = functools.partial(_auth_logout, 4) 306 | auth4.checklogin = functools.partial(_auth_checklogin, 4) 307 | 308 | auth6 = Tunet() 309 | auth6.login = functools.partial(_auth_login, 6) 310 | auth6.logout = functools.partial(_auth_logout, 6) 311 | auth6.checklogin = functools.partial(_auth_checklogin, 6) 312 | 313 | net = Tunet() 314 | net.login = _net_login 315 | net.logout = _net_logout 316 | net.checklogin = functools.partial(_subdomain_info, 'net') 317 | 318 | return auth4, auth6, net 319 | 320 | 321 | auth4, auth6, net = closure() 322 | del closure 323 | --------------------------------------------------------------------------------