mock_sso_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. """Mock 单点登录门户模拟(开发联调用,stdlib 零依赖)。
  2. 模拟真实环境的"门户系统"(大系统):维护门户用户库与登录态,
  3. 界面上的"模拟登录/模拟退出"直接驱动本地 sso-proxy 颁发的 token:
  4. 模拟登录 → 随机生成 accessToken/refreshToken 挂到该用户,
  5. 并把该用户设为 proxy 当前颁发的登录态(currentUserId)
  6. 模拟退出 → 清除该用户的 token;若正是当前登录态则一并清空
  7. 与 tools/mock-sso-proxy 的联动走共享状态文件 sso-state.json(本服务
  8. 拥有并原子写入,proxy 每次请求重读;文件不存在时 proxy 回落内置实例)。
  9. 用法:python tools/mock-sso-service/mock_sso_service.py
  10. 界面:http://127.0.0.1:8890/
  11. """
  12. from __future__ import annotations
  13. import json
  14. import os
  15. import secrets
  16. import threading
  17. from datetime import datetime, timezone
  18. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  19. from pathlib import Path
  20. SERVICE_PORT = 8890
  21. STATE_FILE = Path(__file__).resolve().parent / "sso-state.json"
  22. # 首次启动写入的预置用户(对齐 proxy 原内置实例,无 token 即未登录)
  23. _INITIAL_STATE = {
  24. "users": [
  25. {
  26. "userId": "9001",
  27. "username": "zhangsan",
  28. "realName": "张三",
  29. "sex": "1",
  30. "deptId": "100",
  31. "createTime": "2019-03-20T15:04:10.000+0000",
  32. "cardId": "",
  33. "accessToken": None,
  34. "refreshToken": None,
  35. "tokenIssuedAt": None,
  36. },
  37. {
  38. "userId": "9002",
  39. "username": "lisi",
  40. "realName": "李四",
  41. "sex": "0",
  42. "deptId": "101",
  43. "createTime": "2020-06-01T09:00:00.000+0000",
  44. "cardId": "",
  45. "accessToken": None,
  46. "refreshToken": None,
  47. "tokenIssuedAt": None,
  48. },
  49. ],
  50. # proxy 当前颁发的登录态用户;null = 门户未登录(proxy 颁发 401 信封)
  51. "currentUserId": None,
  52. }
  53. # 读改写串行化(ThreadingHTTPServer 多线程请求共享状态)
  54. _lock = threading.Lock()
  55. # ------------------------------------------------------------ 状态存取
  56. def _read_state() -> dict:
  57. """读状态(不加锁):首次访问落预置文件;已持锁的调用方统一走本函数避免重入死锁。"""
  58. if not STATE_FILE.exists():
  59. _write_state_unlocked(_INITIAL_STATE)
  60. return json.loads(json.dumps(_INITIAL_STATE))
  61. return json.loads(STATE_FILE.read_text(encoding="utf-8"))
  62. def _load_state() -> dict:
  63. with _lock:
  64. return _read_state()
  65. def _write_state_unlocked(state: dict) -> None:
  66. # 原子写:proxy 侧多线程重读,半截文件会解析失败
  67. temp = STATE_FILE.with_suffix(".json.tmp")
  68. temp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
  69. os.replace(temp, STATE_FILE)
  70. def _find_user(state: dict, user_id: str) -> dict | None:
  71. for user in state["users"]:
  72. if user["userId"] == user_id:
  73. return user
  74. return None
  75. def _issue_tokens(user: dict) -> None:
  76. user["accessToken"] = secrets.token_hex(16)
  77. user["refreshToken"] = secrets.token_hex(16)
  78. user["tokenIssuedAt"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "+0000"
  79. def _clear_tokens(user: dict) -> None:
  80. user["accessToken"] = None
  81. user["refreshToken"] = None
  82. user["tokenIssuedAt"] = None
  83. # ------------------------------------------------------------ 内嵌前端
  84. _PAGE = """<!DOCTYPE html>
  85. <html lang="zh-CN">
  86. <head>
  87. <meta charset="utf-8">
  88. <title>Mock 单点登录门户模拟</title>
  89. <style>
  90. body { font-family: "Microsoft YaHei", sans-serif; font-size: 16px; margin: 0;
  91. background: #eef3f8; color: #1e3a5f; }
  92. .wrap { max-width: 1180px; margin: 0 auto; padding: 20px; }
  93. h1 { font-size: 22px; margin: 0 0 6px; }
  94. .sub { color: #5b7695; font-size: 14px; margin-bottom: 16px; }
  95. .banner { background: #fff; border: 1px solid #d6e0ec; border-radius: 8px;
  96. padding: 12px 16px; margin-bottom: 16px; display: flex;
  97. align-items: center; gap: 12px; flex-wrap: wrap; }
  98. .badge { padding: 3px 12px; border-radius: 999px; font-weight: bold; font-size: 14px; }
  99. .badge.on { background: #e3f2e9; color: #1b7a3d; }
  100. .badge.off { background: #f3e6e6; color: #a33; }
  101. form, table { background: #fff; border: 1px solid #d6e0ec; border-radius: 8px; }
  102. form { display: flex; gap: 10px; padding: 12px 16px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
  103. form label { font-size: 14px; color: #5b7695; }
  104. input, select { font-size: 16px; padding: 5px 8px; border: 1px solid #c4d2e3;
  105. border-radius: 4px; }
  106. table { width: 100%; border-collapse: collapse; }
  107. th { background: #1e3a5f; color: #fff; font-size: 15px; padding: 9px 10px; text-align: left; }
  108. td { padding: 9px 10px; border-bottom: 1px solid #e4ebf3; font-size: 15px; }
  109. tr.current td { background: #f2f8ff; }
  110. .token { font-family: Consolas, monospace; font-size: 13px; color: #47678c;
  111. cursor: copy; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
  112. white-space: nowrap; display: inline-block; vertical-align: middle; }
  113. button { font-size: 14px; padding: 5px 12px; border: 1px solid #c4d2e3;
  114. border-radius: 4px; background: #fff; cursor: pointer; }
  115. button:hover { background: #f0f5fb; }
  116. button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
  117. button.primary:hover { background: #1d4fd7; }
  118. button.danger { color: #b33; border-color: #ddb3b3; }
  119. .muted { color: #93a7bd; }
  120. #toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
  121. background: #1e3a5f; color: #fff; padding: 8px 20px; border-radius: 6px;
  122. font-size: 14px; display: none; }
  123. </style>
  124. </head>
  125. <body>
  126. <div class="wrap">
  127. <h1>Mock 单点登录门户模拟</h1>
  128. <div class="sub">模拟门户系统:管理用户与登录态,「模拟登录/退出」直接写入本地 sso-proxy 的 token 存储(sso-state.json)</div>
  129. <div class="banner">
  130. <span>当前登录态:</span><span id="current" class="badge off">未登录</span>
  131. <span class="muted">proxy 颁发地址 http://127.0.0.1:8080/middle/token/get.json</span>
  132. </div>
  133. <form id="add">
  134. <label>新增用户</label>
  135. <input name="username" placeholder="用户名(必填)" required>
  136. <input name="realName" placeholder="姓名(必填)" required>
  137. <select name="sex"><option value="1">男</option><option value="0">女</option></select>
  138. <input name="deptId" placeholder="部门ID" style="width:90px">
  139. <button class="primary" type="submit">添加</button>
  140. </form>
  141. <table>
  142. <thead><tr>
  143. <th>用户ID</th><th>用户名</th><th>姓名</th><th>性别</th><th>部门ID</th>
  144. <th>accessToken(点击复制)</th><th>refreshToken(点击复制)</th><th>操作</th>
  145. </tr></thead>
  146. <tbody id="rows"></tbody>
  147. </table>
  148. </div>
  149. <div id="toast"></div>
  150. <script>
  151. const $ = (id) => document.getElementById(id);
  152. function toast(text) {
  153. const el = $("toast");
  154. el.textContent = text;
  155. el.style.display = "block";
  156. setTimeout(() => (el.style.display = "none"), 1600);
  157. }
  158. async function call(method, path, body) {
  159. const res = await fetch(path, {
  160. method,
  161. headers: body ? { "Content-Type": "application/json" } : undefined,
  162. body: body ? JSON.stringify(body) : undefined,
  163. });
  164. const data = await res.json().catch(() => ({}));
  165. if (!res.ok) throw new Error(data.error || res.statusText);
  166. await render();
  167. return data;
  168. }
  169. function short(token) {
  170. if (!token) return "—";
  171. return token.length <= 16 ? token : token.slice(0, 10) + "…" + token.slice(-4);
  172. }
  173. function tokenCell(token) {
  174. if (!token) return '<span class="muted">—</span>';
  175. return `<span class="token" title="${token}(点击复制)">${short(token)}</span>`;
  176. }
  177. async function render() {
  178. const state = await (await fetch("/api/state")).json();
  179. $("current").textContent = state.currentUserId
  180. ? "已登录:" + state.users.find((u) => u.userId === state.currentUserId)?.realName
  181. : "未登录";
  182. $("current").className = "badge " + (state.currentUserId ? "on" : "off");
  183. $("rows").innerHTML = state.users.map((u) => `
  184. <tr class="${u.userId === state.currentUserId ? "current" : ""}">
  185. <td>${u.userId}</td>
  186. <td>${u.username}</td>
  187. <td>${u.realName}</td>
  188. <td>${u.sex === "1" ? "男" : "女"}</td>
  189. <td>${u.deptId || "—"}</td>
  190. <td>${tokenCell(u.accessToken)}</td>
  191. <td>${tokenCell(u.refreshToken)}</td>
  192. <td style="white-space:nowrap">
  193. <button class="primary" onclick="login('${u.userId}')">模拟登录</button>
  194. <button onclick="logout('${u.userId}')" ${u.accessToken ? "" : "disabled"}>模拟退出</button>
  195. <button class="danger" onclick="remove('${u.userId}')">删除</button>
  196. </td>
  197. </tr>`).join("");
  198. }
  199. async function login(id) {
  200. await call("POST", `/api/users/${id}/login`);
  201. toast("已模拟登录并设为 proxy 当前登录态");
  202. }
  203. async function logout(id) {
  204. await call("POST", `/api/users/${id}/logout`);
  205. toast("已模拟退出并清除 token");
  206. }
  207. async function remove(id) {
  208. if (!confirm("确认删除该用户?")) return;
  209. await call("DELETE", `/api/users/${id}`);
  210. toast("已删除");
  211. }
  212. document.addEventListener("click", (e) => {
  213. if (e.target.classList?.contains("token")) {
  214. navigator.clipboard?.writeText(e.target.title.split("(")[0]);
  215. toast("token 已复制");
  216. }
  217. });
  218. $("add").addEventListener("submit", async (e) => {
  219. e.preventDefault();
  220. const form = new FormData(e.target);
  221. try {
  222. await call("POST", "/api/users", {
  223. username: form.get("username").trim(),
  224. realName: form.get("realName").trim(),
  225. sex: form.get("sex"),
  226. deptId: form.get("deptId").trim(),
  227. });
  228. e.target.reset();
  229. toast("已添加用户");
  230. } catch (err) {
  231. toast("添加失败:" + err.message);
  232. }
  233. });
  234. render();
  235. </script>
  236. </body>
  237. </html>
  238. """
  239. # ------------------------------------------------------------ HTTP 服务
  240. class Handler(BaseHTTPRequestHandler):
  241. """管理界面与状态 API。"""
  242. server_version = "MockSsoService/0.1"
  243. def log_message(self, fmt, *args): # noqa: A003 - 保留 stdlib 签名
  244. print(f"[mock-sso-service] {self.address_string()} {fmt % args}")
  245. def _write(self, payload, status: int = 200) -> None:
  246. body = payload if isinstance(payload, bytes) else json.dumps(
  247. payload, ensure_ascii=False).encode("utf-8")
  248. self.send_response(status)
  249. self.send_header("Content-Type", "application/json; charset=utf-8")
  250. self.send_header("Content-Length", str(len(body)))
  251. self.end_headers()
  252. self.wfile.write(body)
  253. def _error(self, status: int, message: str) -> None:
  254. self._write({"error": message}, status)
  255. def _read_body(self) -> dict:
  256. length = int(self.headers.get("Content-Length") or 0)
  257. if length <= 0:
  258. return {}
  259. return json.loads(self.rfile.read(length).decode("utf-8"))
  260. # ------------------------------------------------------------------ 路由
  261. def do_GET(self) -> None: # noqa: N802 - stdlib 固定命名
  262. if self.path == "/" or self.path.startswith("/index"):
  263. body = _PAGE.encode("utf-8")
  264. self.send_response(200)
  265. self.send_header("Content-Type", "text/html; charset=utf-8")
  266. self.send_header("Content-Length", str(len(body)))
  267. self.end_headers()
  268. self.wfile.write(body)
  269. return
  270. if self.path == "/api/state":
  271. self._write(_load_state())
  272. return
  273. self._error(404, "not found")
  274. def do_POST(self) -> None: # noqa: N802
  275. if self.path == "/api/users":
  276. body = self._read_body()
  277. username = str(body.get("username") or "").strip()
  278. real_name = str(body.get("realName") or "").strip()
  279. if not username or not real_name:
  280. self._error(400, "username与realName必填")
  281. return
  282. with _lock:
  283. state = _read_state()
  284. if any(u["username"] == username for u in state["users"]):
  285. self._error(409, f"用户名已存在:{username}")
  286. return
  287. state["users"].append({
  288. "userId": str(max(int(u["userId"]) for u in state["users"]) + 1),
  289. "username": username,
  290. "realName": real_name,
  291. "sex": "1" if str(body.get("sex") or "1") == "1" else "0",
  292. "deptId": str(body.get("deptId") or "").strip(),
  293. "createTime": datetime.now(timezone.utc).strftime(
  294. "%Y-%m-%dT%H:%M:%S.000+0000"),
  295. "cardId": "",
  296. "accessToken": None,
  297. "refreshToken": None,
  298. "tokenIssuedAt": None,
  299. })
  300. _write_state_unlocked(state)
  301. self._write({"ok": True})
  302. return
  303. # /api/users/<userId>/login | /logout
  304. parts = self.path.strip("/").split("/")
  305. if len(parts) == 4 and parts[0] == "api" and parts[1] == "users" and parts[3] in ("login", "logout"):
  306. action = parts[3]
  307. with _lock:
  308. state = _read_state()
  309. user = _find_user(state, parts[2])
  310. if user is None:
  311. self._error(404, "用户不存在")
  312. return
  313. if action == "login":
  314. _issue_tokens(user)
  315. state["currentUserId"] = user["userId"]
  316. else:
  317. _clear_tokens(user)
  318. if state.get("currentUserId") == user["userId"]:
  319. state["currentUserId"] = None
  320. _write_state_unlocked(state)
  321. self._write({"ok": True})
  322. return
  323. self._error(404, "not found")
  324. def do_DELETE(self) -> None: # noqa: N802
  325. parts = self.path.strip("/").split("/")
  326. if len(parts) == 3 and parts[0] == "api" and parts[1] == "users":
  327. with _lock:
  328. state = _read_state()
  329. user = _find_user(state, parts[2])
  330. if user is None:
  331. self._error(404, "用户不存在")
  332. return
  333. state["users"].remove(user)
  334. if state.get("currentUserId") == user["userId"]:
  335. state["currentUserId"] = None
  336. _write_state_unlocked(state)
  337. self._write({"ok": True})
  338. return
  339. self._error(404, "not found")
  340. def main() -> None:
  341. server = ThreadingHTTPServer(("127.0.0.1", SERVICE_PORT), Handler)
  342. _load_state() # 首次启动即落预置用户文件,proxy 可立即联动
  343. print(f"[mock-sso-service] 管理界面 http://127.0.0.1:{SERVICE_PORT}/")
  344. print(f"[mock-sso-service] 状态文件 {STATE_FILE}")
  345. print("[mock-sso-service] 模拟登录/退出将写入该文件,sso-proxy 每次请求实时重读,Ctrl+C 停止")
  346. try:
  347. server.serve_forever()
  348. except KeyboardInterrupt:
  349. pass
  350. if __name__ == "__main__":
  351. main()