Files
dufe-net-auto-login/login.py
T

705 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import requests
import time
import urllib.parse
import sys
import io
import uuid
import subprocess
import concurrent.futures
import re
import threading
import json
import os
from io import BytesIO
if sys.platform == 'win32':
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
except Exception:
pass
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(sys.executable)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
STATE_FILE = os.path.join(BASE_DIR, ".eportal_state.json")
# 模块级默认值,main() 会从 config.json 覆盖
PORTAL_HOST = "http://192.168.200.100"
PORTAL_BASE_URL = PORTAL_HOST + "/eportal"
USERNAME = ""
PASSWORD = ""
MAX_ATTEMPTS = 60
SLEEP_SECONDS = 3
PING_HOST = "www.dufe.edu.cn"
PUBLIC_KEY_FALLBACK = "10001&94dd2a8675fb779e6b9f7103698634cd400f27a154afa67af6166a43fc26417222a79506d34cacc7641946abda1785b7acf9910ad6a0978c91ec84d40b71d2891379af19ffb333e7517e390bd26ac312fe940c340466b4a5d4af1d65c3b5944078f96a1a51a5a53e4bc302818b7c9f63c4a1b07bd7d874cef1c3d4b2f5eb7871"
DEFAULT_CONFIG = {
"username": "",
"password": "",
"portal_host": "http://192.168.200.100",
"max_attempts": 60,
"sleep_seconds": 3,
"ping_host": "www.dufe.edu.cn",
"public_key_fallback": "10001&94dd2a8675fb779e6b9f7103698634cd400f27a154afa67af6166a43fc26417222a79506d34cacc7641946abda1785b7acf9910ad6a0978c91ec84d40b71d2891379af19ffb333e7517e390bd26ac312fe940c340466b4a5d4af1d65c3b5944078f96a1a51a5a53e4bc302818b7c9f63c4a1b07bd7d874cef1c3d4b2f5eb7871",
}
def load_config():
def _write_default():
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=4)
_print(f"已创建默认配置文件: {CONFIG_FILE}")
except Exception:
pass
if not os.path.exists(CONFIG_FILE):
_write_default()
return dict(DEFAULT_CONFIG)
# 检测空文件或内容为空
try:
if os.path.getsize(CONFIG_FILE) == 0:
_print("config.json 为空,重新生成默认配置", "ERROR")
_write_default()
return dict(DEFAULT_CONFIG)
except Exception:
pass
cfg = dict(DEFAULT_CONFIG)
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
_print("config.json 格式错误,使用默认配置", "ERROR")
return dict(DEFAULT_CONFIG)
cfg.update(data)
except json.JSONDecodeError as e:
_print(f"config.json 解析失败: {e},使用默认配置", "ERROR")
except Exception as e:
_print(f"读取 config.json 失败: {e}", "ERROR")
return cfg
def _print(msg, level="INFO"):
print(f"[{level}] {msg}", flush=True)
_skip_wait = False # Ctrl+C 退出时跳过等待
def _wait_exit():
global _skip_wait
if _skip_wait:
return
try:
input("按任意键退出...")
except (EOFError, KeyboardInterrupt):
pass
def save_state(mac, user_index):
try:
with open(STATE_FILE, "w") as f:
json.dump({"mac": mac, "userIndex": user_index}, f)
except Exception:
pass
def load_state():
try:
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r") as f:
return json.load(f)
except Exception:
pass
return None
def detect_portal():
try:
requests.get(PORTAL_HOST + "/", timeout=(2, 2), allow_redirects=False)
return None, None
except Exception:
pass
for url in [
"http://www.baidu.com",
"http://detectportal.firefox.com/canonical.html",
"http://www.msftconnecttest.com/redirect",
]:
try:
resp = requests.get(url, timeout=(2, 2), allow_redirects=False)
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location", "")
m = re.match(r'(https?://[^/]+(?:/eportal)?)', loc)
if m:
host = re.match(r'https?://[^/]+', m.group(1)).group(0)
base = m.group(1)
return host, base
except Exception:
pass
return None, None
_page_info_cache = None
def _fetch_page_info():
global _page_info_cache
if _page_info_cache:
return _page_info_cache
info = {"public_key": None, "query_string": "", "portal_mac": None}
def parse_page(resp, extract_qs=True):
html = resp.text
if "?" in resp.url:
info["query_string"] = resp.url.split("?", 1)[1]
m = re.search(r'[?&]mac=([^&]+)', resp.url)
if m:
info["portal_mac"] = urllib.parse.unquote(m.group(1)).replace(':', '').replace('-', '').lower()
# 从 HTML 提取 publicKey
for pid in ['publicKey', 'publicKeyModulus', 'publicKeyExponent']:
m = re.search(rf'(?:id|name)=["\']{pid}["\'][^>]*value=["\']([^"\']+)', html)
if m:
if pid == 'publicKey' and '&' in m.group(1):
info["public_key"] = m.group(1)
break
if not info["public_key"]:
exp_match = re.search(r'(?:id|name)=["\']publicKeyExponent["\'][^>]*value=["\']([^"\']+)', html)
mod_match = re.search(r'(?:id|name)=["\']publicKeyModulus["\'][^>]*value=["\']([^"\']+)', html)
if exp_match and mod_match:
info["public_key"] = exp_match.group(1) + "&" + mod_match.group(1)
if not extract_qs:
return
# 从 HTML 提取 queryString(JS 重定向: location.href='...?wlanuserip=...')
if not info["query_string"]:
m = re.search(r"location\.href\s*=\s*['\"]([^'\"]*\?[^'\"]+)", html)
if m:
qs_url = m.group(1)
if "?" in qs_url:
info["query_string"] = qs_url.split("?", 1)[1]
if not info["query_string"]:
params = []
for key in ['wlanuserip', 'wlanacname', 'wlanacip', 'wlanusermac', 'wlanuserfirsturl',
'ssid', 'nasip', 'snmpagentip', 'mac', 't', 'url']:
m = re.search(rf'(?:id|name)=["\']{key}["\'][^>]*value=["\']([^"\']+)', html)
if m:
params.append(f"{key}={m.group(1)}")
if params:
info["query_string"] = "&".join(params)
# 方法1: 跟随 portal 根路径重定向链,提取 JS 重定向中的 queryString
try:
resp = requests.get(PORTAL_HOST + "/", timeout=10, allow_redirects=True)
parse_page(resp)
except Exception:
pass
# 方法2: 访问 eportal 页面获取 publicKey(同时尝试提取 queryString)
try:
resp = requests.get(PORTAL_BASE_URL + "/", timeout=10, allow_redirects=True)
parse_page(resp, extract_qs=not bool(info["query_string"]))
except Exception:
pass
# 方法3: 对 HTTP 外部 URL 发起请求触发 DNS 劫持,从返回的 HTML 提取 queryString
if not info["query_string"]:
for test_url in ["http://www.baidu.com", "http://detectportal.firefox.com/canonical.html"]:
try:
resp = requests.get(test_url, timeout=5, allow_redirects=False)
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location", "")
if "?" in loc:
info["query_string"] = loc.split("?", 1)[1]
m = re.search(r'[?&]mac=([^&]+)', loc)
if m:
info["portal_mac"] = urllib.parse.unquote(m.group(1)).replace(':', '').replace('-', '').lower()
break
elif resp.status_code == 200:
parse_page(resp)
if info["query_string"]:
break
except Exception:
pass
_page_info_cache = info
return info
def get_public_key():
info = _fetch_page_info()
if info["public_key"]:
return info["public_key"]
return PUBLIC_KEY_FALLBACK
def get_query_string():
return _fetch_page_info()["query_string"]
def get_portal_mac():
return _fetch_page_info()["portal_mac"]
def js_rsa_encrypt(data_str, modulus_hex, exponent_hex):
# 模拟 JS RSAUtils.encryptedString:原始RSA,零填充,每2个charCode打包为1个16bit digit
modulus = int(modulus_hex, 16)
exponent = int(exponent_hex, 16)
modulus_bytes = (modulus.bit_length() + 7) // 8
js_digits = (modulus_bytes + 1) // 2
chunk_size = 2 * (js_digits - 1)
a = [ord(ch) for ch in data_str]
while len(a) % chunk_size != 0:
a.append(0)
parts = []
for i in range(0, len(a), chunk_size):
val = 0
j = 0
for k in range(i, i + chunk_size, 2):
val += (a[k] + (a[k + 1] << 8)) << (16 * j)
j += 1
parts.append(hex(pow(val, exponent, modulus))[2:])
return " ".join(parts)
def double_encode(s):
return urllib.parse.quote(urllib.parse.quote(s, safe=''), safe='')
def _ocr_single(img_bytes):
try:
from PIL import Image
import pytesseract
img = Image.open(BytesIO(img_bytes))
gray = img.convert("L")
binary = gray.point(lambda x: 0 if x < 200 else 255)
return pytesseract.image_to_string(
binary,
config="--psm 7 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
).strip()
except (ImportError, Exception):
return ""
def _fetch_captcha():
import random
url = f"{PORTAL_BASE_URL}/validcode?rnd={random.random()}"
resp = requests.get(url, timeout=10)
return resp.content, url
def recognize_captcha(first_url):
try:
if not first_url.startswith("http"):
first_url = urllib.parse.urljoin(PORTAL_BASE_URL, first_url)
r = requests.get(first_url, timeout=10)
text = _ocr_single(r.content)
if text:
_print(f"验证码 OCR 识别: {text}")
return text
except Exception as e:
_print(f"验证码下载失败: {e}", "ERROR")
for i in range(2, 4):
_print(f"OCR未识别,刷新验证码重试({i}/3)...")
try:
img_bytes, _ = _fetch_captcha()
text = _ocr_single(img_bytes)
if text:
_print(f"OCR第{i}次: {text}")
return text
except Exception as e:
_print(f"验证码刷新失败: {e}", "ERROR")
_print("OCR 3次均失败,切换为手动输入...", "ERROR")
try:
from PIL import Image
img_bytes, _ = _fetch_captcha()
Image.open(BytesIO(img_bytes)).show()
except Exception:
pass
return input(" 请手动输入验证码: ").strip()
def get_all_macs():
macs = []
mac_raw = set()
def add_mac(m):
if not m:
return
m = m.replace(':', '').replace('-', '').replace('.', '').lower().strip()
if len(m) == 12 and all(c in '0123456789abcdef' for c in m):
if m not in mac_raw and m != '000000000000':
mac_raw.add(m)
macs.append(m)
# ipconfig /all(Windows 最可靠)
try:
output = subprocess.run(
['ipconfig', '/all'],
capture_output=True, text=True, timeout=5
).stdout
for line in output.split('\n'):
if '物理地址' in line or 'Physical' in line:
parts = line.split(':')
if len(parts) >= 2:
add_mac(parts[-1])
except Exception:
pass
# getmac 库
try:
from getmac import get_mac_address
m = get_mac_address()
if m:
add_mac(m)
except Exception:
pass
# uuid(不可靠,放最后)
try:
m = format(uuid.getnode(), '012x')
if m != '000000000000':
add_mac(m)
except Exception:
pass
# PowerShell 备用
if not macs:
try:
output = subprocess.run(
['powershell', '-Command',
'Get-NetAdapter | Select-Object -ExpandProperty MacAddress'],
capture_output=True, text=True, timeout=5
).stdout
for line in output.strip().split('\n'):
add_mac(line)
except Exception:
pass
return macs
def is_authenticated():
test_urls = [
("https://detectportal.firefox.com/canonical.html", "success"),
("https://www.msftconnecttest.com/connecttest.txt", "Microsoft"),
("https://www.baidu.com", None),
]
passed = 0
for url, keyword in test_urls:
try:
r = requests.get(url, allow_redirects=False, timeout=3)
if r.status_code == 200:
if keyword:
if keyword in r.text:
passed += 1
else:
passed += 1
except Exception:
continue
return passed >= 2
def do_login(mac, query_string=""):
exponent_hex, modulus_hex = get_public_key().split('&')
plain = f"{PASSWORD}>{mac}"
encrypted = js_rsa_encrypt(plain[::-1], modulus_hex, exponent_hex)
def build_body(validcode=""):
return "&".join([
f"userId={double_encode(USERNAME)}",
f"password={double_encode(encrypted)}",
f"service={double_encode('')}",
f"queryString={double_encode(query_string)}",
"operatorPwd=",
"operatorUserId=",
f"validcode={double_encode(validcode)}",
f"passwordEncrypt={double_encode('true')}",
])
def parse_result(result):
ok = result.get("result") == "success"
ui = result.get("userIndex", "")
ki = result.get("keepaliveInterval", 0)
return ok, result.get("message", ""), ui, ki
try:
resp = requests.post(
f"{PORTAL_BASE_URL}/InterFace.do?method=login",
data=build_body(),
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
timeout=10,
)
resp.encoding = 'utf-8'
result = resp.json()
if result.get("result") == "fail" and result.get("validCodeUrl"):
captcha_url = result["validCodeUrl"]
if captcha_url and captcha_url.strip():
_print(f"需要验证码 ({captcha_url})")
code = recognize_captcha(captcha_url)
if code:
_print(f"输入验证码: {code},重试...")
resp2 = requests.post(
f"{PORTAL_BASE_URL}/InterFace.do?method=login",
data=build_body(code),
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
timeout=10,
)
resp2.encoding = 'utf-8'
result = resp2.json()
return parse_result(result)
except Exception as e:
return False, str(e), "", 0
def ping_test(host=None, count=3):
if host is None:
host = PING_HOST
try:
cmd = ["ping", "-n", str(count), host] if sys.platform == "win32" else ["ping", "-c", str(count), host]
output = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if output.returncode == 0:
# 提取平均延迟
m = re.search(r'平均\s*=\s*(\d+)\s*ms|avg.*?(\d+\.?\d*).*?ms', output.stdout)
if m:
avg = m.group(1) or m.group(2)
_print(f"Ping {host} 平均延迟: {avg}ms")
else:
_print(f"Ping {host} 成功(延迟未解析)")
else:
_print(f"Ping {host} 失败: {output.stderr.strip()}", "ERROR")
except Exception as e:
_print(f"Ping {host} 异常: {e}", "ERROR")
def register_mac(mac, user_index):
try:
resp = requests.post(
f"{PORTAL_BASE_URL}/InterFace.do?method=registerMac",
data=f"mac={mac}&userIndex={user_index}",
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
timeout=10,
)
resp.encoding = 'utf-8'
result = resp.json()
if result.get("result") == "success":
_print(f"MAC快速认证已注册: {mac}")
else:
_print(f"MAC注册: {result.get('message', result)}", "ERROR")
except Exception as e:
_print(f"MAC注册异常: {e}", "ERROR")
def start_keepalive(user_index, interval_seconds):
interval = max(60, min(600, int(interval_seconds) if interval_seconds else 180))
def _loop():
count = 0
while True:
time.sleep(interval)
count += 1
try:
resp = requests.post(
f"{PORTAL_BASE_URL}/InterFace.do?method=keepalive",
data=f"userIndex={user_index}",
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
timeout=10,
)
resp.encoding = 'utf-8'
result = resp.json()
_print(f"保活 #{count} → {result.get('result', '?')}")
except Exception as e:
_print(f"保活 #{count} 异常: {e}", "ERROR")
t = threading.Thread(target=_loop, daemon=True)
t.start()
_print(f"保活线程已启动(间隔 {interval}s)")
def main():
global PORTAL_HOST, PORTAL_BASE_URL, _page_info_cache
global USERNAME, PASSWORD, MAX_ATTEMPTS, SLEEP_SECONDS
global PING_HOST, PUBLIC_KEY_FALLBACK
cfg = load_config()
if not cfg["username"] or not cfg["password"]:
_print("请编辑 config.json 填入账号密码后重新运行", "ERROR")
return False
PORTAL_HOST = cfg["portal_host"]
PORTAL_BASE_URL = PORTAL_HOST + "/eportal"
USERNAME = cfg["username"]
PASSWORD = cfg["password"]
MAX_ATTEMPTS = int(cfg["max_attempts"])
SLEEP_SECONDS = int(cfg["sleep_seconds"])
PING_HOST = cfg["ping_host"]
PUBLIC_KEY_FALLBACK = cfg["public_key_fallback"]
_print(f"校园网认证登录")
_print(f"目标: {PORTAL_HOST} 用户: {USERNAME}")
print("[INFO] 自动检测 Portal 地址...", end=" ", flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(detect_portal)
try:
host, base = future.result(timeout=5)
except concurrent.futures.TimeoutError:
host, base = None, None
if host and base:
PORTAL_HOST = host
PORTAL_BASE_URL = base
_page_info_cache = None
print(f"→ {base}")
else:
print("→ 使用默认")
if is_authenticated():
_print("已认证,无需登录!")
return True
saved = load_state()
if saved and saved.get("userIndex"):
try:
resp = requests.post(
f"{PORTAL_BASE_URL}/InterFace.do?method=getOnlineUserInfo",
data=f"userIndex={saved['userIndex']}",
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
timeout=10,
)
resp.encoding = 'utf-8'
info = resp.json()
if info.get("result") == "success":
_print(f"恢复上次会话 userIndex={saved['userIndex']}")
ping_test()
start_keepalive(saved["userIndex"], info.get("keepaliveInterval", 180))
_print("按 Ctrl+C 退出...")
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
_print("退出保活")
return True
else:
_print("上次会话已失效,重新登录...")
except Exception:
pass
all_macs = []
if saved and saved.get("mac"):
all_macs.append(saved["mac"])
_print(f"[上次MAC] {saved['mac']}")
portal_mac = get_portal_mac()
if portal_mac:
all_macs.append(portal_mac)
_print(f"[Portal MAC] {portal_mac}")
local_macs = get_all_macs()
for m in local_macs:
if m not in all_macs:
all_macs.append(m)
_print(f"[扫描] 发现 {len(local_macs)} 个本地网卡MAC:")
for i, m in enumerate(local_macs):
print(f" {i+1}. {m}")
qs = get_query_string()
if qs:
_print(f"[queryString] {qs[:80]}...")
fallbacks = ["111111111111", "000000000000", "ffffffffffff",
"111111111", "123456789abc", "abcdef123456",
"000000000001", "deadbeef0000"]
for m in fallbacks:
if m not in all_macs:
all_macs.append(m)
_print(f"共 {len(all_macs)} 种MAC方案,最多尝试 {MAX_ATTEMPTS} 次\n")
last_msg = ""
mac_index = 0
delay = SLEEP_SECONDS
consecutive_fails = 0
for attempt in range(1, MAX_ATTEMPTS + 1):
mac = all_macs[mac_index % len(all_macs)]
mac_index += 1
if attempt % (len(all_macs) + 1) == 0:
fresh = get_all_macs()
for m in fresh:
if m not in all_macs:
all_macs.append(m)
_print(f"[新增MAC] {m}")
print(f"[{attempt:02d}/{MAX_ATTEMPTS}] MAC={mac:14s}", end=" ")
ok, msg, user_idx, keep_interval = do_login(mac, query_string=qs)
print(f"→ {msg}")
if ok:
_print(f"登录成功!MAC={mac}")
ping_test()
save_state(mac, user_idx)
if user_idx:
_print(f"userIndex: {user_idx}")
register_mac(mac, user_idx)
start_keepalive(user_idx, keep_interval)
_print("按 Ctrl+C 退出保活...")
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
global _skip_wait
_skip_wait = True
_print("退出保活")
return True
consecutive_fails += 1
if msg != last_msg:
delay = SLEEP_SECONDS
consecutive_fails = 0
else:
delay = min(SLEEP_SECONDS * (1.5 ** (consecutive_fails // 3)), 60)
last_msg = msg
if attempt % 5 == 0 and is_authenticated():
_print("检测到已认证重定向!")
return True
time.sleep(delay)
if is_authenticated():
_print("最终检查:已认证")
return True
_print(f"{MAX_ATTEMPTS} 次尝试失败。最后: {last_msg}", "ERROR")
return False
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
except Exception as e:
_print(f"异常退出: {e}", "ERROR")
_wait_exit()