zy-web-gate 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,9 +38,9 @@ npm install zy-web-gate
38
38
 
39
39
  ## 使用
40
40
 
41
- 在**挂载真实应用之前**调用 `ensureGate()`。它只在「已通过门禁」时 resolve,所以未通过时真实应用绝不会挂载(无内容闪现)。
41
+ 在**挂载真实应用之前**调用 `ensureGate({ env })`。它只在「已通过门禁」时 resolve,所以未通过时真实应用绝不会挂载(无内容闪现)。
42
42
 
43
- 校验接口已**内置在包里**,子站接入零参数即可。
43
+ > **`env` 必传**(取值 `dev` / `test`):地址分发接口按 `env` 返回对应环境的真实校验地址。不传或传非法值会**立即抛错**,不做自动探测。
44
44
 
45
45
  ### Vue3 + Vite
46
46
 
@@ -50,7 +50,8 @@ import { createApp } from "vue";
50
50
  import App from "./App.vue";
51
51
  import { ensureGate } from "zy-web-gate";
52
52
 
53
- await ensureGate();
53
+ // 用构建 mode 区分环境(dev 构建传 dev,test 构建传 test)
54
+ await ensureGate({ env: import.meta.env.MODE });
54
55
 
55
56
  createApp(App).mount("#app");
56
57
  ```
@@ -58,7 +59,7 @@ createApp(App).mount("#app");
58
59
  > 顶层 `await` 需要入口是 ESM module(Vite 默认满足)。若环境不支持顶层 await,用 `.then()`:
59
60
  >
60
61
  > ```js
