@fonlan/dsh-web-auth 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/cordis.patch.yml +23 -0
- package/lib/auth-core.d.ts +78 -0
- package/lib/auth-core.js +175 -0
- package/lib/client/index.d.ts +9 -0
- package/lib/client.js +305 -0
- package/lib/context-types.d.ts +37 -0
- package/lib/context-types.js +1 -0
- package/lib/gate.d.ts +32 -0
- package/lib/gate.js +55 -0
- package/lib/handlers.d.ts +121 -0
- package/lib/handlers.js +372 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +165 -0
- package/lib/login-page.d.ts +22 -0
- package/lib/login-page.js +80 -0
- package/lib/profile-patch.d.ts +45 -0
- package/lib/profile-patch.js +154 -0
- package/lib/state.d.ts +12 -0
- package/lib/state.js +78 -0
- package/package.json +70 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-web-auth",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
"use strict";
|
|
7
|
+
var dshWebAuthModule = (() => {
|
|
8
|
+
var __defProp = Object.defineProperty;
|
|
9
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
10
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
13
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
14
|
+
}) : x)(function(x) {
|
|
15
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
16
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
17
|
+
});
|
|
18
|
+
var __export = (target, all) => {
|
|
19
|
+
for (var name in all)
|
|
20
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
21
|
+
};
|
|
22
|
+
var __copyProps = (to, from, except, desc) => {
|
|
23
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
24
|
+
for (let key of __getOwnPropNames(from))
|
|
25
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
26
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
27
|
+
}
|
|
28
|
+
return to;
|
|
29
|
+
};
|
|
30
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
31
|
+
|
|
32
|
+
// src/client/index.tsx
|
|
33
|
+
var index_exports = {};
|
|
34
|
+
__export(index_exports, {
|
|
35
|
+
apply: () => apply,
|
|
36
|
+
inject: () => inject
|
|
37
|
+
});
|
|
38
|
+
var import_react = __require("react");
|
|
39
|
+
var import_jsx_runtime = __require("react/jsx-runtime");
|
|
40
|
+
var inject = ["slots"];
|
|
41
|
+
var CSS = `
|
|
42
|
+
.dwa-card { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
|
|
43
|
+
.dwa-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
44
|
+
.dwa-status { font-size: 13px; line-height: 1.6; }
|
|
45
|
+
.dwa-status[data-on] { color: var(--dsw-alias-label-primary, #e6e6e6); }
|
|
46
|
+
.dwa-status[data-off] { color: var(--dsw-alias-state-warn-primary, #e0a53b); }
|
|
47
|
+
.dwa-hint { color: var(--dsw-alias-label-tertiary, #8b93a7); font-size: 12px; line-height: 1.6; margin: 0; }
|
|
48
|
+
.dwa-field { display: flex; flex-direction: column; gap: 5px; }
|
|
49
|
+
.dwa-label { color: var(--dsw-alias-label-secondary, #aab2c5); font-size: 12px; font-weight: 600; }
|
|
50
|
+
.dwa-input {
|
|
51
|
+
height: 34px; padding: 0 12px; border-radius: 8px; outline: none;
|
|
52
|
+
border: 1px solid var(--dsw-alias-border-l2, #2a3346);
|
|
53
|
+
background: var(--dsw-alias-bg-layer-3, #0d1017);
|
|
54
|
+
color: var(--dsw-alias-label-primary, #e6e6e6); font: inherit; font-size: 13px;
|
|
55
|
+
}
|
|
56
|
+
.dwa-input:focus { border-color: var(--dsw-alias-brand-primary, #4f8cff); }
|
|
57
|
+
.dwa-actions { display: flex; align-items: center; gap: 10px; }
|
|
58
|
+
.dwa-btn {
|
|
59
|
+
height: 32px; padding: 0 14px; border: none; border-radius: 8px; cursor: pointer;
|
|
60
|
+
background: var(--dsw-alias-button-info-fill, #4f8cff); color: #fff;
|
|
61
|
+
font: inherit; font-size: 13px; font-weight: 600;
|
|
62
|
+
}
|
|
63
|
+
.dwa-btn:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover, #6b9dff); }
|
|
64
|
+
.dwa-btn:disabled { opacity: .5; cursor: default; }
|
|
65
|
+
.dwa-btn[data-ghost] {
|
|
66
|
+
background: transparent; color: var(--dsw-alias-label-secondary, #aab2c5);
|
|
67
|
+
border: 1px solid var(--dsw-alias-border-l2, #2a3346); font-weight: 500;
|
|
68
|
+
}
|
|
69
|
+
.dwa-msg { margin: 0; font-size: 12.5px; line-height: 1.6; }
|
|
70
|
+
.dwa-msg[data-kind=ok] { color: var(--dsw-alias-state-success-primary, #4caf7d); }
|
|
71
|
+
.dwa-msg[data-kind=err] { color: var(--dsw-alias-state-error-primary, #ff8f8f); }
|
|
72
|
+
.dwa-divider { border: none; border-top: 1px solid var(--dsw-alias-border-l1, #232a38); margin: 2px 0; }
|
|
73
|
+
`;
|
|
74
|
+
function injectStyle() {
|
|
75
|
+
const id = "dsh-web-auth/card.css";
|
|
76
|
+
if (document.getElementById(id) !== null) return;
|
|
77
|
+
const tag = document.createElement("style");
|
|
78
|
+
tag.id = id;
|
|
79
|
+
tag.dataset.plugin = "dsh-web-auth";
|
|
80
|
+
tag.textContent = CSS;
|
|
81
|
+
document.head.appendChild(tag);
|
|
82
|
+
}
|
|
83
|
+
async function changePassword(oldPassword, newPassword) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch("/api/web-auth/password", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ oldPassword, newPassword })
|
|
89
|
+
});
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
if (response.ok && data.ok === true) return { ok: true, message: "\u5BC6\u7801\u5DF2\u4FEE\u6539\uFF0C\u6240\u6709\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002" };
|
|
92
|
+
if (data.error === "wrong-password") return { ok: false, message: "\u65E7\u5BC6\u7801\u9519\u8BEF\u3002" };
|
|
93
|
+
if (data.error === "password-too-short") return { ok: false, message: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" };
|
|
94
|
+
if (data.error === "rate-limited") return { ok: false, message: "\u5C1D\u8BD5\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002" };
|
|
95
|
+
return { ok: false, message: "\u4FEE\u6539\u5931\u8D25\uFF1A" + String(data.error ?? response.status) };
|
|
96
|
+
} catch {
|
|
97
|
+
return { ok: false, message: "\u7F51\u7EDC\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
var LISTEN_OPTIONS = [
|
|
101
|
+
{ value: "127.0.0.1", label: "127.0.0.1\uFF08\u4EC5\u672C\u673A\uFF09" },
|
|
102
|
+
{ value: "0.0.0.0", label: "0.0.0.0\uFF08\u6240\u6709\u7F51\u5361\uFF0C\u5C40\u57DF\u7F51\u53EF\u8BBF\u95EE\uFF09" }
|
|
103
|
+
];
|
|
104
|
+
function isListenHost(value) {
|
|
105
|
+
return value === "127.0.0.1" || value === "0.0.0.0";
|
|
106
|
+
}
|
|
107
|
+
function PasswordSection(_props) {
|
|
108
|
+
const [configured, setConfigured] = (0, import_react.useState)(null);
|
|
109
|
+
const [oldPassword, setOldPassword] = (0, import_react.useState)("");
|
|
110
|
+
const [newPassword, setNewPassword] = (0, import_react.useState)("");
|
|
111
|
+
const [confirm, setConfirm] = (0, import_react.useState)("");
|
|
112
|
+
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
113
|
+
const [message, setMessage] = (0, import_react.useState)(null);
|
|
114
|
+
const [listenHost, setListenHost] = (0, import_react.useState)(null);
|
|
115
|
+
const [listenPick, setListenPick] = (0, import_react.useState)("127.0.0.1");
|
|
116
|
+
const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
|
|
117
|
+
const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
|
|
118
|
+
(0, import_react.useEffect)(() => {
|
|
119
|
+
let alive = true;
|
|
120
|
+
void fetch("/api/web-auth/status").then((response) => response.ok ? response.json() : null).then((data) => {
|
|
121
|
+
if (!alive) return;
|
|
122
|
+
setConfigured(data?.configured === true);
|
|
123
|
+
if (isListenHost(data?.host)) {
|
|
124
|
+
setListenHost(data.host);
|
|
125
|
+
setListenPick(data.host);
|
|
126
|
+
}
|
|
127
|
+
}).catch(() => {
|
|
128
|
+
if (alive) setConfigured(null);
|
|
129
|
+
});
|
|
130
|
+
return () => {
|
|
131
|
+
alive = false;
|
|
132
|
+
};
|
|
133
|
+
}, []);
|
|
134
|
+
const submit = async () => {
|
|
135
|
+
if (newPassword.length < 8) {
|
|
136
|
+
setMessage({ kind: "err", text: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (newPassword !== confirm) {
|
|
140
|
+
setMessage({ kind: "err", text: "\u4E24\u6B21\u8F93\u5165\u7684\u65B0\u5BC6\u7801\u4E0D\u4E00\u81F4\u3002" });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
setBusy(true);
|
|
144
|
+
setMessage(null);
|
|
145
|
+
const result = await changePassword(oldPassword, newPassword);
|
|
146
|
+
setBusy(false);
|
|
147
|
+
setMessage({ kind: result.ok ? "ok" : "err", text: result.message });
|
|
148
|
+
if (result.ok) {
|
|
149
|
+
setConfigured(true);
|
|
150
|
+
setOldPassword("");
|
|
151
|
+
setNewPassword("");
|
|
152
|
+
setConfirm("");
|
|
153
|
+
window.setTimeout(() => {
|
|
154
|
+
window.location.href = "/login";
|
|
155
|
+
}, 1600);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const applyListen = async () => {
|
|
159
|
+
if (listenHost === listenPick) return;
|
|
160
|
+
setListenBusy(true);
|
|
161
|
+
setListenMsg(null);
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch("/api/web-auth/listen", {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "content-type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ host: listenPick })
|
|
167
|
+
});
|
|
168
|
+
const data = await response.json();
|
|
169
|
+
if (response.ok && data.ok === true) {
|
|
170
|
+
if (data.applying === true) {
|
|
171
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF0C\u9875\u9762\u5C06\u77ED\u6682\u65AD\u8FDE\u2026" });
|
|
172
|
+
for (let i = 0; i < 15; i++) {
|
|
173
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
174
|
+
try {
|
|
175
|
+
const check = await fetch("/api/web-auth/status");
|
|
176
|
+
const status = await check.json();
|
|
177
|
+
if (isListenHost(status.host) && status.host === listenPick) {
|
|
178
|
+
setListenHost(listenPick);
|
|
179
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
|
|
186
|
+
} else {
|
|
187
|
+
setListenHost(listenPick);
|
|
188
|
+
setListenMsg({ kind: "ok", text: "\u76D1\u542C\u5730\u5740\u672A\u53D8\u5316\u3002" });
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
const text = data.error === "invalid-host" ? "\u65E0\u6548\u7684\u76D1\u542C\u5730\u5740\u3002" : data.error === "unsupported" ? "\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u4FEE\u6539\u76D1\u542C\u5730\u5740\u3002" : "\u5207\u6362\u5931\u8D25\uFF1A" + String(data.error ?? response.status);
|
|
192
|
+
setListenMsg({ kind: "err", text });
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
setListenMsg({ kind: "err", text: "\u7F51\u7EDC\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002" });
|
|
196
|
+
} finally {
|
|
197
|
+
setListenBusy(false);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
if (configured === null) {
|
|
201
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-card", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u52A0\u8F7D\u4E2D\u2026" }) });
|
|
202
|
+
}
|
|
203
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-card", children: [
|
|
204
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-row", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-status", "data-on": configured ? void 0 : "", "data-off": configured ? "" : void 0, children: configured ? "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u5DF2\u542F\u7528" : "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u672A\u8BBE\u7F6E\u5BC6\u7801\uFF08\u5F53\u524D\u672A\u542F\u7528\uFF09" }) }),
|
|
205
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u8BBE\u7F6E\u540E\uFF0C\u8BBF\u95EE\u672C\u670D\u52A1\u9700\u8981\u8F93\u5165\u5BC6\u7801\uFF1B\u4F1A\u8BDD\u6709\u6548\u671F 7 \u5929\uFF08\u6D3B\u52A8\u81EA\u52A8\u7EED\u671F\uFF09\u3002\u4FEE\u6539\u5BC6\u7801\u4F1A\u4F7F\u6240\u6709\u5DF2\u767B\u5F55\u4F1A\u8BDD\u7ACB\u5373\u5931\u6548\u3002" }),
|
|
206
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
207
|
+
configured && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
208
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-old", children: "\u65E7\u5BC6\u7801" }),
|
|
209
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
210
|
+
"input",
|
|
211
|
+
{
|
|
212
|
+
id: "dwa-old",
|
|
213
|
+
className: "dwa-input",
|
|
214
|
+
type: "password",
|
|
215
|
+
autoComplete: "current-password",
|
|
216
|
+
value: oldPassword,
|
|
217
|
+
onChange: (e) => setOldPassword(e.target.value)
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
] }),
|
|
221
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
222
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-new", children: "\u65B0\u5BC6\u7801\uFF08\u81F3\u5C11 8 \u4F4D\uFF09" }),
|
|
223
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
224
|
+
"input",
|
|
225
|
+
{
|
|
226
|
+
id: "dwa-new",
|
|
227
|
+
className: "dwa-input",
|
|
228
|
+
type: "password",
|
|
229
|
+
autoComplete: "new-password",
|
|
230
|
+
value: newPassword,
|
|
231
|
+
onChange: (e) => setNewPassword(e.target.value)
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
] }),
|
|
235
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
236
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-confirm", children: "\u786E\u8BA4\u65B0\u5BC6\u7801" }),
|
|
237
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
238
|
+
"input",
|
|
239
|
+
{
|
|
240
|
+
id: "dwa-confirm",
|
|
241
|
+
className: "dwa-input",
|
|
242
|
+
type: "password",
|
|
243
|
+
autoComplete: "new-password",
|
|
244
|
+
value: confirm,
|
|
245
|
+
onChange: (e) => setConfirm(e.target.value)
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
] }),
|
|
249
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-actions", children: [
|
|
250
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", disabled: busy, onClick: () => void submit(), children: "\u4FEE\u6539\u5BC6\u7801" }),
|
|
251
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", "data-ghost": "", onClick: () => {
|
|
252
|
+
window.location.href = "/logout";
|
|
253
|
+
}, children: "\u9000\u51FA\u767B\u5F55" })
|
|
254
|
+
] }),
|
|
255
|
+
message !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": message.kind, role: "alert", children: message.text }),
|
|
256
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
257
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
258
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-listen", children: "\u76D1\u542C\u5730\u5740" }),
|
|
259
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
260
|
+
"select",
|
|
261
|
+
{
|
|
262
|
+
id: "dwa-listen",
|
|
263
|
+
className: "dwa-input",
|
|
264
|
+
value: listenPick,
|
|
265
|
+
disabled: listenBusy || listenHost === null,
|
|
266
|
+
onChange: (e) => setListenPick(e.target.value),
|
|
267
|
+
children: LISTEN_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
] }),
|
|
271
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-actions", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
272
|
+
"button",
|
|
273
|
+
{
|
|
274
|
+
className: "dwa-btn",
|
|
275
|
+
disabled: listenBusy || listenHost === null || listenHost === listenPick,
|
|
276
|
+
onClick: () => void applyListen(),
|
|
277
|
+
children: "\u5E94\u7528\u76D1\u542C\u5730\u5740"
|
|
278
|
+
}
|
|
279
|
+
) }),
|
|
280
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u5207\u6362\u540E dsh \u4F1A\u7ACB\u5373\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF08WebSocket \u77ED\u6682\u65AD\u8FDE\u540E\u81EA\u52A8\u91CD\u8FDE\uFF09\u3002\u6CE8\u610F\uFF1A\u82E5\u5F53\u524D\u901A\u8FC7\u5C40\u57DF\u7F51 IP \u8BBF\u95EE\uFF0C\u5207\u56DE 127.0.0.1 \u540E\u53EA\u80FD\u5728\u672C\u673A\u8BBF\u95EE\u3002" }),
|
|
281
|
+
listenMsg !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": listenMsg.kind, role: "alert", children: listenMsg.text })
|
|
282
|
+
] });
|
|
283
|
+
}
|
|
284
|
+
function apply(ctx) {
|
|
285
|
+
injectStyle();
|
|
286
|
+
const slots = ctx.slots;
|
|
287
|
+
slots.inject(
|
|
288
|
+
"settings.section",
|
|
289
|
+
() => slots.register(
|
|
290
|
+
{
|
|
291
|
+
name: "settings.section",
|
|
292
|
+
id: "web-auth",
|
|
293
|
+
order: 120,
|
|
294
|
+
label: () => "\u8BBF\u95EE\u8BA4\u8BC1"
|
|
295
|
+
},
|
|
296
|
+
PasswordSection
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return __toCommonJS(index_exports);
|
|
301
|
+
})();
|
|
302
|
+
|
|
303
|
+
return typeof dshWebAuthModule !== "undefined" ? dshWebAuthModule : module.exports;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural cordis Context for the host half. A third-party plugin resolves
|
|
3
|
+
* outside the DSH monorepo's single cordis instance, so the upstream
|
|
4
|
+
* `declare module` augmentations do not reach this Context. The members
|
|
5
|
+
* below mirror the runtime shapes this plugin touches.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
8
|
+
/** The webServer service face (subset of @deepseek-ai/dsh-host-webserver). */
|
|
9
|
+
export interface WebServerService {
|
|
10
|
+
/** The underlying node:http server (gated by wrapping its listeners). */
|
|
11
|
+
server: Server;
|
|
12
|
+
/** The listening port (the OS-assigned value when config.port is 0). */
|
|
13
|
+
port: number;
|
|
14
|
+
/** The configured bind host ('127.0.0.1' or '0.0.0.0'). */
|
|
15
|
+
host: string;
|
|
16
|
+
/** Register an exact-path route; returns the disposer. */
|
|
17
|
+
register(route: {
|
|
18
|
+
kind: 'exact';
|
|
19
|
+
path: string;
|
|
20
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void;
|
|
21
|
+
}): () => void;
|
|
22
|
+
}
|
|
23
|
+
/** The plugin context members this plugin consumes. */
|
|
24
|
+
export interface PluginContext {
|
|
25
|
+
webServer: WebServerService;
|
|
26
|
+
logger: {
|
|
27
|
+
warn(message: string): void;
|
|
28
|
+
error(message: string): void;
|
|
29
|
+
};
|
|
30
|
+
/** Register a setup that returns a disposer, tied to this fiber. */
|
|
31
|
+
effect(setup: () => unknown, label?: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* The composition root directory as a file URL (the profile directory the
|
|
34
|
+
* boot anchors on — the loader inherits it down the whole tree).
|
|
35
|
+
*/
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/gate.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — HTTP gate: wraps the node:http server's request/upgrade
|
|
3
|
+
* listeners so EVERY request (routes, static fallback, WebSocket upgrades)
|
|
4
|
+
* passes the auth check before the original dispatch sees it.
|
|
5
|
+
*
|
|
6
|
+
* The webserver package registers exactly one 'request' listener (its route
|
|
7
|
+
* dispatcher) and one 'upgrade' listener. The gate captures them, removes
|
|
8
|
+
* them, and re-installs a single wrapper: denied requests are answered by
|
|
9
|
+
* the gate itself; allowed requests are forwarded to the captured listeners
|
|
10
|
+
* in original order. Disposal restores the originals.
|
|
11
|
+
*
|
|
12
|
+
* Installation is deferred until the server is actually listening, so the
|
|
13
|
+
* captured listener set is the final one. A WeakSet guard makes re-entry
|
|
14
|
+
* (HMR re-mount) a no-op.
|
|
15
|
+
*/
|
|
16
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
17
|
+
import type { Duplex } from 'node:stream';
|
|
18
|
+
export interface GateHandlers {
|
|
19
|
+
/** Decide whether one request may proceed; false = gate answers it. */
|
|
20
|
+
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
21
|
+
/** Hook after an allowed request (cookie refresh). */
|
|
22
|
+
passed?(req: IncomingMessage, res: ServerResponse): void;
|
|
23
|
+
/** Decide whether one WebSocket upgrade may proceed. */
|
|
24
|
+
allowUpgrade(req: IncomingMessage): boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Standard HTTP rejection for a denied WebSocket upgrade. */
|
|
27
|
+
export declare function rejectUpgrade(socket: Duplex, status?: number, body?: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Install the auth gate on a listening HTTP server. Returns the disposer
|
|
30
|
+
* restoring the original listeners (idempotent).
|
|
31
|
+
*/
|
|
32
|
+
export declare function installGate(server: Server, handlers: GateHandlers): () => void;
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const gated = new WeakSet();
|
|
2
|
+
/** Standard HTTP rejection for a denied WebSocket upgrade. */
|
|
3
|
+
export function rejectUpgrade(socket, status = 401, body = 'unauthorized') {
|
|
4
|
+
socket.end([
|
|
5
|
+
`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Forbidden'}`,
|
|
6
|
+
'Connection: close',
|
|
7
|
+
'Content-Type: text/plain; charset=utf-8',
|
|
8
|
+
'Content-Length: ' + Buffer.byteLength(body),
|
|
9
|
+
'',
|
|
10
|
+
body
|
|
11
|
+
].join('\r\n'));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Install the auth gate on a listening HTTP server. Returns the disposer
|
|
15
|
+
* restoring the original listeners (idempotent).
|
|
16
|
+
*/
|
|
17
|
+
export function installGate(server, handlers) {
|
|
18
|
+
if (gated.has(server))
|
|
19
|
+
return () => { };
|
|
20
|
+
gated.add(server);
|
|
21
|
+
const requestListeners = server.listeners('request');
|
|
22
|
+
const upgradeListeners = server.listeners('upgrade');
|
|
23
|
+
server.removeAllListeners('request');
|
|
24
|
+
server.removeAllListeners('upgrade');
|
|
25
|
+
server.on('request', (req, res) => {
|
|
26
|
+
if (!handlers.allow(req, res))
|
|
27
|
+
return;
|
|
28
|
+
handlers.passed?.(req, res);
|
|
29
|
+
for (const listener of requestListeners) {
|
|
30
|
+
;
|
|
31
|
+
listener(req, res);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
server.on('upgrade', (req, socket, head) => {
|
|
35
|
+
if (!handlers.allowUpgrade(req)) {
|
|
36
|
+
rejectUpgrade(socket);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const listener of upgradeListeners) {
|
|
40
|
+
;
|
|
41
|
+
listener(req, socket, head);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
if (!gated.has(server))
|
|
46
|
+
return;
|
|
47
|
+
gated.delete(server);
|
|
48
|
+
server.removeAllListeners('request');
|
|
49
|
+
server.removeAllListeners('upgrade');
|
|
50
|
+
for (const listener of requestListeners)
|
|
51
|
+
server.on('request', listener);
|
|
52
|
+
for (const listener of upgradeListeners)
|
|
53
|
+
server.on('upgrade', listener);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — request handlers: the gate decision (whitelist + session
|
|
3
|
+
* check), the /login page + form processing (including first-password setup),
|
|
4
|
+
* /logout, and the JSON change-password API for the settings card.
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { type RateLimiter } from './auth-core.ts';
|
|
8
|
+
import { type ListenHost } from './profile-patch.ts';
|
|
9
|
+
/** Maximum accepted request body for the login form / API. */
|
|
10
|
+
export declare const MAX_BODY_BYTES: number;
|
|
11
|
+
export interface AuthState {
|
|
12
|
+
/** Current session-signing secret (rotated on password change). */
|
|
13
|
+
secret: Buffer;
|
|
14
|
+
/** Current password hash (re-read per attempt so GUI changes apply live). */
|
|
15
|
+
readHash(): string | undefined;
|
|
16
|
+
writeHash(hash: string): void;
|
|
17
|
+
/** Replace the signing secret; returns the new secret. */
|
|
18
|
+
rotateSecret(): Buffer;
|
|
19
|
+
}
|
|
20
|
+
export interface HandlerEnv {
|
|
21
|
+
state: AuthState;
|
|
22
|
+
limiter: RateLimiter;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
/**
|
|
25
|
+
* Loopback authority ("127.0.0.1:<port>") used to rewrite the Host/Origin
|
|
26
|
+
* of AUTHENTICATED requests (any path prefix).
|
|
27
|
+
* The /api gateway pins its privileged methods (settings.*, credentials.*,
|
|
28
|
+
* llm.discoverModels, agentPreset.*, host.*) to loopback because its
|
|
29
|
+
* trustedHosts fence is DNS-rebinding defense, NOT authentication — the
|
|
30
|
+
* upstream comment says the plane stays loopback "until a real
|
|
31
|
+
* authentication layer exists". Plugin surfaces that mirror the /api fence
|
|
32
|
+
* (dsh-better-sidebar's /sidebar routes) behave the same way. This plugin
|
|
33
|
+
* IS that layer: a request carrying a valid session cookie is same-origin
|
|
34
|
+
* by construction (SameSite=Lax), so it may present as loopback. Requests
|
|
35
|
+
* without a session never reach the fences (the gate rejects them first).
|
|
36
|
+
*/
|
|
37
|
+
loopbackAuthority?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Listen-address controller (bound by the host half when the profile
|
|
40
|
+
* patch layer is reachable). Absent = the settings card cannot change
|
|
41
|
+
* the bind host.
|
|
42
|
+
*/
|
|
43
|
+
listen?: ListenController;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Live listen-host switching: the webserver's bind host is composition
|
|
47
|
+
* config, so the change is written into the owning patch layer (profile or
|
|
48
|
+
* home cordis.patch.yml) and HMR hot-applies it — the webserver row
|
|
49
|
+
* reloads, closes its listener, and rebinds. The controller is split into
|
|
50
|
+
* prepare (reportable failures) and commit (after the response flushed,
|
|
51
|
+
* failures logged) because the reload tears down every connection,
|
|
52
|
+
* including the one carrying the confirmation.
|
|
53
|
+
*/
|
|
54
|
+
export interface ListenController {
|
|
55
|
+
/** The currently resolved bind host (the webserver's runtime config). */
|
|
56
|
+
current(): string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Read + transform the owning patch layer for `host`, returning the next
|
|
59
|
+
* file content. Throws when the patch file cannot be prepared — the
|
|
60
|
+
* change is refused before anything is written.
|
|
61
|
+
*/
|
|
62
|
+
prepare(host: ListenHost): string;
|
|
63
|
+
/**
|
|
64
|
+
* Write the prepared content (typically deferred a beat after the
|
|
65
|
+
* response). Failures are logged, never thrown: the caller already
|
|
66
|
+
* confirmed the change to the client.
|
|
67
|
+
*/
|
|
68
|
+
commit(content: string): void;
|
|
69
|
+
}
|
|
70
|
+
export declare function pathnameOf(req: IncomingMessage): string;
|
|
71
|
+
/** Read one cookie by name from the Cookie header. */
|
|
72
|
+
export declare function cookieValue(header: string | undefined, name: string): string | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* The client address for rate limiting. X-Forwarded-For is honored ONLY when
|
|
75
|
+
* the direct peer is loopback (the reverse proxy sits on the same host), so
|
|
76
|
+
* a remote attacker hitting dsh directly cannot forge a fresh identity.
|
|
77
|
+
*/
|
|
78
|
+
export declare function clientIp(req: IncomingMessage): string;
|
|
79
|
+
/** Whether the browser's connection is HTTPS (reverse-proxied or direct). */
|
|
80
|
+
export declare function isSecureRequest(req: IncomingMessage): boolean;
|
|
81
|
+
/** Collect the request body; undefined when it exceeds the cap (413). */
|
|
82
|
+
export declare function readBody(req: IncomingMessage, maxBytes?: number): Promise<string | undefined>;
|
|
83
|
+
/** Parse application/x-www-form-urlencoded. */
|
|
84
|
+
export declare function parseUrlEncoded(body: string): Record<string, string>;
|
|
85
|
+
/** Set (or refresh) the session cookie on a response. */
|
|
86
|
+
export declare function setSessionCookie(res: ServerResponse, secret: Buffer, now: number, secure: boolean): void;
|
|
87
|
+
export interface Gate {
|
|
88
|
+
allow(req: IncomingMessage, res: ServerResponse): boolean;
|
|
89
|
+
allowUpgrade(req: IncomingMessage): boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the gate used to wrap the HTTP server listeners. Whitelisted paths
|
|
93
|
+
* (login page/form, logout) pass through; everything else requires a valid
|
|
94
|
+
* session cookie. Page requests (GET/HEAD) are redirected to /login with the
|
|
95
|
+
* original URL as `next`; other methods get a plain 401 JSON. WebSocket
|
|
96
|
+
* upgrades without a session are rejected at the handshake.
|
|
97
|
+
*/
|
|
98
|
+
export declare function createGate(env: HandlerEnv): Gate;
|
|
99
|
+
export declare function handleLoginPage(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
|
|
100
|
+
export declare function handleLoginPost(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
|
|
101
|
+
export declare function handleLogout(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
|
|
102
|
+
export declare function handleStatus(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
|
|
103
|
+
export interface ChangePasswordBody {
|
|
104
|
+
oldPassword?: string;
|
|
105
|
+
newPassword?: string;
|
|
106
|
+
}
|
|
107
|
+
export declare function handleChangePassword(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
|
|
108
|
+
export interface ListenChangeBody {
|
|
109
|
+
host?: unknown;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Switch the web server's bind host between '127.0.0.1' and '0.0.0.0'.
|
|
113
|
+
*
|
|
114
|
+
* The change is applied to the owning patch layer (profile or home
|
|
115
|
+
* cordis.patch.yml); HMR then reloads the webserver row, which closes and
|
|
116
|
+
* rebinds the listener. The response is written BEFORE the patch file is
|
|
117
|
+
* committed — the reload tears down every connection, including this one —
|
|
118
|
+
* so the client gets its confirmation first and the write lands a beat
|
|
119
|
+
* later (see {@link ListenController}).
|
|
120
|
+
*/
|
|
121
|
+
export declare function handleListenChange(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
|