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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
5
|
+
//#region src/credentials.ts
|
|
6
|
+
function dshHome() {
|
|
7
|
+
return process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
8
|
+
}
|
|
9
|
+
function authFilePath() {
|
|
10
|
+
return join(dshHome(), "dshb", "web-auth.json");
|
|
11
|
+
}
|
|
12
|
+
function sanitizeUsername(input) {
|
|
13
|
+
return input.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
14
|
+
}
|
|
15
|
+
var CredentialStore = class {
|
|
16
|
+
data = null;
|
|
17
|
+
loaded = false;
|
|
18
|
+
loadedMtime = 0;
|
|
19
|
+
file() {
|
|
20
|
+
return authFilePath();
|
|
21
|
+
}
|
|
22
|
+
maybeReload() {
|
|
23
|
+
if (!this.loaded) {
|
|
24
|
+
this.load();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
if (statSync(this.file()).mtimeMs !== this.loadedMtime) this.load();
|
|
29
|
+
} catch {
|
|
30
|
+
this.data = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
isInitialized() {
|
|
34
|
+
this.maybeReload();
|
|
35
|
+
return this.data !== null;
|
|
36
|
+
}
|
|
37
|
+
get username() {
|
|
38
|
+
this.maybeReload();
|
|
39
|
+
return this.data?.username ?? null;
|
|
40
|
+
}
|
|
41
|
+
get sessionSecret() {
|
|
42
|
+
this.maybeReload();
|
|
43
|
+
return this.data ? Buffer.from(this.data.sessionSecret, "base64") : null;
|
|
44
|
+
}
|
|
45
|
+
register(username, password) {
|
|
46
|
+
this.maybeReload();
|
|
47
|
+
if (this.data) throw new Error("auth: already initialized");
|
|
48
|
+
const salt = randomBytes(16);
|
|
49
|
+
const hash = scryptSync(password, salt, 64);
|
|
50
|
+
this.data = {
|
|
51
|
+
username,
|
|
52
|
+
salt: salt.toString("base64"),
|
|
53
|
+
hash: hash.toString("base64"),
|
|
54
|
+
sessionSecret: randomBytes(32).toString("base64")
|
|
55
|
+
};
|
|
56
|
+
this.save();
|
|
57
|
+
}
|
|
58
|
+
verify(password) {
|
|
59
|
+
this.maybeReload();
|
|
60
|
+
if (!this.data) return false;
|
|
61
|
+
const hash = scryptSync(password, Buffer.from(this.data.salt, "base64"), 64);
|
|
62
|
+
const expected = Buffer.from(this.data.hash, "base64");
|
|
63
|
+
return timingSafeEqual(hash, expected);
|
|
64
|
+
}
|
|
65
|
+
changePassword(username, password) {
|
|
66
|
+
this.maybeReload();
|
|
67
|
+
if (!this.data) throw new Error("auth: not initialized");
|
|
68
|
+
const salt = randomBytes(16);
|
|
69
|
+
this.data = {
|
|
70
|
+
username,
|
|
71
|
+
salt: salt.toString("base64"),
|
|
72
|
+
hash: scryptSync(password, salt, 64).toString("base64"),
|
|
73
|
+
sessionSecret: randomBytes(32).toString("base64")
|
|
74
|
+
};
|
|
75
|
+
this.save();
|
|
76
|
+
}
|
|
77
|
+
changeUsername(username) {
|
|
78
|
+
this.maybeReload();
|
|
79
|
+
if (!this.data) throw new Error("auth: not initialized");
|
|
80
|
+
this.data = {
|
|
81
|
+
...this.data,
|
|
82
|
+
username,
|
|
83
|
+
sessionSecret: randomBytes(32).toString("base64")
|
|
84
|
+
};
|
|
85
|
+
this.save();
|
|
86
|
+
}
|
|
87
|
+
load() {
|
|
88
|
+
this.loaded = true;
|
|
89
|
+
const file = this.file();
|
|
90
|
+
try {
|
|
91
|
+
this.loadedMtime = statSync(file).mtimeMs;
|
|
92
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
93
|
+
if (parsed.username && parsed.salt && parsed.hash && parsed.sessionSecret) this.data = parsed;
|
|
94
|
+
} catch {
|
|
95
|
+
this.data = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
save() {
|
|
99
|
+
const file = this.file();
|
|
100
|
+
mkdirSync(join(file, ".."), {
|
|
101
|
+
recursive: true,
|
|
102
|
+
mode: 448
|
|
103
|
+
});
|
|
104
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
105
|
+
writeFileSync(tmp, JSON.stringify(this.data), { mode: 384 });
|
|
106
|
+
renameSync(tmp, file);
|
|
107
|
+
chmodSync(file, 384);
|
|
108
|
+
try {
|
|
109
|
+
this.loadedMtime = statSync(file).mtimeMs;
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
let shared = null;
|
|
114
|
+
function sharedCredentialStore() {
|
|
115
|
+
if (!shared) shared = new CredentialStore();
|
|
116
|
+
return shared;
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
export { CredentialStore, authFilePath, dshHome, sanitizeUsername, sharedCredentialStore };
|
|
120
|
+
|
|
121
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.js","names":[],"sources":["../src/credentials.ts"],"sourcesContent":["import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'\n\nexport interface AuthStoreData {\n username: string\n salt: string\n hash: string\n sessionSecret: string\n}\n\nexport function dshHome(): string {\n return process.env.DSH_HOME ?? join(homedir(), '.dsh')\n}\n\nexport function authFilePath(): string {\n return join(dshHome(), 'dshb', 'web-auth.json')\n}\n\nexport function sanitizeUsername(input: string): string {\n return input.replace(/[\\x00-\\x1f\\x7f]/g, '').trim()\n}\n\nexport class CredentialStore {\n private data: AuthStoreData | null = null\n private loaded = false\n private loadedMtime = 0\n\n private file(): string {\n return authFilePath()\n }\n\n private maybeReload(): void {\n if (!this.loaded) {\n this.load()\n return\n }\n try {\n const mtime = statSync(this.file()).mtimeMs\n if (mtime !== this.loadedMtime) this.load()\n } catch {\n this.data = null\n }\n }\n\n isInitialized(): boolean {\n this.maybeReload()\n return this.data !== null\n }\n\n get username(): string | null {\n this.maybeReload()\n return this.data?.username ?? null\n }\n\n get sessionSecret(): Buffer | null {\n this.maybeReload()\n return this.data ? Buffer.from(this.data.sessionSecret, 'base64') : null\n }\n\n register(username: string, password: string): void {\n this.maybeReload()\n if (this.data) throw new Error('auth: already initialized')\n const salt = randomBytes(16)\n const hash = scryptSync(password, salt, 64)\n this.data = {\n username,\n salt: salt.toString('base64'),\n hash: hash.toString('base64'),\n sessionSecret: randomBytes(32).toString('base64'),\n }\n this.save()\n }\n\n verify(password: string): boolean {\n this.maybeReload()\n if (!this.data) return false\n const hash = scryptSync(password, Buffer.from(this.data.salt, 'base64'), 64)\n const expected = Buffer.from(this.data.hash, 'base64')\n return timingSafeEqual(hash, expected)\n }\n\n changePassword(username: string, password: string): void {\n this.maybeReload()\n if (!this.data) throw new Error('auth: not initialized')\n const salt = randomBytes(16)\n this.data = {\n username,\n salt: salt.toString('base64'),\n hash: scryptSync(password, salt, 64).toString('base64'),\n sessionSecret: randomBytes(32).toString('base64'),\n }\n this.save()\n }\n\n changeUsername(username: string): void {\n this.maybeReload()\n if (!this.data) throw new Error('auth: not initialized')\n this.data = { ...this.data, username, sessionSecret: randomBytes(32).toString('base64') }\n this.save()\n }\n\n private load(): void {\n this.loaded = true\n const file = this.file()\n try {\n this.loadedMtime = statSync(file).mtimeMs\n const parsed = JSON.parse(readFileSync(file, 'utf8')) as AuthStoreData\n if (parsed.username && parsed.salt && parsed.hash && parsed.sessionSecret) {\n this.data = parsed\n }\n } catch {\n this.data = null\n }\n }\n\n private save(): void {\n const file = this.file()\n mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 })\n const tmp = `${file}.${process.pid}.tmp`\n writeFileSync(tmp, JSON.stringify(this.data), { mode: 0o600 })\n renameSync(tmp, file)\n chmodSync(file, 0o600)\n try {\n this.loadedMtime = statSync(file).mtimeMs\n } catch {}\n }\n}\n\nlet shared: CredentialStore | null = null\n\nexport function sharedCredentialStore(): CredentialStore {\n if (!shared) shared = new CredentialStore()\n return shared\n}\n"],"mappings":"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"}
|
package/lib/gate.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CredentialStore } from "./credentials.js";
|
|
2
|
+
import { IncomingMessage } from "node:http";
|
|
3
|
+
//#region src/gate.d.ts
|
|
4
|
+
type GateDecision = 'allow' | 'exempt' | 'redirect-login' | 'reject';
|
|
5
|
+
declare class AuthGate {
|
|
6
|
+
private readonly store;
|
|
7
|
+
constructor(store: CredentialStore);
|
|
8
|
+
decide(req: IncomingMessage): GateDecision;
|
|
9
|
+
private isPageRequest;
|
|
10
|
+
}
|
|
11
|
+
declare function rewriteToLoopback(req: IncomingMessage, port: number): void;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { AuthGate, GateDecision, rewriteToLoopback };
|
|
14
|
+
//# sourceMappingURL=gate.d.ts.map
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isLoopbackRequest } from "./loopback.js";
|
|
2
|
+
import { SESSION_COOKIE, readCookie, verifySession } from "./session.js";
|
|
3
|
+
//#region src/gate.ts
|
|
4
|
+
var AuthGate = class {
|
|
5
|
+
store;
|
|
6
|
+
constructor(store) {
|
|
7
|
+
this.store = store;
|
|
8
|
+
}
|
|
9
|
+
decide(req) {
|
|
10
|
+
const path = new URL(req.url ?? "/", "http://x").pathname;
|
|
11
|
+
if (path === "/login" || path.startsWith("/api/auth/")) return "exempt";
|
|
12
|
+
if (isLoopbackRequest(req.socket.remoteAddress, req.headers.host)) return "allow";
|
|
13
|
+
const secret = this.store.sessionSecret;
|
|
14
|
+
const username = this.store.username;
|
|
15
|
+
if (secret && username) {
|
|
16
|
+
const value = readCookie(req.headers.cookie, SESSION_COOKIE);
|
|
17
|
+
if (verifySession(value, secret, username)) return "allow";
|
|
18
|
+
}
|
|
19
|
+
if (this.isPageRequest(req)) return "redirect-login";
|
|
20
|
+
return "reject";
|
|
21
|
+
}
|
|
22
|
+
isPageRequest(req) {
|
|
23
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
24
|
+
if (new URL(req.url ?? "/", "http://x").pathname.startsWith("/api/")) return false;
|
|
25
|
+
const accept = req.headers.accept ?? "";
|
|
26
|
+
return accept.includes("text/html") || accept.includes("*/*");
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function rewriteToLoopback(req, port) {
|
|
30
|
+
const authority = `127.0.0.1:${port}`;
|
|
31
|
+
req.headers.host = authority;
|
|
32
|
+
for (const name of ["origin", "referer"]) {
|
|
33
|
+
const value = req.headers[name];
|
|
34
|
+
if (typeof value !== "string") continue;
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
url.host = authority;
|
|
38
|
+
req.headers[name] = url.toString();
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { AuthGate, rewriteToLoopback };
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=gate.js.map
|
package/lib/gate.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate.js","names":[],"sources":["../src/gate.ts"],"sourcesContent":["import type { IncomingMessage } from 'node:http'\nimport { isLoopbackRequest } from './loopback.js'\nimport { readCookie, SESSION_COOKIE, verifySession } from './session.js'\nimport type { CredentialStore } from './credentials.js'\n\nexport type GateDecision = 'allow' | 'exempt' | 'redirect-login' | 'reject'\n\nexport class AuthGate {\n constructor(private readonly store: CredentialStore) {}\n\n decide(req: IncomingMessage): GateDecision {\n const path = new URL(req.url ?? '/', 'http://x').pathname\n if (path === '/login' || path.startsWith('/api/auth/')) return 'exempt'\n if (isLoopbackRequest(req.socket.remoteAddress, req.headers.host)) return 'allow'\n const secret = this.store.sessionSecret\n const username = this.store.username\n if (secret && username) {\n const value = readCookie(req.headers.cookie, SESSION_COOKIE)\n if (verifySession(value, secret, username)) return 'allow'\n }\n if (this.isPageRequest(req)) return 'redirect-login'\n return 'reject'\n }\n\n private isPageRequest(req: IncomingMessage): boolean {\n if (req.method !== 'GET' && req.method !== 'HEAD') return false\n const path = new URL(req.url ?? '/', 'http://x').pathname\n if (path.startsWith('/api/')) return false\n const accept = req.headers.accept ?? ''\n return accept.includes('text/html') || accept.includes('*/*')\n }\n}\n\nexport function rewriteToLoopback(req: IncomingMessage, port: number): void {\n const authority = `127.0.0.1:${port}`\n req.headers.host = authority\n for (const name of ['origin', 'referer'] as const) {\n const value = req.headers[name]\n if (typeof value !== 'string') continue\n try {\n const url = new URL(value)\n url.host = authority\n req.headers[name] = url.toString()\n } catch {}\n }\n}\n"],"mappings":";;;AAOA,IAAa,WAAb,MAAsB;CACS;CAA7B,YAAY,OAAyC;EAAxB,KAAA,QAAA;CAAyB;CAEtD,OAAO,KAAoC;EACzC,MAAM,OAAO,IAAI,IAAI,IAAI,OAAO,KAAK,UAAU,CAAC,CAAC;EACjD,IAAI,SAAS,YAAY,KAAK,WAAW,YAAY,GAAG,OAAO;EAC/D,IAAI,kBAAkB,IAAI,OAAO,eAAe,IAAI,QAAQ,IAAI,GAAG,OAAO;EAC1E,MAAM,SAAS,KAAK,MAAM;EAC1B,MAAM,WAAW,KAAK,MAAM;EAC5B,IAAI,UAAU,UAAU;GACtB,MAAM,QAAQ,WAAW,IAAI,QAAQ,QAAQ,cAAc;GAC3D,IAAI,cAAc,OAAO,QAAQ,QAAQ,GAAG,OAAO;EACrD;EACA,IAAI,KAAK,cAAc,GAAG,GAAG,OAAO;EACpC,OAAO;CACT;CAEA,cAAsB,KAA+B;EACnD,IAAI,IAAI,WAAW,SAAS,IAAI,WAAW,QAAQ,OAAO;EAE1D,IADa,IAAI,IAAI,IAAI,OAAO,KAAK,UAAU,CAAC,CAAC,SACxC,WAAW,OAAO,GAAG,OAAO;EACrC,MAAM,SAAS,IAAI,QAAQ,UAAU;EACrC,OAAO,OAAO,SAAS,WAAW,KAAK,OAAO,SAAS,KAAK;CAC9D;AACF;AAEA,SAAgB,kBAAkB,KAAsB,MAAoB;CAC1E,MAAM,YAAY,aAAa;CAC/B,IAAI,QAAQ,OAAO;CACnB,KAAK,MAAM,QAAQ,CAAC,UAAU,SAAS,GAAY;EACjD,MAAM,QAAQ,IAAI,QAAQ;EAC1B,IAAI,OAAO,UAAU,UAAU;EAC/B,IAAI;GACF,MAAM,MAAM,IAAI,IAAI,KAAK;GACzB,IAAI,OAAO;GACX,IAAI,QAAQ,QAAQ,IAAI,SAAS;EACnC,QAAQ,CAAC;CACX;AACF"}
|