61
- > ensureGate().then(() => {
62
+ > ensureGate({ env: import.meta.env.MODE }).then(() => {
62
63
  > createApp(App).mount("#app");
63
64
  > });
64
65
  > ```
@@ -68,19 +69,19 @@ createApp(App).mount("#app");
68
69
  ```js
69
70
  import { ensureGate } from "zy-web-gate";
70
71
 
71
- ensureGate().then(() => {
72
+ ensureGate({ env: import.meta.env.MODE }).then(() => {
72
73
  ReactDOM.createRoot(document.getElementById("root")).render(<App />);
73
74
  });
74
75
  ```
75
76
 
76
77
  ### 纯 HTML(通过 CDN,无需打包工具)
77
78
 
78
- 用 UMD 产物,全局变量为 `ZyWebGate`:
79
+ 用 UMD 产物,全局变量为 `ZyWebGate`。无构建工具时按部署环境手动指定 `env`:
79
80
 
80
81
  ```html
81
82
  <script src="https://unpkg.com/zy-web-gate"></script>
82
83
  <script>
83
- ZyWebGate.ensureGate().then(function () {
84
+ ZyWebGate.ensureGate({ env: "test" }).then(function () {
84
85
  document.getElementById("app").style.display = "";
85
86
  });
86
87
  </script>
@@ -91,7 +92,7 @@ ensureGate().then(() => {
91
92
  ```html
92
93
  <script type="module">
93
94
  import { ensureGate } from "https://unpkg.com/zy-web-gate?module";
94
- await ensureGate();
95
+ await ensureGate({ env: "test" });
95
96
  document.getElementById("app").style.display = "";
96
97
  </script>
97
98
  ```
@@ -106,10 +107,10 @@ ensureGate().then(() => {
106
107
  |---|---|
107
108
  | 方法 | `GET` |
108
109
  | URL | `src/discover.js` 的 `DISCOVER_URL`(写死在包内) |
109
- | 必带请求头 | `token: <DISCOVER_TOKEN>`(非安全凭证,仅提高扒取门槛) |
110
+ | 必带请求头 | `token` + `env`(按 `ensureGate({ env })` 取,dev/test 各一套,写死在包内;非安全凭证,仅提高扒取门槛) |
110
111
  | 成功 | HTTP 200,`{ "code": 200, "data": { "url": "真实校验地址" } }` |
111
112
 
112
- > 改真实地址:改分发接口(mock)的返回值即可,无需发新版本;改分发接口本身的地址才需改 `src/discover.js` 发新版本。本包每次校验都重新分发拉取,地址变更即时生效。
113
+ > 分发接口按 `env` 头返回对应环境的真实校验地址。改真实地址:改分发接口(mock)的返回值即可,无需发新版本;改分发接口本身的地址、token 或环境才需改 `src/discover.js` 发新版本。本包每次校验都重新分发拉取,地址变更即时生效。
113
114
 
114
115
  **第二级:密码校验**
115
116
 
@@ -120,8 +121,11 @@ ensureGate().then(() => {
120
121
  | 请求体 | `{ "password": "用户输入的密码" }` |
121
122
  | 通过 | HTTP 200,`{ "code": 0, "data": { "match": true, "token": "<JWT>" } }` |
122
123
  | 密码错 | 任何 `data.match !== true` 的响应(HTTP 401 也可) |
124
+ | 无权限 | 密码正确但对当前网站无授权:`data.match !== true` 且带 `message`(如 HTTP 403,`{ "code": 1, "message": "当前访问密码无权限访问当前网站", "data": { "match": false, "reason": "no_permission" } }`) |
123
125
 
124
- 判定规则:**只有「HTTP ok 且 `data.match === true`」算通过**,其余一律当密码错;通过时把 `data.token` 写入 cookie。网络/接口异常会单独提示「网络异常」而非「密码错误」。
126
+ 判定规则:**只有「HTTP ok 且 `data.match === true`」算通过**,其余一律当未通过;通过时把 `data.token` 写入 cookie。未通过时,若响应体带 `message` 字段则原样展示给用户(用于「无权限访问」等场景),否则提示「密码错误」;网络/接口异常单独提示「网络异常」。
127
+
128
+ > 后端可按请求 `Origin`(浏览器跨域自动携带、前端不可伪造)识别「当前网站」,从而实现「某密码只能用于某些网站」「某网站不受门控直接放行」等策略。本包无需为此传任何站点参数。
125
129
 
126
130
  **第三级:token 验签**(已有 cookie 时)
127
131
 
@@ -136,10 +140,11 @@ ensureGate().then(() => {
136
140
 
137
141
  ## 配置项
138
142
 
139
- `ensureGate()` 全部参数可选(校验接口已内置):
143
+ `ensureGate(options)`:除 `env` 外其余均可选。
140
144
 
141
145
  | 选项 | 默认 | 说明 |
142
146
  |---|---|---|
147
+ | `env` | **必传** | 环境名,取值 `dev` / `test`,用于地址分发接口区分环境。缺失/非法立即抛错 |
143
148
  | `cookieName` | `"zy_web_gate"` | 登录态 cookie 名(值为后端签发的 JWT,不可配) |
144
149
  | `cookieDomain` | 自动推断 | 父域;不传则由当前 host 推断(如 `a.example.com` → `example.com`)。localhost / IP 自动不写 Domain |
145
150
  | `maxAgeDays` | `7` | 登录态有效天数 |
@@ -170,9 +175,9 @@ logoutGate(); // 清除父域 cookie,下次进入任一子站会重新要求
170
175
 
171
176
  ## 新增子站如何纳入
172
177
 
173
- 纯前端方案没有「零接入自动保护」——每个子站都要接入本包(装包 + 入口 `await ensureGate()` 几行)。但因登录态是全子域共享 cookie,新站只要接入了,已验证用户进去不会再被拦。
178
+ 纯前端方案没有「零接入自动保护」——每个子站都要接入本包(装包 + 入口 `await ensureGate({ env })` 几行)。但因登录态是全子域共享 cookie,新站只要接入了,已验证用户进去不会再被拦。
174
179
 
175
- 校验接口已内置在包内,新站接入只需装包 + 入口调一行 `await ensureGate()`,可做成共享模板 / 脚手架复制即用。
180
+ 校验接口已内置在包内,新站接入只需装包 + 入口调一行 `await ensureGate({ env })`,可做成共享模板 / 脚手架复制即用。
176
181
 
177
182
  ## 本地验证
178
183
 
@@ -2,19 +2,21 @@
2
2
  * 向地址分发接口换取真实校验后端地址。
3
3
  *
4
4
  * 接口约定:
5
- * GET DISCOVER_URL header: { token: DISCOVER_TOKEN }
5
+ * GET DISCOVER_URL header: { token, env }(按环境取,见 ENV_HEADERS)
6
6
  * 成功 HTTP 200, { code:200, data:{ url:"真实校验地址" } }
7
7
  * 失败 HTTP 401, { code:401, data:{} }
8
8
  *
9
- * @param {object} [opts]
9
+ * @param {object} opts
10
+ * @param {string} opts.env 环境名,必传,取值 dev / test
10
11
  * @param {number} [opts.timeoutMs=10000]
11
12
  * @returns {Promise<string>} 真实校验后端地址(已去除末尾斜杠)
12
- * @throws {Error} 网络异常、未授权或返回里没有 url 时抛出
13
+ * @throws {Error} env 缺失/非法、网络异常、未授权或返回里没有 url 时抛出
13
14
  */
14
15
  export function discoverBaseUrl(opts?: {
16
+ env: string;
15
17
  timeoutMs?: number | undefined;
16
18
  }): Promise<string>;
17
19
  /** 地址分发接口 URL(写死在包内,改地址只改这里并发新版本)。 */
18
- export const DISCOVER_URL: "https://m1.apifoxmock.com/m1/6205743-5899102-default/getWebRateAddress";
19
- /** 换取真实地址必带的固定 token(非安全凭证,仅提高扒取门槛)。 */
20
- export const DISCOVER_TOKEN: "qB8pgQh*pcbBRf3P";
20
+ export const DISCOVER_URL: "https://m1.apifoxmock.com/m1/6205743-5899102-default/getMyWebUrl";
21
+ /** 支持的环境名列表,用于报错提示。 */
22
+ export const SUPPORTED_ENVS: string[];
package/dist/index.d.ts CHANGED
@@ -64,5 +64,9 @@ export type GateOptions = {
64
64
  * 接口超时毫秒,默认 10000
65
65
  */
66
66
  timeoutMs?: number | undefined;
67
+ /**
68
+ * 环境名(dev/test),必传,用于地址分发接口区分环境
69
+ */
70
+ env: string;
67
71
  };
68
72
  export { inferParentDomain, readCookie } from "./cookie.js";
@@ -49,7 +49,7 @@ async function i(e, t, n = {}) {
49
49
  } : {
50
50
  ok: !1,
51
51
  networkError: !1,
52
- message: "密码错误"
52
+ message: s && typeof s.message == "string" && s.message || "密码错误"
53
53
  };
54
54
  }
55
55
  async function a(e, t, n = {}) {
@@ -78,44 +78,55 @@ async function a(e, t, n = {}) {
78
78
  }
79
79
  //#endregion
80
80
  //#region src/discover.js
81
- var o = "https://m1.apifoxmock.com/m1/6205743-5899102-default/getWebRateAddress", s = "qB8pgQh*pcbBRf3P";
82
- async function c(e = {}) {
83
- let { timeoutMs: t = 1e4 } = e, n = new AbortController(), r = setTimeout(() => n.abort(), t), i;
81
+ var o = "https://m1.apifoxmock.com/m1/6205743-5899102-default/getMyWebUrl", s = {
82
+ dev: {
83
+ token: "z9M2beGEawlsBF6O",
84
+ env: "dev"
85
+ },
86
+ test: {
87
+ token: "u3gnR2mpVjMXrnf1",
88
+ env: "test"
89
+ }
90
+ }, c = Object.keys(s);
91
+ async function l(e = {}) {
92
+ let { timeoutMs: t = 1e4, env: n } = e, r = s[n];
93
+ if (!r) throw Error(`zy-web-gate: 必须传入 env(${c.join(" / ")}),当前为 ${JSON.stringify(n)}`);
94
+ let i = new AbortController(), a = setTimeout(() => i.abort(), t), l;
84
95
  try {
85
- i = await fetch(o, {
96
+ l = await fetch(o, {
86
97
  method: "GET",
87
- headers: { token: s },
88
- signal: n.signal
98
+ headers: r,
99
+ signal: i.signal
89
100
  });
90
101
  } catch {
91
102
  throw Error("获取校验接口地址失败:网络异常");
92
103
  } finally {
93
- clearTimeout(r);
104
+ clearTimeout(a);
94
105
  }
95
- let a = null;
106
+ let u = null;
96
107
  try {
97
- a = await i.json();
108
+ u = await l.json();
98
109
  } catch {
99
- a = null;
110
+ u = null;
100
111
  }
101
- let c = a && a.data && a.data.url;
102
- if (!i.ok || a.code !== 200 || !c) throw Error("获取校验接口地址失败");
103
- return String(c).replace(/\/+$/, "");
112
+ let d = u && u.data && u.data.url;
113
+ if (!l.ok || u.code !== 200 || !d) throw Error("获取校验接口地址失败");
114
+ return String(d).replace(/\/+$/, "");
104
115
  }
105
116
  //#endregion
106
117
  //#region src/ui.js
107
- var l = "\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; align-items: center; justify-content: center;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.card {\n width: 320px; max-width: calc(100vw - 48px);\n padding: 32px 28px;\n background: #1b1e25; border-radius: 14px;\n box-shadow: 0 12px 40px rgba(0,0,0,.45);\n box-sizing: border-box;\n}\n.title { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: #f2f4f8; }\n.subtitle { margin: 0 0 22px; font-size: 13px; color: #8a92a6; line-height: 1.5; }\n.field { position: relative; }\n.input {\n width: 100%; box-sizing: border-box;\n padding: 11px 13px; font-size: 14px;\n color: #f2f4f8; background: #11141a;\n border: 1px solid #2c313c; border-radius: 9px; outline: none;\n transition: border-color .15s;\n}\n.input:focus { border-color: #4c8dff; }\n.btn {\n width: 100%; margin-top: 14px; padding: 11px 0;\n font-size: 14px; font-weight: 600; color: #fff; cursor: pointer;\n background: #4c8dff; border: none; border-radius: 9px;\n transition: background .15s, opacity .15s;\n}\n.btn:hover { background: #3a7df0; }\n.btn:disabled { opacity: .6; cursor: not-allowed; }\n.error {\n min-height: 18px; margin-top: 12px;\n font-size: 12.5px; color: #ff6b6b; line-height: 1.4;\n}\n";
108
- function u(e) {
118
+ var u = "\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; align-items: center; justify-content: center;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.card {\n width: 320px; max-width: calc(100vw - 48px);\n padding: 32px 28px;\n background: #1b1e25; border-radius: 14px;\n box-shadow: 0 12px 40px rgba(0,0,0,.45);\n box-sizing: border-box;\n}\n.title { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: #f2f4f8; }\n.subtitle { margin: 0 0 22px; font-size: 13px; color: #8a92a6; line-height: 1.5; }\n.field { position: relative; }\n.input {\n width: 100%; box-sizing: border-box;\n padding: 11px 13px; font-size: 14px;\n color: #f2f4f8; background: #11141a;\n border: 1px solid #2c313c; border-radius: 9px; outline: none;\n transition: border-color .15s;\n}\n.input:focus { border-color: #4c8dff; }\n.btn {\n width: 100%; margin-top: 14px; padding: 11px 0;\n font-size: 14px; font-weight: 600; color: #fff; cursor: pointer;\n background: #4c8dff; border: none; border-radius: 9px;\n transition: background .15s, opacity .15s;\n}\n.btn:hover { background: #3a7df0; }\n.btn:disabled { opacity: .6; cursor: not-allowed; }\n.error {\n min-height: 18px; margin-top: 12px;\n font-size: 12.5px; color: #ff6b6b; line-height: 1.4;\n}\n";
119
+ function d(e) {
109
120
  let t = document.createElement("div");
110
121
  t.setAttribute("data-zy-web-gate", "");
111
122
  let n = t.attachShadow({ mode: "open" }), r = document.createElement("style");
112
- r.textContent = l, n.appendChild(r);
123
+ r.textContent = u, n.appendChild(r);
113
124
  let i = document.createElement("div");
114
125
  i.className = "mask", i.innerHTML = "\n <div class=\"card\">\n <h1 class=\"title\"></h1>\n <p class=\"subtitle\"></p>\n <div class=\"field\">\n <input class=\"input\" type=\"password\" autocomplete=\"current-password\" />\n </div>\n <button class=\"btn\" type=\"button\"></button>\n <div class=\"error\" aria-live=\"polite\"></div>\n </div>\n ", n.appendChild(i), n.querySelector(".title").textContent = e.title, n.querySelector(".subtitle").textContent = e.subtitle;
115
126
  let a = n.querySelector(".input"), o = n.querySelector(".btn"), s = n.querySelector(".error");
116
127
  a.placeholder = e.placeholder, o.textContent = e.buttonText;
117
128
  let c = !1;
118
- async function u() {
129
+ async function l() {
119
130
  if (c) return;
120
131
  let t = a.value;
121
132
  if (!t) {
@@ -133,26 +144,26 @@ function u(e) {
133
144
  }
134
145
  r && r.ok ? d() : (s.textContent = r && r.message || "密码错误", a.select());
135
146
  }
136
- o.addEventListener("click", u), a.addEventListener("keydown", (e) => {
137
- e.key === "Enter" && u();
147
+ o.addEventListener("click", l), a.addEventListener("keydown", (e) => {
148
+ e.key === "Enter" && l();
138
149
  });
139
150
  function d() {
140
151
  t.remove();
141
152
  }
142
153
  return document.body.appendChild(t), setTimeout(() => a.focus(), 0), { destroy: d };
143
154
  }
144
- var d = "\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.spinner {\n width: 34px; height: 34px;\n border: 3px solid #2c313c;\n border-top-color: #4c8dff;\n border-radius: 50%;\n animation: zy-gate-spin .8s linear infinite;\n}\n.text { font-size: 13px; color: #8a92a6; }\n@keyframes zy-gate-spin { to { transform: rotate(360deg); } }\n";
145
- function f(e = {}) {
155
+ var f = "\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.spinner {\n width: 34px; height: 34px;\n border: 3px solid #2c313c;\n border-top-color: #4c8dff;\n border-radius: 50%;\n animation: zy-gate-spin .8s linear infinite;\n}\n.text { font-size: 13px; color: #8a92a6; }\n@keyframes zy-gate-spin { to { transform: rotate(360deg); } }\n";
156
+ function p(e = {}) {
146
157
  let { text: t = "正在验证访问权限…" } = e, n = document.createElement("div");
147
158
  n.setAttribute("data-zy-web-gate-loading", "");
148
159
  let r = n.attachShadow({ mode: "open" }), i = document.createElement("style");
149
- i.textContent = d, r.appendChild(i);
160
+ i.textContent = f, r.appendChild(i);
150
161
  let a = document.createElement("div");
151
162
  return a.className = "mask", a.innerHTML = "<div class=\"spinner\"></div><div class=\"text\"></div>", r.appendChild(a), r.querySelector(".text").textContent = t, document.body.appendChild(n), { destroy: () => n.remove() };
152
163
  }
153
164
  //#endregion
154
165
  //#region src/index.js
155
- var p = {
166
+ var m = {
156
167
  cookieName: "zy_web_gate",
157
168
  cookieDomain: void 0,
158
169
  maxAgeDays: 7,
@@ -163,28 +174,34 @@ var p = {
163
174
  placeholder: "访问密码",
164
175
  buttonText: "进入",
165
176
  loadingText: "正在验证访问权限…",
166
- timeoutMs: 1e4
177
+ timeoutMs: 1e4,
178
+ env: void 0
167
179
  };
168
- function m(t) {
180
+ function h(t) {
169
181
  let n = {
170
- ...p,
182
+ ...m,
171
183
  ...t || {}
172
184
  };
173
185
  return n.cookieDomain === void 0 && (n.cookieDomain = e()), n;
174
186
  }
175
- async function h(e) {
176
- let o = m(e), s = t(o.cookieName);
187
+ async function g(e) {
188
+ let o = h(e);
189
+ if (!o.env || !c.includes(o.env)) throw Error(`zy-web-gate: ensureGate 必须传入 env(${c.join(" / ")}),当前为 ${JSON.stringify(o.env)}`);
190
+ let s = t(o.cookieName);
177
191
  if (s) {
178
- let e = f({ text: o.loadingText });
192
+ let e = p({ text: o.loadingText });
179
193
  try {
180
- if (await a(await c({ timeoutMs: o.timeoutMs }), s, { timeoutMs: o.timeoutMs })) return;
194
+ if (await a(await l({
195
+ timeoutMs: o.timeoutMs,
196
+ env: o.env
197
+ }), s, { timeoutMs: o.timeoutMs })) return;
181
198
  } catch {} finally {
182
199
  e.destroy();
183
200
  }
184
201
  r(o.cookieName, { domain: o.cookieDomain });
185
202
  }
186
203
  return new Promise((e) => {
187
- u({
204
+ d({
188
205
  title: o.title,
189
206
  subtitle: o.subtitle,
190
207
  placeholder: o.placeholder,
@@ -192,7 +209,10 @@ async function h(e) {
192
209
  onSubmit: async (t) => {
193
210
  let r;
194
211
  try {
195
- r = await c({ timeoutMs: o.timeoutMs });
212
+ r = await l({
213
+ timeoutMs: o.timeoutMs,
214
+ env: o.env
215
+ });
196
216
  } catch {
197
217
  return {
198
218
  ok: !1,
@@ -213,14 +233,14 @@ async function h(e) {
213
233
  });
214
234
  });
215
235
  }
216
- function g(t = {}) {
236
+ function _(t = {}) {
217
237
  let n = {
218
- ...p,
238
+ ...m,
219
239
  ...t
220
240
  }, i = n.cookieDomain === void 0 ? e() : n.cookieDomain;
221
241
  r(n.cookieName, { domain: i });
222
242
  }
223
243
  //#endregion
224
- export { h as ensureGate, e as inferParentDomain, g as logoutGate, t as readCookie };
244
+ export { g as ensureGate, e as inferParentDomain, _ as logoutGate, t as readCookie };
225
245
 
226
246
  //# sourceMappingURL=zy-web-gate.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"zy-web-gate.js","names":[],"sources":["../src/cookie.js","../src/verify.js","../src/discover.js","../src/ui.js","../src/index.js"],"sourcesContent":["// 跨子域共享登录态的核心:父域 cookie 读写。\n//\n// 为什么必须用 cookie 而不是 localStorage:\n// localStorage 按 origin(scheme+host+port)隔离,a.example.com 写的东西\n// b.example.com 读不到,无法实现「一次验证、全子域通行」。\n// cookie 可以通过 Domain 属性设置到父域,被该域下所有子域共享读取。\n\n/**\n * 由当前 host 推断可用于跨子域共享的父域。\n *\n * 例如当前在 a.example.com,应返回 example.com,\n * 这样写入的 cookie 能被 my./b./x. 等所有兄弟子域读到。\n *\n * 注意:eu.org 在 Public Suffix List 上,浏览器禁止把 cookie 设到 eu.org,\n * 所以父域只能取到 example.com 这一层,刚好是我们要的、也是安全的。\n *\n * 这里采用「去掉最左一段」的朴素策略:a.example.com -> example.com。\n * 大多数「单层子域」场景够用;若有多级子域或想显式指定,用 options.cookieDomain 覆盖。\n *\n * @param {string} [hostname] 默认取 location.hostname\n * @returns {string} 用于 cookie Domain 的父域;localhost / IP 等场景返回空串(表示按当前 host)\n */\nexport function inferParentDomain(hostname) {\n const host = hostname || window.location.hostname;\n\n // localhost、单段主机名、IP 地址:不设 Domain,cookie 只对当前 host 生效。\n if (host === \"localhost\" || !host.includes(\".\")) return \"\";\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) return \"\";\n\n const parts = host.split(\".\");\n // 形如 example.com(两段):父域就是它自己。\n if (parts.length <= 2) return host;\n // 形如 a.example.com:去掉最左一段。\n return parts.slice(1).join(\".\");\n}\n\n/**\n * 读取指定名称的 cookie 值。\n * @param {string} name\n * @returns {string|null}\n */\nexport function readCookie(name) {\n const prefix = encodeURIComponent(name) + \"=\";\n const items = document.cookie ? document.cookie.split(\"; \") : [];\n for (const item of items) {\n if (item.indexOf(prefix) === 0) {\n return decodeURIComponent(item.slice(prefix.length));\n }\n }\n return null;\n}\n\n/**\n * 写入跨子域 cookie。\n *\n * @param {string} name\n * @param {string} value\n * @param {object} opts\n * @param {string} opts.domain cookie 的 Domain,传空串则不写 Domain(仅当前 host)\n * @param {number} opts.maxAgeSeconds 有效期(秒)\n * @param {string} [opts.sameSite=\"Lax\"]\n * @param {boolean} [opts.secure=true] HTTPS 下应为 true;本地 http 调试时会自动放宽\n * @param {string} [opts.path=\"/\"]\n */\nexport function writeCookie(name, value, opts) {\n const {\n domain,\n maxAgeSeconds,\n sameSite = \"Lax\",\n path = \"/\",\n } = opts;\n\n // 本地 http 调试时 Secure 会让 cookie 写不进去,这里按当前协议自动判断。\n const secure = opts.secure !== undefined\n ? opts.secure\n : window.location.protocol === \"https:\";\n\n let str =\n encodeURIComponent(name) + \"=\" + encodeURIComponent(value) +\n \"; path=\" + path +\n \"; max-age=\" + Math.floor(maxAgeSeconds) +\n \"; samesite=\" + sameSite;\n\n if (domain) str += \"; domain=\" + domain;\n if (secure) str += \"; secure\";\n\n document.cookie = str;\n}\n\n/**\n * 删除跨子域 cookie(用于「登出」)。Domain / Path 必须和写入时一致才能删掉。\n * @param {string} name\n * @param {object} opts\n * @param {string} opts.domain\n * @param {string} [opts.path=\"/\"]\n */\nexport function deleteCookie(name, opts) {\n const { domain, path = \"/\" } = opts;\n let str = encodeURIComponent(name) + \"=; path=\" + path + \"; max-age=0\";\n if (domain) str += \"; domain=\" + domain;\n document.cookie = str;\n}\n","// 第二/三级:向真实校验后端(gate-auth-server)校验密码与 token。\n//\n// 设计要点:真密码只存在后端,前端 bundle 里没有任何密码信息。\n// 校验通过后端会签发一枚 JWT,前端把它存进 cookie;再次进入时调 /check\n// 验签——攻击者没有服务端密钥,签不出合法 token,伪造 cookie 进不来。\n\n/**\n * 密码校验结果。\n * @typedef {object} VerifyResult\n * @property {boolean} ok 密码是否正确\n * @property {string|null} [token] 后端签发的 JWT(ok 时有值),写入 cookie\n * @property {boolean} [networkError] 是否因网络/接口异常导致(区别于「密码错」)\n * @property {string} [message] 可展示给用户的提示\n */\n\n/**\n * 第二级:调 `{baseUrl}/verify` 校验密码。\n *\n * 接口约定:\n * POST {baseUrl}/verify body: {\"password\":\"用户输入\"}\n * 通过 HTTP 200, { code:0, data:{ match:true, token:\"<JWT>\", expiresIn:604800 } }\n * 密码错 HTTP 401, { code:1, data:{ match:false } }\n *\n * 判定规则:只有「HTTP ok 且 data.match === true」才算通过;其余一律当密码错。\n * 网络层异常单独标记 networkError,便于 UI 区分提示。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} password\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<VerifyResult>}\n */\nexport async function verifyPassword(baseUrl, password, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/verify`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n // fetch 抛错只可能是网络层问题(断网、CORS、超时 abort 等),不是密码错。\n return { ok: false, networkError: true, message: \"网络异常,请稍后重试\" };\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n // 响应不是合法 JSON:当作密码错/异常处理,不放行。\n data = null;\n }\n\n const match = !!(resp.ok && data && data.data && data.data.match === true);\n\n if (match) {\n return { ok: true, token: (data.data && data.data.token) || null };\n }\n\n return { ok: false, networkError: false, message: \"密码错误\" };\n}\n\n/**\n * 第三级:调 `{baseUrl}/check` 验签 cookie 里的 JWT。\n *\n * 接口约定:\n * POST {baseUrl}/check header: { Authorization: \"Bearer <token>\" }\n * 有效 HTTP 200, { code:0, data:{ valid:true } }\n * 无效/过期/伪造 HTTP 401, { code:1, data:{ valid:false } }\n *\n * 用于「已有 cookie」分支:验过才放行,否则重新弹密码框。\n * 网络异常时返回 false(保守起见不放行),由调用方决定是否回退到弹框。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} token cookie 里存的 JWT\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<boolean>} token 是否有效\n */\nexport async function checkToken(baseUrl, token, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/check`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n return false;\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n return !!(resp.ok && data && data.data && data.data.valid === true);\n}\n","// 第一级:地址分发接口。用一个固定 token 换取真实的校验后端地址。\n//\n// 为什么要这一级:避免真实校验接口地址被写死在前端 bundle 字符串里。\n// 这里的 token 不是安全凭证——它本来就会出现在前端请求里,作用只是\n// 「让真实地址不直接出现在 bundle 里」,提高一点点扒取门槛。\n// 真正的安全靠第二/三级的 JWT 校验。\n//\n// 设计选择:不在内存缓存 data.url,每次校验都重新分发拉取,\n// 这样后端在 mock 里改地址即时生效。\n\n/** 地址分发接口 URL(写死在包内,改地址只改这里并发新版本)。 */\nexport const DISCOVER_URL =\n \"https://m1.apifoxmock.com/m1/6205743-5899102-default/getWebRateAddress\";\n\n/** 换取真实地址必带的固定 token(非安全凭证,仅提高扒取门槛)。 */\nexport const DISCOVER_TOKEN = \"qB8pgQh*pcbBRf3P\";\n\n/**\n * 向地址分发接口换取真实校验后端地址。\n *\n * 接口约定:\n * GET DISCOVER_URL header: { token: DISCOVER_TOKEN }\n * 成功 HTTP 200, { code:200, data:{ url:\"真实校验地址\" } }\n * 失败 HTTP 401, { code:401, data:{} }\n *\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<string>} 真实校验后端地址(已去除末尾斜杠)\n * @throws {Error} 网络异常、未授权或返回里没有 url 时抛出\n */\nexport async function discoverBaseUrl(opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(DISCOVER_URL, {\n method: \"GET\",\n headers: { token: DISCOVER_TOKEN },\n signal: controller.signal,\n });\n } catch {\n throw new Error(\"获取校验接口地址失败:网络异常\");\n } finally {\n clearTimeout(timer);\n }\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n const url = data && data.data && data.data.url;\n if (!resp.ok || data.code !== 200 || !url) {\n throw new Error(\"获取校验接口地址失败\");\n }\n\n // 去掉末尾斜杠,方便后续拼 `${base}/verify`、`${base}/check`。\n return String(url).replace(/\\/+$/, \"\");\n}\n","// 框架无关的密码输入页(原生 DOM)。\n//\n// 用 Shadow DOM 把样式和结构完全隔离,既不会污染子站的 CSS,\n// 也不会被子站的全局样式影响。任何前端项目(Vue / React / 纯 HTML)都能用。\n\nconst STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; align-items: center; justify-content: center;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.card {\n width: 320px; max-width: calc(100vw - 48px);\n padding: 32px 28px;\n background: #1b1e25; border-radius: 14px;\n box-shadow: 0 12px 40px rgba(0,0,0,.45);\n box-sizing: border-box;\n}\n.title { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: #f2f4f8; }\n.subtitle { margin: 0 0 22px; font-size: 13px; color: #8a92a6; line-height: 1.5; }\n.field { position: relative; }\n.input {\n width: 100%; box-sizing: border-box;\n padding: 11px 13px; font-size: 14px;\n color: #f2f4f8; background: #11141a;\n border: 1px solid #2c313c; border-radius: 9px; outline: none;\n transition: border-color .15s;\n}\n.input:focus { border-color: #4c8dff; }\n.btn {\n width: 100%; margin-top: 14px; padding: 11px 0;\n font-size: 14px; font-weight: 600; color: #fff; cursor: pointer;\n background: #4c8dff; border: none; border-radius: 9px;\n transition: background .15s, opacity .15s;\n}\n.btn:hover { background: #3a7df0; }\n.btn:disabled { opacity: .6; cursor: not-allowed; }\n.error {\n min-height: 18px; margin-top: 12px;\n font-size: 12.5px; color: #ff6b6b; line-height: 1.4;\n}\n`;\n\n/**\n * 创建并挂载密码页。返回一个对象,可用于销毁。\n *\n * @param {object} cfg\n * @param {string} cfg.title\n * @param {string} cfg.subtitle\n * @param {string} cfg.placeholder\n * @param {string} cfg.buttonText\n * @param {(password: string) => Promise<{ok: boolean, message?: string}>} cfg.onSubmit\n * 提交回调;返回 ok=true 时 UI 自动销毁,false 时显示 message。\n * @returns {{ destroy: () => void }}\n */\nexport function mountPasswordGate(cfg) {\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `\n <div class=\"card\">\n <h1 class=\"title\"></h1>\n <p class=\"subtitle\"></p>\n <div class=\"field\">\n <input class=\"input\" type=\"password\" autocomplete=\"current-password\" />\n </div>\n <button class=\"btn\" type=\"button\"></button>\n <div class=\"error\" aria-live=\"polite\"></div>\n </div>\n `;\n shadow.appendChild(mask);\n\n // 用 textContent 赋值,避免把配置文本当 HTML 注入。\n shadow.querySelector(\".title\").textContent = cfg.title;\n shadow.querySelector(\".subtitle\").textContent = cfg.subtitle;\n const input = shadow.querySelector(\".input\");\n const btn = shadow.querySelector(\".btn\");\n const errorEl = shadow.querySelector(\".error\");\n input.placeholder = cfg.placeholder;\n btn.textContent = cfg.buttonText;\n\n let submitting = false;\n\n async function submit() {\n if (submitting) return;\n const password = input.value;\n if (!password) {\n errorEl.textContent = \"请输入密码\";\n return;\n }\n\n submitting = true;\n btn.disabled = true;\n errorEl.textContent = \"\";\n const originalBtnText = btn.textContent;\n btn.textContent = \"验证中…\";\n\n let result;\n try {\n result = await cfg.onSubmit(password);\n } finally {\n submitting = false;\n btn.disabled = false;\n btn.textContent = originalBtnText;\n }\n\n if (result && result.ok) {\n destroy();\n } else {\n errorEl.textContent = (result && result.message) || \"密码错误\";\n input.select();\n }\n }\n\n btn.addEventListener(\"click\", submit);\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") submit();\n });\n\n function destroy() {\n host.remove();\n }\n\n document.body.appendChild(host);\n // 自动聚焦输入框,方便直接打字。\n setTimeout(() => input.focus(), 0);\n\n return { destroy };\n}\n\nconst LOADING_STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.spinner {\n width: 34px; height: 34px;\n border: 3px solid #2c313c;\n border-top-color: #4c8dff;\n border-radius: 50%;\n animation: zy-gate-spin .8s linear infinite;\n}\n.text { font-size: 13px; color: #8a92a6; }\n@keyframes zy-gate-spin { to { transform: rotate(360deg); } }\n`;\n\n/**\n * 挂载一个全屏 loading 遮罩,盖住「换地址 / 验签」等慢网空窗,避免白屏。\n *\n * 与密码页同一套深色背景和字体,所以它消失、密码页出现时观感是平滑过渡的。\n * 返回 destroy 用于在校验完成(放行或转入密码页)时移除。\n *\n * @param {object} [cfg]\n * @param {string} [cfg.text=\"正在验证访问权限…\"] 遮罩下方提示文案\n * @returns {{ destroy: () => void }}\n */\nexport function mountLoading(cfg = {}) {\n const { text = \"正在验证访问权限…\" } = cfg;\n\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate-loading\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = LOADING_STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `<div class=\"spinner\"></div><div class=\"text\"></div>`;\n shadow.appendChild(mask);\n shadow.querySelector(\".text\").textContent = text;\n\n document.body.appendChild(host);\n\n return { destroy: () => host.remove() };\n}\n","// zy-web-gate —— 纯前端「共享密码」页面访问门(两级接口 + JWT 方案)。\n//\n// 用法(在 Vue/React/任意前端入口、挂载真实应用之前调用):\n//\n// import { ensureGate } from \"zy-web-gate\";\n//\n// await ensureGate(); // 接口已固定在包内,零参数即可\n//\n// // 走到这里说明已通过门禁,再启动真实应用:\n// createApp(App).mount(\"#app\");\n//\n// 流程(详见 INTEGRATION.md):\n// 1. 已有 cookie(JWT)?-> 调 /check 验签,过则直接放行(无 UI)。\n// 2. 否则:GET 地址分发接口换真实校验地址 -> 弹密码框 -> POST /verify。\n// 3. 通过 -> 把后端签发的 JWT 写进父域 cookie -> 放行。\n//\n// 跨子域:验证通过后写父域 cookie(Domain=example.com),同主域其他子站\n// 读到该 cookie 并验签通过即直接放行,无需再次输入密码。\n//\n// 安全边界:纯前端门禁防的是「伪造 cookie 进 UI」与「照搬旧 =1 绕过」;\n// 挡不住绕过 UI 直接扒静态资源 / 直接打业务 API——真正的安全必须靠\n// 业务数据接口自己校验 token(即第三级 /check)。务必全链路走 HTTPS。\n\nimport { inferParentDomain, readCookie, writeCookie, deleteCookie } from \"./cookie.js\";\nimport { verifyPassword, checkToken } from \"./verify.js\";\nimport { discoverBaseUrl } from \"./discover.js\";\nimport { mountPasswordGate, mountLoading } from \"./ui.js\";\n\n/** @type {Required<GateOptions>} 默认配置 */\nconst DEFAULTS = {\n cookieName: \"zy_web_gate\",\n cookieDomain: undefined, // undefined 表示自动推断父域\n maxAgeDays: 7,\n sameSite: \"Lax\",\n secure: undefined, // undefined 表示按当前协议自动判断\n title: \"访问验证\",\n subtitle: \"请输入访问密码后继续。\",\n placeholder: \"访问密码\",\n buttonText: \"进入\",\n loadingText: \"正在验证访问权限…\",\n timeoutMs: 10000,\n};\n\n/**\n * @typedef {object} GateOptions\n * @property {string} [cookieName] 登录态 cookie 名,默认 \"zy_web_gate\";值为后端签发的 JWT\n * @property {string} [cookieDomain] 显式指定父域;不传则自动推断(如 example.com)\n * @property {number} [maxAgeDays] 登录态有效天数,默认 7\n * @property {string} [sameSite] cookie SameSite,默认 \"Lax\"\n * @property {boolean} [secure] cookie Secure;不传则按当前协议自动判断\n * @property {string} [title] 密码页标题\n * @property {string} [subtitle] 密码页副标题\n * @property {string} [placeholder] 输入框占位文案\n * @property {string} [buttonText] 按钮文案\n * @property {string} [loadingText] 已有 cookie 验签时的 loading 文案,默认 \"正在验证访问权限…\"\n * @property {number} [timeoutMs] 接口超时毫秒,默认 10000\n */\n\n/**\n * 解析最终配置。\n * @param {GateOptions} [options]\n */\nfunction resolveConfig(options) {\n const cfg = { ...DEFAULTS, ...(options || {}) };\n if (cfg.cookieDomain === undefined) {\n cfg.cookieDomain = inferParentDomain();\n }\n return cfg;\n}\n\n/**\n * 访问门主入口。\n *\n * 行为:\n * 1. 父域 cookie 已存在 JWT -> 换真实地址 -> 调 /check 验签;过则放行(无 UI)。\n * 验签不过 / 网络异常 -> 视作未登录,进入弹框流程。\n * 2. 弹出密码页,等用户输入并通过 /verify 校验。\n * 3. 校验通过 -> 把后端签发的 JWT 写进父域 cookie -> resolve。\n *\n * 该 Promise 只在「已通过门禁」时 resolve;密码未通过时不会 resolve,\n * 因此把它放在 createApp().mount() 之前 await,可保证未通过时真实应用绝不挂载。\n *\n * @param {GateOptions} [options]\n * @returns {Promise<void>}\n */\nexport async function ensureGate(options) {\n const cfg = resolveConfig(options);\n\n // 已有 cookie:取出 JWT,换真实地址后调 /check 验签——验过才放行。\n // 这一步是「JWT 方案」相对旧 =1 方案的关键:仅 cookie 存在不够,必须验签,\n // 否则攻击者随便塞个字符串就能进 UI。\n //\n // 慢网下「换地址 + 验签」是一段没有 UI 的空窗,会白屏;这里挂一个 loading\n // 遮罩盖住它,无论验过放行、验不过转密码页还是抛错,finally 都会移除。\n const existing = readCookie(cfg.cookieName);\n if (existing) {\n const loading = mountLoading({ text: cfg.loadingText });\n try {\n const baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs });\n if (await checkToken(baseUrl, existing, { timeoutMs: cfg.timeoutMs })) {\n return; // 验签通过,放行(finally 会先移除 loading)。\n }\n } catch {\n // 换地址失败:保守起见不放行,落到下方弹框流程。\n } finally {\n loading.destroy();\n }\n // 验签未过:清掉无效 cookie,避免每次都白跑一遍 /check。\n deleteCookie(cfg.cookieName, { domain: cfg.cookieDomain });\n }\n\n return new Promise((resolve) => {\n mountPasswordGate({\n title: cfg.title,\n subtitle: cfg.subtitle,\n placeholder: cfg.placeholder,\n buttonText: cfg.buttonText,\n onSubmit: async (password) => {\n let baseUrl;\n try {\n baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs });\n } catch {\n return { ok: false, message: \"网络异常,请稍后重试\" };\n }\n\n const result = await verifyPassword(baseUrl, password, {\n timeoutMs: cfg.timeoutMs,\n });\n\n if (result.ok && result.token) {\n writeCookie(cfg.cookieName, result.token, {\n domain: cfg.cookieDomain,\n maxAgeSeconds: cfg.maxAgeDays * 24 * 60 * 60,\n sameSite: cfg.sameSite,\n secure: cfg.secure,\n });\n // 通过门禁:UI 会在本回调返回 ok 后自动销毁,这里同时 resolve 外层 Promise,\n // 让 await ensureGate() 的调用方继续往下挂载真实应用。\n resolve();\n return { ok: true };\n }\n\n // 密码对但后端没给 token:当作异常,不放行(避免写空 cookie)。\n if (result.ok && !result.token) {\n return { ok: false, message: \"登录态签发失败,请重试\" };\n }\n\n return result;\n },\n });\n });\n}\n\n/**\n * 主动登出:清除父域 cookie。调用后下次进入任一子站会重新要求输入密码。\n * @param {GateOptions} options 至少需与 ensureGate 一致的 cookieName / cookieDomain\n */\nexport function logoutGate(options = {}) {\n const cfg = { ...DEFAULTS, ...options };\n const domain = cfg.cookieDomain === undefined\n ? inferParentDomain()\n : cfg.cookieDomain;\n deleteCookie(cfg.cookieName, { domain });\n}\n\nexport { inferParentDomain, readCookie } from \"./cookie.js\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"zy-web-gate.js","names":[],"sources":["../src/cookie.js","../src/verify.js","../src/discover.js","../src/ui.js","../src/index.js"],"sourcesContent":["// 跨子域共享登录态的核心:父域 cookie 读写。\n//\n// 为什么必须用 cookie 而不是 localStorage:\n// localStorage 按 origin(scheme+host+port)隔离,a.example.com 写的东西\n// b.example.com 读不到,无法实现「一次验证、全子域通行」。\n// cookie 可以通过 Domain 属性设置到父域,被该域下所有子域共享读取。\n\n/**\n * 由当前 host 推断可用于跨子域共享的父域。\n *\n * 例如当前在 a.example.com,应返回 example.com,\n * 这样写入的 cookie 能被 my./b./x. 等所有兄弟子域读到。\n *\n * 注意:eu.org 在 Public Suffix List 上,浏览器禁止把 cookie 设到 eu.org,\n * 所以父域只能取到 example.com 这一层,刚好是我们要的、也是安全的。\n *\n * 这里采用「去掉最左一段」的朴素策略:a.example.com -> example.com。\n * 大多数「单层子域」场景够用;若有多级子域或想显式指定,用 options.cookieDomain 覆盖。\n *\n * @param {string} [hostname] 默认取 location.hostname\n * @returns {string} 用于 cookie Domain 的父域;localhost / IP 等场景返回空串(表示按当前 host)\n */\nexport function inferParentDomain(hostname) {\n const host = hostname || window.location.hostname;\n\n // localhost、单段主机名、IP 地址:不设 Domain,cookie 只对当前 host 生效。\n if (host === \"localhost\" || !host.includes(\".\")) return \"\";\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) return \"\";\n\n const parts = host.split(\".\");\n // 形如 example.com(两段):父域就是它自己。\n if (parts.length <= 2) return host;\n // 形如 a.example.com:去掉最左一段。\n return parts.slice(1).join(\".\");\n}\n\n/**\n * 读取指定名称的 cookie 值。\n * @param {string} name\n * @returns {string|null}\n */\nexport function readCookie(name) {\n const prefix = encodeURIComponent(name) + \"=\";\n const items = document.cookie ? document.cookie.split(\"; \") : [];\n for (const item of items) {\n if (item.indexOf(prefix) === 0) {\n return decodeURIComponent(item.slice(prefix.length));\n }\n }\n return null;\n}\n\n/**\n * 写入跨子域 cookie。\n *\n * @param {string} name\n * @param {string} value\n * @param {object} opts\n * @param {string} opts.domain cookie 的 Domain,传空串则不写 Domain(仅当前 host)\n * @param {number} opts.maxAgeSeconds 有效期(秒)\n * @param {string} [opts.sameSite=\"Lax\"]\n * @param {boolean} [opts.secure=true] HTTPS 下应为 true;本地 http 调试时会自动放宽\n * @param {string} [opts.path=\"/\"]\n */\nexport function writeCookie(name, value, opts) {\n const {\n domain,\n maxAgeSeconds,\n sameSite = \"Lax\",\n path = \"/\",\n } = opts;\n\n // 本地 http 调试时 Secure 会让 cookie 写不进去,这里按当前协议自动判断。\n const secure = opts.secure !== undefined\n ? opts.secure\n : window.location.protocol === \"https:\";\n\n let str =\n encodeURIComponent(name) + \"=\" + encodeURIComponent(value) +\n \"; path=\" + path +\n \"; max-age=\" + Math.floor(maxAgeSeconds) +\n \"; samesite=\" + sameSite;\n\n if (domain) str += \"; domain=\" + domain;\n if (secure) str += \"; secure\";\n\n document.cookie = str;\n}\n\n/**\n * 删除跨子域 cookie(用于「登出」)。Domain / Path 必须和写入时一致才能删掉。\n * @param {string} name\n * @param {object} opts\n * @param {string} opts.domain\n * @param {string} [opts.path=\"/\"]\n */\nexport function deleteCookie(name, opts) {\n const { domain, path = \"/\" } = opts;\n let str = encodeURIComponent(name) + \"=; path=\" + path + \"; max-age=0\";\n if (domain) str += \"; domain=\" + domain;\n document.cookie = str;\n}\n","// 第二/三级:向真实校验后端(gate-auth-server)校验密码与 token。\n//\n// 设计要点:真密码只存在后端,前端 bundle 里没有任何密码信息。\n// 校验通过后端会签发一枚 JWT,前端把它存进 cookie;再次进入时调 /check\n// 验签——攻击者没有服务端密钥,签不出合法 token,伪造 cookie 进不来。\n\n/**\n * 密码校验结果。\n * @typedef {object} VerifyResult\n * @property {boolean} ok 密码是否正确\n * @property {string|null} [token] 后端签发的 JWT(ok 时有值),写入 cookie\n * @property {boolean} [networkError] 是否因网络/接口异常导致(区别于「密码错」)\n * @property {string} [message] 可展示给用户的提示\n */\n\n/**\n * 第二级:调 `{baseUrl}/verify` 校验密码。\n *\n * 接口约定:\n * POST {baseUrl}/verify body: {\"password\":\"用户输入\"}\n * 通过 HTTP 200, { code:0, data:{ match:true, token:\"<JWT>\", expiresIn:604800 } }\n * 密码错 HTTP 401, { code:1, data:{ match:false } }\n *\n * 判定规则:只有「HTTP ok 且 data.match === true」才算通过;其余一律当密码错。\n * 网络层异常单独标记 networkError,便于 UI 区分提示。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} password\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<VerifyResult>}\n */\nexport async function verifyPassword(baseUrl, password, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/verify`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n // fetch 抛错只可能是网络层问题(断网、CORS、超时 abort 等),不是密码错。\n return { ok: false, networkError: true, message: \"网络异常,请稍后重试\" };\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n // 响应不是合法 JSON:当作密码错/异常处理,不放行。\n data = null;\n }\n\n const match = !!(resp.ok && data && data.data && data.data.match === true);\n\n if (match) {\n return { ok: true, token: (data.data && data.data.token) || null };\n }\n\n // 未通过:优先展示后端返回的提示(如「当前访问密码无权限访问当前网站」),\n // 后端没给文案时回落到「密码错误」。\n const message = (data && typeof data.message === \"string\" && data.message) || \"密码错误\";\n return { ok: false, networkError: false, message };\n}\n\n/**\n * 第三级:调 `{baseUrl}/check` 验签 cookie 里的 JWT。\n *\n * 接口约定:\n * POST {baseUrl}/check header: { Authorization: \"Bearer <token>\" }\n * 有效 HTTP 200, { code:0, data:{ valid:true } }\n * 无效/过期/伪造 HTTP 401, { code:1, data:{ valid:false } }\n *\n * 用于「已有 cookie」分支:验过才放行,否则重新弹密码框。\n * 网络异常时返回 false(保守起见不放行),由调用方决定是否回退到弹框。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} token cookie 里存的 JWT\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<boolean>} token 是否有效\n */\nexport async function checkToken(baseUrl, token, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/check`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n return false;\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n return !!(resp.ok && data && data.data && data.data.valid === true);\n}\n","// 第一级:地址分发接口。用一个固定 token 换取真实的校验后端地址。\n//\n// 为什么要这一级:避免真实校验接口地址被写死在前端 bundle 字符串里。\n// 这里的 token 不是安全凭证——它本来就会出现在前端请求里,作用只是\n// 「让真实地址不直接出现在 bundle 里」,提高一点点扒取门槛。\n// 真正的安全靠第二/三级的 JWT 校验。\n//\n// 设计选择:不在内存缓存 data.url,每次校验都重新分发拉取,\n// 这样后端在 mock 里改地址即时生效。\n\n/** 地址分发接口 URL(写死在包内,改地址只改这里并发新版本)。 */\nexport const DISCOVER_URL =\n \"https://m1.apifoxmock.com/m1/6205743-5899102-default/getMyWebUrl\";\n\n/**\n * 各环境换取真实地址所需的请求头(token 非安全凭证,仅提高扒取门槛)。\n * 分发接口按 env 头返回对应环境的真实校验地址。\n */\nconst ENV_HEADERS = {\n dev: { token: \"z9M2beGEawlsBF6O\", env: \"dev\" },\n test: { token: \"u3gnR2mpVjMXrnf1\", env: \"test\" },\n};\n\n/** 支持的环境名列表,用于报错提示。 */\nexport const SUPPORTED_ENVS = Object.keys(ENV_HEADERS);\n\n/**\n * 向地址分发接口换取真实校验后端地址。\n *\n * 接口约定:\n * GET DISCOVER_URL header: { token, env }(按环境取,见 ENV_HEADERS)\n * 成功 HTTP 200, { code:200, data:{ url:\"真实校验地址\" } }\n * 失败 HTTP 401, { code:401, data:{} }\n *\n * @param {object} opts\n * @param {string} opts.env 环境名,必传,取值 dev / test\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<string>} 真实校验后端地址(已去除末尾斜杠)\n * @throws {Error} env 缺失/非法、网络异常、未授权或返回里没有 url 时抛出\n */\nexport async function discoverBaseUrl(opts = {}) {\n const { timeoutMs = 10000, env } = opts;\n\n const headers = ENV_HEADERS[env];\n if (!headers) {\n throw new Error(\n `zy-web-gate: 必须传入 env(${SUPPORTED_ENVS.join(\" / \")}),当前为 ${JSON.stringify(env)}`,\n );\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(DISCOVER_URL, {\n method: \"GET\",\n headers,\n signal: controller.signal,\n });\n } catch {\n throw new Error(\"获取校验接口地址失败:网络异常\");\n } finally {\n clearTimeout(timer);\n }\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n const url = data && data.data && data.data.url;\n if (!resp.ok || data.code !== 200 || !url) {\n throw new Error(\"获取校验接口地址失败\");\n }\n\n // 去掉末尾斜杠,方便后续拼 `${base}/verify`、`${base}/check`。\n return String(url).replace(/\\/+$/, \"\");\n}\n","// 框架无关的密码输入页(原生 DOM)。\n//\n// 用 Shadow DOM 把样式和结构完全隔离,既不会污染子站的 CSS,\n// 也不会被子站的全局样式影响。任何前端项目(Vue / React / 纯 HTML)都能用。\n\nconst STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; align-items: center; justify-content: center;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.card {\n width: 320px; max-width: calc(100vw - 48px);\n padding: 32px 28px;\n background: #1b1e25; border-radius: 14px;\n box-shadow: 0 12px 40px rgba(0,0,0,.45);\n box-sizing: border-box;\n}\n.title { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: #f2f4f8; }\n.subtitle { margin: 0 0 22px; font-size: 13px; color: #8a92a6; line-height: 1.5; }\n.field { position: relative; }\n.input {\n width: 100%; box-sizing: border-box;\n padding: 11px 13px; font-size: 14px;\n color: #f2f4f8; background: #11141a;\n border: 1px solid #2c313c; border-radius: 9px; outline: none;\n transition: border-color .15s;\n}\n.input:focus { border-color: #4c8dff; }\n.btn {\n width: 100%; margin-top: 14px; padding: 11px 0;\n font-size: 14px; font-weight: 600; color: #fff; cursor: pointer;\n background: #4c8dff; border: none; border-radius: 9px;\n transition: background .15s, opacity .15s;\n}\n.btn:hover { background: #3a7df0; }\n.btn:disabled { opacity: .6; cursor: not-allowed; }\n.error {\n min-height: 18px; margin-top: 12px;\n font-size: 12.5px; color: #ff6b6b; line-height: 1.4;\n}\n`;\n\n/**\n * 创建并挂载密码页。返回一个对象,可用于销毁。\n *\n * @param {object} cfg\n * @param {string} cfg.title\n * @param {string} cfg.subtitle\n * @param {string} cfg.placeholder\n * @param {string} cfg.buttonText\n * @param {(password: string) => Promise<{ok: boolean, message?: string}>} cfg.onSubmit\n * 提交回调;返回 ok=true 时 UI 自动销毁,false 时显示 message。\n * @returns {{ destroy: () => void }}\n */\nexport function mountPasswordGate(cfg) {\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `\n <div class=\"card\">\n <h1 class=\"title\"></h1>\n <p class=\"subtitle\"></p>\n <div class=\"field\">\n <input class=\"input\" type=\"password\" autocomplete=\"current-password\" />\n </div>\n <button class=\"btn\" type=\"button\"></button>\n <div class=\"error\" aria-live=\"polite\"></div>\n </div>\n `;\n shadow.appendChild(mask);\n\n // 用 textContent 赋值,避免把配置文本当 HTML 注入。\n shadow.querySelector(\".title\").textContent = cfg.title;\n shadow.querySelector(\".subtitle\").textContent = cfg.subtitle;\n const input = shadow.querySelector(\".input\");\n const btn = shadow.querySelector(\".btn\");\n const errorEl = shadow.querySelector(\".error\");\n input.placeholder = cfg.placeholder;\n btn.textContent = cfg.buttonText;\n\n let submitting = false;\n\n async function submit() {\n if (submitting) return;\n const password = input.value;\n if (!password) {\n errorEl.textContent = \"请输入密码\";\n return;\n }\n\n submitting = true;\n btn.disabled = true;\n errorEl.textContent = \"\";\n const originalBtnText = btn.textContent;\n btn.textContent = \"验证中…\";\n\n let result;\n try {\n result = await cfg.onSubmit(password);\n } finally {\n submitting = false;\n btn.disabled = false;\n btn.textContent = originalBtnText;\n }\n\n if (result && result.ok) {\n destroy();\n } else {\n errorEl.textContent = (result && result.message) || \"密码错误\";\n input.select();\n }\n }\n\n btn.addEventListener(\"click\", submit);\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") submit();\n });\n\n function destroy() {\n host.remove();\n }\n\n document.body.appendChild(host);\n // 自动聚焦输入框,方便直接打字。\n setTimeout(() => input.focus(), 0);\n\n return { destroy };\n}\n\nconst LOADING_STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.spinner {\n width: 34px; height: 34px;\n border: 3px solid #2c313c;\n border-top-color: #4c8dff;\n border-radius: 50%;\n animation: zy-gate-spin .8s linear infinite;\n}\n.text { font-size: 13px; color: #8a92a6; }\n@keyframes zy-gate-spin { to { transform: rotate(360deg); } }\n`;\n\n/**\n * 挂载一个全屏 loading 遮罩,盖住「换地址 / 验签」等慢网空窗,避免白屏。\n *\n * 与密码页同一套深色背景和字体,所以它消失、密码页出现时观感是平滑过渡的。\n * 返回 destroy 用于在校验完成(放行或转入密码页)时移除。\n *\n * @param {object} [cfg]\n * @param {string} [cfg.text=\"正在验证访问权限…\"] 遮罩下方提示文案\n * @returns {{ destroy: () => void }}\n */\nexport function mountLoading(cfg = {}) {\n const { text = \"正在验证访问权限…\" } = cfg;\n\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate-loading\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = LOADING_STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `<div class=\"spinner\"></div><div class=\"text\"></div>`;\n shadow.appendChild(mask);\n shadow.querySelector(\".text\").textContent = text;\n\n document.body.appendChild(host);\n\n return { destroy: () => host.remove() };\n}\n","// zy-web-gate —— 纯前端「共享密码」页面访问门(两级接口 + JWT 方案)。\n//\n// 用法(在 Vue/React/任意前端入口、挂载真实应用之前调用):\n//\n// import { ensureGate } from \"zy-web-gate\";\n//\n// await ensureGate(); // 接口已固定在包内,零参数即可\n//\n// // 走到这里说明已通过门禁,再启动真实应用:\n// createApp(App).mount(\"#app\");\n//\n// 流程(详见 INTEGRATION.md):\n// 1. 已有 cookie(JWT)?-> 调 /check 验签,过则直接放行(无 UI)。\n// 2. 否则:GET 地址分发接口换真实校验地址 -> 弹密码框 -> POST /verify。\n// 3. 通过 -> 把后端签发的 JWT 写进父域 cookie -> 放行。\n//\n// 跨子域:验证通过后写父域 cookie(Domain=example.com),同主域其他子站\n// 读到该 cookie 并验签通过即直接放行,无需再次输入密码。\n//\n// 安全边界:纯前端门禁防的是「伪造 cookie 进 UI」与「照搬旧 =1 绕过」;\n// 挡不住绕过 UI 直接扒静态资源 / 直接打业务 API——真正的安全必须靠\n// 业务数据接口自己校验 token(即第三级 /check)。务必全链路走 HTTPS。\n\nimport { inferParentDomain, readCookie, writeCookie, deleteCookie } from \"./cookie.js\";\nimport { verifyPassword, checkToken } from \"./verify.js\";\nimport { discoverBaseUrl, SUPPORTED_ENVS } from \"./discover.js\";\nimport { mountPasswordGate, mountLoading } from \"./ui.js\";\n\n/** @type {Required<GateOptions>} 默认配置 */\nconst DEFAULTS = {\n cookieName: \"zy_web_gate\",\n cookieDomain: undefined, // undefined 表示自动推断父域\n maxAgeDays: 7,\n sameSite: \"Lax\",\n secure: undefined, // undefined 表示按当前协议自动判断\n title: \"访问验证\",\n subtitle: \"请输入访问密码后继续。\",\n placeholder: \"访问密码\",\n buttonText: \"进入\",\n loadingText: \"正在验证访问权限…\",\n timeoutMs: 10000,\n env: undefined, // 环境名(dev/test),必传,用于地址分发接口区分环境\n};\n\n/**\n * @typedef {object} GateOptions\n * @property {string} [cookieName] 登录态 cookie 名,默认 \"zy_web_gate\";值为后端签发的 JWT\n * @property {string} [cookieDomain] 显式指定父域;不传则自动推断(如 example.com)\n * @property {number} [maxAgeDays] 登录态有效天数,默认 7\n * @property {string} [sameSite] cookie SameSite,默认 \"Lax\"\n * @property {boolean} [secure] cookie Secure;不传则按当前协议自动判断\n * @property {string} [title] 密码页标题\n * @property {string} [subtitle] 密码页副标题\n * @property {string} [placeholder] 输入框占位文案\n * @property {string} [buttonText] 按钮文案\n * @property {string} [loadingText] 已有 cookie 验签时的 loading 文案,默认 \"正在验证访问权限…\"\n * @property {number} [timeoutMs] 接口超时毫秒,默认 10000\n * @property {string} env 环境名(dev/test),必传,用于地址分发接口区分环境\n */\n\n/**\n * 解析最终配置。\n * @param {GateOptions} [options]\n */\nfunction resolveConfig(options) {\n const cfg = { ...DEFAULTS, ...(options || {}) };\n if (cfg.cookieDomain === undefined) {\n cfg.cookieDomain = inferParentDomain();\n }\n return cfg;\n}\n\n/**\n * 访问门主入口。\n *\n * 行为:\n * 1. 父域 cookie 已存在 JWT -> 换真实地址 -> 调 /check 验签;过则放行(无 UI)。\n * 验签不过 / 网络异常 -> 视作未登录,进入弹框流程。\n * 2. 弹出密码页,等用户输入并通过 /verify 校验。\n * 3. 校验通过 -> 把后端签发的 JWT 写进父域 cookie -> resolve。\n *\n * 该 Promise 只在「已通过门禁」时 resolve;密码未通过时不会 resolve,\n * 因此把它放在 createApp().mount() 之前 await,可保证未通过时真实应用绝不挂载。\n *\n * @param {GateOptions} [options]\n * @returns {Promise<void>}\n */\nexport async function ensureGate(options) {\n const cfg = resolveConfig(options);\n\n // env 必传:尽早校验,避免运行到换地址时才报错。\n if (!cfg.env || !SUPPORTED_ENVS.includes(cfg.env)) {\n throw new Error(\n `zy-web-gate: ensureGate 必须传入 env(${SUPPORTED_ENVS.join(\" / \")}),当前为 ${JSON.stringify(cfg.env)}`,\n );\n }\n\n // 已有 cookie:取出 JWT,换真实地址后调 /check 验签——验过才放行。\n // 这一步是「JWT 方案」相对旧 =1 方案的关键:仅 cookie 存在不够,必须验签,\n // 否则攻击者随便塞个字符串就能进 UI。\n //\n // 慢网下「换地址 + 验签」是一段没有 UI 的空窗,会白屏;这里挂一个 loading\n // 遮罩盖住它,无论验过放行、验不过转密码页还是抛错,finally 都会移除。\n const existing = readCookie(cfg.cookieName);\n if (existing) {\n const loading = mountLoading({ text: cfg.loadingText });\n try {\n const baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs, env: cfg.env });\n if (await checkToken(baseUrl, existing, { timeoutMs: cfg.timeoutMs })) {\n return; // 验签通过,放行(finally 会先移除 loading)。\n }\n } catch {\n // 换地址失败:保守起见不放行,落到下方弹框流程。\n } finally {\n loading.destroy();\n }\n // 验签未过:清掉无效 cookie,避免每次都白跑一遍 /check。\n deleteCookie(cfg.cookieName, { domain: cfg.cookieDomain });\n }\n\n return new Promise((resolve) => {\n mountPasswordGate({\n title: cfg.title,\n subtitle: cfg.subtitle,\n placeholder: cfg.placeholder,\n buttonText: cfg.buttonText,\n onSubmit: async (password) => {\n let baseUrl;\n try {\n baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs, env: cfg.env });\n } catch {\n return { ok: false, message: \"网络异常,请稍后重试\" };\n }\n\n const result = await verifyPassword(baseUrl, password, {\n timeoutMs: cfg.timeoutMs,\n });\n\n if (result.ok && result.token) {\n writeCookie(cfg.cookieName, result.token, {\n domain: cfg.cookieDomain,\n maxAgeSeconds: cfg.maxAgeDays * 24 * 60 * 60,\n sameSite: cfg.sameSite,\n secure: cfg.secure,\n });\n // 通过门禁:UI 会在本回调返回 ok 后自动销毁,这里同时 resolve 外层 Promise,\n // 让 await ensureGate() 的调用方继续往下挂载真实应用。\n resolve();\n return { ok: true };\n }\n\n // 密码对但后端没给 token:当作异常,不放行(避免写空 cookie)。\n if (result.ok && !result.token) {\n return { ok: false, message: \"登录态签发失败,请重试\" };\n }\n\n return result;\n },\n });\n });\n}\n\n/**\n * 主动登出:清除父域 cookie。调用后下次进入任一子站会重新要求输入密码。\n * @param {GateOptions} options 至少需与 ensureGate 一致的 cookieName / cookieDomain\n */\nexport function logoutGate(options = {}) {\n const cfg = { ...DEFAULTS, ...options };\n const domain = cfg.cookieDomain === undefined\n ? inferParentDomain()\n : cfg.cookieDomain;\n deleteCookie(cfg.cookieName, { domain });\n}\n\nexport { inferParentDomain, readCookie } from \"./cookie.js\";\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- (function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports):typeof define==`function`&&define.amd?define([`exports`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.ZyWebGate={}))})(this,function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});function t(e){let t=e||window.location.hostname;if(t===`localhost`||!t.includes(`.`)||/^\d{1,3}(\.\d{1,3}){3}$/.test(t))return``;let n=t.split(`.`);return n.length<=2?t:n.slice(1).join(`.`)}function n(e){let t=encodeURIComponent(e)+`=`,n=document.cookie?document.cookie.split(`; `):[];for(let e of n)if(e.indexOf(t)===0)return decodeURIComponent(e.slice(t.length));return null}function r(e,t,n){let{domain:r,maxAgeSeconds:i,sameSite:a=`Lax`,path:o=`/`}=n,s=n.secure===void 0?window.location.protocol===`https:`:n.secure,c=encodeURIComponent(e)+`=`+encodeURIComponent(t)+`; path=`+o+`; max-age=`+Math.floor(i)+`; samesite=`+a;r&&(c+=`; domain=`+r),s&&(c+=`; secure`),document.cookie=c}function i(e,t){let{domain:n,path:r=`/`}=t,i=encodeURIComponent(e)+`=; path=`+r+`; max-age=0`;n&&(i+=`; domain=`+n),document.cookie=i}async function a(e,t,n={}){let{timeoutMs:r=1e4}=n,i=new AbortController,a=setTimeout(()=>i.abort(),r),o;try{o=await fetch(`${e}/verify`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({password:t}),signal:i.signal})}catch{return clearTimeout(a),{ok:!1,networkError:!0,message:`网络异常,请稍后重试`}}clearTimeout(a);let s=null;try{s=await o.json()}catch{s=null}return o.ok&&s&&s.data&&s.data.match===!0?{ok:!0,token:s.data&&s.data.token||null}:{ok:!1,networkError:!1,message:`密码错误`}}async function o(e,t,n={}){let{timeoutMs:r=1e4}=n,i=new AbortController,a=setTimeout(()=>i.abort(),r),o;try{o=await fetch(`${e}/check`,{method:`POST`,headers:{"Content-Type":`application/json`,Authorization:`Bearer ${t}`},body:JSON.stringify({token:t}),signal:i.signal})}catch{return clearTimeout(a),!1}clearTimeout(a);let s=null;try{s=await o.json()}catch{s=null}return!!(o.ok&&s&&s.data&&s.data.valid===!0)}var s=`https://m1.apifoxmock.com/m1/6205743-5899102-default/getWebRateAddress`,c=`qB8pgQh*pcbBRf3P`;async function l(e={}){let{timeoutMs:t=1e4}=e,n=new AbortController,r=setTimeout(()=>n.abort(),t),i;try{i=await fetch(s,{method:`GET`,headers:{token:c},signal:n.signal})}catch{throw Error(`获取校验接口地址失败:网络异常`)}finally{clearTimeout(r)}let a=null;try{a=await i.json()}catch{a=null}let o=a&&a.data&&a.data.url;if(!i.ok||a.code!==200||!o)throw Error(`获取校验接口地址失败`);return String(o).replace(/\/+$/,``)}var u=`
1
+ (function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports):typeof define==`function`&&define.amd?define([`exports`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.ZyWebGate={}))})(this,function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});function t(e){let t=e||window.location.hostname;if(t===`localhost`||!t.includes(`.`)||/^\d{1,3}(\.\d{1,3}){3}$/.test(t))return``;let n=t.split(`.`);return n.length<=2?t:n.slice(1).join(`.`)}function n(e){let t=encodeURIComponent(e)+`=`,n=document.cookie?document.cookie.split(`; `):[];for(let e of n)if(e.indexOf(t)===0)return decodeURIComponent(e.slice(t.length));return null}function r(e,t,n){let{domain:r,maxAgeSeconds:i,sameSite:a=`Lax`,path:o=`/`}=n,s=n.secure===void 0?window.location.protocol===`https:`:n.secure,c=encodeURIComponent(e)+`=`+encodeURIComponent(t)+`; path=`+o+`; max-age=`+Math.floor(i)+`; samesite=`+a;r&&(c+=`; domain=`+r),s&&(c+=`; secure`),document.cookie=c}function i(e,t){let{domain:n,path:r=`/`}=t,i=encodeURIComponent(e)+`=; path=`+r+`; max-age=0`;n&&(i+=`; domain=`+n),document.cookie=i}async function a(e,t,n={}){let{timeoutMs:r=1e4}=n,i=new AbortController,a=setTimeout(()=>i.abort(),r),o;try{o=await fetch(`${e}/verify`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({password:t}),signal:i.signal})}catch{return clearTimeout(a),{ok:!1,networkError:!0,message:`网络异常,请稍后重试`}}clearTimeout(a);let s=null;try{s=await o.json()}catch{s=null}return o.ok&&s&&s.data&&s.data.match===!0?{ok:!0,token:s.data&&s.data.token||null}:{ok:!1,networkError:!1,message:s&&typeof s.message==`string`&&s.message||`密码错误`}}async function o(e,t,n={}){let{timeoutMs:r=1e4}=n,i=new AbortController,a=setTimeout(()=>i.abort(),r),o;try{o=await fetch(`${e}/check`,{method:`POST`,headers:{"Content-Type":`application/json`,Authorization:`Bearer ${t}`},body:JSON.stringify({token:t}),signal:i.signal})}catch{return clearTimeout(a),!1}clearTimeout(a);let s=null;try{s=await o.json()}catch{s=null}return!!(o.ok&&s&&s.data&&s.data.valid===!0)}var s=`https://m1.apifoxmock.com/m1/6205743-5899102-default/getMyWebUrl`,c={dev:{token:`z9M2beGEawlsBF6O`,env:`dev`},test:{token:`u3gnR2mpVjMXrnf1`,env:`test`}},l=Object.keys(c);async function u(e={}){let{timeoutMs:t=1e4,env:n}=e,r=c[n];if(!r)throw Error(`zy-web-gate: 必须传入 env(${l.join(` / `)}),当前为 ${JSON.stringify(n)}`);let i=new AbortController,a=setTimeout(()=>i.abort(),t),o;try{o=await fetch(s,{method:`GET`,headers:r,signal:i.signal})}catch{throw Error(`获取校验接口地址失败:网络异常`)}finally{clearTimeout(a)}let u=null;try{u=await o.json()}catch{u=null}let d=u&&u.data&&u.data.url;if(!o.ok||u.code!==200||!d)throw Error(`获取校验接口地址失败`);return String(d).replace(/\/+$/,``)}var d=`
2
2
  :host { all: initial; }
3
3
  .mask {
4
4
  position: fixed; inset: 0; z-index: 2147483647;
@@ -36,7 +36,7 @@
36
36
  min-height: 18px; margin-top: 12px;
37
37
  font-size: 12.5px; color: #ff6b6b; line-height: 1.4;
38
38
  }
39
- `;function d(e){let t=document.createElement(`div`);t.setAttribute(`data-zy-web-gate`,``);let n=t.attachShadow({mode:`open`}),r=document.createElement(`style`);r.textContent=u,n.appendChild(r);let i=document.createElement(`div`);i.className=`mask`,i.innerHTML=`
39
+ `;function f(e){let t=document.createElement(`div`);t.setAttribute(`data-zy-web-gate`,``);let n=t.attachShadow({mode:`open`}),r=document.createElement(`style`);r.textContent=d,n.appendChild(r);let i=document.createElement(`div`);i.className=`mask`,i.innerHTML=`
40
40
  <div class="card">
41
41
  <h1 class="title"></h1>
42
42
  <p class="subtitle"></p>
@@ -46,7 +46,7 @@
46
46
  <button class="btn" type="button"></button>
47
47
  <div class="error" aria-live="polite"></div>
48
48
  </div>
49
- `,n.appendChild(i),n.querySelector(`.title`).textContent=e.title,n.querySelector(`.subtitle`).textContent=e.subtitle;let a=n.querySelector(`.input`),o=n.querySelector(`.btn`),s=n.querySelector(`.error`);a.placeholder=e.placeholder,o.textContent=e.buttonText;let c=!1;async function l(){if(c)return;let t=a.value;if(!t){s.textContent=`请输入密码`;return}c=!0,o.disabled=!0,s.textContent=``;let n=o.textContent;o.textContent=`验证中…`;let r;try{r=await e.onSubmit(t)}finally{c=!1,o.disabled=!1,o.textContent=n}r&&r.ok?d():(s.textContent=r&&r.message||`密码错误`,a.select())}o.addEventListener(`click`,l),a.addEventListener(`keydown`,e=>{e.key===`Enter`&&l()});function d(){t.remove()}return document.body.appendChild(t),setTimeout(()=>a.focus(),0),{destroy:d}}var f=`
49
+ `,n.appendChild(i),n.querySelector(`.title`).textContent=e.title,n.querySelector(`.subtitle`).textContent=e.subtitle;let a=n.querySelector(`.input`),o=n.querySelector(`.btn`),s=n.querySelector(`.error`);a.placeholder=e.placeholder,o.textContent=e.buttonText;let c=!1;async function l(){if(c)return;let t=a.value;if(!t){s.textContent=`请输入密码`;return}c=!0,o.disabled=!0,s.textContent=``;let n=o.textContent;o.textContent=`验证中…`;let r;try{r=await e.onSubmit(t)}finally{c=!1,o.disabled=!1,o.textContent=n}r&&r.ok?u():(s.textContent=r&&r.message||`密码错误`,a.select())}o.addEventListener(`click`,l),a.addEventListener(`keydown`,e=>{e.key===`Enter`&&l()});function u(){t.remove()}return document.body.appendChild(t),setTimeout(()=>a.focus(),0),{destroy:u}}var p=`
50
50
  :host { all: initial; }
51
51
  .mask {
52
52
  position: fixed; inset: 0; z-index: 2147483647;
@@ -64,5 +64,5 @@
64
64
  }
65
65
  .text { font-size: 13px; color: #8a92a6; }
66
66
  @keyframes zy-gate-spin { to { transform: rotate(360deg); } }
67
- `;function p(e={}){let{text:t=`正在验证访问权限…`}=e,n=document.createElement(`div`);n.setAttribute(`data-zy-web-gate-loading`,``);let r=n.attachShadow({mode:`open`}),i=document.createElement(`style`);i.textContent=f,r.appendChild(i);let a=document.createElement(`div`);return a.className=`mask`,a.innerHTML=`<div class="spinner"></div><div class="text"></div>`,r.appendChild(a),r.querySelector(`.text`).textContent=t,document.body.appendChild(n),{destroy:()=>n.remove()}}var m={cookieName:`zy_web_gate`,cookieDomain:void 0,maxAgeDays:7,sameSite:`Lax`,secure:void 0,title:`访问验证`,subtitle:`请输入访问密码后继续。`,placeholder:`访问密码`,buttonText:`进入`,loadingText:`正在验证访问权限…`,timeoutMs:1e4};function h(e){let n={...m,...e||{}};return n.cookieDomain===void 0&&(n.cookieDomain=t()),n}async function g(e){let t=h(e),s=n(t.cookieName);if(s){let e=p({text:t.loadingText});try{if(await o(await l({timeoutMs:t.timeoutMs}),s,{timeoutMs:t.timeoutMs}))return}catch{}finally{e.destroy()}i(t.cookieName,{domain:t.cookieDomain})}return new Promise(e=>{d({title:t.title,subtitle:t.subtitle,placeholder:t.placeholder,buttonText:t.buttonText,onSubmit:async n=>{let i;try{i=await l({timeoutMs:t.timeoutMs})}catch{return{ok:!1,message:`网络异常,请稍后重试`}}let o=await a(i,n,{timeoutMs:t.timeoutMs});return o.ok&&o.token?(r(t.cookieName,o.token,{domain:t.cookieDomain,maxAgeSeconds:t.maxAgeDays*24*60*60,sameSite:t.sameSite,secure:t.secure}),e(),{ok:!0}):o.ok&&!o.token?{ok:!1,message:`登录态签发失败,请重试`}:o}})})}function _(e={}){let n={...m,...e},r=n.cookieDomain===void 0?t():n.cookieDomain;i(n.cookieName,{domain:r})}e.ensureGate=g,e.inferParentDomain=t,e.logoutGate=_,e.readCookie=n});
67
+ `;function m(e={}){let{text:t=`正在验证访问权限…`}=e,n=document.createElement(`div`);n.setAttribute(`data-zy-web-gate-loading`,``);let r=n.attachShadow({mode:`open`}),i=document.createElement(`style`);i.textContent=p,r.appendChild(i);let a=document.createElement(`div`);return a.className=`mask`,a.innerHTML=`<div class="spinner"></div><div class="text"></div>`,r.appendChild(a),r.querySelector(`.text`).textContent=t,document.body.appendChild(n),{destroy:()=>n.remove()}}var h={cookieName:`zy_web_gate`,cookieDomain:void 0,maxAgeDays:7,sameSite:`Lax`,secure:void 0,title:`访问验证`,subtitle:`请输入访问密码后继续。`,placeholder:`访问密码`,buttonText:`进入`,loadingText:`正在验证访问权限…`,timeoutMs:1e4,env:void 0};function g(e){let n={...h,...e||{}};return n.cookieDomain===void 0&&(n.cookieDomain=t()),n}async function _(e){let t=g(e);if(!t.env||!l.includes(t.env))throw Error(`zy-web-gate: ensureGate 必须传入 env(${l.join(` / `)}),当前为 ${JSON.stringify(t.env)}`);let s=n(t.cookieName);if(s){let e=m({text:t.loadingText});try{if(await o(await u({timeoutMs:t.timeoutMs,env:t.env}),s,{timeoutMs:t.timeoutMs}))return}catch{}finally{e.destroy()}i(t.cookieName,{domain:t.cookieDomain})}return new Promise(e=>{f({title:t.title,subtitle:t.subtitle,placeholder:t.placeholder,buttonText:t.buttonText,onSubmit:async n=>{let i;try{i=await u({timeoutMs:t.timeoutMs,env:t.env})}catch{return{ok:!1,message:`网络异常,请稍后重试`}}let o=await a(i,n,{timeoutMs:t.timeoutMs});return o.ok&&o.token?(r(t.cookieName,o.token,{domain:t.cookieDomain,maxAgeSeconds:t.maxAgeDays*24*60*60,sameSite:t.sameSite,secure:t.secure}),e(),{ok:!0}):o.ok&&!o.token?{ok:!1,message:`登录态签发失败,请重试`}:o}})})}function v(e={}){let n={...h,...e},r=n.cookieDomain===void 0?t():n.cookieDomain;i(n.cookieName,{domain:r})}e.ensureGate=_,e.inferParentDomain=t,e.logoutGate=v,e.readCookie=n});
68
68
  //# sourceMappingURL=zy-web-gate.umd.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"zy-web-gate.umd.cjs","names":[],"sources":["../src/cookie.js","../src/verify.js","../src/discover.js","../src/ui.js","../src/index.js"],"sourcesContent":["// 跨子域共享登录态的核心:父域 cookie 读写。\n//\n// 为什么必须用 cookie 而不是 localStorage:\n// localStorage 按 origin(scheme+host+port)隔离,a.example.com 写的东西\n// b.example.com 读不到,无法实现「一次验证、全子域通行」。\n// cookie 可以通过 Domain 属性设置到父域,被该域下所有子域共享读取。\n\n/**\n * 由当前 host 推断可用于跨子域共享的父域。\n *\n * 例如当前在 a.example.com,应返回 example.com,\n * 这样写入的 cookie 能被 my./b./x. 等所有兄弟子域读到。\n *\n * 注意:eu.org 在 Public Suffix List 上,浏览器禁止把 cookie 设到 eu.org,\n * 所以父域只能取到 example.com 这一层,刚好是我们要的、也是安全的。\n *\n * 这里采用「去掉最左一段」的朴素策略:a.example.com -> example.com。\n * 大多数「单层子域」场景够用;若有多级子域或想显式指定,用 options.cookieDomain 覆盖。\n *\n * @param {string} [hostname] 默认取 location.hostname\n * @returns {string} 用于 cookie Domain 的父域;localhost / IP 等场景返回空串(表示按当前 host)\n */\nexport function inferParentDomain(hostname) {\n const host = hostname || window.location.hostname;\n\n // localhost、单段主机名、IP 地址:不设 Domain,cookie 只对当前 host 生效。\n if (host === \"localhost\" || !host.includes(\".\")) return \"\";\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) return \"\";\n\n const parts = host.split(\".\");\n // 形如 example.com(两段):父域就是它自己。\n if (parts.length <= 2) return host;\n // 形如 a.example.com:去掉最左一段。\n return parts.slice(1).join(\".\");\n}\n\n/**\n * 读取指定名称的 cookie 值。\n * @param {string} name\n * @returns {string|null}\n */\nexport function readCookie(name) {\n const prefix = encodeURIComponent(name) + \"=\";\n const items = document.cookie ? document.cookie.split(\"; \") : [];\n for (const item of items) {\n if (item.indexOf(prefix) === 0) {\n return decodeURIComponent(item.slice(prefix.length));\n }\n }\n return null;\n}\n\n/**\n * 写入跨子域 cookie。\n *\n * @param {string} name\n * @param {string} value\n * @param {object} opts\n * @param {string} opts.domain cookie 的 Domain,传空串则不写 Domain(仅当前 host)\n * @param {number} opts.maxAgeSeconds 有效期(秒)\n * @param {string} [opts.sameSite=\"Lax\"]\n * @param {boolean} [opts.secure=true] HTTPS 下应为 true;本地 http 调试时会自动放宽\n * @param {string} [opts.path=\"/\"]\n */\nexport function writeCookie(name, value, opts) {\n const {\n domain,\n maxAgeSeconds,\n sameSite = \"Lax\",\n path = \"/\",\n } = opts;\n\n // 本地 http 调试时 Secure 会让 cookie 写不进去,这里按当前协议自动判断。\n const secure = opts.secure !== undefined\n ? opts.secure\n : window.location.protocol === \"https:\";\n\n let str =\n encodeURIComponent(name) + \"=\" + encodeURIComponent(value) +\n \"; path=\" + path +\n \"; max-age=\" + Math.floor(maxAgeSeconds) +\n \"; samesite=\" + sameSite;\n\n if (domain) str += \"; domain=\" + domain;\n if (secure) str += \"; secure\";\n\n document.cookie = str;\n}\n\n/**\n * 删除跨子域 cookie(用于「登出」)。Domain / Path 必须和写入时一致才能删掉。\n * @param {string} name\n * @param {object} opts\n * @param {string} opts.domain\n * @param {string} [opts.path=\"/\"]\n */\nexport function deleteCookie(name, opts) {\n const { domain, path = \"/\" } = opts;\n let str = encodeURIComponent(name) + \"=; path=\" + path + \"; max-age=0\";\n if (domain) str += \"; domain=\" + domain;\n document.cookie = str;\n}\n","// 第二/三级:向真实校验后端(gate-auth-server)校验密码与 token。\n//\n// 设计要点:真密码只存在后端,前端 bundle 里没有任何密码信息。\n// 校验通过后端会签发一枚 JWT,前端把它存进 cookie;再次进入时调 /check\n// 验签——攻击者没有服务端密钥,签不出合法 token,伪造 cookie 进不来。\n\n/**\n * 密码校验结果。\n * @typedef {object} VerifyResult\n * @property {boolean} ok 密码是否正确\n * @property {string|null} [token] 后端签发的 JWT(ok 时有值),写入 cookie\n * @property {boolean} [networkError] 是否因网络/接口异常导致(区别于「密码错」)\n * @property {string} [message] 可展示给用户的提示\n */\n\n/**\n * 第二级:调 `{baseUrl}/verify` 校验密码。\n *\n * 接口约定:\n * POST {baseUrl}/verify body: {\"password\":\"用户输入\"}\n * 通过 HTTP 200, { code:0, data:{ match:true, token:\"<JWT>\", expiresIn:604800 } }\n * 密码错 HTTP 401, { code:1, data:{ match:false } }\n *\n * 判定规则:只有「HTTP ok 且 data.match === true」才算通过;其余一律当密码错。\n * 网络层异常单独标记 networkError,便于 UI 区分提示。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} password\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<VerifyResult>}\n */\nexport async function verifyPassword(baseUrl, password, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/verify`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n // fetch 抛错只可能是网络层问题(断网、CORS、超时 abort 等),不是密码错。\n return { ok: false, networkError: true, message: \"网络异常,请稍后重试\" };\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n // 响应不是合法 JSON:当作密码错/异常处理,不放行。\n data = null;\n }\n\n const match = !!(resp.ok && data && data.data && data.data.match === true);\n\n if (match) {\n return { ok: true, token: (data.data && data.data.token) || null };\n }\n\n return { ok: false, networkError: false, message: \"密码错误\" };\n}\n\n/**\n * 第三级:调 `{baseUrl}/check` 验签 cookie 里的 JWT。\n *\n * 接口约定:\n * POST {baseUrl}/check header: { Authorization: \"Bearer <token>\" }\n * 有效 HTTP 200, { code:0, data:{ valid:true } }\n * 无效/过期/伪造 HTTP 401, { code:1, data:{ valid:false } }\n *\n * 用于「已有 cookie」分支:验过才放行,否则重新弹密码框。\n * 网络异常时返回 false(保守起见不放行),由调用方决定是否回退到弹框。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} token cookie 里存的 JWT\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<boolean>} token 是否有效\n */\nexport async function checkToken(baseUrl, token, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/check`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n return false;\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n return !!(resp.ok && data && data.data && data.data.valid === true);\n}\n","// 第一级:地址分发接口。用一个固定 token 换取真实的校验后端地址。\n//\n// 为什么要这一级:避免真实校验接口地址被写死在前端 bundle 字符串里。\n// 这里的 token 不是安全凭证——它本来就会出现在前端请求里,作用只是\n// 「让真实地址不直接出现在 bundle 里」,提高一点点扒取门槛。\n// 真正的安全靠第二/三级的 JWT 校验。\n//\n// 设计选择:不在内存缓存 data.url,每次校验都重新分发拉取,\n// 这样后端在 mock 里改地址即时生效。\n\n/** 地址分发接口 URL(写死在包内,改地址只改这里并发新版本)。 */\nexport const DISCOVER_URL =\n \"https://m1.apifoxmock.com/m1/6205743-5899102-default/getWebRateAddress\";\n\n/** 换取真实地址必带的固定 token(非安全凭证,仅提高扒取门槛)。 */\nexport const DISCOVER_TOKEN = \"qB8pgQh*pcbBRf3P\";\n\n/**\n * 向地址分发接口换取真实校验后端地址。\n *\n * 接口约定:\n * GET DISCOVER_URL header: { token: DISCOVER_TOKEN }\n * 成功 HTTP 200, { code:200, data:{ url:\"真实校验地址\" } }\n * 失败 HTTP 401, { code:401, data:{} }\n *\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<string>} 真实校验后端地址(已去除末尾斜杠)\n * @throws {Error} 网络异常、未授权或返回里没有 url 时抛出\n */\nexport async function discoverBaseUrl(opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(DISCOVER_URL, {\n method: \"GET\",\n headers: { token: DISCOVER_TOKEN },\n signal: controller.signal,\n });\n } catch {\n throw new Error(\"获取校验接口地址失败:网络异常\");\n } finally {\n clearTimeout(timer);\n }\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n const url = data && data.data && data.data.url;\n if (!resp.ok || data.code !== 200 || !url) {\n throw new Error(\"获取校验接口地址失败\");\n }\n\n // 去掉末尾斜杠,方便后续拼 `${base}/verify`、`${base}/check`。\n return String(url).replace(/\\/+$/, \"\");\n}\n","// 框架无关的密码输入页(原生 DOM)。\n//\n// 用 Shadow DOM 把样式和结构完全隔离,既不会污染子站的 CSS,\n// 也不会被子站的全局样式影响。任何前端项目(Vue / React / 纯 HTML)都能用。\n\nconst STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; align-items: center; justify-content: center;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.card {\n width: 320px; max-width: calc(100vw - 48px);\n padding: 32px 28px;\n background: #1b1e25; border-radius: 14px;\n box-shadow: 0 12px 40px rgba(0,0,0,.45);\n box-sizing: border-box;\n}\n.title { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: #f2f4f8; }\n.subtitle { margin: 0 0 22px; font-size: 13px; color: #8a92a6; line-height: 1.5; }\n.field { position: relative; }\n.input {\n width: 100%; box-sizing: border-box;\n padding: 11px 13px; font-size: 14px;\n color: #f2f4f8; background: #11141a;\n border: 1px solid #2c313c; border-radius: 9px; outline: none;\n transition: border-color .15s;\n}\n.input:focus { border-color: #4c8dff; }\n.btn {\n width: 100%; margin-top: 14px; padding: 11px 0;\n font-size: 14px; font-weight: 600; color: #fff; cursor: pointer;\n background: #4c8dff; border: none; border-radius: 9px;\n transition: background .15s, opacity .15s;\n}\n.btn:hover { background: #3a7df0; }\n.btn:disabled { opacity: .6; cursor: not-allowed; }\n.error {\n min-height: 18px; margin-top: 12px;\n font-size: 12.5px; color: #ff6b6b; line-height: 1.4;\n}\n`;\n\n/**\n * 创建并挂载密码页。返回一个对象,可用于销毁。\n *\n * @param {object} cfg\n * @param {string} cfg.title\n * @param {string} cfg.subtitle\n * @param {string} cfg.placeholder\n * @param {string} cfg.buttonText\n * @param {(password: string) => Promise<{ok: boolean, message?: string}>} cfg.onSubmit\n * 提交回调;返回 ok=true 时 UI 自动销毁,false 时显示 message。\n * @returns {{ destroy: () => void }}\n */\nexport function mountPasswordGate(cfg) {\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `\n <div class=\"card\">\n <h1 class=\"title\"></h1>\n <p class=\"subtitle\"></p>\n <div class=\"field\">\n <input class=\"input\" type=\"password\" autocomplete=\"current-password\" />\n </div>\n <button class=\"btn\" type=\"button\"></button>\n <div class=\"error\" aria-live=\"polite\"></div>\n </div>\n `;\n shadow.appendChild(mask);\n\n // 用 textContent 赋值,避免把配置文本当 HTML 注入。\n shadow.querySelector(\".title\").textContent = cfg.title;\n shadow.querySelector(\".subtitle\").textContent = cfg.subtitle;\n const input = shadow.querySelector(\".input\");\n const btn = shadow.querySelector(\".btn\");\n const errorEl = shadow.querySelector(\".error\");\n input.placeholder = cfg.placeholder;\n btn.textContent = cfg.buttonText;\n\n let submitting = false;\n\n async function submit() {\n if (submitting) return;\n const password = input.value;\n if (!password) {\n errorEl.textContent = \"请输入密码\";\n return;\n }\n\n submitting = true;\n btn.disabled = true;\n errorEl.textContent = \"\";\n const originalBtnText = btn.textContent;\n btn.textContent = \"验证中…\";\n\n let result;\n try {\n result = await cfg.onSubmit(password);\n } finally {\n submitting = false;\n btn.disabled = false;\n btn.textContent = originalBtnText;\n }\n\n if (result && result.ok) {\n destroy();\n } else {\n errorEl.textContent = (result && result.message) || \"密码错误\";\n input.select();\n }\n }\n\n btn.addEventListener(\"click\", submit);\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") submit();\n });\n\n function destroy() {\n host.remove();\n }\n\n document.body.appendChild(host);\n // 自动聚焦输入框,方便直接打字。\n setTimeout(() => input.focus(), 0);\n\n return { destroy };\n}\n\nconst LOADING_STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.spinner {\n width: 34px; height: 34px;\n border: 3px solid #2c313c;\n border-top-color: #4c8dff;\n border-radius: 50%;\n animation: zy-gate-spin .8s linear infinite;\n}\n.text { font-size: 13px; color: #8a92a6; }\n@keyframes zy-gate-spin { to { transform: rotate(360deg); } }\n`;\n\n/**\n * 挂载一个全屏 loading 遮罩,盖住「换地址 / 验签」等慢网空窗,避免白屏。\n *\n * 与密码页同一套深色背景和字体,所以它消失、密码页出现时观感是平滑过渡的。\n * 返回 destroy 用于在校验完成(放行或转入密码页)时移除。\n *\n * @param {object} [cfg]\n * @param {string} [cfg.text=\"正在验证访问权限…\"] 遮罩下方提示文案\n * @returns {{ destroy: () => void }}\n */\nexport function mountLoading(cfg = {}) {\n const { text = \"正在验证访问权限…\" } = cfg;\n\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate-loading\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = LOADING_STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `<div class=\"spinner\"></div><div class=\"text\"></div>`;\n shadow.appendChild(mask);\n shadow.querySelector(\".text\").textContent = text;\n\n document.body.appendChild(host);\n\n return { destroy: () => host.remove() };\n}\n","// zy-web-gate —— 纯前端「共享密码」页面访问门(两级接口 + JWT 方案)。\n//\n// 用法(在 Vue/React/任意前端入口、挂载真实应用之前调用):\n//\n// import { ensureGate } from \"zy-web-gate\";\n//\n// await ensureGate(); // 接口已固定在包内,零参数即可\n//\n// // 走到这里说明已通过门禁,再启动真实应用:\n// createApp(App).mount(\"#app\");\n//\n// 流程(详见 INTEGRATION.md):\n// 1. 已有 cookie(JWT)?-> 调 /check 验签,过则直接放行(无 UI)。\n// 2. 否则:GET 地址分发接口换真实校验地址 -> 弹密码框 -> POST /verify。\n// 3. 通过 -> 把后端签发的 JWT 写进父域 cookie -> 放行。\n//\n// 跨子域:验证通过后写父域 cookie(Domain=example.com),同主域其他子站\n// 读到该 cookie 并验签通过即直接放行,无需再次输入密码。\n//\n// 安全边界:纯前端门禁防的是「伪造 cookie 进 UI」与「照搬旧 =1 绕过」;\n// 挡不住绕过 UI 直接扒静态资源 / 直接打业务 API——真正的安全必须靠\n// 业务数据接口自己校验 token(即第三级 /check)。务必全链路走 HTTPS。\n\nimport { inferParentDomain, readCookie, writeCookie, deleteCookie } from \"./cookie.js\";\nimport { verifyPassword, checkToken } from \"./verify.js\";\nimport { discoverBaseUrl } from \"./discover.js\";\nimport { mountPasswordGate, mountLoading } from \"./ui.js\";\n\n/** @type {Required<GateOptions>} 默认配置 */\nconst DEFAULTS = {\n cookieName: \"zy_web_gate\",\n cookieDomain: undefined, // undefined 表示自动推断父域\n maxAgeDays: 7,\n sameSite: \"Lax\",\n secure: undefined, // undefined 表示按当前协议自动判断\n title: \"访问验证\",\n subtitle: \"请输入访问密码后继续。\",\n placeholder: \"访问密码\",\n buttonText: \"进入\",\n loadingText: \"正在验证访问权限…\",\n timeoutMs: 10000,\n};\n\n/**\n * @typedef {object} GateOptions\n * @property {string} [cookieName] 登录态 cookie 名,默认 \"zy_web_gate\";值为后端签发的 JWT\n * @property {string} [cookieDomain] 显式指定父域;不传则自动推断(如 example.com)\n * @property {number} [maxAgeDays] 登录态有效天数,默认 7\n * @property {string} [sameSite] cookie SameSite,默认 \"Lax\"\n * @property {boolean} [secure] cookie Secure;不传则按当前协议自动判断\n * @property {string} [title] 密码页标题\n * @property {string} [subtitle] 密码页副标题\n * @property {string} [placeholder] 输入框占位文案\n * @property {string} [buttonText] 按钮文案\n * @property {string} [loadingText] 已有 cookie 验签时的 loading 文案,默认 \"正在验证访问权限…\"\n * @property {number} [timeoutMs] 接口超时毫秒,默认 10000\n */\n\n/**\n * 解析最终配置。\n * @param {GateOptions} [options]\n */\nfunction resolveConfig(options) {\n const cfg = { ...DEFAULTS, ...(options || {}) };\n if (cfg.cookieDomain === undefined) {\n cfg.cookieDomain = inferParentDomain();\n }\n return cfg;\n}\n\n/**\n * 访问门主入口。\n *\n * 行为:\n * 1. 父域 cookie 已存在 JWT -> 换真实地址 -> 调 /check 验签;过则放行(无 UI)。\n * 验签不过 / 网络异常 -> 视作未登录,进入弹框流程。\n * 2. 弹出密码页,等用户输入并通过 /verify 校验。\n * 3. 校验通过 -> 把后端签发的 JWT 写进父域 cookie -> resolve。\n *\n * 该 Promise 只在「已通过门禁」时 resolve;密码未通过时不会 resolve,\n * 因此把它放在 createApp().mount() 之前 await,可保证未通过时真实应用绝不挂载。\n *\n * @param {GateOptions} [options]\n * @returns {Promise<void>}\n */\nexport async function ensureGate(options) {\n const cfg = resolveConfig(options);\n\n // 已有 cookie:取出 JWT,换真实地址后调 /check 验签——验过才放行。\n // 这一步是「JWT 方案」相对旧 =1 方案的关键:仅 cookie 存在不够,必须验签,\n // 否则攻击者随便塞个字符串就能进 UI。\n //\n // 慢网下「换地址 + 验签」是一段没有 UI 的空窗,会白屏;这里挂一个 loading\n // 遮罩盖住它,无论验过放行、验不过转密码页还是抛错,finally 都会移除。\n const existing = readCookie(cfg.cookieName);\n if (existing) {\n const loading = mountLoading({ text: cfg.loadingText });\n try {\n const baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs });\n if (await checkToken(baseUrl, existing, { timeoutMs: cfg.timeoutMs })) {\n return; // 验签通过,放行(finally 会先移除 loading)。\n }\n } catch {\n // 换地址失败:保守起见不放行,落到下方弹框流程。\n } finally {\n loading.destroy();\n }\n // 验签未过:清掉无效 cookie,避免每次都白跑一遍 /check。\n deleteCookie(cfg.cookieName, { domain: cfg.cookieDomain });\n }\n\n return new Promise((resolve) => {\n mountPasswordGate({\n title: cfg.title,\n subtitle: cfg.subtitle,\n placeholder: cfg.placeholder,\n buttonText: cfg.buttonText,\n onSubmit: async (password) => {\n let baseUrl;\n try {\n baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs });\n } catch {\n return { ok: false, message: \"网络异常,请稍后重试\" };\n }\n\n const result = await verifyPassword(baseUrl, password, {\n timeoutMs: cfg.timeoutMs,\n });\n\n if (result.ok && result.token) {\n writeCookie(cfg.cookieName, result.token, {\n domain: cfg.cookieDomain,\n maxAgeSeconds: cfg.maxAgeDays * 24 * 60 * 60,\n sameSite: cfg.sameSite,\n secure: cfg.secure,\n });\n // 通过门禁:UI 会在本回调返回 ok 后自动销毁,这里同时 resolve 外层 Promise,\n // 让 await ensureGate() 的调用方继续往下挂载真实应用。\n resolve();\n return { ok: true };\n }\n\n // 密码对但后端没给 token:当作异常,不放行(避免写空 cookie)。\n if (result.ok && !result.token) {\n return { ok: false, message: \"登录态签发失败,请重试\" };\n }\n\n return result;\n },\n });\n });\n}\n\n/**\n * 主动登出:清除父域 cookie。调用后下次进入任一子站会重新要求输入密码。\n * @param {GateOptions} options 至少需与 ensureGate 一致的 cookieName / cookieDomain\n */\nexport function logoutGate(options = {}) {\n const cfg = { ...DEFAULTS, ...options };\n const domain = cfg.cookieDomain === undefined\n ? inferParentDomain()\n : cfg.cookieDomain;\n deleteCookie(cfg.cookieName, { domain });\n}\n\nexport { inferParentDomain, readCookie } from \"./cookie.js\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"zy-web-gate.umd.cjs","names":[],"sources":["../src/cookie.js","../src/verify.js","../src/discover.js","../src/ui.js","../src/index.js"],"sourcesContent":["// 跨子域共享登录态的核心:父域 cookie 读写。\n//\n// 为什么必须用 cookie 而不是 localStorage:\n// localStorage 按 origin(scheme+host+port)隔离,a.example.com 写的东西\n// b.example.com 读不到,无法实现「一次验证、全子域通行」。\n// cookie 可以通过 Domain 属性设置到父域,被该域下所有子域共享读取。\n\n/**\n * 由当前 host 推断可用于跨子域共享的父域。\n *\n * 例如当前在 a.example.com,应返回 example.com,\n * 这样写入的 cookie 能被 my./b./x. 等所有兄弟子域读到。\n *\n * 注意:eu.org 在 Public Suffix List 上,浏览器禁止把 cookie 设到 eu.org,\n * 所以父域只能取到 example.com 这一层,刚好是我们要的、也是安全的。\n *\n * 这里采用「去掉最左一段」的朴素策略:a.example.com -> example.com。\n * 大多数「单层子域」场景够用;若有多级子域或想显式指定,用 options.cookieDomain 覆盖。\n *\n * @param {string} [hostname] 默认取 location.hostname\n * @returns {string} 用于 cookie Domain 的父域;localhost / IP 等场景返回空串(表示按当前 host)\n */\nexport function inferParentDomain(hostname) {\n const host = hostname || window.location.hostname;\n\n // localhost、单段主机名、IP 地址:不设 Domain,cookie 只对当前 host 生效。\n if (host === \"localhost\" || !host.includes(\".\")) return \"\";\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) return \"\";\n\n const parts = host.split(\".\");\n // 形如 example.com(两段):父域就是它自己。\n if (parts.length <= 2) return host;\n // 形如 a.example.com:去掉最左一段。\n return parts.slice(1).join(\".\");\n}\n\n/**\n * 读取指定名称的 cookie 值。\n * @param {string} name\n * @returns {string|null}\n */\nexport function readCookie(name) {\n const prefix = encodeURIComponent(name) + \"=\";\n const items = document.cookie ? document.cookie.split(\"; \") : [];\n for (const item of items) {\n if (item.indexOf(prefix) === 0) {\n return decodeURIComponent(item.slice(prefix.length));\n }\n }\n return null;\n}\n\n/**\n * 写入跨子域 cookie。\n *\n * @param {string} name\n * @param {string} value\n * @param {object} opts\n * @param {string} opts.domain cookie 的 Domain,传空串则不写 Domain(仅当前 host)\n * @param {number} opts.maxAgeSeconds 有效期(秒)\n * @param {string} [opts.sameSite=\"Lax\"]\n * @param {boolean} [opts.secure=true] HTTPS 下应为 true;本地 http 调试时会自动放宽\n * @param {string} [opts.path=\"/\"]\n */\nexport function writeCookie(name, value, opts) {\n const {\n domain,\n maxAgeSeconds,\n sameSite = \"Lax\",\n path = \"/\",\n } = opts;\n\n // 本地 http 调试时 Secure 会让 cookie 写不进去,这里按当前协议自动判断。\n const secure = opts.secure !== undefined\n ? opts.secure\n : window.location.protocol === \"https:\";\n\n let str =\n encodeURIComponent(name) + \"=\" + encodeURIComponent(value) +\n \"; path=\" + path +\n \"; max-age=\" + Math.floor(maxAgeSeconds) +\n \"; samesite=\" + sameSite;\n\n if (domain) str += \"; domain=\" + domain;\n if (secure) str += \"; secure\";\n\n document.cookie = str;\n}\n\n/**\n * 删除跨子域 cookie(用于「登出」)。Domain / Path 必须和写入时一致才能删掉。\n * @param {string} name\n * @param {object} opts\n * @param {string} opts.domain\n * @param {string} [opts.path=\"/\"]\n */\nexport function deleteCookie(name, opts) {\n const { domain, path = \"/\" } = opts;\n let str = encodeURIComponent(name) + \"=; path=\" + path + \"; max-age=0\";\n if (domain) str += \"; domain=\" + domain;\n document.cookie = str;\n}\n","// 第二/三级:向真实校验后端(gate-auth-server)校验密码与 token。\n//\n// 设计要点:真密码只存在后端,前端 bundle 里没有任何密码信息。\n// 校验通过后端会签发一枚 JWT,前端把它存进 cookie;再次进入时调 /check\n// 验签——攻击者没有服务端密钥,签不出合法 token,伪造 cookie 进不来。\n\n/**\n * 密码校验结果。\n * @typedef {object} VerifyResult\n * @property {boolean} ok 密码是否正确\n * @property {string|null} [token] 后端签发的 JWT(ok 时有值),写入 cookie\n * @property {boolean} [networkError] 是否因网络/接口异常导致(区别于「密码错」)\n * @property {string} [message] 可展示给用户的提示\n */\n\n/**\n * 第二级:调 `{baseUrl}/verify` 校验密码。\n *\n * 接口约定:\n * POST {baseUrl}/verify body: {\"password\":\"用户输入\"}\n * 通过 HTTP 200, { code:0, data:{ match:true, token:\"<JWT>\", expiresIn:604800 } }\n * 密码错 HTTP 401, { code:1, data:{ match:false } }\n *\n * 判定规则:只有「HTTP ok 且 data.match === true」才算通过;其余一律当密码错。\n * 网络层异常单独标记 networkError,便于 UI 区分提示。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} password\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<VerifyResult>}\n */\nexport async function verifyPassword(baseUrl, password, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/verify`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n // fetch 抛错只可能是网络层问题(断网、CORS、超时 abort 等),不是密码错。\n return { ok: false, networkError: true, message: \"网络异常,请稍后重试\" };\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n // 响应不是合法 JSON:当作密码错/异常处理,不放行。\n data = null;\n }\n\n const match = !!(resp.ok && data && data.data && data.data.match === true);\n\n if (match) {\n return { ok: true, token: (data.data && data.data.token) || null };\n }\n\n // 未通过:优先展示后端返回的提示(如「当前访问密码无权限访问当前网站」),\n // 后端没给文案时回落到「密码错误」。\n const message = (data && typeof data.message === \"string\" && data.message) || \"密码错误\";\n return { ok: false, networkError: false, message };\n}\n\n/**\n * 第三级:调 `{baseUrl}/check` 验签 cookie 里的 JWT。\n *\n * 接口约定:\n * POST {baseUrl}/check header: { Authorization: \"Bearer <token>\" }\n * 有效 HTTP 200, { code:0, data:{ valid:true } }\n * 无效/过期/伪造 HTTP 401, { code:1, data:{ valid:false } }\n *\n * 用于「已有 cookie」分支:验过才放行,否则重新弹密码框。\n * 网络异常时返回 false(保守起见不放行),由调用方决定是否回退到弹框。\n *\n * @param {string} baseUrl 真实校验后端地址(不含末尾斜杠)\n * @param {string} token cookie 里存的 JWT\n * @param {object} [opts]\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<boolean>} token 是否有效\n */\nexport async function checkToken(baseUrl, token, opts = {}) {\n const { timeoutMs = 10000 } = opts;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(`${baseUrl}/check`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ token }),\n signal: controller.signal,\n });\n } catch {\n clearTimeout(timer);\n return false;\n }\n clearTimeout(timer);\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n return !!(resp.ok && data && data.data && data.data.valid === true);\n}\n","// 第一级:地址分发接口。用一个固定 token 换取真实的校验后端地址。\n//\n// 为什么要这一级:避免真实校验接口地址被写死在前端 bundle 字符串里。\n// 这里的 token 不是安全凭证——它本来就会出现在前端请求里,作用只是\n// 「让真实地址不直接出现在 bundle 里」,提高一点点扒取门槛。\n// 真正的安全靠第二/三级的 JWT 校验。\n//\n// 设计选择:不在内存缓存 data.url,每次校验都重新分发拉取,\n// 这样后端在 mock 里改地址即时生效。\n\n/** 地址分发接口 URL(写死在包内,改地址只改这里并发新版本)。 */\nexport const DISCOVER_URL =\n \"https://m1.apifoxmock.com/m1/6205743-5899102-default/getMyWebUrl\";\n\n/**\n * 各环境换取真实地址所需的请求头(token 非安全凭证,仅提高扒取门槛)。\n * 分发接口按 env 头返回对应环境的真实校验地址。\n */\nconst ENV_HEADERS = {\n dev: { token: \"z9M2beGEawlsBF6O\", env: \"dev\" },\n test: { token: \"u3gnR2mpVjMXrnf1\", env: \"test\" },\n};\n\n/** 支持的环境名列表,用于报错提示。 */\nexport const SUPPORTED_ENVS = Object.keys(ENV_HEADERS);\n\n/**\n * 向地址分发接口换取真实校验后端地址。\n *\n * 接口约定:\n * GET DISCOVER_URL header: { token, env }(按环境取,见 ENV_HEADERS)\n * 成功 HTTP 200, { code:200, data:{ url:\"真实校验地址\" } }\n * 失败 HTTP 401, { code:401, data:{} }\n *\n * @param {object} opts\n * @param {string} opts.env 环境名,必传,取值 dev / test\n * @param {number} [opts.timeoutMs=10000]\n * @returns {Promise<string>} 真实校验后端地址(已去除末尾斜杠)\n * @throws {Error} env 缺失/非法、网络异常、未授权或返回里没有 url 时抛出\n */\nexport async function discoverBaseUrl(opts = {}) {\n const { timeoutMs = 10000, env } = opts;\n\n const headers = ENV_HEADERS[env];\n if (!headers) {\n throw new Error(\n `zy-web-gate: 必须传入 env(${SUPPORTED_ENVS.join(\" / \")}),当前为 ${JSON.stringify(env)}`,\n );\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let resp;\n try {\n resp = await fetch(DISCOVER_URL, {\n method: \"GET\",\n headers,\n signal: controller.signal,\n });\n } catch {\n throw new Error(\"获取校验接口地址失败:网络异常\");\n } finally {\n clearTimeout(timer);\n }\n\n let data = null;\n try {\n data = await resp.json();\n } catch {\n data = null;\n }\n\n const url = data && data.data && data.data.url;\n if (!resp.ok || data.code !== 200 || !url) {\n throw new Error(\"获取校验接口地址失败\");\n }\n\n // 去掉末尾斜杠,方便后续拼 `${base}/verify`、`${base}/check`。\n return String(url).replace(/\\/+$/, \"\");\n}\n","// 框架无关的密码输入页(原生 DOM)。\n//\n// 用 Shadow DOM 把样式和结构完全隔离,既不会污染子站的 CSS,\n// 也不会被子站的全局样式影响。任何前端项目(Vue / React / 纯 HTML)都能用。\n\nconst STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; align-items: center; justify-content: center;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.card {\n width: 320px; max-width: calc(100vw - 48px);\n padding: 32px 28px;\n background: #1b1e25; border-radius: 14px;\n box-shadow: 0 12px 40px rgba(0,0,0,.45);\n box-sizing: border-box;\n}\n.title { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: #f2f4f8; }\n.subtitle { margin: 0 0 22px; font-size: 13px; color: #8a92a6; line-height: 1.5; }\n.field { position: relative; }\n.input {\n width: 100%; box-sizing: border-box;\n padding: 11px 13px; font-size: 14px;\n color: #f2f4f8; background: #11141a;\n border: 1px solid #2c313c; border-radius: 9px; outline: none;\n transition: border-color .15s;\n}\n.input:focus { border-color: #4c8dff; }\n.btn {\n width: 100%; margin-top: 14px; padding: 11px 0;\n font-size: 14px; font-weight: 600; color: #fff; cursor: pointer;\n background: #4c8dff; border: none; border-radius: 9px;\n transition: background .15s, opacity .15s;\n}\n.btn:hover { background: #3a7df0; }\n.btn:disabled { opacity: .6; cursor: not-allowed; }\n.error {\n min-height: 18px; margin-top: 12px;\n font-size: 12.5px; color: #ff6b6b; line-height: 1.4;\n}\n`;\n\n/**\n * 创建并挂载密码页。返回一个对象,可用于销毁。\n *\n * @param {object} cfg\n * @param {string} cfg.title\n * @param {string} cfg.subtitle\n * @param {string} cfg.placeholder\n * @param {string} cfg.buttonText\n * @param {(password: string) => Promise<{ok: boolean, message?: string}>} cfg.onSubmit\n * 提交回调;返回 ok=true 时 UI 自动销毁,false 时显示 message。\n * @returns {{ destroy: () => void }}\n */\nexport function mountPasswordGate(cfg) {\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `\n <div class=\"card\">\n <h1 class=\"title\"></h1>\n <p class=\"subtitle\"></p>\n <div class=\"field\">\n <input class=\"input\" type=\"password\" autocomplete=\"current-password\" />\n </div>\n <button class=\"btn\" type=\"button\"></button>\n <div class=\"error\" aria-live=\"polite\"></div>\n </div>\n `;\n shadow.appendChild(mask);\n\n // 用 textContent 赋值,避免把配置文本当 HTML 注入。\n shadow.querySelector(\".title\").textContent = cfg.title;\n shadow.querySelector(\".subtitle\").textContent = cfg.subtitle;\n const input = shadow.querySelector(\".input\");\n const btn = shadow.querySelector(\".btn\");\n const errorEl = shadow.querySelector(\".error\");\n input.placeholder = cfg.placeholder;\n btn.textContent = cfg.buttonText;\n\n let submitting = false;\n\n async function submit() {\n if (submitting) return;\n const password = input.value;\n if (!password) {\n errorEl.textContent = \"请输入密码\";\n return;\n }\n\n submitting = true;\n btn.disabled = true;\n errorEl.textContent = \"\";\n const originalBtnText = btn.textContent;\n btn.textContent = \"验证中…\";\n\n let result;\n try {\n result = await cfg.onSubmit(password);\n } finally {\n submitting = false;\n btn.disabled = false;\n btn.textContent = originalBtnText;\n }\n\n if (result && result.ok) {\n destroy();\n } else {\n errorEl.textContent = (result && result.message) || \"密码错误\";\n input.select();\n }\n }\n\n btn.addEventListener(\"click\", submit);\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") submit();\n });\n\n function destroy() {\n host.remove();\n }\n\n document.body.appendChild(host);\n // 自动聚焦输入框,方便直接打字。\n setTimeout(() => input.focus(), 0);\n\n return { destroy };\n}\n\nconst LOADING_STYLE = `\n:host { all: initial; }\n.mask {\n position: fixed; inset: 0; z-index: 2147483647;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px;\n background: #0f1115;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n}\n.spinner {\n width: 34px; height: 34px;\n border: 3px solid #2c313c;\n border-top-color: #4c8dff;\n border-radius: 50%;\n animation: zy-gate-spin .8s linear infinite;\n}\n.text { font-size: 13px; color: #8a92a6; }\n@keyframes zy-gate-spin { to { transform: rotate(360deg); } }\n`;\n\n/**\n * 挂载一个全屏 loading 遮罩,盖住「换地址 / 验签」等慢网空窗,避免白屏。\n *\n * 与密码页同一套深色背景和字体,所以它消失、密码页出现时观感是平滑过渡的。\n * 返回 destroy 用于在校验完成(放行或转入密码页)时移除。\n *\n * @param {object} [cfg]\n * @param {string} [cfg.text=\"正在验证访问权限…\"] 遮罩下方提示文案\n * @returns {{ destroy: () => void }}\n */\nexport function mountLoading(cfg = {}) {\n const { text = \"正在验证访问权限…\" } = cfg;\n\n const host = document.createElement(\"div\");\n host.setAttribute(\"data-zy-web-gate-loading\", \"\");\n const shadow = host.attachShadow({ mode: \"open\" });\n\n const style = document.createElement(\"style\");\n style.textContent = LOADING_STYLE;\n shadow.appendChild(style);\n\n const mask = document.createElement(\"div\");\n mask.className = \"mask\";\n mask.innerHTML = `<div class=\"spinner\"></div><div class=\"text\"></div>`;\n shadow.appendChild(mask);\n shadow.querySelector(\".text\").textContent = text;\n\n document.body.appendChild(host);\n\n return { destroy: () => host.remove() };\n}\n","// zy-web-gate —— 纯前端「共享密码」页面访问门(两级接口 + JWT 方案)。\n//\n// 用法(在 Vue/React/任意前端入口、挂载真实应用之前调用):\n//\n// import { ensureGate } from \"zy-web-gate\";\n//\n// await ensureGate(); // 接口已固定在包内,零参数即可\n//\n// // 走到这里说明已通过门禁,再启动真实应用:\n// createApp(App).mount(\"#app\");\n//\n// 流程(详见 INTEGRATION.md):\n// 1. 已有 cookie(JWT)?-> 调 /check 验签,过则直接放行(无 UI)。\n// 2. 否则:GET 地址分发接口换真实校验地址 -> 弹密码框 -> POST /verify。\n// 3. 通过 -> 把后端签发的 JWT 写进父域 cookie -> 放行。\n//\n// 跨子域:验证通过后写父域 cookie(Domain=example.com),同主域其他子站\n// 读到该 cookie 并验签通过即直接放行,无需再次输入密码。\n//\n// 安全边界:纯前端门禁防的是「伪造 cookie 进 UI」与「照搬旧 =1 绕过」;\n// 挡不住绕过 UI 直接扒静态资源 / 直接打业务 API——真正的安全必须靠\n// 业务数据接口自己校验 token(即第三级 /check)。务必全链路走 HTTPS。\n\nimport { inferParentDomain, readCookie, writeCookie, deleteCookie } from \"./cookie.js\";\nimport { verifyPassword, checkToken } from \"./verify.js\";\nimport { discoverBaseUrl, SUPPORTED_ENVS } from \"./discover.js\";\nimport { mountPasswordGate, mountLoading } from \"./ui.js\";\n\n/** @type {Required<GateOptions>} 默认配置 */\nconst DEFAULTS = {\n cookieName: \"zy_web_gate\",\n cookieDomain: undefined, // undefined 表示自动推断父域\n maxAgeDays: 7,\n sameSite: \"Lax\",\n secure: undefined, // undefined 表示按当前协议自动判断\n title: \"访问验证\",\n subtitle: \"请输入访问密码后继续。\",\n placeholder: \"访问密码\",\n buttonText: \"进入\",\n loadingText: \"正在验证访问权限…\",\n timeoutMs: 10000,\n env: undefined, // 环境名(dev/test),必传,用于地址分发接口区分环境\n};\n\n/**\n * @typedef {object} GateOptions\n * @property {string} [cookieName] 登录态 cookie 名,默认 \"zy_web_gate\";值为后端签发的 JWT\n * @property {string} [cookieDomain] 显式指定父域;不传则自动推断(如 example.com)\n * @property {number} [maxAgeDays] 登录态有效天数,默认 7\n * @property {string} [sameSite] cookie SameSite,默认 \"Lax\"\n * @property {boolean} [secure] cookie Secure;不传则按当前协议自动判断\n * @property {string} [title] 密码页标题\n * @property {string} [subtitle] 密码页副标题\n * @property {string} [placeholder] 输入框占位文案\n * @property {string} [buttonText] 按钮文案\n * @property {string} [loadingText] 已有 cookie 验签时的 loading 文案,默认 \"正在验证访问权限…\"\n * @property {number} [timeoutMs] 接口超时毫秒,默认 10000\n * @property {string} env 环境名(dev/test),必传,用于地址分发接口区分环境\n */\n\n/**\n * 解析最终配置。\n * @param {GateOptions} [options]\n */\nfunction resolveConfig(options) {\n const cfg = { ...DEFAULTS, ...(options || {}) };\n if (cfg.cookieDomain === undefined) {\n cfg.cookieDomain = inferParentDomain();\n }\n return cfg;\n}\n\n/**\n * 访问门主入口。\n *\n * 行为:\n * 1. 父域 cookie 已存在 JWT -> 换真实地址 -> 调 /check 验签;过则放行(无 UI)。\n * 验签不过 / 网络异常 -> 视作未登录,进入弹框流程。\n * 2. 弹出密码页,等用户输入并通过 /verify 校验。\n * 3. 校验通过 -> 把后端签发的 JWT 写进父域 cookie -> resolve。\n *\n * 该 Promise 只在「已通过门禁」时 resolve;密码未通过时不会 resolve,\n * 因此把它放在 createApp().mount() 之前 await,可保证未通过时真实应用绝不挂载。\n *\n * @param {GateOptions} [options]\n * @returns {Promise<void>}\n */\nexport async function ensureGate(options) {\n const cfg = resolveConfig(options);\n\n // env 必传:尽早校验,避免运行到换地址时才报错。\n if (!cfg.env || !SUPPORTED_ENVS.includes(cfg.env)) {\n throw new Error(\n `zy-web-gate: ensureGate 必须传入 env(${SUPPORTED_ENVS.join(\" / \")}),当前为 ${JSON.stringify(cfg.env)}`,\n );\n }\n\n // 已有 cookie:取出 JWT,换真实地址后调 /check 验签——验过才放行。\n // 这一步是「JWT 方案」相对旧 =1 方案的关键:仅 cookie 存在不够,必须验签,\n // 否则攻击者随便塞个字符串就能进 UI。\n //\n // 慢网下「换地址 + 验签」是一段没有 UI 的空窗,会白屏;这里挂一个 loading\n // 遮罩盖住它,无论验过放行、验不过转密码页还是抛错,finally 都会移除。\n const existing = readCookie(cfg.cookieName);\n if (existing) {\n const loading = mountLoading({ text: cfg.loadingText });\n try {\n const baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs, env: cfg.env });\n if (await checkToken(baseUrl, existing, { timeoutMs: cfg.timeoutMs })) {\n return; // 验签通过,放行(finally 会先移除 loading)。\n }\n } catch {\n // 换地址失败:保守起见不放行,落到下方弹框流程。\n } finally {\n loading.destroy();\n }\n // 验签未过:清掉无效 cookie,避免每次都白跑一遍 /check。\n deleteCookie(cfg.cookieName, { domain: cfg.cookieDomain });\n }\n\n return new Promise((resolve) => {\n mountPasswordGate({\n title: cfg.title,\n subtitle: cfg.subtitle,\n placeholder: cfg.placeholder,\n buttonText: cfg.buttonText,\n onSubmit: async (password) => {\n let baseUrl;\n try {\n baseUrl = await discoverBaseUrl({ timeoutMs: cfg.timeoutMs, env: cfg.env });\n } catch {\n return { ok: false, message: \"网络异常,请稍后重试\" };\n }\n\n const result = await verifyPassword(baseUrl, password, {\n timeoutMs: cfg.timeoutMs,\n });\n\n if (result.ok && result.token) {\n writeCookie(cfg.cookieName, result.token, {\n domain: cfg.cookieDomain,\n maxAgeSeconds: cfg.maxAgeDays * 24 * 60 * 60,\n sameSite: cfg.sameSite,\n secure: cfg.secure,\n });\n // 通过门禁:UI 会在本回调返回 ok 后自动销毁,这里同时 resolve 外层 Promise,\n // 让 await ensureGate() 的调用方继续往下挂载真实应用。\n resolve();\n return { ok: true };\n }\n\n // 密码对但后端没给 token:当作异常,不放行(避免写空 cookie)。\n if (result.ok && !result.token) {\n return { ok: false, message: \"登录态签发失败,请重试\" };\n }\n\n return result;\n },\n });\n });\n}\n\n/**\n * 主动登出:清除父域 cookie。调用后下次进入任一子站会重新要求输入密码。\n * @param {GateOptions} options 至少需与 ensureGate 一致的 cookieName / cookieDomain\n */\nexport function logoutGate(options = {}) {\n const cfg = { ...DEFAULTS, ...options };\n const domain = cfg.cookieDomain === undefined\n ? inferParentDomain()\n : cfg.cookieDomain;\n deleteCookie(cfg.cookieName, { domain });\n}\n\nexport { inferParentDomain, readCookie } from \"./cookie.js\";\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zy-web-gate",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Framework-agnostic, pure-frontend shared-password page gate with cross-subdomain login state via a parent-domain cookie. No backend auth, no accounts — verification is delegated to a check-password API.",
5
5
  "type": "module",
6
6
  "main": "./dist/zy-web-gate.umd.cjs",