@flun/html-template 4.4.2 → 5.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/customize/account.js +125 -64
- package/dev-server.js +1 -1
- package/f-CHANGELOG.md +13 -7
- package/f-README.md +22 -23
- package/package.json +1 -1
- package/templates/account/2fa.html +211 -69
- package/templates/account/forgot-password.html +53 -4
- package/templates/account/login.html +79 -49
- package/templates/account/profile.html +652 -331
- package/templates/account/register.html +58 -2
- package/templates/account/reset-password.html +54 -3
- package/templates/account/verify-email.html +61 -6
package/customize/account.js
CHANGED
|
@@ -29,9 +29,7 @@ if (!env.MAIL_HOST || !env.MAIL_USER || !env.MAIL_PWD)
|
|
|
29
29
|
|
|
30
30
|
// 邮件发送配置
|
|
31
31
|
const transporter = createTransport({
|
|
32
|
-
host: env.MAIL_HOST,
|
|
33
|
-
port: env.MAIL_PORT,
|
|
34
|
-
secure: true,
|
|
32
|
+
host: env.MAIL_HOST, port: env.MAIL_PORT, secure: true,
|
|
35
33
|
auth: { user: env.MAIL_USER, pass: env.MAIL_PWD }
|
|
36
34
|
}),
|
|
37
35
|
// ========== 辅助函数 ==========
|
|
@@ -51,10 +49,10 @@ const transporter = createTransport({
|
|
|
51
49
|
emailVerified, emailVerificationToken: null,
|
|
52
50
|
passwordResetToken: null, passwordResetExpires: null,
|
|
53
51
|
twoFactorSecret: null, twoFactorEnabled: false,
|
|
54
|
-
backupCodes: [],
|
|
55
52
|
webauthnCredentials: [], webauthnEnabled: false,
|
|
56
53
|
createdAt: now, updatedAt: now, passwordChangedAt: now,
|
|
57
|
-
pendingEmail: null, pendingEmailToken: null, pendingEmailExpires: null
|
|
54
|
+
pendingEmail: null, pendingEmailToken: null, pendingEmailExpires: null,
|
|
55
|
+
backupCodes: [], codeFailures: 0, codeLockUntil: null
|
|
58
56
|
};
|
|
59
57
|
},
|
|
60
58
|
initAdminUser = () => {
|
|
@@ -66,7 +64,7 @@ const transporter = createTransport({
|
|
|
66
64
|
}
|
|
67
65
|
},
|
|
68
66
|
sendEmail = async (to, subject, html) => {
|
|
69
|
-
if (transporter) await transporter.sendMail({ from: `"
|
|
67
|
+
if (transporter) await transporter.sendMail({ from: `"我的网站" <${env.MAIL_USER}>`, to, subject, html });
|
|
70
68
|
else console.log(`\n--- 模拟邮件 ---\n收件人: ${to}\n主题: ${subject}\n内容:\n${html}\n---`);
|
|
71
69
|
},
|
|
72
70
|
validateEmail = email => mailRegex.test(email), generateToken = () => randomBytes(32).toString('hex'),
|
|
@@ -132,13 +130,17 @@ const transporter = createTransport({
|
|
|
132
130
|
subject = '添加验证硬件通知';
|
|
133
131
|
actionDescription = '添加了一台新的硬件验证设备';
|
|
134
132
|
break;
|
|
133
|
+
case 'backup_code_lock': // 新增:备份码锁定
|
|
134
|
+
subject = '安全警报:账户临时锁定';
|
|
135
|
+
actionDescription = '连续多次输入错误的备用码,账户已被临时锁定24小时';
|
|
136
|
+
break;
|
|
135
137
|
default: return;
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
const html = `
|
|
139
|
-
<p
|
|
140
|
-
<p
|
|
141
|
-
<p
|
|
141
|
+
<p>主人好!</p>
|
|
142
|
+
<p>网站账户(${user.username})于 <strong>${now}</strong>在 <strong>${clientIp}</strong> ${actionDescription};</p>
|
|
143
|
+
<p>如果是您的操作,请忽略此邮件,否则请前往处理;</p>
|
|
142
144
|
<p style="margin-left:65%;">此致<br/>  安全中心</p>
|
|
143
145
|
`;
|
|
144
146
|
await sendEmail(user.email, subject, html);
|
|
@@ -180,16 +182,14 @@ export const accountRouter = app => {
|
|
|
180
182
|
set(sid, session, cb) {
|
|
181
183
|
const file = path.join(this.sessionsDir, `${sid}.json`);
|
|
182
184
|
try {
|
|
183
|
-
fs.writeFileSync(file, JSON.stringify(session));
|
|
184
|
-
cb(null);
|
|
185
|
+
fs.writeFileSync(file, JSON.stringify(session)), cb(null);
|
|
185
186
|
} catch (e) { cb(e); }
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
destroy(sid, cb) {
|
|
189
190
|
const file = path.join(this.sessionsDir, `${sid}.json`);
|
|
190
191
|
try {
|
|
191
|
-
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
192
|
-
cb(null);
|
|
192
|
+
if (fs.existsSync(file)) fs.unlinkSync(file), cb(null);
|
|
193
193
|
} catch (e) { cb(e); }
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -225,11 +225,9 @@ export const accountRouter = app => {
|
|
|
225
225
|
app.use(session({
|
|
226
226
|
secret: env.SESSION_SECRET || 'dev-secret-change-in-production',
|
|
227
227
|
store: sessionStore,
|
|
228
|
-
resave: false,
|
|
229
|
-
saveUninitialized: false,
|
|
228
|
+
resave: false, saveUninitialized: false,
|
|
230
229
|
cookie: { secure: false, httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * oneHour }
|
|
231
230
|
}));
|
|
232
|
-
|
|
233
231
|
app.use(express.json(), express.urlencoded({ extended: true })), initAdminUser();
|
|
234
232
|
|
|
235
233
|
// 2. 全局登录保护中间件
|
|
@@ -260,9 +258,7 @@ export const accountRouter = app => {
|
|
|
260
258
|
const sessionLoginTime = req.session.loginTime || 0;
|
|
261
259
|
if (user.passwordChangedAt > sessionLoginTime) {
|
|
262
260
|
req.session.destroy(() => {
|
|
263
|
-
if (req.path.startsWith('/api/')) {
|
|
264
|
-
return res.status(401).json({ message: '密码已修改,请重新登录' });
|
|
265
|
-
}
|
|
261
|
+
if (req.path.startsWith('/api/')) return res.status(401).json({ message: '密码已修改,请重新登录' });
|
|
266
262
|
return res.redirect('/login');
|
|
267
263
|
});
|
|
268
264
|
return;
|
|
@@ -281,7 +277,7 @@ export const accountRouter = app => {
|
|
|
281
277
|
}, oneHour);
|
|
282
278
|
|
|
283
279
|
// 定义安全限制和所有页面(公共页面 + 受保护页面)
|
|
284
|
-
const authLimiter = rateLimit({ windowMs: fifteenMin, max:
|
|
280
|
+
const authLimiter = rateLimit({ windowMs: fifteenMin, max: 15, message: { message: '尝试次数过多,请稍后再试' } }),
|
|
285
281
|
allPages = [...publicPage, '/profile'];
|
|
286
282
|
allPages.forEach(page => {
|
|
287
283
|
app.get(page, (req, res) => {
|
|
@@ -306,8 +302,7 @@ export const accountRouter = app => {
|
|
|
306
302
|
// ========== 公开认证API ==========
|
|
307
303
|
app.post('/api/register', authLimiter, async (req, res) => {
|
|
308
304
|
const { username, email, password } = req.body;
|
|
309
|
-
if (!hasAllFields(req.body, ['username', 'email', 'password']))
|
|
310
|
-
return res.status(400).json({ message: '所有字段必填' });
|
|
305
|
+
if (!hasAllFields(req.body, ['username', 'email', 'password'])) return res.status(400).json({ message: '所有字段必填' });
|
|
311
306
|
if (!validateEmail(email)) return res.status(400).json({ message: '邮箱格式不正确' });
|
|
312
307
|
if (!validatePasswordLength(password)) return res.status(400).json({ message: '密码至少6位' });
|
|
313
308
|
|
|
@@ -354,12 +349,15 @@ export const accountRouter = app => {
|
|
|
354
349
|
const valid = await verifyPassword(password, user.password);
|
|
355
350
|
if (!valid) return res.status(401).json({ message: '用户名/邮箱或密码错误' });
|
|
356
351
|
if (!user.emailVerified) return res.status(403).json({ message: '请先验证邮箱', needsVerification: true });
|
|
357
|
-
if (user.webauthnEnabled && user.webauthnCredentials.length > 0)
|
|
358
|
-
return req.session.tempUserId = user.id, res.json({ requireWebAuthn: true });
|
|
359
|
-
if (user.twoFactorEnabled) return req.session.tempUserId = user.id, res.json({ require2FA: true });
|
|
360
352
|
|
|
353
|
+
req.session.tempUserId = user.id; // 保存临时用户ID,供后续多因素验证使用
|
|
354
|
+
if (user.webauthnEnabled && user.webauthnCredentials.length > 0) return res.json({ requireWebAuthn: true });
|
|
355
|
+
if (user.twoFactorEnabled) return res.json({ require2FA: true });
|
|
356
|
+
|
|
357
|
+
// 没有多因素认证,直接登录
|
|
361
358
|
req.session.userId = user.id, req.session.username = user.username, req.session.loginTime = Date.now();
|
|
362
|
-
recentPasswordResets.delete(user.email)
|
|
359
|
+
delete req.session.tempUserId, recentPasswordResets.delete(user.email);
|
|
360
|
+
res.json({ success: true, message: '登录成功' });
|
|
363
361
|
});
|
|
364
362
|
|
|
365
363
|
app.post('/api/verify-2fa', authLimiter, async (req, res) => {
|
|
@@ -370,25 +368,59 @@ export const accountRouter = app => {
|
|
|
370
368
|
if (!tempUserId) return res.status(401).json({ message: '请先完成第一步登录' });
|
|
371
369
|
|
|
372
370
|
const users = readUsers(), user = users.find(u => u.id === tempUserId);
|
|
373
|
-
if (!user?.twoFactorEnabled
|
|
371
|
+
if (!user?.twoFactorEnabled && !user?.webauthnEnabled)
|
|
372
|
+
return res.status(400).json({ message: '用户未启用多因素认证' });
|
|
373
|
+
|
|
374
|
+
// 过期锁定自动清除
|
|
375
|
+
if (user.codeLockUntil && user.codeLockUntil <= Date.now())
|
|
376
|
+
user.codeLockUntil = null, user.codeFailures = 0, touchAndSaveUser(users, user);
|
|
377
|
+
else if (user.codeLockUntil && user.codeLockUntil > Date.now())
|
|
378
|
+
return res.status(401).json({ message: '账户已被临时锁定,请24小时后重试', locked: true });
|
|
379
|
+
|
|
380
|
+
const { twoFactorSecret, backupCodes } = user;
|
|
381
|
+
let verified = false, backupValid = false;
|
|
382
|
+
|
|
383
|
+
// TOTP 验证(6位)
|
|
384
|
+
if (token.length === 6) {
|
|
385
|
+
try {
|
|
386
|
+
verified = await verifyTotp(twoFactorSecret, token);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
console.error('TOTP 验证异常:', err.message);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
374
391
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (!verified && backupCodes?.length)
|
|
392
|
+
// 备份码验证(长度:10位)
|
|
393
|
+
if (!verified && backupCodes?.length) {
|
|
378
394
|
for (let i = 0; i < backupCodes.length; i++) {
|
|
379
395
|
const match = await verifyPassword(token, backupCodes[i]);
|
|
380
396
|
if (match) {
|
|
381
|
-
backupValid = true, backupCodes.splice(i, 1)
|
|
397
|
+
backupValid = true, backupCodes.splice(i, 1);
|
|
382
398
|
break;
|
|
383
399
|
}
|
|
384
400
|
}
|
|
401
|
+
}
|
|
385
402
|
|
|
403
|
+
// 输入错误失败计数与锁定
|
|
404
|
+
if (!verified && !backupValid) {
|
|
405
|
+
user.codeFailures = (user.codeFailures || 0) + 1;
|
|
406
|
+
if (user.codeFailures >= 9) {
|
|
407
|
+
user.codeLockUntil = Date.now() + 24 * oneHour;
|
|
408
|
+
await sendSecurityAlertEmail(req, user, 'backup_code_lock'), touchAndSaveUser(users, user);
|
|
409
|
+
return res.status(401).json({ message: '输入错误次数过多,账户已被锁定24小时,请隔天重试或联系管理员', locked: true });
|
|
410
|
+
} else {
|
|
411
|
+
const remaining = 9 - user.codeFailures;
|
|
412
|
+
touchAndSaveUser(users, user);
|
|
413
|
+
return res.status(401).json({ message: `输入错误,剩余尝试次数:${remaining}`, remainingAttempts: remaining });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 验证成功,重置所有限制
|
|
386
418
|
if (verified || backupValid) {
|
|
387
|
-
|
|
388
|
-
req.session.
|
|
389
|
-
|
|
419
|
+
user.codeFailures = 0, user.codeLockUntil = null, delete req.session.tempUserId;
|
|
420
|
+
req.session.userId = user.id, req.session.username = user.username, req.session.loginTime = Date.now();
|
|
421
|
+
recentPasswordResets.delete(user.email), touchAndSaveUser(users, user);
|
|
422
|
+
return res.json({ success: true, message: '二次验证成功' });
|
|
390
423
|
}
|
|
391
|
-
else res.status(401).json({ message: '验证码无效' });
|
|
392
424
|
});
|
|
393
425
|
|
|
394
426
|
app.post('/api/logout', (req, res) => {
|
|
@@ -452,10 +484,10 @@ export const accountRouter = app => {
|
|
|
452
484
|
const result = getCurrentUser(req);
|
|
453
485
|
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
454
486
|
const { id, username, email, emailVerified, twoFactorEnabled, createdAt, pendingEmail, webauthnEnabled,
|
|
455
|
-
webauthnCredentials } = result.user;
|
|
487
|
+
webauthnCredentials, backupCodes } = result.user;
|
|
456
488
|
res.json({
|
|
457
489
|
id, username, email, emailVerified, twoFactorEnabled, createdAt, pendingEmail, webauthnEnabled,
|
|
458
|
-
webauthnCredentials
|
|
490
|
+
webauthnCredentials, hasBackupCodes: !!(backupCodes?.length > 0)
|
|
459
491
|
});
|
|
460
492
|
});
|
|
461
493
|
|
|
@@ -523,7 +555,7 @@ export const accountRouter = app => {
|
|
|
523
555
|
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
524
556
|
|
|
525
557
|
const { user, users } = result, secret = generateSecret({ length: 32 }), otpauth_url = generateURI({
|
|
526
|
-
issuer: '
|
|
558
|
+
issuer: '我的网站', label: user.username, secret,
|
|
527
559
|
}), qrCodeUrl = await toDataURL(otpauth_url);
|
|
528
560
|
|
|
529
561
|
user.twoFactorSecret = secret, touchAndSaveUser(users, user), res.json({ secret, qrCode: qrCodeUrl });
|
|
@@ -539,8 +571,14 @@ export const accountRouter = app => {
|
|
|
539
571
|
const { user, users } = result, verified = await verifyTotp(user.twoFactorSecret, token);
|
|
540
572
|
if (!verified) return res.status(400).json({ message: '验证码错误' });
|
|
541
573
|
|
|
542
|
-
|
|
543
|
-
|
|
574
|
+
let plainCodes = null;
|
|
575
|
+
// 仅当用户当前没有备份码时,生成一组新的
|
|
576
|
+
if (!user.backupCodes || user.backupCodes.length === 0) {
|
|
577
|
+
const { plainCodes: newPlain, hashedCodes } = generateBackupCodes();
|
|
578
|
+
user.backupCodes = hashedCodes, plainCodes = newPlain;;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
user.twoFactorEnabled = true, touchAndSaveUser(users, user);
|
|
544
582
|
res.json({ success: true, backupCodes: plainCodes, message: '2FA 已启用' });
|
|
545
583
|
});
|
|
546
584
|
|
|
@@ -549,20 +587,26 @@ export const accountRouter = app => {
|
|
|
549
587
|
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
550
588
|
|
|
551
589
|
const { user, users } = result;
|
|
552
|
-
user.twoFactorEnabled = false, user.twoFactorSecret = null, user.
|
|
553
|
-
touchAndSaveUser(users, user), res.json({ success: true });
|
|
590
|
+
user.twoFactorEnabled = false, user.twoFactorSecret = null, touchAndSaveUser(users, user), res.json({ success: true });
|
|
554
591
|
});
|
|
555
592
|
|
|
556
593
|
app.post('/api/regenerate-backup-codes', async (req, res) => {
|
|
557
594
|
const result = getCurrentUser(req);
|
|
558
595
|
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
559
596
|
const { user, users } = result;
|
|
560
|
-
if (!user.twoFactorEnabled) return res.status(400).json({ message: '
|
|
597
|
+
if (!user.twoFactorEnabled && !user.webauthnEnabled) return res.status(400).json({ message: '未启用任何多因素认证' });
|
|
561
598
|
|
|
562
599
|
const { plainCodes, hashedCodes } = generateBackupCodes();
|
|
563
600
|
user.backupCodes = hashedCodes, touchAndSaveUser(users, user), res.json({ backupCodes: plainCodes });
|
|
564
601
|
});
|
|
565
602
|
|
|
603
|
+
app.post('/api/delete-backup-codes', async (req, res) => {
|
|
604
|
+
const result = getCurrentUser(req);
|
|
605
|
+
if (!result) return res.status(401).json({ message: '未登录' });
|
|
606
|
+
const { user, users } = result;
|
|
607
|
+
user.backupCodes = [], touchAndSaveUser(users, user), res.json({ success: true, message: '备份码已删除' });
|
|
608
|
+
});
|
|
609
|
+
|
|
566
610
|
// ========== WebAuthn 硬件验证 API ==========
|
|
567
611
|
app.post('/api/webauthn/register/begin', authLimiter, async (req, res) => {
|
|
568
612
|
try {
|
|
@@ -570,20 +614,18 @@ export const accountRouter = app => {
|
|
|
570
614
|
if (!result) return res.status(401).json({ message: '未登录' });
|
|
571
615
|
const { id, username, webauthnCredentials } = result.user, rpID = getRpId(req),
|
|
572
616
|
options = await generateRegistrationOptions({
|
|
573
|
-
rpName: '
|
|
617
|
+
rpName: '我的网站',
|
|
574
618
|
rpID, userID: Buffer.from(String(id)),
|
|
575
619
|
userName: username, userDisplayName: username,
|
|
576
620
|
attestationType: 'none',
|
|
577
621
|
excludeCredentials: webauthnCredentials.map(cred => ({
|
|
578
|
-
id: cred.id,
|
|
579
|
-
type: 'public-key',
|
|
580
|
-
transports: cred.transports
|
|
622
|
+
id: cred.id, type: 'public-key', transports: cred.transports
|
|
581
623
|
})),
|
|
582
624
|
authenticatorSelection: { userVerification: 'preferred' },
|
|
583
625
|
});
|
|
584
626
|
req.session.webauthnRegisterChallenge = options.challenge, res.json(options);
|
|
585
627
|
} catch (err) {
|
|
586
|
-
return res.status(400).json({ message: err.message
|
|
628
|
+
return res.status(400).json({ message: err.message });
|
|
587
629
|
}
|
|
588
630
|
});
|
|
589
631
|
|
|
@@ -615,18 +657,27 @@ export const accountRouter = app => {
|
|
|
615
657
|
|
|
616
658
|
const clientIp = getClientIp(req), { id, publicKey, counter, transports = [], deviceType, backedUp } = cred,
|
|
617
659
|
newCredential = {
|
|
618
|
-
id,
|
|
619
|
-
publicKey: fromBuffer(publicKey),
|
|
620
|
-
counter: Number(counter),
|
|
660
|
+
id, publicKey: fromBuffer(publicKey), counter: Number(counter),
|
|
621
661
|
transports, deviceType, backedUp,
|
|
622
662
|
deviceName: clientIp, createdAt: Date.now()
|
|
623
663
|
};
|
|
624
664
|
|
|
625
665
|
result.user.webauthnCredentials.push(newCredential);
|
|
626
666
|
if (!result.user.webauthnEnabled) result.user.webauthnEnabled = true;
|
|
667
|
+
|
|
668
|
+
// 如果用户还没有备份码且至少启用了一个多因素认证,则自动生成备份码
|
|
669
|
+
let newBackupCodes = null;
|
|
670
|
+
if ((!result.user.backupCodes || result.user.backupCodes.length === 0) && !result.user.twoFactorEnabled) {
|
|
671
|
+
const { plainCodes, hashedCodes } = generateBackupCodes();
|
|
672
|
+
result.user.backupCodes = hashedCodes, newBackupCodes = plainCodes;
|
|
673
|
+
}
|
|
674
|
+
|
|
627
675
|
touchAndSaveUser(result.users, result.user), delete req.session.webauthnRegisterChallenge;
|
|
628
676
|
await sendSecurityAlertEmail(req, result.user, 'webauthn_added');
|
|
629
|
-
|
|
677
|
+
|
|
678
|
+
const responseData = { success: true, credentials: result.user.webauthnCredentials };
|
|
679
|
+
if (newBackupCodes) responseData.backupCodes = newBackupCodes;
|
|
680
|
+
res.json(responseData);
|
|
630
681
|
});
|
|
631
682
|
|
|
632
683
|
// 获取用户凭证列表
|
|
@@ -643,26 +694,38 @@ export const accountRouter = app => {
|
|
|
643
694
|
|
|
644
695
|
const result = getCurrentUser(req);
|
|
645
696
|
if (!result) return res.status(401).json({ message: '未登录' });
|
|
646
|
-
if (!result.user) return res.status(500).json({ message: '用户数据异常' });
|
|
647
697
|
|
|
648
|
-
const credentials =
|
|
698
|
+
const { user, users } = result, credentials = user.webauthnCredentials ?? [],
|
|
699
|
+
index = credentials.findIndex(c => c.id === credentialId);
|
|
700
|
+
|
|
649
701
|
if (index === -1) return res.status(404).json({ message: '凭证不存在' });
|
|
702
|
+
credentials.splice(index, 1), user.webauthnCredentials = credentials;
|
|
650
703
|
|
|
651
|
-
|
|
652
|
-
if (credentials.length === 0)
|
|
653
|
-
|
|
654
|
-
touchAndSaveUser(result.users, result.user), res.json({ success: true, message: '设备已删除' });
|
|
704
|
+
// 如果没有凭证了,自动关闭硬件验证标志
|
|
705
|
+
if (credentials.length === 0) user.webauthnEnabled = false;
|
|
706
|
+
touchAndSaveUser(users, user), res.json({ success: true, message: '设备已删除' });
|
|
655
707
|
});
|
|
656
708
|
|
|
657
709
|
// 切换硬件验证启用状态
|
|
658
710
|
app.post('/api/webauthn/toggle', authLimiter, async (req, res) => {
|
|
659
711
|
const result = getCurrentUser(req);
|
|
660
712
|
if (!result) return res.status(401).json({ message: '未登录' });
|
|
661
|
-
|
|
713
|
+
|
|
714
|
+
const newState = !result.user.webauthnEnabled;
|
|
715
|
+
if (newState && result.user.webauthnCredentials.length === 0)
|
|
662
716
|
return res.status(400).json({ message: '没有可用的硬件凭证,请先添加设备' });
|
|
663
717
|
|
|
664
|
-
result.user.webauthnEnabled =
|
|
665
|
-
|
|
718
|
+
result.user.webauthnEnabled = newState;
|
|
719
|
+
let newBackupCodes = null;
|
|
720
|
+
if (newState && (!result.user.backupCodes || result.user.backupCodes.length === 0)) {
|
|
721
|
+
const { plainCodes, hashedCodes } = generateBackupCodes();
|
|
722
|
+
result.user.backupCodes = hashedCodes, newBackupCodes = plainCodes;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
touchAndSaveUser(result.users, result.user);
|
|
726
|
+
const response = { success: true, enabled: result.user.webauthnEnabled };
|
|
727
|
+
if (newBackupCodes) response.backupCodes = newBackupCodes;
|
|
728
|
+
res.json(response);
|
|
666
729
|
});
|
|
667
730
|
|
|
668
731
|
// 开始硬件验证登录(生成断言选项)
|
|
@@ -678,9 +741,7 @@ export const accountRouter = app => {
|
|
|
678
741
|
options = await generateAuthenticationOptions({
|
|
679
742
|
rpID,
|
|
680
743
|
allowCredentials: user.webauthnCredentials.map(cred => ({
|
|
681
|
-
id: cred.id,
|
|
682
|
-
type: 'public-key',
|
|
683
|
-
transports: cred.transports,
|
|
744
|
+
id: cred.id, type: 'public-key', transports: cred.transports,
|
|
684
745
|
})),
|
|
685
746
|
userVerification: 'preferred', timeout: 60000,
|
|
686
747
|
});
|
package/dev-server.js
CHANGED
|
@@ -150,7 +150,7 @@ const printAvailablePages = (pages, port, hotReload, useHttps, host) => {
|
|
|
150
150
|
|
|
151
151
|
if (useHttps && (host === 'localhost' || host === '127.0.0.1')) {
|
|
152
152
|
console.warn('⚠️ 警告: 使用 HTTPS 访问 localhost 会导致浏览器证书安全警告(自签名证书或证书域名不匹配)');
|
|
153
|
-
console.warn(' 请使用 --host 参数指定与证书 CN/SAN 匹配的域名,例如: --host
|
|
153
|
+
console.warn(' 请使用 --host 参数指定与证书 CN/SAN 匹配的域名,例如: --host www.abc.com');
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
console.log('\n可访问页面:');
|
package/f-CHANGELOG.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# 变更日志
|
|
2
|
+
## [5.0.0] - 2026-06-10 10:05
|
|
3
|
+
### 重大更新
|
|
4
|
+
- 对templates/account目录中的页面结构进行了大面积优化,如果你是老用户请注意对比迁移!!!;
|
|
5
|
+
> - 登录页面的具体二次验证统一由 zfa.html 处理;
|
|
6
|
+
> - 新增登录二次验证错误次数限制,超出将锁定24小时;
|
|
7
|
+
> - 备份码支持硬件使用;
|
|
8
|
+
> - 备份码分离为独立卡片,将由启用2FA或添加硬件时判断是否存在智能生成,并保留了重新生成按钮;
|
|
9
|
+
> > **更多细节请自行体验;**
|
|
10
|
+
- customize\account.js文件为配合上述功能实现也做了相应适配;
|
|
11
|
+
## [4.4.3] - 2026-06-05 22:16
|
|
12
|
+
### 优化
|
|
13
|
+
- 模板中用户页面的移动端样式适配;
|
|
2
14
|
## [4.4.2] - 2026-05-31 15:08
|
|
3
15
|
### 修复
|
|
4
|
-
- 修复全局中间件中 `getCurrentUser(req)` 返回 `null`
|
|
5
|
-
## [4.4.1] - 2026-05-31 14:39
|
|
6
|
-
### 优化
|
|
7
|
-
- 删除了 customize/routes.js 文件中上一次优化造成的冗余路由;
|
|
8
|
-
## [4.4.0] - 2026-05-31 14:25
|
|
9
|
-
### 优化
|
|
10
|
-
- 修改辅助功能(在线编辑css文件)中预览逻辑:删除单预览按钮,增加上、下、左、右和单页面预览按钮,及相关逻辑的全面优化,让体验更好;
|
|
16
|
+
- 修复全局中间件中 `getCurrentUser(req)` 返回 `null` 时解构报错导致服务崩溃的问题,增加空值判断并自动清理无效登录态;
|
package/f-README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- **推荐方式**:使用 `import` / `export` 语法,静态分析更友好,工具链兼容性最佳;
|
|
6
6
|
- **兼容方式**:Node.js ≥ 23.5.0 原生支持 `require(esm)`;22.12+ 需开启 `--experimental-require-module` 标志;
|
|
7
|
-
- **重要**:本文档所有示例均采用 **ESM
|
|
7
|
+
- **重要**:本文档所有示例均采用 **ESM 标准**,请确保你的项目 `package.json` 中已设置 `"type": "module"`,或将脚本后缀改为 `.mjs`;
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -80,7 +80,7 @@ npm i @flun/webauthn-server # 身份验证服务后端处理
|
|
|
80
80
|
npm i @flun/webauthn-browser # 身份验证前端处理
|
|
81
81
|
# 所有家族包在支持 .d.ts 提示环境下,鼠标焦点包名都有丰富的导出和使用示例提示,再也不必到处翻找使用文档;建议在VScode环境下使用;
|
|
82
82
|
```
|
|
83
|
-
>
|
|
83
|
+
> **重要提示**:初次使用最好在空项目中执行安装,安装过程会自动复制 ESM 示例和必要文件到根目录
|
|
84
84
|
|
|
85
85
|
---
|
|
86
86
|
|
|
@@ -104,7 +104,7 @@ npm i @flun/webauthn-browser # 身份验证前端处理
|
|
|
104
104
|
```javascript
|
|
105
105
|
import { startDevServer } from '@flun/html-template';
|
|
106
106
|
startDevServer({ port: 7296, hotReload: true });
|
|
107
|
-
// 如需启用 HTTPS
|
|
107
|
+
// 如需启用 HTTPS,请参考下文“配置选项 → 启用 HTTPS”章节
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
**build.js(ESM)**
|
|
@@ -121,7 +121,7 @@ initProject({ mode: 'overwrite', verbose: true });
|
|
|
121
121
|
|
|
122
122
|
然后使用:
|
|
123
123
|
```sh
|
|
124
|
-
#
|
|
124
|
+
# 启动开发服务器(实时预览和热重载,默认不启用登录)
|
|
125
125
|
npm run dev
|
|
126
126
|
|
|
127
127
|
# 启动开发服务器并启用登录系统
|
|
@@ -209,7 +209,7 @@ compile({ outputDir: 'dist' }); // 默认参数:目录名 dist;
|
|
|
209
209
|
|
|
210
210
|
#### 方式二:使用自定义证书
|
|
211
211
|
|
|
212
|
-
若已有证书文件(`.key` 和 `.crt`/`.pem
|
|
212
|
+
若已有证书文件(`.key` 和 `.crt`/`.pem`),直接指定路径:
|
|
213
213
|
|
|
214
214
|
```javascript
|
|
215
215
|
startDevServer({
|
|
@@ -230,9 +230,9 @@ node dev.js --https --https-key 你的私钥文件路径和文件名 --https-cer
|
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
> **注意**:
|
|
233
|
-
> - 使用 HTTPS
|
|
234
|
-
> - 若用 `localhost` 访问 HTTPS
|
|
235
|
-
> - 有关 `DnsAutoSSL.js`
|
|
233
|
+
> - 使用 HTTPS 时,`host` 参数必须与证书中的域名(CN 或 SAN)完全一致;
|
|
234
|
+
> - 若用 `localhost` 访问 HTTPS,浏览器会提示不安全,请按控制台警告改用正确的域名访问;
|
|
235
|
+
> - 有关 `DnsAutoSSL.js` 的详细配置,请参考该文件内的注释或 `@flun/dns-auto-ssl` 文档;
|
|
236
236
|
|
|
237
237
|
## 模板标签使用指南
|
|
238
238
|
|
|
@@ -244,7 +244,7 @@ node dev.js --https --https-key 你的私钥文件路径和文件名 --https-cer
|
|
|
244
244
|
### 标签快捷输入方式
|
|
245
245
|
**HBuilder用户**:
|
|
246
246
|
1. 输入 `b!` 后按 Enter
|
|
247
|
-
2.
|
|
247
|
+
2. 输入标签名,自动创建开闭标签对
|
|
248
248
|
HBuilder自定义代码块配置(HTML和js):
|
|
249
249
|
```json
|
|
250
250
|
{
|
|
@@ -261,7 +261,7 @@ HBuilder自定义代码块配置(HTML和js):
|
|
|
261
261
|
```
|
|
262
262
|
**VS Code用户**:
|
|
263
263
|
1. 安装 "html-custom-tags" 扩展
|
|
264
|
-
2. 输入 `[!标签名]`
|
|
264
|
+
2. 输入 `[!标签名]` 后按空格,自动输出 `[~标签名]`
|
|
265
265
|
***并且其还支持大部分语言语法高亮,标签特殊高亮,标签统计,标签一键全复制等功能***
|
|
266
266
|
|
|
267
267
|
---
|
|
@@ -316,7 +316,7 @@ initProject({ mode: 'skip-files', verbose: true, account: false }); // 跳过已
|
|
|
316
316
|
4. **开发调试**:使用 `node dev.js` 运行开发服务器
|
|
317
317
|
5. **编译部署**:使用 `node build.js` 编译模板
|
|
318
318
|
|
|
319
|
-
> **注意**:编译只会打包 `customize`、`static`
|
|
319
|
+
> **注意**:编译只会打包 `customize`、`static` 目录及文件,和编译模板生成的文件;根据是否有自定义路由,动态创建 `server.js` 入口文件;
|
|
320
320
|
|
|
321
321
|
---
|
|
322
322
|
|
|
@@ -487,7 +487,7 @@ export default {
|
|
|
487
487
|
- 资源使用限制
|
|
488
488
|
- 恶意代码拦截
|
|
489
489
|
|
|
490
|
-
|
|
490
|
+
**用户无需额外配置**,所有安全防护在后台自动运行;
|
|
491
491
|
|
|
492
492
|
### 💡 使用建议
|
|
493
493
|
|
|
@@ -509,19 +509,18 @@ export default {
|
|
|
509
509
|
[include {{dynamicPath}}]
|
|
510
510
|
```
|
|
511
511
|
|
|
512
|
+
---
|
|
512
513
|
### ⚠️ 重要提醒
|
|
513
514
|
|
|
514
|
-
|
|
515
|
+
虽然我们提供了多重安全防护,但您仍需:
|
|
515
516
|
1. 谨慎处理用户输入数据
|
|
516
517
|
2. 验证自定义函数的参数安全性
|
|
517
518
|
3. 定期更新到最新版本
|
|
518
|
-
|
|
519
|
-
> **安全是共同责任** -
|
|
520
|
-
|
|
521
|
-
这些防护措施确保您的模板渲染过程安全可靠,让您可以专注于业务开发。
|
|
519
|
+
> 安装更新时不会覆盖根目录中已存在目录(customize,static和templates)
|
|
520
|
+
> **安全是共同责任** - 我们负责引擎安全,您负责业务逻辑安全;
|
|
521
|
+
这些防护措施确保您的模板渲染过程安全可靠,让您可以专注于业务开发;
|
|
522
522
|
|
|
523
523
|
---
|
|
524
|
-
|
|
525
524
|
## 故障排除
|
|
526
525
|
|
|
527
526
|
### 常见问题
|
|
@@ -529,16 +528,16 @@ export default {
|
|
|
529
528
|
2. **区块不显示**:检查开闭标签名称是否一致
|
|
530
529
|
3. **热重载失效**:确认修改的是模板或静态目录中的文件
|
|
531
530
|
4. **路由不工作**:检查是否正确定义了 `setupRoutes` 导出
|
|
532
|
-
5. **找不到函数**: 1.
|
|
533
|
-
6. **ESM 相关错误**:检查 `package.json` 是否包含 `"type": "module"
|
|
534
|
-
7. **HTTPS 证书错误**:请确保 `host`
|
|
531
|
+
5. **找不到函数**: 1. 检查函数名是否正确,2. 确认文件在 `customize` 目录内,3. 确认使用了 `export const functions = {...}` 语法
|
|
532
|
+
6. **ESM 相关错误**:检查 `package.json` 是否包含 `"type": "module"`,或启动文件是否具有 `.mjs` 扩展名;
|
|
533
|
+
7. **HTTPS 证书错误**:请确保 `host` 参数与证书中的域名完全匹配,且证书未被吊销或过期;开发环境可使用 `@flun/dns-auto-ssl` 自动生成受信任的证书;
|
|
535
534
|
|
|
536
535
|
### 获取帮助
|
|
537
|
-
|
|
536
|
+
如果遇到问题,可以:
|
|
538
537
|
1. 运行恢复初始文件脚本
|
|
539
538
|
2. 检查浏览器控制台错误信息
|
|
540
539
|
3. 确认文件路径和名称是否正确
|
|
541
540
|
|
|
542
541
|
---
|
|
543
542
|
|
|
544
|
-
|
|
543
|
+
此模板工具包提供了从开发到生产的完整解决方案,适合各种规模的Web项目开发;
|