demo_login.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. """
  2. 996盒子 Web 通行证 - 最小单文件 Demo
  3. =====================================
  4. 目的:验证 POC 报告中的技术细节,确认能跑通账号密码登录。
  5. 用法:
  6. python demo_login.py --login # 触发登录,输入账号密码
  7. python demo_login.py --check # 用已保存的 token 探测登录态
  8. python demo_login.py --clear # 清掉本地保存的 token
  9. python demo_login.py --info # 打印 session 文件信息
  10. 注意:
  11. - 这是 DEMO,token 明文存到本地(不带加密),不要在生产环境用
  12. - 第一次跑 --login 前,先用环境变量或编辑下面的 CONFIG 配置游戏参数
  13. - 如果服务端返回 "请先通过滑块验证" 等错误,说明你这次触发了风控,
  14. 换 IP / 等几小时 / 用浏览器登录一次后再试
  15. 依赖:
  16. pip install requests
  17. """
  18. import argparse
  19. import hashlib
  20. import hmac
  21. import json
  22. import os
  23. import sys
  24. import time
  25. import urllib.parse
  26. from pathlib import Path
  27. try:
  28. import requests
  29. except ImportError:
  30. print("缺少依赖,请先运行: pip install requests")
  31. sys.exit(1)
  32. # ============================================================================
  33. # 配置区 - 根据你 996盒子登录页 URL 的参数填
  34. # ============================================================================
  35. # 登录页 URL 形如:
  36. # https://land.996box.com/chuanshi-login/chuanshi-login.html?ID=...&appid=155342&channel=PC%E5%8C%85&gameid=214256&promote_id=261888&sdkappid=2zn9bGS0DlJW&type=pc
  37. #
  38. # 下面的值是从示例 URL 抽出来的,你必须按你自己的 URL 替换
  39. CONFIG = {
  40. # 996盒子登录接口域(前端源码里写死的)
  41. "register_url": "https://user-sdkv2.dhsf.996db.cn",
  42. # 从你登录页 URL 拿
  43. "appid": 155342, # &appid=
  44. "game_id": 214256, # &gameid=
  45. "promote_id": 261888, # &promote_id=
  46. "sdkappid": "2zn9bGS0DlJW", # &sdkappid=
  47. "sdkchannel": "PC包", # &channel= (URL 解码后)
  48. "sdkid": "", # &ID= (留空即可,会自己生成)
  49. # 固定值
  50. "vType": "csvKey",
  51. # 签名 key(前端 JS 硬编码,所有人共用同一个)
  52. "SIGN_KEY": "k1ebbkxlojjdbnffnp3w2ykk3p625xnn",
  53. }
  54. # 本地 session 文件位置
  55. SESSION_DIR = Path(os.environ.get("APPDATA", str(Path.home()))) / "chuanshi_demo"
  56. SESSION_FILE = SESSION_DIR / "session.json"
  57. # ============================================================================
  58. # 核心工具
  59. # ============================================================================
  60. def key_sort(params: dict) -> str:
  61. """复现前端 keySort:按 key 字典序升序,拼成 key1=val1&key2=val2&..."""
  62. return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
  63. def hmac_str(sign_str: str, key: str) -> str:
  64. """复现前端 Hmacstr:HmacSHA1 后 hex 再 URL encode"""
  65. sig = hmac.new(key.encode("utf-8"), sign_str.encode("utf-8"), hashlib.sha1).hexdigest()
  66. return urllib.parse.quote(sig, safe="")
  67. def make_signature(params: dict, key: str) -> str:
  68. """在 params 上加 sign 字段"""
  69. out = dict(params)
  70. out["sign"] = hmac_str(key_sort(out), key)
  71. return out
  72. def make_device_info(sdkappid: str, sdkchannel: str, sdkid: str) -> str:
  73. """复现前端 Escalation:构造 PC 模式的 _device_info"""
  74. data = {
  75. "net_type": "UNKNOWN",
  76. "app_ver": "1.0.0",
  77. "appid": sdkappid,
  78. "channel": sdkchannel,
  79. "sdk_ver": "1.0.0",
  80. "device": {
  81. "type": "PC",
  82. "model": "pc",
  83. "id": sdkid or "demo-device-id-not-real",
  84. "id1": "",
  85. "id2": "",
  86. "id3": "",
  87. "os": "Win11",
  88. "width": 1920,
  89. "height": 1080,
  90. "lang": "zh-CN",
  91. "net_type": "UNKNOWN",
  92. },
  93. }
  94. return urllib.parse.quote(json.dumps(data, ensure_ascii=False), safe="")
  95. # ============================================================================
  96. # 登录
  97. # ============================================================================
  98. def login(username: str, password: str) -> dict:
  99. """调用 /api/user/v5w/login,返回完整响应 dict"""
  100. cfg = CONFIG
  101. params = {
  102. "username": username,
  103. "password": password,
  104. "game_id": cfg["game_id"],
  105. "vType": cfg["vType"],
  106. "appid": cfg["appid"],
  107. "promote_id": cfg["promote_id"],
  108. "_device_info": make_device_info(
  109. cfg["sdkappid"], cfg["sdkchannel"], cfg["sdkid"]
  110. ),
  111. }
  112. signed = make_signature(params, cfg["SIGN_KEY"])
  113. url = f"{cfg['register_url']}/api/user/v5w/login"
  114. print(f"[POST] {url}")
  115. print(f"[BODY] {json.dumps(signed, ensure_ascii=False, indent=2)[:500]}...")
  116. r = requests.post(url, json=signed, timeout=15)
  117. print(f"[STATUS] {r.status_code}")
  118. try:
  119. body = r.json()
  120. except Exception:
  121. body = {"raw": r.text}
  122. print(f"[RESPONSE] {json.dumps(body, ensure_ascii=False, indent=2)[:1500]}")
  123. return body
  124. # ============================================================================
  125. # 探测登录态
  126. # ============================================================================
  127. def check_session(token: str) -> dict:
  128. """
  129. 用 token 调 /api/user/identification,看返回。
  130. - 服务端要求这个接口带 GET-X-Token,token 错的话会拒
  131. - 业务逻辑里这个接口是提交实名认证的,但只发 GET-X-Token 不发实名数据
  132. 也能让服务端做 token 校验
  133. """
  134. cfg = CONFIG
  135. params = {
  136. "vType": cfg["vType"],
  137. "appid": cfg["appid"],
  138. "promote_id": cfg["promote_id"],
  139. "_device_info": make_device_info(
  140. cfg["sdkappid"], cfg["sdkchannel"], cfg["sdkid"]
  141. ),
  142. }
  143. signed = make_signature(params, cfg["SIGN_KEY"])
  144. url = f"{cfg['register_url']}/api/user/identification?GET-X-Token={token}"
  145. print(f"[POST] {url}")
  146. r = requests.post(url, json=signed, timeout=15)
  147. print(f"[STATUS] {r.status_code}")
  148. try:
  149. body = r.json()
  150. except Exception:
  151. body = {"raw": r.text}
  152. print(f"[RESPONSE] {json.dumps(body, ensure_ascii=False, indent=2)[:1500]}")
  153. return body
  154. # ============================================================================
  155. # Session 存储(DEMO 阶段明文存,不加密)
  156. # ============================================================================
  157. def save_session(data: dict) -> None:
  158. SESSION_DIR.mkdir(parents=True, exist_ok=True)
  159. data["_saved_at"] = int(time.time())
  160. SESSION_FILE.write_text(
  161. json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
  162. )
  163. print(f"[OK] Session 已保存到 {SESSION_FILE}")
  164. def load_session() -> dict | None:
  165. if not SESSION_FILE.exists():
  166. return None
  167. try:
  168. return json.loads(SESSION_FILE.read_text(encoding="utf-8"))
  169. except Exception as e:
  170. print(f"[WARN] 读取 session 失败: {e}")
  171. return None
  172. def clear_session() -> None:
  173. if SESSION_FILE.exists():
  174. SESSION_FILE.unlink()
  175. print(f"[OK] Session 已清掉: {SESSION_FILE}")
  176. else:
  177. print(f"[INFO] 没有 session 文件: {SESSION_FILE}")
  178. # ============================================================================
  179. # CLI
  180. # ============================================================================
  181. def prompt_account():
  182. print("=" * 60)
  183. print("首次登录 - 请输入账号密码(输入将用于本次登录,不保存)")
  184. print("=" * 60)
  185. username = input("账号: ").strip()
  186. password = input("密码: ").strip()
  187. return username, password
  188. def cmd_login():
  189. session = load_session()
  190. if session and session.get("token"):
  191. print(f"[INFO] 已有 session: uid={session.get('uid')}, "
  192. f"username={session.get('username')}")
  193. ans = input("是否重新登录?(y/N): ").strip().lower()
  194. if ans != "y":
  195. return
  196. username, password = prompt_account()
  197. if not username or not password:
  198. print("[ERROR] 账号密码不能为空")
  199. return
  200. resp = login(username, password)
  201. if resp.get("code") == 200 and resp.get("data", {}).get("token"):
  202. data = resp["data"]
  203. save_session({
  204. "token": data["token"],
  205. "uid": data.get("uid"),
  206. "username": data.get("username"),
  207. "mobile": data.get("mobile"),
  208. "raw": data,
  209. })
  210. print()
  211. print("=" * 60)
  212. print("✅ 登录成功!")
  213. print(f" token = {data['token'][:8]}...{data['token'][-4:]}")
  214. print(f" uid = {data.get('uid')}")
  215. print(f" 账号 = {data.get('username')}")
  216. print(f" 手机 = {data.get('mobile')}")
  217. print("=" * 60)
  218. print()
  219. print("下一步可以跑:")
  220. print(" python demo_login.py --check # 用 token 探测登录态")
  221. else:
  222. print()
  223. print("=" * 60)
  224. print("❌ 登录失败")
  225. print(f" code = {resp.get('code')}")
  226. print(f" msg = {resp.get('msg')}")
  227. print("=" * 60)
  228. if "极验" in str(resp) or "captcha" in str(resp).lower() or "jiyan" in str(resp).lower():
  229. print()
  230. print("⚠️ 触发了滑块验证。本次 POC 暂不处理滑块,")
  231. print(" 你可以:")
  232. print(" 1) 换 IP / 用浏览器手动登录一次后再试")
  233. print(" 2) 等几小时风控降级")
  234. print(" 3) 启动 Playwright 走半自动重登(见报告)")
  235. def cmd_check():
  236. session = load_session()
  237. if not session or not session.get("token"):
  238. print("[ERROR] 没有 session,请先跑 --login")
  239. return
  240. token = session["token"]
  241. print(f"[INFO] 用 uid={session.get('uid')} 的 token 探测登录态")
  242. print(f"[INFO] token = {token[:8]}...{token[-4:]}")
  243. print()
  244. resp = check_session(token)
  245. print()
  246. print("=" * 60)
  247. code = resp.get("code")
  248. msg = resp.get("msg", "")
  249. if code == 200:
  250. print("✅ Token 有效(接口返回 200)")
  251. elif code in (401, 403) or "token" in msg.lower() or "登录" in msg or "失效" in msg:
  252. print("❌ Token 失效或已过期,请重新跑 --login")
  253. else:
  254. print(f"⚠️ 返回 code={code}, msg={msg!r}")
  255. print(" 如果是 '参数错误'/'缺少参数' 类,说明 token 有效,")
  256. print(" 只是这个接口需要更多 body 参数。")
  257. print("=" * 60)
  258. def cmd_clear():
  259. clear_session()
  260. def cmd_info():
  261. session = load_session()
  262. if not session:
  263. print(f"[INFO] 没有 session 文件: {SESSION_FILE}")
  264. return
  265. print(f"[INFO] Session 文件: {SESSION_FILE}")
  266. print(f" uid = {session.get('uid')}")
  267. print(f" username = {session.get('username')}")
  268. print(f" mobile = {session.get('mobile')}")
  269. print(f" token = {session.get('token', '')[:8]}..."
  270. f"{session.get('token', '')[-4:] if session.get('token') else ''}")
  271. print(f" saved_at = {session.get('_saved_at')}")
  272. if session.get("_saved_at"):
  273. age_hours = (time.time() - session["_saved_at"]) / 3600
  274. print(f" 距今 = {age_hours:.1f} 小时")
  275. def main():
  276. parser = argparse.ArgumentParser(
  277. description="996盒子 Web 通行证 - 最小单文件 Demo",
  278. formatter_class=argparse.RawDescriptionHelpFormatter,
  279. epilog=__doc__,
  280. )
  281. parser.add_argument("--login", action="store_true", help="执行登录流程")
  282. parser.add_argument("--check", action="store_true", help="探测已保存 token 是否有效")
  283. parser.add_argument("--clear", action="store_true", help="清掉本地 session")
  284. parser.add_argument("--info", action="store_true", help="显示当前 session 信息")
  285. args = parser.parse_args()
  286. if args.login:
  287. cmd_login()
  288. elif args.check:
  289. cmd_check()
  290. elif args.clear:
  291. cmd_clear()
  292. elif args.info:
  293. cmd_info()
  294. else:
  295. parser.print_help()
  296. if __name__ == "__main__":
  297. main()