dshb-auth 0.0.1
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/cordis.patch.yml +20 -0
- package/lib/client.js +468 -0
- package/lib/client.js.map +1 -0
- package/lib/credentials.d.ts +30 -0
- package/lib/credentials.js +121 -0
- package/lib/credentials.js.map +1 -0
- package/lib/gate.d.ts +14 -0
- package/lib/gate.js +45 -0
- package/lib/gate.js.map +1 -0
- package/lib/index-CLOUBKxU.d.ts +1125 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -0
- package/lib/login-page.d.ts +5 -0
- package/lib/login-page.js +87 -0
- package/lib/login-page.js.map +1 -0
- package/lib/loopback-compat.d.ts +6 -0
- package/lib/loopback-compat.js +72 -0
- package/lib/loopback-compat.js.map +1 -0
- package/lib/loopback.d.ts +7 -0
- package/lib/loopback.js +24 -0
- package/lib/loopback.js.map +1 -0
- package/lib/ratelimit.d.ts +11 -0
- package/lib/ratelimit.js +37 -0
- package/lib/ratelimit.js.map +1 -0
- package/lib/routes.d.ts +8 -0
- package/lib/routes.js +224 -0
- package/lib/routes.js.map +1 -0
- package/lib/session.d.ts +11 -0
- package/lib/session.js +52 -0
- package/lib/session.js.map +1 -0
- package/lib/startup.d.ts +15 -0
- package/lib/startup.js +68 -0
- package/lib/startup.js.map +1 -0
- package/lib/webserver.d.ts +18 -0
- package/lib/webserver.js +60 -0
- package/lib/webserver.js.map +1 -0
- package/package.json +49 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { t as Context } from "./index-CLOUBKxU.js";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
declare const name = "dshb-auth";
|
|
4
|
+
declare const inject: string[];
|
|
5
|
+
declare function apply(ctx: Context): void;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { apply, inject, name };
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sharedCredentialStore } from "./credentials.js";
|
|
2
|
+
import { installLoopbackCompat } from "./loopback-compat.js";
|
|
3
|
+
import { LoginRateLimiter } from "./ratelimit.js";
|
|
4
|
+
import { registerAuthRoutes } from "./routes.js";
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
const name = "dshb-auth";
|
|
7
|
+
const inject = ["webServer"];
|
|
8
|
+
function apply(ctx) {
|
|
9
|
+
ctx.webServer.tapIndex(installLoopbackCompat);
|
|
10
|
+
registerAuthRoutes(ctx, sharedCredentialStore(), new LoginRateLimiter());
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { apply, inject, name };
|
|
14
|
+
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { Context } from '@deepseek-ai/cordis'\nimport { sharedCredentialStore } from './credentials.js'\nimport { installLoopbackCompat } from './loopback-compat.js'\nimport { LoginRateLimiter } from './ratelimit.js'\nimport { registerAuthRoutes } from './routes.js'\n\nexport const name = 'dshb-auth'\n\nexport const inject = ['webServer']\n\nexport function apply(ctx: Context): void {\n ctx.webServer.tapIndex(installLoopbackCompat)\n registerAuthRoutes(ctx, sharedCredentialStore(), new LoginRateLimiter())\n}\n"],"mappings":";;;;;AAMA,MAAa,OAAO;AAEpB,MAAa,SAAS,CAAC,WAAW;AAElC,SAAgB,MAAM,KAAoB;CACxC,IAAI,UAAU,SAAS,qBAAqB;CAC5C,mBAAmB,KAAK,sBAAsB,GAAG,IAAI,iBAAiB,CAAC;AACzE"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//#region src/login-page.ts
|
|
2
|
+
function loginPageHtml() {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="zh-CN">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
8
|
+
<title>DSHB 登录</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root { color-scheme: dark; }
|
|
11
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0f1115; color: #e8eaf0; font-family: system-ui, -apple-system, "PingFang SC", sans-serif; }
|
|
12
|
+
.card { width: min(92vw, 360px); padding: 32px 28px; background: #171a21; border: 1px solid #262b36; border-radius: 12px; }
|
|
13
|
+
h1 { font-size: 20px; margin: 0 0 6px; }
|
|
14
|
+
p.sub { margin: 0 0 20px; font-size: 13px; color: #8b93a5; }
|
|
15
|
+
label { display: block; font-size: 13px; color: #aab1c0; margin: 14px 0 6px; }
|
|
16
|
+
input { width: 100%; box-sizing: border-box; padding: 12px; font-size: 16px; border-radius: 8px; border: 1px solid #2c3242; background: #0f1115; color: #e8eaf0; }
|
|
17
|
+
input:focus { outline: 2px solid #3b82f6; border-color: transparent; }
|
|
18
|
+
button { margin-top: 22px; width: 100%; padding: 12px; font-size: 15px; border: 0; border-radius: 8px; background: #3b82f6; color: #fff; cursor: pointer; }
|
|
19
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
20
|
+
.err { min-height: 18px; margin-top: 12px; font-size: 13px; color: #f87171; }
|
|
21
|
+
</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<div class="card">
|
|
25
|
+
<h1 id="title">DSHB 登录</h1>
|
|
26
|
+
<p class="sub" id="subtitle">DeepSeek Harness 管理端</p>
|
|
27
|
+
<form id="form">
|
|
28
|
+
<label for="username">用户名</label>
|
|
29
|
+
<input id="username" name="username" autocomplete="username" required>
|
|
30
|
+
<label for="password">密码</label>
|
|
31
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
32
|
+
<button id="submit" type="submit">登录</button>
|
|
33
|
+
<div class="err" id="error"></div>
|
|
34
|
+
</form>
|
|
35
|
+
</div>
|
|
36
|
+
<script>
|
|
37
|
+
(async () => {
|
|
38
|
+
const res = await fetch('/api/auth/status')
|
|
39
|
+
const status = await res.json()
|
|
40
|
+
const form = document.getElementById('form')
|
|
41
|
+
const title = document.getElementById('title')
|
|
42
|
+
const subtitle = document.getElementById('subtitle')
|
|
43
|
+
const submit = document.getElementById('submit')
|
|
44
|
+
const error = document.getElementById('error')
|
|
45
|
+
const password = document.getElementById('password')
|
|
46
|
+
let mode = 'login'
|
|
47
|
+
if (!status.initialized) {
|
|
48
|
+
mode = 'register'
|
|
49
|
+
title.textContent = '设置管理员账号'
|
|
50
|
+
subtitle.textContent = '首次使用,请创建管理员账号(密码至少 8 位)'
|
|
51
|
+
submit.textContent = '创建并登录'
|
|
52
|
+
password.autocomplete = 'new-password'
|
|
53
|
+
}
|
|
54
|
+
form.addEventListener('submit', async (ev) => {
|
|
55
|
+
ev.preventDefault()
|
|
56
|
+
submit.disabled = true
|
|
57
|
+
error.textContent = ''
|
|
58
|
+
try {
|
|
59
|
+
const resp = await fetch('/api/auth/' + mode, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'content-type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
username: document.getElementById('username').value,
|
|
64
|
+
password: password.value,
|
|
65
|
+
}),
|
|
66
|
+
})
|
|
67
|
+
const data = await resp.json()
|
|
68
|
+
if (resp.ok && data.ok) {
|
|
69
|
+
location.href = '/'
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
error.textContent = data.error || ('请求失败(' + resp.status + ')')
|
|
73
|
+
} catch {
|
|
74
|
+
error.textContent = '网络错误,请重试'
|
|
75
|
+
} finally {
|
|
76
|
+
submit.disabled = false
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})()
|
|
80
|
+
<\/script>
|
|
81
|
+
</body>
|
|
82
|
+
</html>`;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
export { loginPageHtml };
|
|
86
|
+
|
|
87
|
+
//# sourceMappingURL=login-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-page.js","names":[],"sources":["../src/login-page.ts"],"sourcesContent":["export function loginPageHtml(): string {\n return `<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>DSHB 登录</title>\n<style>\n:root { color-scheme: dark; }\nbody { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0f1115; color: #e8eaf0; font-family: system-ui, -apple-system, \"PingFang SC\", sans-serif; }\n.card { width: min(92vw, 360px); padding: 32px 28px; background: #171a21; border: 1px solid #262b36; border-radius: 12px; }\nh1 { font-size: 20px; margin: 0 0 6px; }\np.sub { margin: 0 0 20px; font-size: 13px; color: #8b93a5; }\nlabel { display: block; font-size: 13px; color: #aab1c0; margin: 14px 0 6px; }\ninput { width: 100%; box-sizing: border-box; padding: 12px; font-size: 16px; border-radius: 8px; border: 1px solid #2c3242; background: #0f1115; color: #e8eaf0; }\ninput:focus { outline: 2px solid #3b82f6; border-color: transparent; }\nbutton { margin-top: 22px; width: 100%; padding: 12px; font-size: 15px; border: 0; border-radius: 8px; background: #3b82f6; color: #fff; cursor: pointer; }\nbutton:disabled { opacity: .5; cursor: default; }\n.err { min-height: 18px; margin-top: 12px; font-size: 13px; color: #f87171; }\n</style>\n</head>\n<body>\n<div class=\"card\">\n<h1 id=\"title\">DSHB 登录</h1>\n<p class=\"sub\" id=\"subtitle\">DeepSeek Harness 管理端</p>\n<form id=\"form\">\n<label for=\"username\">用户名</label>\n<input id=\"username\" name=\"username\" autocomplete=\"username\" required>\n<label for=\"password\">密码</label>\n<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required>\n<button id=\"submit\" type=\"submit\">登录</button>\n<div class=\"err\" id=\"error\"></div>\n</form>\n</div>\n<script>\n(async () => {\n const res = await fetch('/api/auth/status')\n const status = await res.json()\n const form = document.getElementById('form')\n const title = document.getElementById('title')\n const subtitle = document.getElementById('subtitle')\n const submit = document.getElementById('submit')\n const error = document.getElementById('error')\n const password = document.getElementById('password')\n let mode = 'login'\n if (!status.initialized) {\n mode = 'register'\n title.textContent = '设置管理员账号'\n subtitle.textContent = '首次使用,请创建管理员账号(密码至少 8 位)'\n submit.textContent = '创建并登录'\n password.autocomplete = 'new-password'\n }\n form.addEventListener('submit', async (ev) => {\n ev.preventDefault()\n submit.disabled = true\n error.textContent = ''\n try {\n const resp = await fetch('/api/auth/' + mode, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n username: document.getElementById('username').value,\n password: password.value,\n }),\n })\n const data = await resp.json()\n if (resp.ok && data.ok) {\n location.href = '/'\n return\n }\n error.textContent = data.error || ('请求失败(' + resp.status + ')')\n } catch {\n error.textContent = '网络错误,请重试'\n } finally {\n submit.disabled = false\n }\n })\n})()\n</script>\n</body>\n</html>`\n}\n"],"mappings":";AAAA,SAAgB,gBAAwB;CACtC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgFT"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
//#region src/loopback-compat.d.ts
|
|
2
|
+
declare function loopbackCompatScript(): string;
|
|
3
|
+
declare function installLoopbackCompat(html: string): string;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { installLoopbackCompat, loopbackCompatScript };
|
|
6
|
+
//# sourceMappingURL=loopback-compat.d.ts.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region src/loopback-compat.ts
|
|
2
|
+
function loopbackCompatScript() {
|
|
3
|
+
return `<script>
|
|
4
|
+
;(function () {
|
|
5
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function' && typeof crypto.getRandomValues === 'function') {
|
|
6
|
+
crypto.randomUUID = function () {
|
|
7
|
+
var b = crypto.getRandomValues(new Uint8Array(16))
|
|
8
|
+
b[6] = (b[6] & 0x0f) | 0x40
|
|
9
|
+
b[8] = (b[8] & 0x3f) | 0x80
|
|
10
|
+
var h = ''
|
|
11
|
+
for (var i = 0; i < 16; i++) {
|
|
12
|
+
if (i === 4 || i === 6 || i === 8 || i === 10) h += '-'
|
|
13
|
+
var hex = b[i].toString(16)
|
|
14
|
+
if (hex.length < 2) hex = '0' + hex
|
|
15
|
+
h += hex
|
|
16
|
+
}
|
|
17
|
+
return h
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function installIsLoopbackOverride() {
|
|
21
|
+
var loader = window.__ModuleLoader__
|
|
22
|
+
if (!loader || loader.__dshbIsLoopbackHooked) return false
|
|
23
|
+
if (loader.mode !== 'live') return false
|
|
24
|
+
loader.__dshbIsLoopbackHooked = true
|
|
25
|
+
var origLoad = loader.load.bind(loader)
|
|
26
|
+
loader.load = function (handoff) {
|
|
27
|
+
var factory = handoff && handoff.factory
|
|
28
|
+
if (typeof factory === 'function') {
|
|
29
|
+
handoff.factory = function (require) {
|
|
30
|
+
var exports = factory(require)
|
|
31
|
+
var apply = exports && exports.apply
|
|
32
|
+
if (typeof apply === 'function') {
|
|
33
|
+
exports.apply = function (ctx) {
|
|
34
|
+
var result = apply(ctx)
|
|
35
|
+
try {
|
|
36
|
+
var connection = ctx && ctx.get && ctx.get('connection')
|
|
37
|
+
if (connection) {
|
|
38
|
+
Object.defineProperty(connection, 'isLoopback', { configurable: true, get: function () { return true } })
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return exports
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return origLoad(handoff)
|
|
48
|
+
}
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
function tryInstall() { if (!installIsLoopbackOverride()) setTimeout(tryInstall, 0) }
|
|
52
|
+
tryInstall()
|
|
53
|
+
})()
|
|
54
|
+
;(async function () {
|
|
55
|
+
try {
|
|
56
|
+
var res = await fetch('/api/auth/status')
|
|
57
|
+
var data = await res.json()
|
|
58
|
+
if (window.location.pathname !== '/login' && !data.authenticated) {
|
|
59
|
+
window.location.replace('/login')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {}
|
|
63
|
+
})()
|
|
64
|
+
<\/script>`;
|
|
65
|
+
}
|
|
66
|
+
function installLoopbackCompat(html) {
|
|
67
|
+
return html.replace("</head>", `${loopbackCompatScript()}</head>`);
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { installLoopbackCompat, loopbackCompatScript };
|
|
71
|
+
|
|
72
|
+
//# sourceMappingURL=loopback-compat.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loopback-compat.js","names":[],"sources":["../src/loopback-compat.ts"],"sourcesContent":["export function loopbackCompatScript(): string {\n return `<script>\n;(function () {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function' && typeof crypto.getRandomValues === 'function') {\n crypto.randomUUID = function () {\n var b = crypto.getRandomValues(new Uint8Array(16))\n b[6] = (b[6] & 0x0f) | 0x40\n b[8] = (b[8] & 0x3f) | 0x80\n var h = ''\n for (var i = 0; i < 16; i++) {\n if (i === 4 || i === 6 || i === 8 || i === 10) h += '-'\n var hex = b[i].toString(16)\n if (hex.length < 2) hex = '0' + hex\n h += hex\n }\n return h\n }\n }\n function installIsLoopbackOverride() {\n var loader = window.__ModuleLoader__\n if (!loader || loader.__dshbIsLoopbackHooked) return false\n if (loader.mode !== 'live') return false\n loader.__dshbIsLoopbackHooked = true\n var origLoad = loader.load.bind(loader)\n loader.load = function (handoff) {\n var factory = handoff && handoff.factory\n if (typeof factory === 'function') {\n handoff.factory = function (require) {\n var exports = factory(require)\n var apply = exports && exports.apply\n if (typeof apply === 'function') {\n exports.apply = function (ctx) {\n var result = apply(ctx)\n try {\n var connection = ctx && ctx.get && ctx.get('connection')\n if (connection) {\n Object.defineProperty(connection, 'isLoopback', { configurable: true, get: function () { return true } })\n }\n } catch (e) {}\n return result\n }\n }\n return exports\n }\n }\n return origLoad(handoff)\n }\n return true\n }\n function tryInstall() { if (!installIsLoopbackOverride()) setTimeout(tryInstall, 0) }\n tryInstall()\n})()\n;(async function () {\n try {\n var res = await fetch('/api/auth/status')\n var data = await res.json()\n if (window.location.pathname !== '/login' && !data.authenticated) {\n window.location.replace('/login')\n return\n }\n } catch (e) {}\n})()\n</script>`\n}\n\nexport function installLoopbackCompat(html: string): string {\n return html.replace('</head>', `${loopbackCompatScript()}</head>`)\n}\n"],"mappings":";AAAA,SAAgB,uBAA+B;CAC7C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8DT;AAEA,SAAgB,sBAAsB,MAAsB;CAC1D,OAAO,KAAK,QAAQ,WAAW,GAAG,qBAAqB,EAAE,QAAQ;AACnE"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/loopback.d.ts
|
|
2
|
+
declare function isLoopbackAddress(address: string | undefined | null): boolean;
|
|
3
|
+
declare function isLoopbackHost(hostHeader: string | undefined | null): boolean;
|
|
4
|
+
declare function isLoopbackRequest(remoteAddress: string | undefined | null, hostHeader: string | undefined | null): boolean;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { isLoopbackAddress, isLoopbackHost, isLoopbackRequest };
|
|
7
|
+
//# sourceMappingURL=loopback.d.ts.map
|
package/lib/loopback.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/loopback.ts
|
|
2
|
+
function isLoopbackAddress(address) {
|
|
3
|
+
if (!address) return false;
|
|
4
|
+
let addr = address.trim().toLowerCase();
|
|
5
|
+
const v4mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(addr);
|
|
6
|
+
if (v4mapped) addr = v4mapped[1];
|
|
7
|
+
if (addr === "::1" || addr === "::") return true;
|
|
8
|
+
return /^127\./.test(addr);
|
|
9
|
+
}
|
|
10
|
+
function isLoopbackHost(hostHeader) {
|
|
11
|
+
if (!hostHeader) return false;
|
|
12
|
+
const authority = hostHeader.trim().toLowerCase();
|
|
13
|
+
if (!authority) return false;
|
|
14
|
+
const host = authority.startsWith("[") ? authority.slice(1, authority.indexOf("]")) : authority.split(":")[0];
|
|
15
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
16
|
+
return isLoopbackAddress(host);
|
|
17
|
+
}
|
|
18
|
+
function isLoopbackRequest(remoteAddress, hostHeader) {
|
|
19
|
+
return isLoopbackAddress(remoteAddress) && isLoopbackHost(hostHeader);
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { isLoopbackAddress, isLoopbackHost, isLoopbackRequest };
|
|
23
|
+
|
|
24
|
+
//# sourceMappingURL=loopback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loopback.js","names":[],"sources":["../src/loopback.ts"],"sourcesContent":["export function isLoopbackAddress(address: string | undefined | null): boolean {\n if (!address) return false\n let addr = address.trim().toLowerCase()\n const v4mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/.exec(addr)\n if (v4mapped) addr = v4mapped[1]\n if (addr === '::1' || addr === '::') return true\n return /^127\\./.test(addr)\n}\n\nexport function isLoopbackHost(hostHeader: string | undefined | null): boolean {\n if (!hostHeader) return false\n const authority = hostHeader.trim().toLowerCase()\n if (!authority) return false\n const host = authority.startsWith('[')\n ? authority.slice(1, authority.indexOf(']'))\n : authority.split(':')[0]\n if (host === 'localhost' || host.endsWith('.localhost')) return true\n return isLoopbackAddress(host)\n}\n\nexport function isLoopbackRequest(\n remoteAddress: string | undefined | null,\n hostHeader: string | undefined | null,\n): boolean {\n return isLoopbackAddress(remoteAddress) && isLoopbackHost(hostHeader)\n}\n"],"mappings":";AAAA,SAAgB,kBAAkB,SAA6C;CAC7E,IAAI,CAAC,SAAS,OAAO;CACrB,IAAI,OAAO,QAAQ,KAAK,CAAC,CAAC,YAAY;CACtC,MAAM,WAAW,qCAAqC,KAAK,IAAI;CAC/D,IAAI,UAAU,OAAO,SAAS;CAC9B,IAAI,SAAS,SAAS,SAAS,MAAM,OAAO;CAC5C,OAAO,SAAS,KAAK,IAAI;AAC3B;AAEA,SAAgB,eAAe,YAAgD;CAC7E,IAAI,CAAC,YAAY,OAAO;CACxB,MAAM,YAAY,WAAW,KAAK,CAAC,CAAC,YAAY;CAChD,IAAI,CAAC,WAAW,OAAO;CACvB,MAAM,OAAO,UAAU,WAAW,GAAG,IACjC,UAAU,MAAM,GAAG,UAAU,QAAQ,GAAG,CAAC,IACzC,UAAU,MAAM,GAAG,CAAC,CAAC;CACzB,IAAI,SAAS,eAAe,KAAK,SAAS,YAAY,GAAG,OAAO;CAChE,OAAO,kBAAkB,IAAI;AAC/B;AAEA,SAAgB,kBACd,eACA,YACS;CACT,OAAO,kBAAkB,aAAa,KAAK,eAAe,UAAU;AACtE"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/ratelimit.d.ts
|
|
2
|
+
declare class LoginRateLimiter {
|
|
3
|
+
private readonly entries;
|
|
4
|
+
isLocked(ip: string, now?: number): boolean;
|
|
5
|
+
recordFailure(ip: string, now?: number): void;
|
|
6
|
+
recordSuccess(ip: string): void;
|
|
7
|
+
private sweep;
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
export { LoginRateLimiter };
|
|
11
|
+
//# sourceMappingURL=ratelimit.d.ts.map
|
package/lib/ratelimit.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/ratelimit.ts
|
|
2
|
+
const WINDOW_MS = 3e4;
|
|
3
|
+
const MAX_FAILURES = 5;
|
|
4
|
+
var LoginRateLimiter = class {
|
|
5
|
+
entries = /* @__PURE__ */ new Map();
|
|
6
|
+
isLocked(ip, now = Date.now()) {
|
|
7
|
+
const entry = this.entries.get(ip);
|
|
8
|
+
if (!entry) return false;
|
|
9
|
+
if (entry.lockedUntil > now) return true;
|
|
10
|
+
if (entry.lockedUntil !== 0 && entry.lockedUntil <= now) this.entries.delete(ip);
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
recordFailure(ip, now = Date.now()) {
|
|
14
|
+
const entry = this.entries.get(ip) ?? {
|
|
15
|
+
failures: 0,
|
|
16
|
+
lockedUntil: 0
|
|
17
|
+
};
|
|
18
|
+
entry.failures += 1;
|
|
19
|
+
if (entry.failures >= MAX_FAILURES) {
|
|
20
|
+
entry.lockedUntil = now + WINDOW_MS;
|
|
21
|
+
entry.failures = 0;
|
|
22
|
+
}
|
|
23
|
+
this.entries.set(ip, entry);
|
|
24
|
+
this.sweep(now);
|
|
25
|
+
}
|
|
26
|
+
recordSuccess(ip) {
|
|
27
|
+
this.entries.delete(ip);
|
|
28
|
+
}
|
|
29
|
+
sweep(now) {
|
|
30
|
+
if (this.entries.size < 1024) return;
|
|
31
|
+
for (const [ip, entry] of this.entries) if (entry.lockedUntil <= now) this.entries.delete(ip);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { LoginRateLimiter };
|
|
36
|
+
|
|
37
|
+
//# sourceMappingURL=ratelimit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ratelimit.js","names":[],"sources":["../src/ratelimit.ts"],"sourcesContent":["const WINDOW_MS = 30_000\nconst MAX_FAILURES = 5\n\ninterface Entry {\n failures: number\n lockedUntil: number\n}\n\nexport class LoginRateLimiter {\n private readonly entries = new Map<string, Entry>()\n\n isLocked(ip: string, now = Date.now()): boolean {\n const entry = this.entries.get(ip)\n if (!entry) return false\n if (entry.lockedUntil > now) return true\n if (entry.lockedUntil !== 0 && entry.lockedUntil <= now) this.entries.delete(ip)\n return false\n }\n\n recordFailure(ip: string, now = Date.now()): void {\n const entry = this.entries.get(ip) ?? { failures: 0, lockedUntil: 0 }\n entry.failures += 1\n if (entry.failures >= MAX_FAILURES) {\n entry.lockedUntil = now + WINDOW_MS\n entry.failures = 0\n }\n this.entries.set(ip, entry)\n this.sweep(now)\n }\n\n recordSuccess(ip: string): void {\n this.entries.delete(ip)\n }\n\n private sweep(now: number): void {\n if (this.entries.size < 1024) return\n for (const [ip, entry] of this.entries) {\n if (entry.lockedUntil <= now) this.entries.delete(ip)\n }\n }\n}\n"],"mappings":";AAAA,MAAM,YAAY;AAClB,MAAM,eAAe;AAOrB,IAAa,mBAAb,MAA8B;CAC5B,0BAA2B,IAAI,IAAmB;CAElD,SAAS,IAAY,MAAM,KAAK,IAAI,GAAY;EAC9C,MAAM,QAAQ,KAAK,QAAQ,IAAI,EAAE;EACjC,IAAI,CAAC,OAAO,OAAO;EACnB,IAAI,MAAM,cAAc,KAAK,OAAO;EACpC,IAAI,MAAM,gBAAgB,KAAK,MAAM,eAAe,KAAK,KAAK,QAAQ,OAAO,EAAE;EAC/E,OAAO;CACT;CAEA,cAAc,IAAY,MAAM,KAAK,IAAI,GAAS;EAChD,MAAM,QAAQ,KAAK,QAAQ,IAAI,EAAE,KAAK;GAAE,UAAU;GAAG,aAAa;EAAE;EACpE,MAAM,YAAY;EAClB,IAAI,MAAM,YAAY,cAAc;GAClC,MAAM,cAAc,MAAM;GAC1B,MAAM,WAAW;EACnB;EACA,KAAK,QAAQ,IAAI,IAAI,KAAK;EAC1B,KAAK,MAAM,GAAG;CAChB;CAEA,cAAc,IAAkB;EAC9B,KAAK,QAAQ,OAAO,EAAE;CACxB;CAEA,MAAc,KAAmB;EAC/B,IAAI,KAAK,QAAQ,OAAO,MAAM;EAC9B,KAAK,MAAM,CAAC,IAAI,UAAU,KAAK,SAC7B,IAAI,MAAM,eAAe,KAAK,KAAK,QAAQ,OAAO,EAAE;CAExD;AACF"}
|
package/lib/routes.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CredentialStore } from "./credentials.js";
|
|
2
|
+
import { t as Context } from "./index-CLOUBKxU.js";
|
|
3
|
+
import { LoginRateLimiter } from "./ratelimit.js";
|
|
4
|
+
//#region src/routes.d.ts
|
|
5
|
+
declare function registerAuthRoutes(ctx: Context, store: CredentialStore, limiter: LoginRateLimiter): void;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { registerAuthRoutes };
|
|
8
|
+
//# sourceMappingURL=routes.d.ts.map
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { sanitizeUsername } from "./credentials.js";
|
|
2
|
+
import { isLoopbackRequest } from "./loopback.js";
|
|
3
|
+
import { SESSION_COOKIE, clearCookieHeader, issueSession, readCookie, sessionCookieHeader, verifySession } from "./session.js";
|
|
4
|
+
import { loginPageHtml } from "./login-page.js";
|
|
5
|
+
//#region src/routes.ts
|
|
6
|
+
const MAX_BODY = 65536;
|
|
7
|
+
function sendJson(res, status, body, headers = {}) {
|
|
8
|
+
res.writeHead(status, {
|
|
9
|
+
"content-type": "application/json",
|
|
10
|
+
...headers
|
|
11
|
+
});
|
|
12
|
+
res.end(JSON.stringify(body));
|
|
13
|
+
}
|
|
14
|
+
function readBody(req) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
if ((req.headers["content-type"] ?? "") !== "application/json") {
|
|
17
|
+
resolve(null);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
req.on("data", (chunk) => {
|
|
23
|
+
size += chunk.length;
|
|
24
|
+
if (size > MAX_BODY) {
|
|
25
|
+
resolve(null);
|
|
26
|
+
req.destroy();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
});
|
|
31
|
+
req.on("end", () => {
|
|
32
|
+
try {
|
|
33
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
34
|
+
} catch {
|
|
35
|
+
resolve(null);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
req.on("error", () => resolve(null));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function clientIp(req) {
|
|
42
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
43
|
+
}
|
|
44
|
+
function registerAuthRoutes(ctx, store, limiter) {
|
|
45
|
+
const webServer = ctx.webServer;
|
|
46
|
+
ctx.effect(() => webServer.register({
|
|
47
|
+
kind: "exact",
|
|
48
|
+
path: "/login",
|
|
49
|
+
handler: (_req, res) => {
|
|
50
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
51
|
+
res.end(loginPageHtml());
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
ctx.effect(() => webServer.register({
|
|
55
|
+
kind: "prefix",
|
|
56
|
+
path: "/api/auth",
|
|
57
|
+
handler: async (req, res) => {
|
|
58
|
+
const path = new URL(req.url ?? "/", "http://x").pathname;
|
|
59
|
+
if (path === "/api/auth/status" && req.method === "GET") {
|
|
60
|
+
const loopback = isLoopbackRequest(req.socket.remoteAddress, req.headers.host);
|
|
61
|
+
let authenticated = loopback;
|
|
62
|
+
let username;
|
|
63
|
+
if (!loopback) {
|
|
64
|
+
const secret = store.sessionSecret;
|
|
65
|
+
const currentName = store.username;
|
|
66
|
+
if (secret && currentName) {
|
|
67
|
+
const cookie = readCookie(req.headers.cookie, SESSION_COOKIE);
|
|
68
|
+
if (verifySession(cookie, secret, currentName)) {
|
|
69
|
+
authenticated = true;
|
|
70
|
+
username = currentName;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
sendJson(res, 200, {
|
|
75
|
+
ok: true,
|
|
76
|
+
authenticated,
|
|
77
|
+
initialized: store.isInitialized(),
|
|
78
|
+
username
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (req.method !== "POST") {
|
|
83
|
+
sendJson(res, 405, {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: "method not allowed"
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const body = await readBody(req);
|
|
90
|
+
if (!body) {
|
|
91
|
+
sendJson(res, 400, {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: "请求体必须是 JSON"
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (path === "/api/auth/register") {
|
|
98
|
+
if (store.isInitialized()) {
|
|
99
|
+
sendJson(res, 409, {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: "管理员已存在"
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const username = sanitizeUsername(String(body.username ?? ""));
|
|
106
|
+
const password = String(body.password ?? "");
|
|
107
|
+
if (!username) {
|
|
108
|
+
sendJson(res, 400, {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: "用户名不能为空"
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (password.length < 8) {
|
|
115
|
+
sendJson(res, 400, {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: "密码至少 8 个字符"
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
store.register(username, password);
|
|
122
|
+
const cookie = issueSession(username, store.sessionSecret);
|
|
123
|
+
sendJson(res, 200, { ok: true }, { "set-cookie": sessionCookieHeader(cookie) });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (path === "/api/auth/login") {
|
|
127
|
+
const ip = clientIp(req);
|
|
128
|
+
if (limiter.isLocked(ip)) {
|
|
129
|
+
sendJson(res, 429, {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: "尝试次数过多,请 30 秒后再试"
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!store.isInitialized()) {
|
|
136
|
+
sendJson(res, 409, {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: "尚未初始化管理员"
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const username = sanitizeUsername(String(body.username ?? ""));
|
|
143
|
+
const password = String(body.password ?? "");
|
|
144
|
+
if (username !== store.username || !store.verify(password)) {
|
|
145
|
+
limiter.recordFailure(ip);
|
|
146
|
+
sendJson(res, 401, {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: "用户名或密码错误"
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
limiter.recordSuccess(ip);
|
|
153
|
+
const cookie = issueSession(username, store.sessionSecret);
|
|
154
|
+
sendJson(res, 200, { ok: true }, { "set-cookie": sessionCookieHeader(cookie) });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (path === "/api/auth/logout") {
|
|
158
|
+
sendJson(res, 200, { ok: true }, { "set-cookie": clearCookieHeader() });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (path === "/api/auth/change-password" || path === "/api/auth/change-username") {
|
|
162
|
+
const secret = store.sessionSecret;
|
|
163
|
+
const currentName = store.username;
|
|
164
|
+
const cookie = secret && currentName ? readCookie(req.headers.cookie, SESSION_COOKIE) : void 0;
|
|
165
|
+
if (!secret || !currentName || !cookie || !verifySession(cookie, secret, currentName)) {
|
|
166
|
+
sendJson(res, 401, {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: "unauthorized"
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const ip = clientIp(req);
|
|
173
|
+
if (limiter.isLocked(ip)) {
|
|
174
|
+
sendJson(res, 429, {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: "尝试次数过多,请 30 秒后再试"
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!store.verify(String(body.currentPassword ?? ""))) {
|
|
181
|
+
limiter.recordFailure(ip);
|
|
182
|
+
sendJson(res, 401, {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: "当前密码错误"
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
limiter.recordSuccess(ip);
|
|
189
|
+
if (path === "/api/auth/change-password") {
|
|
190
|
+
const password = String(body.password ?? "");
|
|
191
|
+
if (password.length < 8) {
|
|
192
|
+
sendJson(res, 400, {
|
|
193
|
+
ok: false,
|
|
194
|
+
error: "密码至少 8 个字符"
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
store.changePassword(currentName, password);
|
|
199
|
+
} else {
|
|
200
|
+
const username = sanitizeUsername(String(body.username ?? ""));
|
|
201
|
+
if (!username) {
|
|
202
|
+
sendJson(res, 400, {
|
|
203
|
+
ok: false,
|
|
204
|
+
error: "用户名不能为空"
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
store.changeUsername(username);
|
|
209
|
+
}
|
|
210
|
+
const fresh = issueSession(store.username, store.sessionSecret);
|
|
211
|
+
sendJson(res, 200, { ok: true }, { "set-cookie": sessionCookieHeader(fresh) });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
sendJson(res, 404, {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: "not found"
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
export { registerAuthRoutes };
|
|
223
|
+
|
|
224
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.js","names":[],"sources":["../src/routes.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { Context } from '@deepseek-ai/cordis'\nimport { sanitizeUsername, type CredentialStore } from './credentials.js'\nimport { isLoopbackRequest } from './loopback.js'\nimport { clearCookieHeader, issueSession, readCookie, SESSION_COOKIE, sessionCookieHeader, verifySession } from './session.js'\nimport type { LoginRateLimiter } from './ratelimit.js'\nimport { loginPageHtml } from './login-page.js'\n\nconst MAX_BODY = 64 * 1024\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {\n res.writeHead(status, { 'content-type': 'application/json', ...headers })\n res.end(JSON.stringify(body))\n}\n\nfunction readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {\n return new Promise((resolve) => {\n if ((req.headers['content-type'] ?? '') !== 'application/json') {\n resolve(null)\n return\n }\n const chunks: Buffer[] = []\n let size = 0\n req.on('data', (chunk: Buffer) => {\n size += chunk.length\n if (size > MAX_BODY) {\n resolve(null)\n req.destroy()\n return\n }\n chunks.push(chunk)\n })\n req.on('end', () => {\n try {\n resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>)\n } catch {\n resolve(null)\n }\n })\n req.on('error', () => resolve(null))\n })\n}\n\nfunction clientIp(req: IncomingMessage): string {\n return req.socket.remoteAddress ?? 'unknown'\n}\n\nexport function registerAuthRoutes(ctx: Context, store: CredentialStore, limiter: LoginRateLimiter): void {\n const webServer = ctx.webServer\n\n ctx.effect(() =>\n webServer.register({\n kind: 'exact',\n path: '/login',\n handler: (_req, res) => {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })\n res.end(loginPageHtml())\n },\n }),\n )\n\n ctx.effect(() =>\n webServer.register({\n kind: 'prefix',\n path: '/api/auth',\n handler: async (req, res) => {\n const path = new URL(req.url ?? '/', 'http://x').pathname\n\n if (path === '/api/auth/status' && req.method === 'GET') {\n const loopback = isLoopbackRequest(req.socket.remoteAddress, req.headers.host)\n let authenticated = loopback\n let username: string | undefined\n if (!loopback) {\n const secret = store.sessionSecret\n const currentName = store.username\n if (secret && currentName) {\n const cookie = readCookie(req.headers.cookie, SESSION_COOKIE)\n if (verifySession(cookie, secret, currentName)) {\n authenticated = true\n username = currentName\n }\n }\n }\n sendJson(res, 200, { ok: true, authenticated, initialized: store.isInitialized(), username })\n return\n }\n\n if (req.method !== 'POST') {\n sendJson(res, 405, { ok: false, error: 'method not allowed' })\n return\n }\n\n const body = await readBody(req)\n if (!body) {\n sendJson(res, 400, { ok: false, error: '请求体必须是 JSON' })\n return\n }\n\n if (path === '/api/auth/register') {\n if (store.isInitialized()) {\n sendJson(res, 409, { ok: false, error: '管理员已存在' })\n return\n }\n const username = sanitizeUsername(String(body.username ?? ''))\n const password = String(body.password ?? '')\n if (!username) {\n sendJson(res, 400, { ok: false, error: '用户名不能为空' })\n return\n }\n if (password.length < 8) {\n sendJson(res, 400, { ok: false, error: '密码至少 8 个字符' })\n return\n }\n store.register(username, password)\n const cookie = issueSession(username, store.sessionSecret!)\n sendJson(res, 200, { ok: true }, { 'set-cookie': sessionCookieHeader(cookie) })\n return\n }\n\n if (path === '/api/auth/login') {\n const ip = clientIp(req)\n if (limiter.isLocked(ip)) {\n sendJson(res, 429, { ok: false, error: '尝试次数过多,请 30 秒后再试' })\n return\n }\n if (!store.isInitialized()) {\n sendJson(res, 409, { ok: false, error: '尚未初始化管理员' })\n return\n }\n const username = sanitizeUsername(String(body.username ?? ''))\n const password = String(body.password ?? '')\n if (username !== store.username || !store.verify(password)) {\n limiter.recordFailure(ip)\n sendJson(res, 401, { ok: false, error: '用户名或密码错误' })\n return\n }\n limiter.recordSuccess(ip)\n const cookie = issueSession(username, store.sessionSecret!)\n sendJson(res, 200, { ok: true }, { 'set-cookie': sessionCookieHeader(cookie) })\n return\n }\n\n if (path === '/api/auth/logout') {\n sendJson(res, 200, { ok: true }, { 'set-cookie': clearCookieHeader() })\n return\n }\n\n if (path === '/api/auth/change-password' || path === '/api/auth/change-username') {\n const secret = store.sessionSecret\n const currentName = store.username\n const cookie = secret && currentName ? readCookie(req.headers.cookie, SESSION_COOKIE) : undefined\n if (!secret || !currentName || !cookie || !verifySession(cookie, secret, currentName)) {\n sendJson(res, 401, { ok: false, error: 'unauthorized' })\n return\n }\n const ip = clientIp(req)\n if (limiter.isLocked(ip)) {\n sendJson(res, 429, { ok: false, error: '尝试次数过多,请 30 秒后再试' })\n return\n }\n if (!store.verify(String(body.currentPassword ?? ''))) {\n limiter.recordFailure(ip)\n sendJson(res, 401, { ok: false, error: '当前密码错误' })\n return\n }\n limiter.recordSuccess(ip)\n if (path === '/api/auth/change-password') {\n const password = String(body.password ?? '')\n if (password.length < 8) {\n sendJson(res, 400, { ok: false, error: '密码至少 8 个字符' })\n return\n }\n store.changePassword(currentName, password)\n } else {\n const username = sanitizeUsername(String(body.username ?? ''))\n if (!username) {\n sendJson(res, 400, { ok: false, error: '用户名不能为空' })\n return\n }\n store.changeUsername(username)\n }\n const fresh = issueSession(store.username!, store.sessionSecret!)\n sendJson(res, 200, { ok: true }, { 'set-cookie': sessionCookieHeader(fresh) })\n return\n }\n\n sendJson(res, 404, { ok: false, error: 'not found' })\n },\n }),\n )\n}\n"],"mappings":"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"}
|
package/lib/session.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/session.d.ts
|
|
2
|
+
declare const SESSION_COOKIE = "dshb_sid";
|
|
3
|
+
declare const SESSION_MAX_AGE_SEC: number;
|
|
4
|
+
declare function issueSession(username: string, secret: Buffer): string;
|
|
5
|
+
declare function verifySession(value: string | undefined, secret: Buffer, currentUsername: string): boolean;
|
|
6
|
+
declare function readCookie(header: string | undefined, name: string): string | undefined;
|
|
7
|
+
declare function sessionCookieHeader(value: string): string;
|
|
8
|
+
declare function clearCookieHeader(): string;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { SESSION_COOKIE, SESSION_MAX_AGE_SEC, clearCookieHeader, issueSession, readCookie, sessionCookieHeader, verifySession };
|
|
11
|
+
//# sourceMappingURL=session.d.ts.map
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/session.ts
|
|
3
|
+
const SESSION_COOKIE = "dshb_sid";
|
|
4
|
+
const SESSION_MAX_AGE_SEC = 1209600;
|
|
5
|
+
function base64url(input) {
|
|
6
|
+
return Buffer.from(input).toString("base64url");
|
|
7
|
+
}
|
|
8
|
+
function sign(secret, payload) {
|
|
9
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
10
|
+
}
|
|
11
|
+
function issueSession(username, secret) {
|
|
12
|
+
const payload = `${base64url(username)}.${Math.floor(Date.now() / 1e3)}`;
|
|
13
|
+
return `${payload}.${sign(secret, payload)}`;
|
|
14
|
+
}
|
|
15
|
+
function verifySession(value, secret, currentUsername) {
|
|
16
|
+
if (!value) return false;
|
|
17
|
+
const parts = value.split(".");
|
|
18
|
+
if (parts.length !== 3) return false;
|
|
19
|
+
const [b64user, iatText, sig] = parts;
|
|
20
|
+
const expected = sign(secret, `${b64user}.${iatText}`);
|
|
21
|
+
const a = Buffer.from(sig);
|
|
22
|
+
const b = Buffer.from(expected);
|
|
23
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
|
|
24
|
+
const iat = Number(iatText);
|
|
25
|
+
if (!Number.isInteger(iat)) return false;
|
|
26
|
+
if (Math.floor(Date.now() / 1e3) - iat > 1209600) return false;
|
|
27
|
+
let username;
|
|
28
|
+
try {
|
|
29
|
+
username = Buffer.from(b64user, "base64url").toString("utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return username === currentUsername;
|
|
34
|
+
}
|
|
35
|
+
function readCookie(header, name) {
|
|
36
|
+
if (!header) return void 0;
|
|
37
|
+
for (const part of header.split(";")) {
|
|
38
|
+
const eq = part.indexOf("=");
|
|
39
|
+
if (eq < 0) continue;
|
|
40
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function sessionCookieHeader(value) {
|
|
44
|
+
return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_SEC}`;
|
|
45
|
+
}
|
|
46
|
+
function clearCookieHeader() {
|
|
47
|
+
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { SESSION_COOKIE, SESSION_MAX_AGE_SEC, clearCookieHeader, issueSession, readCookie, sessionCookieHeader, verifySession };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","names":[],"sources":["../src/session.ts"],"sourcesContent":["import { createHmac, timingSafeEqual } from 'node:crypto'\n\nexport const SESSION_COOKIE = 'dshb_sid'\nexport const SESSION_MAX_AGE_SEC = 14 * 24 * 3600\n\nfunction base64url(input: Buffer | string): string {\n return Buffer.from(input).toString('base64url')\n}\n\nfunction sign(secret: Buffer, payload: string): string {\n return createHmac('sha256', secret).update(payload).digest('base64url')\n}\n\nexport function issueSession(username: string, secret: Buffer): string {\n const payload = `${base64url(username)}.${Math.floor(Date.now() / 1000)}`\n return `${payload}.${sign(secret, payload)}`\n}\n\nexport function verifySession(\n value: string | undefined,\n secret: Buffer,\n currentUsername: string,\n): boolean {\n if (!value) return false\n const parts = value.split('.')\n if (parts.length !== 3) return false\n const [b64user, iatText, sig] = parts\n const payload = `${b64user}.${iatText}`\n const expected = sign(secret, payload)\n const a = Buffer.from(sig)\n const b = Buffer.from(expected)\n if (a.length !== b.length || !timingSafeEqual(a, b)) return false\n const iat = Number(iatText)\n if (!Number.isInteger(iat)) return false\n if (Math.floor(Date.now() / 1000) - iat > SESSION_MAX_AGE_SEC) return false\n let username: string\n try {\n username = Buffer.from(b64user, 'base64url').toString('utf8')\n } catch {\n return false\n }\n return username === currentUsername\n}\n\nexport function readCookie(header: string | undefined, name: string): string | undefined {\n if (!header) return undefined\n for (const part of header.split(';')) {\n const eq = part.indexOf('=')\n if (eq < 0) continue\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()\n }\n return undefined\n}\n\nexport function sessionCookieHeader(value: string): string {\n return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_SEC}`\n}\n\nexport function clearCookieHeader(): string {\n return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`\n}\n"],"mappings":";;AAEA,MAAa,iBAAiB;AAC9B,MAAa,sBAAsB;AAEnC,SAAS,UAAU,OAAgC;CACjD,OAAO,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,WAAW;AAChD;AAEA,SAAS,KAAK,QAAgB,SAAyB;CACrD,OAAO,WAAW,UAAU,MAAM,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,OAAO,WAAW;AACxE;AAEA,SAAgB,aAAa,UAAkB,QAAwB;CACrE,MAAM,UAAU,GAAG,UAAU,QAAQ,EAAE,GAAG,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;CACtE,OAAO,GAAG,QAAQ,GAAG,KAAK,QAAQ,OAAO;AAC3C;AAEA,SAAgB,cACd,OACA,QACA,iBACS;CACT,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,QAAQ,MAAM,MAAM,GAAG;CAC7B,IAAI,MAAM,WAAW,GAAG,OAAO;CAC/B,MAAM,CAAC,SAAS,SAAS,OAAO;CAEhC,MAAM,WAAW,KAAK,QAAQ,GADX,QAAQ,GAAG,SACO;CACrC,MAAM,IAAI,OAAO,KAAK,GAAG;CACzB,MAAM,IAAI,OAAO,KAAK,QAAQ;CAC9B,IAAI,EAAE,WAAW,EAAE,UAAU,CAAC,gBAAgB,GAAG,CAAC,GAAG,OAAO;CAC5D,MAAM,MAAM,OAAO,OAAO;CAC1B,IAAI,CAAC,OAAO,UAAU,GAAG,GAAG,OAAO;CACnC,IAAI,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI,MAAA,SAA2B,OAAO;CACtE,IAAI;CACJ,IAAI;EACF,WAAW,OAAO,KAAK,SAAS,WAAW,CAAC,CAAC,SAAS,MAAM;CAC9D,QAAQ;EACN,OAAO;CACT;CACA,OAAO,aAAa;AACtB;AAEA,SAAgB,WAAW,QAA4B,MAAkC;CACvF,IAAI,CAAC,QAAQ,OAAO,KAAA;CACpB,KAAK,MAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;EACpC,MAAM,KAAK,KAAK,QAAQ,GAAG;EAC3B,IAAI,KAAK,GAAG;EACZ,IAAI,KAAK,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,KAAK,CAAC,CAAC,CAAC,KAAK;CACxE;AAEF;AAEA,SAAgB,oBAAoB,OAAuB;CACzD,OAAO,GAAG,eAAe,GAAG,MAAM,4CAA4C;AAChF;AAEA,SAAgB,oBAA4B;CAC1C,OAAO,GAAG,eAAe;AAC3B"}
|