@lvxianchao/dsh-web-guard 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 admin page, and a session that
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 { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'
17
- import { dirname } from 'node:path'
18
-
19
- const SCRYPT_N = 16384
20
- const SCRYPT_R = 8
21
- const SCRYPT_P = 1
22
- const KEYLEN = 32
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
- const key = scryptSync(password, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 64 * 1024 * 1024 })
27
- return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString('base64')}$${key.toString('base64')}`
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
- if (typeof password !== 'string' || typeof stored !== 'string' || stored.length === 0) return false
33
- if (!stored.startsWith('scrypt$')) {
34
- // Plaintext config: constant-time compare on equal-length buffers.
35
- const a = Buffer.from(password)
36
- const b = Buffer.from(stored)
37
- if (a.length !== b.length) return false
38
- return timingSafeEqual(a, b)
39
- }
40
- const parts = stored.split('$')
41
- if (parts.length !== 6) return false
42
- const [, n, r, p, saltB64, keyB64] = parts
43
- let expected
44
- try {
45
- expected = Buffer.from(keyB64, 'base64')
46
- } catch {
47
- return false
48
- }
49
- if (expected.length === 0) return false
50
- const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
51
- N: Number(n), r: Number(r), p: Number(p), maxmem: 64 * 1024 * 1024,
52
- })
53
- return actual.length === expected.length && timingSafeEqual(actual, expected)
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
- * @param {object} options
62
- * @param {number} options.sessionDays - session lifetime in days; 0 = browser session only
63
- * @param {number} options.maxAttempts - failures before a temporary lock
64
- * @param {number} options.lockMinutes - lock duration in minutes
65
- */
66
- constructor({ sessionDays = 7, maxAttempts = 5, lockMinutes = 5, file } = {}) {
67
- this.sessionMs = Math.max(0, sessionDays) * 24 * 60 * 60 * 1000
68
- this.maxAttempts = Math.max(1, maxAttempts)
69
- this.lockMs = Math.max(1, lockMinutes) * 60 * 1000
70
- /** @type {Map<string, {createdAt:number,lastSeen:number,ip:string,ua:string}>} */
71
- this.sessions = new Map()
72
- /** @type {Map<string, {fails:number,until:number}>} */
73
- this.failures = new Map()
74
- this.file = typeof file === 'string' && file.length > 0 ? file : ''
75
- this.persistTimer = null
76
- this.timer = setInterval(() => this.sweep(), 10 * 60 * 1000)
77
- this.timer.unref?.()
78
- }
79
-
80
- /**
81
- * Restore the table a previous process wrote. A missing or corrupt file is
82
- * treated as "no sessions" — a damaged cache must never block sign-in.
83
- */
84
- load() {
85
- if (!this.file) return
86
- let data
87
- try {
88
- data = JSON.parse(readFileSync(this.file, 'utf8'))
89
- } catch {
90
- return
91
- }
92
- if (data === null || typeof data !== 'object' || data.version !== 1) return
93
- if (data.sessions === null || typeof data.sessions !== 'object') return
94
- const now = Date.now()
95
- const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
96
- for (const [key, row] of Object.entries(data.sessions)) {
97
- if (!/^[0-9a-f]{64}$/.test(key)) continue
98
- if (typeof row?.createdAt !== 'number' || typeof row?.lastSeen !== 'number') continue
99
- if (now - row.lastSeen > limit) continue
100
- this.sessions.set(key, {
101
- createdAt: row.createdAt,
102
- lastSeen: row.lastSeen,
103
- ip: typeof row.ip === 'string' ? row.ip : '',
104
- ua: typeof row.ua === 'string' ? row.ua : '',
105
- })
106
- }
107
- }
108
-
109
- /** Write the table atomically (tmp + rename); disk problems never block auth. */
110
- persist() {
111
- if (!this.file) return
112
- try {
113
- const sessions = {}
114
- for (const [key, row] of this.sessions) sessions[key] = row
115
- mkdirSync(dirname(this.file), { recursive: true })
116
- const tmp = `${this.file}.${process.pid}.tmp`
117
- writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), sessions }), { mode: 0o600 })
118
- renameSync(tmp, this.file)
119
- } catch {
120
- // Read-only data dir or full disk: this process keeps working from memory.
121
- }
122
- }
123
-
124
- /** Coalesce a burst of changes into one write. */
125
- touch() {
126
- if (!this.file || this.persistTimer !== null) return
127
- this.persistTimer = setTimeout(() => {
128
- this.persistTimer = null
129
- this.persist()
130
- }, 500)
131
- this.persistTimer.unref?.()
132
- }
133
-
134
- /** Whether this client is currently locked out. */
135
- isLocked(ip) {
136
- const row = this.failures.get(ip)
137
- if (!row) return false
138
- if (row.until === 0) return false // below the threshold: keep counting
139
- if (row.until > Date.now()) return true
140
- this.failures.delete(ip) // lock expired
141
- return false
142
- }
143
-
144
- /** Record a failed attempt; returns the remaining attempts before lock. */
145
- recordFailure(ip) {
146
- const row = this.failures.get(ip) ?? { fails: 0, until: 0 }
147
- row.fails += 1
148
- if (row.fails >= this.maxAttempts) {
149
- row.until = Date.now() + this.lockMs
150
- row.fails = 0
151
- }
152
- this.failures.set(ip, row)
153
- return Math.max(0, this.maxAttempts - row.fails)
154
- }
155
-
156
- clearFailures(ip) {
157
- this.failures.delete(ip)
158
- }
159
-
160
- /** Create a session and return the raw token (only ever held by the client). */
161
- issue(ip, ua) {
162
- const token = randomBytes(32).toString('base64url')
163
- const now = Date.now()
164
- this.sessions.set(sha256(token), { createdAt: now, lastSeen: now, ip, ua })
165
- this.touch()
166
- return token
167
- }
168
-
169
- /** Resolve a token to its session, sliding last-seen. */
170
- resolve(token) {
171
- if (typeof token !== 'string' || token.length === 0) return undefined
172
- const key = sha256(token)
173
- const row = this.sessions.get(key)
174
- if (!row) return undefined
175
- const now = Date.now()
176
- const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
177
- if (now - row.lastSeen > limit) {
178
- this.sessions.delete(key)
179
- this.touch()
180
- return undefined
181
- }
182
- row.lastSeen = now
183
- this.touch()
184
- return row
185
- }
186
-
187
- revoke(token) {
188
- if (typeof token !== 'string' || token.length === 0) return
189
- if (this.sessions.delete(sha256(token))) this.touch()
190
- }
191
-
192
- /** List live sessions with a short opaque id (the hashed key prefix). */
193
- list() {
194
- const now = Date.now()
195
- const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
196
- const out = []
197
- for (const [key, row] of this.sessions) {
198
- if (now - row.lastSeen > limit) continue
199
- out.push({ id: key.slice(0, 16), ip: row.ip, ua: row.ua, createdAt: row.createdAt, lastSeen: row.lastSeen })
200
- }
201
- return out.sort((a, b) => b.lastSeen - a.lastSeen)
202
- }
203
-
204
- /** Drop one session by the id returned from {@link list}. */
205
- removeById(id) {
206
- if (typeof id !== 'string') return false
207
- for (const key of this.sessions.keys()) {
208
- if (key.slice(0, 16) === id) {
209
- this.sessions.delete(key)
210
- this.touch()
211
- return true
212
- }
213
- }
214
- return false
215
- }
216
-
217
- /** Id of the session owning this raw token, for "current" highlighting. */
218
- idOf(token) {
219
- if (typeof token !== 'string' || token.length === 0) return ''
220
- return sha256(token).slice(0, 16)
221
- }
222
-
223
- revokeAll() {
224
- this.sessions.clear()
225
- this.touch()
226
- }
227
-
228
- sweep() {
229
- const now = Date.now()
230
- let dropped = false
231
- const limit = this.sessionMs === 0 ? 12 * 60 * 60 * 1000 : this.sessionMs
232
- for (const [key, row] of this.sessions) {
233
- if (now - row.lastSeen > limit) {
234
- this.sessions.delete(key)
235
- dropped = true
236
- }
237
- }
238
- for (const [ip, row] of this.failures) {
239
- if (row.until > 0 && row.until < now) this.failures.delete(ip)
240
- }
241
- if (dropped) this.touch()
242
- }
243
-
244
- dispose() {
245
- clearInterval(this.timer)
246
- if (this.persistTimer !== null) {
247
- clearTimeout(this.persistTimer)
248
- this.persistTimer = null
249
- }
250
- this.persist()
251
- this.sessions.clear()
252
- this.failures.clear()
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
  }