@lvxianchao/dsh-web-guard 2.1.1 → 3.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/CHANGELOG.md +49 -0
- package/README.md +53 -23
- package/README.zh.md +52 -31
- package/lib/auth.js +244 -232
- package/lib/client.js +1071 -0
- package/lib/i18n.js +49 -247
- package/lib/index.js +573 -465
- package/lib/inject.js +20 -69
- package/lib/page.js +52 -62
- package/lib/polyfill.js +9 -8
- package/lib/preferences.js +144 -0
- package/lib/proxy.js +178 -156
- package/lib/shared/api.js +19 -0
- package/lib/shared/theme.js +109 -0
- package/package.json +36 -4
- package/lib/admin.js +0 -190
package/lib/auth.js
CHANGED
|
@@ -7,248 +7,260 @@
|
|
|
7
7
|
* - Sessions are hashed before they reach memory *and* the on-disk cache, so
|
|
8
8
|
* neither a heap dump nor the state file can mint a working cookie. The
|
|
9
9
|
* cache exists so a restart (upgrade, config change) does not sign everyone
|
|
10
|
-
* out; revocation still goes through the
|
|
10
|
+
* out; revocation still goes through the settings page API, and a session that
|
|
11
11
|
* expired while the process was down is dropped on load.
|
|
12
12
|
* - Every session carries its own expiry and last-seen stamp; the cookie
|
|
13
13
|
* lifetime is fixed at issue time, the server side slides on activity.
|
|
14
14
|
*/
|
|
15
|
-
import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
16
|
-
import {
|
|
17
|
-
import { dirname } from 'node:path'
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
15
|
+
import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
const SCRYPT_N = 16384;
|
|
19
|
+
const SCRYPT_R = 8;
|
|
20
|
+
const SCRYPT_P = 1;
|
|
21
|
+
const KEYLEN = 32;
|
|
22
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
24
23
|
/** Hash a plaintext password into the portable scrypt string form. */
|
|
25
24
|
export function hashPassword(password, salt = randomBytes(16)) {
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const key = scryptSync(password, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 64 * 1024 * 1024 });
|
|
26
|
+
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString('base64')}$${key.toString('base64')}`;
|
|
28
27
|
}
|
|
29
|
-
|
|
30
28
|
/** Verify a password against either the scrypt string or a bare plaintext. */
|
|
31
29
|
export function verifyPassword(password, stored) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
30
|
+
if (typeof password !== 'string' || typeof stored !== 'string' || stored.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
if (!stored.startsWith('scrypt$')) {
|
|
33
|
+
// Plaintext config: constant-time compare on equal-length buffers.
|
|
34
|
+
const a = Buffer.from(password);
|
|
35
|
+
const b = Buffer.from(stored);
|
|
36
|
+
if (a.length !== b.length)
|
|
37
|
+
return false;
|
|
38
|
+
return timingSafeEqual(a, b);
|
|
39
|
+
}
|
|
40
|
+
const parts = stored.split('$');
|
|
41
|
+
if (parts.length !== 6)
|
|
42
|
+
return false;
|
|
43
|
+
const [, n, r, p, saltB64, keyB64] = parts;
|
|
44
|
+
let expected;
|
|
45
|
+
try {
|
|
46
|
+
expected = Buffer.from(keyB64, 'base64');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (expected.length === 0)
|
|
52
|
+
return false;
|
|
53
|
+
const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
|
54
|
+
N: Number(n), r: Number(r), p: Number(p), maxmem: 64 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
54
57
|
}
|
|
55
|
-
|
|
56
|
-
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
|
57
|
-
|
|
58
58
|
/** In-memory session table with per-IP failure accounting. */
|
|
59
59
|
export class SessionStore {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.sessions
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
60
|
+
sessionMs;
|
|
61
|
+
maxAttempts;
|
|
62
|
+
lockMs;
|
|
63
|
+
sessions = new Map();
|
|
64
|
+
failures = new Map();
|
|
65
|
+
file;
|
|
66
|
+
persistTimer = null;
|
|
67
|
+
timer;
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
const { sessionDays = 7, maxAttempts = 5, lockMinutes = 5, file } = options;
|
|
70
|
+
this.sessionMs = Math.max(0, sessionDays) * 24 * 60 * 60 * 1000;
|
|
71
|
+
this.maxAttempts = Math.max(1, maxAttempts);
|
|
72
|
+
this.lockMs = Math.max(1, lockMinutes) * 60 * 1000;
|
|
73
|
+
this.file = typeof file === 'string' && file.length > 0 ? file : '';
|
|
74
|
+
this.timer = setInterval(() => this.sweep(), 10 * 60 * 1000);
|
|
75
|
+
this.timer.unref();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Restore the table a previous process wrote. A missing or corrupt file is
|
|
79
|
+
* treated as "no sessions" — a damaged cache must never block sign-in.
|
|
80
|
+
*/
|
|
81
|
+
load() {
|
|
82
|
+
if (!this.file)
|
|
83
|
+
return;
|
|
84
|
+
let data;
|
|
85
|
+
try {
|
|
86
|
+
data = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (data === null || typeof data !== 'object')
|
|
92
|
+
return;
|
|
93
|
+
const cache = data;
|
|
94
|
+
if (cache.version !== 1)
|
|
95
|
+
return;
|
|
96
|
+
if (cache.sessions === null || typeof cache.sessions !== 'object')
|
|
97
|
+
return;
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs;
|
|
100
|
+
for (const [key, value] of Object.entries(cache.sessions)) {
|
|
101
|
+
if (!/^[0-9a-f]{64}$/.test(key))
|
|
102
|
+
continue;
|
|
103
|
+
if (value === null || typeof value !== 'object')
|
|
104
|
+
continue;
|
|
105
|
+
const row = value;
|
|
106
|
+
if (typeof row.createdAt !== 'number' || typeof row.lastSeen !== 'number')
|
|
107
|
+
continue;
|
|
108
|
+
if (now - row.lastSeen > limit)
|
|
109
|
+
continue;
|
|
110
|
+
this.sessions.set(key, {
|
|
111
|
+
createdAt: row.createdAt,
|
|
112
|
+
lastSeen: row.lastSeen,
|
|
113
|
+
ip: typeof row.ip === 'string' ? row.ip : '',
|
|
114
|
+
ua: typeof row.ua === 'string' ? row.ua : '',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Write the table atomically (tmp + rename); disk problems never block auth. */
|
|
119
|
+
persist() {
|
|
120
|
+
if (!this.file)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const sessions = {};
|
|
124
|
+
for (const [key, row] of this.sessions)
|
|
125
|
+
sessions[key] = row;
|
|
126
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
127
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
128
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), sessions }), { mode: 0o600 });
|
|
129
|
+
renameSync(tmp, this.file);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Read-only data dir or full disk: this process keeps working from memory.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Coalesce a burst of changes into one write. */
|
|
136
|
+
touch() {
|
|
137
|
+
if (!this.file || this.persistTimer !== null)
|
|
138
|
+
return;
|
|
139
|
+
this.persistTimer = setTimeout(() => {
|
|
140
|
+
this.persistTimer = null;
|
|
141
|
+
this.persist();
|
|
142
|
+
}, 500);
|
|
143
|
+
this.persistTimer.unref();
|
|
144
|
+
}
|
|
145
|
+
/** Whether this client is currently locked out. */
|
|
146
|
+
isLocked(ip) {
|
|
147
|
+
const row = this.failures.get(ip);
|
|
148
|
+
if (!row)
|
|
149
|
+
return false;
|
|
150
|
+
if (row.until === 0)
|
|
151
|
+
return false; // below the threshold: keep counting
|
|
152
|
+
if (row.until > Date.now())
|
|
153
|
+
return true;
|
|
154
|
+
this.failures.delete(ip); // lock expired
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
/** Record a failed attempt; returns the remaining attempts before lock. */
|
|
158
|
+
recordFailure(ip) {
|
|
159
|
+
const row = this.failures.get(ip) ?? { fails: 0, until: 0 };
|
|
160
|
+
row.fails += 1;
|
|
161
|
+
if (row.fails >= this.maxAttempts) {
|
|
162
|
+
row.until = Date.now() + this.lockMs;
|
|
163
|
+
row.fails = 0;
|
|
164
|
+
}
|
|
165
|
+
this.failures.set(ip, row);
|
|
166
|
+
return Math.max(0, this.maxAttempts - row.fails);
|
|
167
|
+
}
|
|
168
|
+
clearFailures(ip) {
|
|
169
|
+
this.failures.delete(ip);
|
|
170
|
+
}
|
|
171
|
+
/** Create a session and return the raw token (only ever held by the client). */
|
|
172
|
+
issue(ip, ua) {
|
|
173
|
+
const token = randomBytes(32).toString('base64url');
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
this.sessions.set(sha256(token), { createdAt: now, lastSeen: now, ip, ua });
|
|
176
|
+
this.touch();
|
|
177
|
+
return token;
|
|
178
|
+
}
|
|
179
|
+
/** Resolve a token to its session, sliding last-seen. */
|
|
180
|
+
resolve(token) {
|
|
181
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
182
|
+
return undefined;
|
|
183
|
+
const key = sha256(token);
|
|
184
|
+
const row = this.sessions.get(key);
|
|
185
|
+
if (!row)
|
|
186
|
+
return undefined;
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs;
|
|
189
|
+
if (now - row.lastSeen > limit) {
|
|
190
|
+
this.sessions.delete(key);
|
|
191
|
+
this.touch();
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
row.lastSeen = now;
|
|
195
|
+
this.touch();
|
|
196
|
+
return row;
|
|
197
|
+
}
|
|
198
|
+
revoke(token) {
|
|
199
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
200
|
+
return;
|
|
201
|
+
if (this.sessions.delete(sha256(token)))
|
|
202
|
+
this.touch();
|
|
203
|
+
}
|
|
204
|
+
/** List live sessions with a short opaque id (the hashed key prefix). */
|
|
205
|
+
list() {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs;
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const [key, row] of this.sessions) {
|
|
210
|
+
if (now - row.lastSeen > limit)
|
|
211
|
+
continue;
|
|
212
|
+
out.push({ id: key.slice(0, 16), ip: row.ip, ua: row.ua, createdAt: row.createdAt, lastSeen: row.lastSeen });
|
|
213
|
+
}
|
|
214
|
+
return out.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
215
|
+
}
|
|
216
|
+
/** Drop one session by the id returned from {@link list}. */
|
|
217
|
+
removeById(id) {
|
|
218
|
+
if (typeof id !== 'string')
|
|
219
|
+
return false;
|
|
220
|
+
for (const key of this.sessions.keys()) {
|
|
221
|
+
if (key.slice(0, 16) === id) {
|
|
222
|
+
this.sessions.delete(key);
|
|
223
|
+
this.touch();
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
/** Id of the session owning this raw token, for "current" highlighting. */
|
|
230
|
+
idOf(token) {
|
|
231
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
232
|
+
return '';
|
|
233
|
+
return sha256(token).slice(0, 16);
|
|
234
|
+
}
|
|
235
|
+
revokeAll() {
|
|
236
|
+
this.sessions.clear();
|
|
237
|
+
this.touch();
|
|
238
|
+
}
|
|
239
|
+
sweep() {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
let dropped = false;
|
|
242
|
+
const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs;
|
|
243
|
+
for (const [key, row] of this.sessions) {
|
|
244
|
+
if (now - row.lastSeen > limit) {
|
|
245
|
+
this.sessions.delete(key);
|
|
246
|
+
dropped = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const [ip, row] of this.failures) {
|
|
250
|
+
if (row.until > 0 && row.until < now)
|
|
251
|
+
this.failures.delete(ip);
|
|
252
|
+
}
|
|
253
|
+
if (dropped)
|
|
254
|
+
this.touch();
|
|
255
|
+
}
|
|
256
|
+
dispose() {
|
|
257
|
+
clearInterval(this.timer);
|
|
258
|
+
if (this.persistTimer !== null) {
|
|
259
|
+
clearTimeout(this.persistTimer);
|
|
260
|
+
this.persistTimer = null;
|
|
261
|
+
}
|
|
262
|
+
this.persist();
|
|
263
|
+
this.sessions.clear();
|
|
264
|
+
this.failures.clear();
|
|
265
|
+
}
|
|
254
266
|
}
|