├── requirements.txt ├── tunet ├── __init__.py ├── api.py └── lib.py ├── test.py ├── LICENSE ├── .gitignore ├── README.md └── cli.py /requirements.txt: -------------------------------------------------------------------------------- 1 | six 2 | -------------------------------------------------------------------------------- /tunet/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | from .api import auth4, auth6, net, NotLoginError 4 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import pprint 4 | import tunet 5 | 6 | 7 | username = 'username' 8 | password = 'password' 9 | 10 | if __name__ == '__main__': 11 | pprint.pprint(tunet.auth4.checklogin()) 12 | pprint.pprint(tunet.auth4.login(username, password)) 13 | pprint.pprint(tunet.net.checklogin()) 14 | -------------------------------------------------------------------------------- /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)) 13 | >>> print(tunet.net.checklogin()) 14 | ``` 15 | 16 | 在需要认证的网络环境下,可以用 `tunet.auth4.login(username, password, net=True)` 同时完成认证和登录,相当于在 auth4 网页端勾选“访问校外网络”。 17 | 18 | 行为定义: 19 | 20 | | | 无需认证时 | 需认证但未认证时 | 已认证时 | 21 | | :--------------- | :----------- | :--------------- | :------- | 22 | | auth4.login | 即时返回 | 即时返回 | 即时返回 | 23 | | auth4.logout | 即时异常退出 | 即时异常退出 | 即时返回 | 24 | | auth4.checklogin | 即时返回 | 即时返回 | 即时返回 | 25 | | auth6.login | 即时返回 | 即时返回 | 即时返回 | 26 | | auth6.logout | 即时异常退出 | 即时异常退出 | 即时返回 | 27 | | auth6.checklogin | 即时返回 | 即时返回 | 即时返回 | 28 | | net.login | 即时返回 | 超时异常退出 | 即时返回 | 29 | | net.logout | 即时返回 | 超时异常退出 | 即时返回 | 30 | | net.checklogin | 即时返回 | 超时异常退出 | 即时返回 | 31 | 32 | 特殊地, 33 | 34 | - 如果使用的是无线网络且没有登录 net,则无法访问 auth4、auth6,此时 auth4 和 auth6 的三项功能都会超时异常退出; 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 -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 | from six.moves import urllib 14 | 15 | 16 | if __name__ == '__main__': 17 | parser = argparse.ArgumentParser( 18 | description='TUNet Command-Line Interface') 19 | parser.add_argument('target', 20 | help='Select target: auth4 / auth6 / net') 21 | parser.add_argument('action', 22 | help='Select action: login / logout / checklogin') 23 | parser.add_argument('-u', '--user', '--username', 24 | help='Login username', required=False) 25 | parser.add_argument('-n', '--net', action='store_true', 26 | help='Also login net.tsinghua.edu.cn', required=False) 27 | args = parser.parse_args() 28 | 29 | def error(s): 30 | print(s) 31 | exit(1) 32 | 33 | if args.target not in ('auth4', 'auth6', 'net'): 34 | error('tunet: no such target') 35 | if args.action not in ('login', 'logout', 'checklogin'): 36 | error('tunet: no such action') 37 | target = getattr(tunet, args.target) 38 | action = getattr(target, args.action) 39 | if args.action == 'login': 40 | if not args.user: 41 | error('login: username required') 42 | if sys.stdin.isatty(): 43 | password = getpass.getpass() 44 | else: 45 | password = sys.stdin.readline().rstrip('\n') 46 | try: 47 | if args.target == 'net': 48 | res = action(args.user, password) 49 | else: 50 | res = action(args.user, password, bool(args.net)) 51 | except urllib.error.URLError as e: 52 | error('URLError: {:s}'.format(e)) 53 | else: 54 | try: 55 | res = action() 56 | except (tunet.NotLoginError, urllib.error.URLError) as e: 57 | if isinstance(e, tunet.NotLoginError): 58 | print('not log in') 59 | exit(0) 60 | else: 61 | error('URLError: {:s}'.format(e)) 62 | 63 | if args.target == 'net': 64 | if args.action == 'checklogin': 65 | if not res.get('username'): 66 | print('not login') 67 | exit(1) 68 | else: 69 | print('Username:', res['username']) 70 | print('Time online:', res['time_query'] - res['time_login']) 71 | print('Session traffic incoming:', res['session_incoming']) 72 | print('Session traffic outgoing:', res['session_outgoing']) 73 | print('Cumulative traffic:', res['cumulative_incoming']) 74 | print('Cumulative online time', res['cumulative_time']) 75 | print('IPv4 address:', res['ipv4_address']) 76 | print('Balance:', res['balance']) 77 | exit(0) 78 | else: 79 | print('message:', res['msg']) 80 | if 'is successful' in res['msg'] or \ 81 | 'has been online' in res['msg'] or \ 82 | 'are not online' in res['msg']: 83 | exit(0) 84 | else: 85 | exit(1) 86 | else: 87 | if args.action == 'checklogin': 88 | if not res.get('username'): 89 | print('not login') 90 | exit(1) 91 | else: 92 | print('username:', res['username']) 93 | exit(0) 94 | else: 95 | print('return:', res.get('error')) 96 | print('result:', res.get('res')) 97 | print('message:', res.get('error_msg')) 98 | if res.get('error') == 'ok' or \ 99 | res.get('error') == 'ip_already_online_error': 100 | exit(0) 101 | else: 102 | exit(1) 103 | -------------------------------------------------------------------------------- /tunet/api.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 functools 9 | import hashlib 10 | 11 | from . import lib 12 | from six.moves.urllib import parse, request 13 | 14 | 15 | class NotLoginError(Exception): 16 | pass 17 | 18 | 19 | def _subdomain_info(subdomain): 20 | line = lib.get('https://{:s}.tsinghua.edu.cn/rad_user_info.php' 21 | .format(subdomain), {}, None, 'raw') 22 | line = line.strip() 23 | if not line: 24 | return {} 25 | else: 26 | words = [s.strip() for s in line.split(',')] 27 | return { 28 | 'username': words[0], 29 | 'time_login': int(words[1]), 30 | 'time_query': int(words[2]), 31 | 'session_incoming': int(words[3]), 32 | 'session_outgoing': int(words[4]), 33 | 'cumulative_incoming': int(words[6]), 34 | 'cumulative_time': int(words[7]), 35 | 'ipv4_address': words[8], 36 | 'balance': words[11], 37 | } 38 | 39 | 40 | def _auth_login(ipv, username, password, net=False): 41 | if not net: 42 | username = '{}@tsinghua'.format(username) 43 | res = lib.getJSON( 44 | 'https://auth{:d}.tsinghua.edu.cn/cgi-bin/srun_portal'.format(ipv), 45 | { 46 | 'action': 'login', 47 | 'username': username, 48 | 'password': password, 49 | 'ac_id': '163', 50 | 'ip': '', 51 | 'double_stack': '1', 52 | }, 53 | None, 54 | ) 55 | return res 56 | 57 | 58 | def _auth_logout(ipv): 59 | username = _auth_checklogin(ipv).get('username') 60 | if not username: 61 | raise NotLoginError('username not found') 62 | res = lib.getJSON( 63 | 'https://auth{:d}.tsinghua.edu.cn/cgi-bin/srun_portal'.format(ipv), 64 | { 65 | 'action': 'logout', 66 | 'username': username, 67 | 'ac_id': '1', 68 | 'ip': '', 69 | 'double_stack': '1', 70 | }, 71 | None, 72 | ) 73 | return res 74 | 75 | 76 | def _auth_checklogin(ipv): 77 | req = request.Request( 78 | 'https://auth{:d}.tsinghua.edu.cn/ac_detect.php?ac_id=1' 79 | .format(ipv) 80 | ) 81 | res = request.urlopen(req, timeout=5) 82 | assert 200 == res.getcode() 83 | url = res.geturl() 84 | username = parse.parse_qs(parse.urlparse(url).query).get('username') 85 | if not username: 86 | return {} 87 | else: 88 | return { 89 | 'username': username[0], 90 | } 91 | 92 | 93 | def _net_login(username, password): 94 | res = lib.get( 95 | 'https://net.tsinghua.edu.cn/do_login.php', 96 | { 97 | 'action': 'login', 98 | 'username': username, 99 | 'password': '{MD5_HEX}' + hashlib.md5( 100 | password.encode('latin1')).hexdigest(), 101 | 'ac_id': '1', 102 | }, 103 | None, 104 | 'raw' 105 | ) 106 | return { 107 | 'msg': res, 108 | } 109 | 110 | 111 | def _net_logout(): 112 | res = lib.get( 113 | 'https://net.tsinghua.edu.cn/do_login.php', 114 | {'action': 'logout'}, 115 | None, 116 | 'raw' 117 | ) 118 | return { 119 | 'msg': res, 120 | } 121 | 122 | 123 | class Tunet(object): 124 | pass 125 | 126 | 127 | auth4 = Tunet() 128 | auth4.login = functools.partial(_auth_login, 4) 129 | auth4.logout = functools.partial(_auth_logout, 4) 130 | auth4.checklogin = functools.partial(_auth_checklogin, 4) 131 | 132 | auth6 = Tunet() 133 | auth6.login = functools.partial(_auth_login, 6) 134 | auth6.logout = functools.partial(_auth_logout, 6) 135 | auth6.checklogin = functools.partial(_auth_checklogin, 6) 136 | 137 | net = Tunet() 138 | net.login = _net_login 139 | net.logout = _net_logout 140 | net.checklogin = functools.partial(_subdomain_info, 'net') 141 | 142 | 143 | if __name__ == '__main__': 144 | print(net.checklogin()) 145 | -------------------------------------------------------------------------------- /tunet/lib.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 base64 9 | import copy 10 | import hashlib 11 | import json 12 | import six 13 | 14 | from six.moves.urllib import parse, request 15 | 16 | 17 | def xEncode(str, key): 18 | def s(a, b): 19 | c = len(a) 20 | v = [] 21 | for i in range(0, c, 4): 22 | v.append( 23 | ord(a[i]) | 24 | lshift(0 if i + 1 >= len(a) else ord(a[i + 1]), 8) | 25 | lshift(0 if i + 2 >= len(a) else ord(a[i + 2]), 16) | 26 | lshift(0 if i + 3 >= len(a) else ord(a[i + 3]), 24) 27 | ) 28 | if b: 29 | v.append(c) 30 | return v 31 | 32 | def l(a, b): 33 | d = len(a) 34 | c = lshift(d - 1, 2) 35 | if b: 36 | m = a[d - 1] 37 | if m < c - 3 or m > c: 38 | return None 39 | c = m 40 | for i in range(d): 41 | a[i] = six.int2byte(a[i] & 0xff) \ 42 | + six.int2byte(rshift(a[i], 8) & 0xff) \ 43 | + six.int2byte(rshift(a[i], 16) & 0xff) \ 44 | + six.int2byte(rshift(a[i], 24) & 0xff) 45 | if b: 46 | return b''.join(a)[:c] 47 | else: 48 | return b''.join(a) 49 | 50 | def rshift(x, n): 51 | return x >> n 52 | 53 | def lshift(x, n): 54 | return (x << n) & ((1 << 32) - 1) 55 | 56 | if str == '': 57 | return '' 58 | v = s(str, True) 59 | k = s(key, False) 60 | while len(k) < 4: 61 | k.append(None) 62 | n = len(v) - 1 63 | z = v[n] 64 | c = 0x86014019 | 0x183639A0 65 | q = 6 + 52 // (n + 1) 66 | d = 0 67 | while 0 < q: 68 | q -= 1 69 | d = d + c & (0x8CE0D9BF | 0x731F2640) 70 | e = rshift(d, 2) & 3 71 | for p in range(n): 72 | y = v[p + 1] 73 | m = rshift(z, 5) ^ lshift(y, 2) 74 | m += rshift(y, 3) ^ lshift(z, 4) ^ (d ^ y) 75 | m += k[(p & 3) ^ e] ^ z 76 | z = v[p] = v[p] + m & (0xEFB8D130 | 0x10472ECF) 77 | p = n 78 | y = v[0] 79 | m = rshift(z, 5) ^ lshift(y, 2) 80 | m += rshift(y, 3) ^ lshift(z, 4) ^ (d ^ y) 81 | m += k[(p & 3) ^ e] ^ z 82 | z = v[n] = v[n] + m & (0xBB390742 | 0x44C6F8BD) 83 | return l(v, False) 84 | 85 | 86 | def get(url, data, callback, dataType): 87 | if dataType == 'jsonp': 88 | data = copy.deepcopy(data) 89 | data['callback'] = 'callback' 90 | _data = parse.urlencode(data) 91 | req = request.Request(url + '?' + _data if _data else url) 92 | res = request.urlopen(req, timeout=5) # TODO: remove hardcoded timeout 93 | assert 200 == res.getcode() 94 | page = res.read().decode('utf-8').strip() 95 | assert page.startswith(data['callback'] + '({') and page.endswith('})') 96 | page = page[len(data['callback']) + 1:-1] 97 | page = json.loads(page) 98 | if callback: 99 | page = callback(page) 100 | return page 101 | elif dataType == 'raw': 102 | data = parse.urlencode(data) 103 | req = request.Request(url + '?' + data if data else url) 104 | res = request.urlopen(req, timeout=5) 105 | assert 200 == res.getcode() 106 | page = res.read().decode('utf-8') 107 | if callback: 108 | page = callback(page) 109 | return page 110 | else: 111 | raise NotImplementedError 112 | 113 | 114 | def base64_encode(s): 115 | a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 116 | b = 'LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA' 117 | s = base64.b64encode(s) 118 | return s.decode().translate({ord(x): y for (x, y) in zip(a, b)}).encode() 119 | 120 | 121 | def getJSON(url, data, callback): 122 | if 'srun_portal' in url or 'get_challenge' in url: 123 | enc = 'srun_bx1' 124 | n = 200 125 | type = 1 126 | _data = data 127 | if data.get('action') == 'login': 128 | def foo(data): 129 | assert data.get('res') == 'ok', data.get('error') 130 | token = data.get('challenge') 131 | _data['info'] = '{SRBX1}' + base64_encode(xEncode(json.dumps({ 132 | 'username': _data.get('username'), 133 | 'password': _data.get('password'), 134 | 'ip': _data.get('ip'), 135 | 'acid': _data.get('ac_id'), 136 | 'enc_ver': enc, 137 | }), token)).decode() 138 | hmd5 = hashlib.md5(data.get('password', 'undefined') 139 | .encode('latin1')).hexdigest() 140 | _data['password'] = '{MD5}' + hmd5 141 | _data['chksum'] = hashlib.sha1(( 142 | token + _data.get('username') + 143 | token + hmd5 + 144 | token + '{}'.format(_data.get('ac_id')) + 145 | token + _data.get('ip') + 146 | token + '{}'.format(n) + 147 | token + '{}'.format(type) + 148 | token + _data.get('info') 149 | ).encode('latin1')).hexdigest() 150 | _data['n'] = n 151 | _data['type'] = type 152 | return get(url, _data, callback, 'jsonp') 153 | return getJSON( 154 | url.replace('srun_portal', 'get_challenge'), 155 | { 156 | 'username': _data.get('username'), 157 | 'ip': _data.get('ip'), 158 | 'double_stack': '1', 159 | }, 160 | foo, 161 | ) 162 | elif data.get('action') == 'logout': 163 | def foo(data): 164 | assert data.get('res') == 'ok', data.get('error') 165 | token = data.get('challenge') 166 | _data['info'] = '{SRBX1}' + base64_encode(xEncode(json.dumps({ 167 | 'username': _data.get('username'), 168 | 'ip': _data.get('ip'), 169 | 'acid': _data.get('ac_id'), 170 | 'enc_ver': enc, 171 | }), token)).decode() 172 | _data['chksum'] = hashlib.sha1(( 173 | token + _data.get('username') + 174 | token + '{}'.format(_data.get('ac_id')) + 175 | token + _data.get('ip') + 176 | token + '{}'.format(n) + 177 | token + '{}'.format(type) + 178 | token + _data.get('info') 179 | ).encode('latin1')).hexdigest() 180 | _data['n'] = n 181 | _data['type'] = type 182 | return get(url, _data, callback, 'jsonp') 183 | return getJSON( 184 | url.replace('srun_portal', 'get_challenge'), 185 | { 186 | 'username': _data.get('username'), 187 | 'ip': _data.get('ip'), 188 | 'double_stack': '1', 189 | }, 190 | foo, 191 | ) 192 | else: 193 | return get(url, data, callback, 'jsonp') 194 | return get(url, data, callback, 'jsonp') 195 | 196 | 197 | if __name__ == '__main__': 198 | getJSON( 199 | 'https://auth4.tsinghua.edu.cn/cgi-bin/srun_portal', 200 | { 201 | 'action': 'login', 202 | 'username': 'username', 203 | 'password': 'password', 204 | 'ac_id': '1', 205 | 'ip': '', 206 | 'double_stack': '1', 207 | }, 208 | print, 209 | ) 210 | --------------------------------------------------------------------------------