@flun/html-template 4.0.10
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/.env +9 -0
- package/LICENSE +15 -0
- package/build.js +3 -0
- package/compile.js +349 -0
- package/copy-files.js +200 -0
- package/customize/account.js +726 -0
- package/customize/data.json +484 -0
- package/customize/functions.js +48 -0
- package/customize/hotReloadInjector.js +25 -0
- package/customize/routes.js +141 -0
- package/customize/users.json +44 -0
- package/customize/variables.js +70 -0
- package/dev-server.js +344 -0
- package/dev.js +4 -0
- package/f-CHANGELOG.md +4 -0
- package/f-README.md +485 -0
- package/index.d.ts +133 -0
- package/index.js +4 -0
- package/package.json +77 -0
- package/restoreDefaults.js +8 -0
- package/services/templateService.js +962 -0
- package/static/about.css +118 -0
- package/static/auth.js +27 -0
- package/static/constants.css +138 -0
- package/static/img/dark.png +0 -0
- package/static/img/favicon.ico +0 -0
- package/static/img/light.png +0 -0
- package/static/img/top.png +0 -0
- package/static/index.css +86 -0
- package/static/mouseOrTouch.js +156 -0
- package/static/public.css +288 -0
- package/static/script.css +318 -0
- package/static/script.js +392 -0
- package/static/styling.css +874 -0
- package/static/styling.js +933 -0
- package/static/themeImg.css +10 -0
- package/static/themeImg.js +19 -0
- package/static/themeModule.js +222 -0
- package/static/topImg.css +19 -0
- package/static/topImg.js +21 -0
- package/static/utils/browser13.js +270 -0
- package/static/utils/closebrackets.js +166 -0
- package/static/utils/css-lint.js +308 -0
- package/static/utils/custom-css-hint.js +876 -0
- package/static/utils/foldgutter.js +141 -0
- package/static/utils/match-highlighter.js +70 -0
- package/templates/about.html +236 -0
- package/templates/account/2fa.html +184 -0
- package/templates/account/forgot-password.html +226 -0
- package/templates/account/login.html +230 -0
- package/templates/account/profile.html +977 -0
- package/templates/account/register.html +224 -0
- package/templates/account/reset-password.html +205 -0
- package/templates/account/verify-email.html +163 -0
- package/templates/base.html +71 -0
- package/templates/footer-content.html +5 -0
- package/templates/index.html +140 -0
- package/templates/script.html +209 -0
- package/templates/test-include.html +11 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
// /customize/account.js
|
|
2
|
+
// 用户认证模块:包含 session、登录保护中间件及所有认证路由
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { env } from '@flun/env';
|
|
7
|
+
import { createTransport } from '@flun/mail';
|
|
8
|
+
import {
|
|
9
|
+
generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse
|
|
10
|
+
} from '@flun/webauthn-server';
|
|
11
|
+
import { fromBuffer, toBuffer } from '@flun/webauthn-server/helpers';
|
|
12
|
+
import { randomBytes } from 'crypto';
|
|
13
|
+
import { hashSync, hash, compare } from 'bcrypt';
|
|
14
|
+
import { toDataURL } from 'qrcode';
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import session from 'express-session';
|
|
17
|
+
import { rateLimit } from 'express-rate-limit';
|
|
18
|
+
import { generateSecret, verify, generateURI } from 'otplib';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
import { injectScript } from './hotReloadInjector.js';
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url), __dirname = path.dirname(__filename), CWD = process.cwd(),
|
|
23
|
+
pageDir = 'templates', accountDir = 'account', usersFile = path.join(__dirname, 'users.json'), pendingRegistrations = new Map(),
|
|
24
|
+
recentPasswordResets = new Map(), mailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/, Store = session.Store;
|
|
25
|
+
|
|
26
|
+
// 邮件发送配置检查
|
|
27
|
+
if (!env.MAIL_HOST || !env.MAIL_USER || !env.MAIL_PASS)
|
|
28
|
+
console.error('❌ 邮件服务未配置,请在根目录env文件中正确配置 MAIL_HOST、MAIL_USER、MAIL_PASS 后重新启动!'), process.exit(1);
|
|
29
|
+
|
|
30
|
+
// 邮件发送配置
|
|
31
|
+
const transporter = createTransport({
|
|
32
|
+
host: env.MAIL_HOST,
|
|
33
|
+
port: env.MAIL_PORT,
|
|
34
|
+
secure: true,
|
|
35
|
+
auth: { user: env.MAIL_USER, pass: env.MAIL_PASS }
|
|
36
|
+
}),
|
|
37
|
+
// ========== 辅助函数 ==========
|
|
38
|
+
readUsers = () => {
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(usersFile)) return [];
|
|
41
|
+
const data = fs.readFileSync(usersFile, 'utf8');
|
|
42
|
+
return JSON.parse(data);
|
|
43
|
+
} catch (err) { return []; }
|
|
44
|
+
},
|
|
45
|
+
writeUsers = users => fs.writeFileSync(usersFile, JSON.stringify(users, null, 2)),
|
|
46
|
+
createUserObject = (username, email, hashedPassword, emailVerified = false) => {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
return {
|
|
49
|
+
id: now,
|
|
50
|
+
username, email, password: hashedPassword,
|
|
51
|
+
emailVerified, emailVerificationToken: null,
|
|
52
|
+
passwordResetToken: null, passwordResetExpires: null,
|
|
53
|
+
twoFactorSecret: null, twoFactorEnabled: false,
|
|
54
|
+
backupCodes: [],
|
|
55
|
+
webauthnCredentials: [], webauthnEnabled: false,
|
|
56
|
+
createdAt: now, updatedAt: now, passwordChangedAt: now,
|
|
57
|
+
pendingEmail: null, pendingEmailToken: null, pendingEmailExpires: null
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
initAdminUser = () => {
|
|
61
|
+
const users = readUsers();
|
|
62
|
+
if (users.length <= 0) {
|
|
63
|
+
const adminPassword = env.PWD || 'admin', hashedPassword = hashSync(adminPassword, 10),
|
|
64
|
+
adminUser = createUserObject('admin', null, hashedPassword);
|
|
65
|
+
users.push(adminUser), writeUsers(users);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
sendEmail = async (to, subject, html) => {
|
|
69
|
+
if (transporter) await transporter.sendMail({ from: `"Your App" <${env.MAIL_USER}>`, to, subject, html });
|
|
70
|
+
else console.log(`\n--- 模拟邮件 ---\n收件人: ${to}\n主题: ${subject}\n内容:\n${html}\n---`);
|
|
71
|
+
},
|
|
72
|
+
validateEmail = email => mailRegex.test(email), generateToken = () => randomBytes(32).toString('hex'),
|
|
73
|
+
hasAllFields = (body, fields) => fields.every(f => body[f]),
|
|
74
|
+
getCurrentUser = req => {
|
|
75
|
+
const users = readUsers(), user = users.find(u => u.id === req.session.userId);
|
|
76
|
+
if (!user) return null;
|
|
77
|
+
return { user, users };
|
|
78
|
+
},
|
|
79
|
+
findUserByToken = (users, token, tokenField, expiresField) =>
|
|
80
|
+
users.find(u => u[tokenField] === token && u[expiresField] > Date.now()),
|
|
81
|
+
clearPendingFields = user => {
|
|
82
|
+
user.pendingEmail = null, user.pendingEmailToken = null, user.pendingEmailExpires = null;
|
|
83
|
+
},
|
|
84
|
+
generateBackupCodes = () => {
|
|
85
|
+
const plainCodes = [], hashedCodes = [];
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
const code = randomBytes(5).toString('hex').toUpperCase();
|
|
88
|
+
plainCodes.push(code), hashedCodes.push(hashSync(code, 10));
|
|
89
|
+
}
|
|
90
|
+
return { plainCodes, hashedCodes };
|
|
91
|
+
},
|
|
92
|
+
validatePasswordLength = password => password && password.length >= 6,
|
|
93
|
+
hashPassword = async password => await hash(password, 10),
|
|
94
|
+
verifyPassword = async (plain, hash) => await compare(plain, hash),
|
|
95
|
+
isUsernameTaken = (users, username, excludeUserId = null) =>
|
|
96
|
+
users.some(u => u.id !== excludeUserId && u.username === username),
|
|
97
|
+
isEmailTaken = (users, email, excludeUserId = null) => users.some(u => u.id !== excludeUserId && u.email === email),
|
|
98
|
+
touchAndSaveUser = (users, user) => { user.updatedAt = Date.now(), writeUsers(users); },
|
|
99
|
+
verifyTotp = async (secret, token) => (await verify({ secret, token, window: 1 })).valid,
|
|
100
|
+
getAppBaseUrl = req => env.APP_URL || `http://${req.headers.host}`,
|
|
101
|
+
sendVerificationEmail = async (req, email, token, type) => {
|
|
102
|
+
const baseUrl = getAppBaseUrl(req);
|
|
103
|
+
let subject, link;
|
|
104
|
+
switch (type) {
|
|
105
|
+
case 'register':
|
|
106
|
+
subject = '请验证您的邮箱';
|
|
107
|
+
link = 'verify-email?';
|
|
108
|
+
break;
|
|
109
|
+
case 'reset':
|
|
110
|
+
subject = '密码重置请求';
|
|
111
|
+
link = 'reset-password?';
|
|
112
|
+
break;
|
|
113
|
+
case 'change-email':
|
|
114
|
+
subject = '请验证您的新邮箱';
|
|
115
|
+
link = 'verify-email?type=new-email&';
|
|
116
|
+
break;
|
|
117
|
+
default: return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const html = `<p>点击👉 <a href="${baseUrl}/${link}token=${token}">验证链接</a> 完成验证;此链接一小时内有效!</p>`;
|
|
121
|
+
await sendEmail(email, subject, html);
|
|
122
|
+
},
|
|
123
|
+
sendSecurityAlertEmail = async (req, user, actionType) => {
|
|
124
|
+
const clientIp = getClientIp(req), now = new Date().toLocaleString('zh-CN');
|
|
125
|
+
let subject, actionDescription;
|
|
126
|
+
switch (actionType) {
|
|
127
|
+
case 'password_change':
|
|
128
|
+
subject = '修改密码通知';
|
|
129
|
+
actionDescription = '修改了密码';
|
|
130
|
+
break;
|
|
131
|
+
case 'webauthn_added':
|
|
132
|
+
subject = '添加验证硬件通知';
|
|
133
|
+
actionDescription = '添加了一台新的硬件验证设备';
|
|
134
|
+
break;
|
|
135
|
+
default: return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const html = `
|
|
139
|
+
<p>您好!</p>
|
|
140
|
+
<p>您的账户(${user.username})于 <strong>${now}</strong>在 <strong>${clientIp}</strong> 成功${actionDescription};</p>
|
|
141
|
+
<p>如果是您本人操作,请忽略此邮件,否则请立即处理;</p>
|
|
142
|
+
<p style="margin-left:65%;">此致<br/>  安全中心</p>
|
|
143
|
+
`;
|
|
144
|
+
await sendEmail(user.email, subject, html);
|
|
145
|
+
},
|
|
146
|
+
getRpId = req => {
|
|
147
|
+
let hostname = req.hostname;
|
|
148
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') return hostname;
|
|
149
|
+
|
|
150
|
+
const domainRegex = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
151
|
+
if (domainRegex.test(hostname)) return hostname;
|
|
152
|
+
throw new Error(`无效的 RP ID (${hostname}),请使用有效的域名、localhost或127.0.0.1,并确保使用HTTPS或localhost访问;`);
|
|
153
|
+
},
|
|
154
|
+
getOrigin = req => `${req.protocol}://${req.get('host')}`,
|
|
155
|
+
getClientIp = req => {
|
|
156
|
+
const xForwardedFor = req.headers['x-forwarded-for'],
|
|
157
|
+
ip = xForwardedFor ? xForwardedFor.split(',')[0].trim() : (req.socket.remoteAddress || req.ip);
|
|
158
|
+
return (['::1', '127.0.0.1', '::ffff:127.0.0.1'].includes(ip)) ? '本地' : `IP(${ip})`;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ========== 路由设置 ==========
|
|
162
|
+
export const accountRouter = app => {
|
|
163
|
+
// ========== 1. 配置 session ==========
|
|
164
|
+
class SimpleFileStore extends Store {
|
|
165
|
+
constructor(sessionsDir) {
|
|
166
|
+
super(), this.sessionsDir = sessionsDir;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get(sid, cb) {
|
|
170
|
+
const file = path.join(this.sessionsDir, `${sid}.json`);
|
|
171
|
+
try {
|
|
172
|
+
if (fs.existsSync(file)) {
|
|
173
|
+
const data = fs.readFileSync(file, 'utf8');
|
|
174
|
+
cb(null, JSON.parse(data));
|
|
175
|
+
}
|
|
176
|
+
else cb(null, null);
|
|
177
|
+
} catch (e) { cb(e); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
set(sid, session, cb) {
|
|
181
|
+
const file = path.join(this.sessionsDir, `${sid}.json`);
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify(session));
|
|
184
|
+
cb(null);
|
|
185
|
+
} catch (e) { cb(e); }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
destroy(sid, cb) {
|
|
189
|
+
const file = path.join(this.sessionsDir, `${sid}.json`);
|
|
190
|
+
try {
|
|
191
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
192
|
+
cb(null);
|
|
193
|
+
} catch (e) { cb(e); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
touch(sid, session, cb) {
|
|
197
|
+
const file = path.join(this.sessionsDir, `${sid}.json`);
|
|
198
|
+
try {
|
|
199
|
+
if (fs.existsSync(file)) {
|
|
200
|
+
const now = new Date();
|
|
201
|
+
fs.utimesSync(file, now, now);
|
|
202
|
+
}
|
|
203
|
+
cb(null);
|
|
204
|
+
} catch (e) { cb(e); }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sessionsDir = path.join(CWD, 'sessions');
|
|
209
|
+
if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
const sessionStore = new SimpleFileStore(sessionsDir), oneHour = 3600000, fifteenMin = 900000;
|
|
212
|
+
|
|
213
|
+
// 定期清理超过 30 天的 session 文件(每天执行一次)
|
|
214
|
+
setInterval(() => {
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
fs.readdirSync(sessionsDir).forEach(f => {
|
|
217
|
+
if (!f.endsWith('.json')) return;
|
|
218
|
+
const p = path.join(sessionsDir, f);
|
|
219
|
+
try {
|
|
220
|
+
if (now - fs.statSync(p).mtimeMs > 30 * 24 * oneHour) fs.unlinkSync(p);
|
|
221
|
+
} catch (_) { }
|
|
222
|
+
});
|
|
223
|
+
}, 24 * oneHour);
|
|
224
|
+
|
|
225
|
+
app.use(session({
|
|
226
|
+
secret: env.SESSION_SECRET || 'dev-secret-change-in-production',
|
|
227
|
+
store: sessionStore,
|
|
228
|
+
resave: false,
|
|
229
|
+
saveUninitialized: false,
|
|
230
|
+
cookie: { secure: false, httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * oneHour }
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
app.use(express.json(), express.urlencoded({ extended: true })), initAdminUser();
|
|
234
|
+
|
|
235
|
+
// 2. 全局登录保护中间件
|
|
236
|
+
const publicPage = ['/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/2fa'],
|
|
237
|
+
publicPaths = [...publicPage, '/api/login', '/api/register', '/api/verify-email', '/api/forgot-password',
|
|
238
|
+
'/api/reset-password', '/api/verify-new-email', '/api/check-email-verified', '/api/check-password-reset',
|
|
239
|
+
'/api/verify-2fa', '/api/webauthn/login/begin', '/api/webauthn/login/complete', '/static', '/favicon.ico'];
|
|
240
|
+
app.use((req, res, next) => {
|
|
241
|
+
if (publicPaths.some(p => req.path.startsWith(p))) return next();
|
|
242
|
+
if (!req.session.userId) {
|
|
243
|
+
if (req.path.startsWith('/api/')) return res.status(401).json({ message: '请先登录' });
|
|
244
|
+
return req.session.returnTo = req.originalUrl, res.redirect('/login');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 检查密码是否在本次登录后被修改
|
|
248
|
+
const { user } = getCurrentUser(req);
|
|
249
|
+
if (user?.passwordChangedAt) {
|
|
250
|
+
const sessionLoginTime = req.session.loginTime || 0;
|
|
251
|
+
if (user.passwordChangedAt > sessionLoginTime) {
|
|
252
|
+
req.session.destroy(() => {
|
|
253
|
+
if (req.path.startsWith('/api/')) return res.status(401).json({ message: '密码已修改,请重新登录' });
|
|
254
|
+
return res.redirect('/login');
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
next();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// 3. 每小时清理过期临时数据
|
|
263
|
+
setInterval(() => {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
for (const [token, data] of pendingRegistrations.entries())
|
|
266
|
+
if (data.expires < now) pendingRegistrations.delete(token);
|
|
267
|
+
for (const [email, timestamp] of recentPasswordResets.entries())
|
|
268
|
+
if (timestamp < now) recentPasswordResets.delete(email);
|
|
269
|
+
}, oneHour);
|
|
270
|
+
|
|
271
|
+
// 定义安全限制和所有页面(公共页面 + 受保护页面)
|
|
272
|
+
const authLimiter = rateLimit({ windowMs: fifteenMin, max: 50, message: { message: '尝试次数过多,请稍后再试' } }),
|
|
273
|
+
allPages = [...publicPage, '/profile'];
|
|
274
|
+
allPages.forEach(page => {
|
|
275
|
+
app.get(page, (req, res) => {
|
|
276
|
+
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
277
|
+
res.set('Pragma', 'no-cache'), res.set('Expires', '0');
|
|
278
|
+
if ((page === '/login' || page === '/2fa') && req.session.userId) {
|
|
279
|
+
const { user } = getCurrentUser(req), reset = recentPasswordResets.get(user.email);
|
|
280
|
+
if (!reset || reset <= Date.now()) return res.redirect('/');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const filePath = path.join(CWD, pageDir, accountDir, `.${page}.html`);
|
|
284
|
+
try {
|
|
285
|
+
const html = fs.readFileSync(filePath, 'utf8'), injectedHtml = injectScript(html);
|
|
286
|
+
res.type('html').send(injectedHtml);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error(`读取页面文件失败: ${filePath}`, err);
|
|
289
|
+
res.status(500).send('服务器内部错误');
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ========== 公开认证API ==========
|
|
295
|
+
app.post('/api/register', authLimiter, async (req, res) => {
|
|
296
|
+
const { username, email, password } = req.body;
|
|
297
|
+
if (!hasAllFields(req.body, ['username', 'email', 'password']))
|
|
298
|
+
return res.status(400).json({ message: '所有字段必填' });
|
|
299
|
+
if (!validateEmail(email)) return res.status(400).json({ message: '邮箱格式不正确' });
|
|
300
|
+
if (!validatePasswordLength(password)) return res.status(400).json({ message: '密码至少6位' });
|
|
301
|
+
|
|
302
|
+
const users = readUsers();
|
|
303
|
+
if (isUsernameTaken(users, username)) return res.status(409).json({ message: '用户名已存在' });
|
|
304
|
+
if (isEmailTaken(users, email)) return res.status(409).json({ message: '邮箱已被注册' });
|
|
305
|
+
|
|
306
|
+
const hashedPassword = await hashPassword(password), verificationToken = generateToken();
|
|
307
|
+
pendingRegistrations.set(verificationToken, {
|
|
308
|
+
username, email, password: hashedPassword, expires: Date.now() + oneHour
|
|
309
|
+
});
|
|
310
|
+
await sendVerificationEmail(req, email, verificationToken, 'register');
|
|
311
|
+
res.json({ success: true, message: '验证邮件已发送,请查收并点击链接完成注册' });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
app.get('/api/verify-email', async (req, res) => {
|
|
315
|
+
const { token } = req.query;
|
|
316
|
+
if (!token) return res.status(400).json({ message: '缺少令牌' });
|
|
317
|
+
|
|
318
|
+
const pending = pendingRegistrations.get(token);
|
|
319
|
+
if (!pending) return res.status(400).json({ message: '链接已失效或不存在' });
|
|
320
|
+
const { expires, username, email, password } = pending;
|
|
321
|
+
if (expires < Date.now()) return pendingRegistrations.delete(token), res.status(400).json({ message: '链接已过期' });
|
|
322
|
+
|
|
323
|
+
const users = readUsers(), newUser = createUserObject(username, email, password, true);
|
|
324
|
+
users.push(newUser), writeUsers(users), pendingRegistrations.delete(token);
|
|
325
|
+
res.json({ success: true, message: '邮箱验证成功' });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
app.get('/api/check-email-verified', authLimiter, (req, res) => {
|
|
329
|
+
const { email } = req.query;
|
|
330
|
+
if (!email) return res.status(400).json({ message: '缺少邮箱参数' });
|
|
331
|
+
const users = readUsers(), user = users.find(u => u.email === email);
|
|
332
|
+
res.json({ verified: user?.emailVerified || false });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
app.post('/api/login', authLimiter, async (req, res) => {
|
|
336
|
+
const { username, password } = req.body;
|
|
337
|
+
if (!username || !password) return res.status(400).json({ message: '用户名/邮箱和密码不能为空' });
|
|
338
|
+
|
|
339
|
+
const users = readUsers(), user = users.find(u => u.username === username || u.email === username);
|
|
340
|
+
if (!user) return res.status(401).json({ message: '用户名/邮箱或密码错误' });
|
|
341
|
+
|
|
342
|
+
const valid = await verifyPassword(password, user.password);
|
|
343
|
+
if (!valid) return res.status(401).json({ message: '用户名/邮箱或密码错误' });
|
|
344
|
+
if (!user.emailVerified) return res.status(403).json({ message: '请先验证邮箱', needsVerification: true });
|
|
345
|
+
if (user.webauthnEnabled && user.webauthnCredentials.length > 0)
|
|
346
|
+
return req.session.tempUserId = user.id, res.json({ requireWebAuthn: true });
|
|
347
|
+
if (user.twoFactorEnabled) return req.session.tempUserId = user.id, res.json({ require2FA: true });
|
|
348
|
+
|
|
349
|
+
req.session.userId = user.id, req.session.username = user.username, req.session.loginTime = Date.now();
|
|
350
|
+
recentPasswordResets.delete(user.email), res.json({ success: true, message: '登录成功' });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
app.post('/api/verify-2fa', authLimiter, async (req, res) => {
|
|
354
|
+
const { token } = req.body;
|
|
355
|
+
if (!token) return res.status(400).json({ message: '验证码不能为空' });
|
|
356
|
+
|
|
357
|
+
const tempUserId = req.session.tempUserId;
|
|
358
|
+
if (!tempUserId) return res.status(401).json({ message: '请先完成第一步登录' });
|
|
359
|
+
|
|
360
|
+
const users = readUsers(), user = users.find(u => u.id === tempUserId);
|
|
361
|
+
if (!user?.twoFactorEnabled) return res.status(400).json({ message: '用户未启用2FA' });
|
|
362
|
+
|
|
363
|
+
const { twoFactorSecret, backupCodes } = user, verified = await verifyTotp(twoFactorSecret, token);
|
|
364
|
+
let backupValid = false;
|
|
365
|
+
if (!verified && backupCodes?.length)
|
|
366
|
+
for (let i = 0; i < backupCodes.length; i++) {
|
|
367
|
+
const match = await verifyPassword(token, backupCodes[i]);
|
|
368
|
+
if (match) {
|
|
369
|
+
backupValid = true, backupCodes.splice(i, 1), touchAndSaveUser(users, user);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (verified || backupValid) {
|
|
375
|
+
delete req.session.tempUserId, req.session.userId = user.id, req.session.username = user.username;
|
|
376
|
+
req.session.loginTime = Date.now(), recentPasswordResets.delete(user.email);
|
|
377
|
+
res.json({ success: true, message: '2FA验证成功' });
|
|
378
|
+
}
|
|
379
|
+
else res.status(401).json({ message: '验证码无效' });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
app.post('/api/logout', (req, res) => {
|
|
383
|
+
req.session.destroy(err => {
|
|
384
|
+
if (err) return res.status(500).json({ message: '退出登录失败' });
|
|
385
|
+
res.json({ success: true });
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
app.post('/api/forgot-password', authLimiter, async (req, res) => {
|
|
390
|
+
const { email } = req.body;
|
|
391
|
+
if (!email) return res.status(400).json({ message: '邮箱必填' });
|
|
392
|
+
|
|
393
|
+
const users = readUsers(), user = users.find(u => u.email === email);
|
|
394
|
+
if (!user) return res.json({ success: true, message: '如果邮箱存在,你将收到一封重置邮件' });
|
|
395
|
+
|
|
396
|
+
const resetToken = generateToken();
|
|
397
|
+
user.passwordResetToken = resetToken, user.passwordResetExpires = Date.now() + oneHour;
|
|
398
|
+
touchAndSaveUser(users, user), await sendVerificationEmail(req, email, resetToken, 'reset');
|
|
399
|
+
res.json({ success: true, message: '请查收重置邮件,重置后将自动跳转!' });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
app.post('/api/reset-password', async (req, res) => {
|
|
403
|
+
const { token, newPassword } = req.body;
|
|
404
|
+
if (!token || !newPassword) return res.status(400).json({ message: '缺少参数' });
|
|
405
|
+
if (!validatePasswordLength(newPassword)) return res.status(400).json({ message: '密码至少6位' });
|
|
406
|
+
|
|
407
|
+
const users = readUsers(), user = findUserByToken(users, token, 'passwordResetToken', 'passwordResetExpires');
|
|
408
|
+
if (!user) return res.status(400).json({ message: '令牌无效或已过期' });
|
|
409
|
+
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
user.password = await hashPassword(newPassword), user.passwordChangedAt = now, user.passwordResetToken = null;
|
|
412
|
+
user.passwordResetExpires = null, touchAndSaveUser(users, user);
|
|
413
|
+
recentPasswordResets.set(user.email, Date.now() + fifteenMin), res.json({ success: true, message: '密码已重置' });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
app.get('/api/verify-new-email', async (req, res) => {
|
|
417
|
+
const { token } = req.query;
|
|
418
|
+
if (!token) return res.status(400).json({ message: '缺少令牌' });
|
|
419
|
+
|
|
420
|
+
const users = readUsers(), user = findUserByToken(users, token, 'pendingEmailToken', 'pendingEmailExpires');
|
|
421
|
+
if (!user) return res.status(400).json({ message: '链接无效或已过期' });
|
|
422
|
+
|
|
423
|
+
const emailTaken = isEmailTaken(users, user.pendingEmail, user.id);
|
|
424
|
+
if (emailTaken)
|
|
425
|
+
return clearPendingFields(user), writeUsers(users), res.status(409).json({ message: '该邮箱已被使用,请检查修改' });
|
|
426
|
+
|
|
427
|
+
user.email = user.pendingEmail, user.emailVerified = true, clearPendingFields(user);
|
|
428
|
+
touchAndSaveUser(users, user), res.json({ success: true, message: '新邮箱验证成功' });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
app.get('/api/check-password-reset', authLimiter, (req, res) => {
|
|
432
|
+
const { email } = req.query;
|
|
433
|
+
if (!email) return res.status(400).json({ message: '缺少邮箱参数' });
|
|
434
|
+
const resetTimes = recentPasswordResets.get(email);
|
|
435
|
+
res.json({ reset: resetTimes && resetTimes > Date.now() });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ========== 需要登录的认证 API ==========
|
|
439
|
+
app.get('/api/user', (req, res) => {
|
|
440
|
+
const result = getCurrentUser(req);
|
|
441
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
442
|
+
const { id, username, email, emailVerified, twoFactorEnabled, createdAt, pendingEmail, webauthnEnabled,
|
|
443
|
+
webauthnCredentials } = result.user;
|
|
444
|
+
res.json({
|
|
445
|
+
id, username, email, emailVerified, twoFactorEnabled, createdAt, pendingEmail, webauthnEnabled,
|
|
446
|
+
webauthnCredentials
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
app.post('/api/delete-account', async (req, res) => {
|
|
451
|
+
const { password } = req.body;
|
|
452
|
+
if (!password) return res.status(400).json({ message: '密码不能为空' });
|
|
453
|
+
|
|
454
|
+
const result = getCurrentUser(req);
|
|
455
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
456
|
+
|
|
457
|
+
const { user, users } = result, valid = await verifyPassword(password, user.password);
|
|
458
|
+
if (!valid) return res.status(401).json({ message: '密码错误' });
|
|
459
|
+
|
|
460
|
+
const userIndex = users.findIndex(u => u.id === user.id);
|
|
461
|
+
users.splice(userIndex, 1), writeUsers(users);
|
|
462
|
+
req.session.destroy(err => {
|
|
463
|
+
if (err) console.error('销毁 session 失败:', err);
|
|
464
|
+
res.json({ success: true, message: '账户已永久注销' });
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
app.post('/api/update-profile', async (req, res) => {
|
|
469
|
+
const { username: newUsername, email: newEmail, currentPassword } = req.body;
|
|
470
|
+
if (!hasAllFields(req.body, ['username', 'email', 'currentPassword']))
|
|
471
|
+
return res.status(400).json({ message: '所有字段必填' });
|
|
472
|
+
if (!validateEmail(newEmail)) return res.status(400).json({ message: '邮箱格式不正确' });
|
|
473
|
+
|
|
474
|
+
const result = getCurrentUser(req);
|
|
475
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
476
|
+
const { user, users } = result, valid = await verifyPassword(currentPassword, user.password);
|
|
477
|
+
if (!valid) return res.status(401).json({ message: '当前密码错误' });
|
|
478
|
+
if (isUsernameTaken(users, newUsername, user.id)) return res.status(409).json({ message: '用户名已存在' });
|
|
479
|
+
if (isEmailTaken(users, newEmail, user.id)) return res.status(409).json({ message: '邮箱已被注册' });
|
|
480
|
+
|
|
481
|
+
const emailChanged = newEmail !== user.email;
|
|
482
|
+
if (emailChanged) {
|
|
483
|
+
const token = generateToken();
|
|
484
|
+
user.pendingEmail = newEmail, user.pendingEmailToken = token, user.pendingEmailExpires = Date.now() + oneHour;
|
|
485
|
+
user.username = newUsername, touchAndSaveUser(users, user);
|
|
486
|
+
await sendVerificationEmail(req, newEmail, token, 'change-email');
|
|
487
|
+
res.json({ success: true, message: '资料修改已提交,请查收新邮箱,并完成验证更新', emailChanged: true });
|
|
488
|
+
}
|
|
489
|
+
else user.username = newUsername, touchAndSaveUser(users, user), res.json({ success: true, message: '资料修改成功' });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.post('/api/change-password', async (req, res) => {
|
|
493
|
+
const { currentPassword, newPassword } = req.body;
|
|
494
|
+
if (!currentPassword || !newPassword) return res.status(400).json({ message: '当前密码和新密码不能为空' });
|
|
495
|
+
if (!validatePasswordLength(newPassword)) return res.status(400).json({ message: '新密码至少6位' });
|
|
496
|
+
|
|
497
|
+
const result = getCurrentUser(req);
|
|
498
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
499
|
+
|
|
500
|
+
const { user, users } = result;
|
|
501
|
+
if (!(await verifyPassword(currentPassword, user.password))) return res.status(401).json({ message: '当前密码错误' });
|
|
502
|
+
if (await verifyPassword(newPassword, user.password)) return res.status(400).json({ message: '新旧密码不能相同' });
|
|
503
|
+
|
|
504
|
+
const now = Date.now();
|
|
505
|
+
user.password = await hashPassword(newPassword), user.passwordChangedAt = now, touchAndSaveUser(users, user);
|
|
506
|
+
await sendSecurityAlertEmail(req, user, 'password_change'), res.json({ success: true, message: '密码已修改' });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
app.post('/api/enable-2fa', async (req, res) => {
|
|
510
|
+
const result = getCurrentUser(req);
|
|
511
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
512
|
+
|
|
513
|
+
const { user, users } = result, secret = generateSecret({ length: 32 }), otpauth_url = generateURI({
|
|
514
|
+
issuer: 'YourApp', label: user.username, secret,
|
|
515
|
+
}), qrCodeUrl = await toDataURL(otpauth_url);
|
|
516
|
+
|
|
517
|
+
user.twoFactorSecret = secret, touchAndSaveUser(users, user), res.json({ secret, qrCode: qrCodeUrl });
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
app.post('/api/confirm-2fa', async (req, res) => {
|
|
521
|
+
const { token } = req.body;
|
|
522
|
+
if (!token) return res.status(400).json({ message: '验证码必填' });
|
|
523
|
+
|
|
524
|
+
const result = getCurrentUser(req);
|
|
525
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
526
|
+
|
|
527
|
+
const { user, users } = result, verified = await verifyTotp(user.twoFactorSecret, token);
|
|
528
|
+
if (!verified) return res.status(400).json({ message: '验证码错误' });
|
|
529
|
+
|
|
530
|
+
const { plainCodes, hashedCodes } = generateBackupCodes();
|
|
531
|
+
user.backupCodes = hashedCodes, user.twoFactorEnabled = true, touchAndSaveUser(users, user);
|
|
532
|
+
res.json({ success: true, backupCodes: plainCodes, message: '2FA 已启用' });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
app.post('/api/disable-2fa', async (req, res) => {
|
|
536
|
+
const result = getCurrentUser(req);
|
|
537
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
538
|
+
|
|
539
|
+
const { user, users } = result;
|
|
540
|
+
user.twoFactorEnabled = false, user.twoFactorSecret = null, user.backupCodes = [];
|
|
541
|
+
touchAndSaveUser(users, user), res.json({ success: true });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
app.post('/api/regenerate-backup-codes', async (req, res) => {
|
|
545
|
+
const result = getCurrentUser(req);
|
|
546
|
+
if (!result) return res.status(404).json({ message: '用户不存在' });
|
|
547
|
+
const { user, users } = result;
|
|
548
|
+
if (!user.twoFactorEnabled) return res.status(400).json({ message: '2FA未启用' });
|
|
549
|
+
|
|
550
|
+
const { plainCodes, hashedCodes } = generateBackupCodes();
|
|
551
|
+
user.backupCodes = hashedCodes, touchAndSaveUser(users, user), res.json({ backupCodes: plainCodes });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ========== WebAuthn 硬件验证 API ==========
|
|
555
|
+
app.post('/api/webauthn/register/begin', authLimiter, async (req, res) => {
|
|
556
|
+
try {
|
|
557
|
+
const result = getCurrentUser(req);
|
|
558
|
+
if (!result) return res.status(401).json({ message: '未登录' });
|
|
559
|
+
const { id, username, webauthnCredentials } = result.user, rpID = getRpId(req),
|
|
560
|
+
options = await generateRegistrationOptions({
|
|
561
|
+
rpName: 'Your App',
|
|
562
|
+
rpID, userID: Buffer.from(String(id)),
|
|
563
|
+
userName: username, userDisplayName: username,
|
|
564
|
+
attestationType: 'none',
|
|
565
|
+
excludeCredentials: webauthnCredentials.map(cred => ({
|
|
566
|
+
id: cred.id,
|
|
567
|
+
type: 'public-key',
|
|
568
|
+
transports: cred.transports
|
|
569
|
+
})),
|
|
570
|
+
authenticatorSelection: { userVerification: 'preferred' },
|
|
571
|
+
});
|
|
572
|
+
req.session.webauthnRegisterChallenge = options.challenge, res.json(options);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
return res.status(400).json({ message: err.message || '硬件验证初始化失败,请确保使用 HTTPS 或 localhost 访问;' });
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// 完成注册,保存凭证,并发送邮件通知
|
|
579
|
+
app.post('/api/webauthn/register/complete', authLimiter, async (req, res) => {
|
|
580
|
+
const { attestationResponse } = req.body, result = getCurrentUser(req);
|
|
581
|
+
if (!result) return res.status(401).json({ message: '未登录' });
|
|
582
|
+
|
|
583
|
+
const challenge = req.session.webauthnRegisterChallenge;
|
|
584
|
+
if (!challenge) return res.status(400).json({ message: '缺少注册挑战' });
|
|
585
|
+
|
|
586
|
+
const rpID = getRpId(req), origin = getOrigin(req);
|
|
587
|
+
let verification;
|
|
588
|
+
try {
|
|
589
|
+
verification = await verifyRegistrationResponse({
|
|
590
|
+
response: attestationResponse,
|
|
591
|
+
expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID,
|
|
592
|
+
requireUserVerification: false,
|
|
593
|
+
});
|
|
594
|
+
} catch (err) {
|
|
595
|
+
return res.status(400).json({ message: err.message });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const { verified, registrationInfo } = verification;
|
|
599
|
+
if (!verified || !registrationInfo) return res.status(400).json({ message: '注册验证未通过' });
|
|
600
|
+
|
|
601
|
+
const cred = registrationInfo.credential;
|
|
602
|
+
if (!cred) return res.status(400).json({ message: '凭证数据不完整' });
|
|
603
|
+
|
|
604
|
+
const clientIp = getClientIp(req), { id, publicKey, counter, transports = [], deviceType, backedUp } = cred,
|
|
605
|
+
newCredential = {
|
|
606
|
+
id,
|
|
607
|
+
publicKey: fromBuffer(publicKey),
|
|
608
|
+
counter: Number(counter),
|
|
609
|
+
transports, deviceType, backedUp,
|
|
610
|
+
deviceName: clientIp, createdAt: Date.now()
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
result.user.webauthnCredentials.push(newCredential);
|
|
614
|
+
if (!result.user.webauthnEnabled) result.user.webauthnEnabled = true;
|
|
615
|
+
touchAndSaveUser(result.users, result.user), delete req.session.webauthnRegisterChallenge;
|
|
616
|
+
await sendSecurityAlertEmail(req, result.user, 'webauthn_added');
|
|
617
|
+
res.json({ success: true, credentials: result.user.webauthnCredentials });
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// 获取用户凭证列表
|
|
621
|
+
app.get('/api/webauthn/credentials', (req, res) => {
|
|
622
|
+
const result = getCurrentUser(req);
|
|
623
|
+
if (!result) return res.status(401).json({ message: '未登录' });
|
|
624
|
+
res.json({ credentials: result.user.webauthnCredentials, enabled: result.user.webauthnEnabled });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// 删除某个硬件凭证
|
|
628
|
+
app.post('/api/webauthn/credentials/delete', authLimiter, async (req, res) => {
|
|
629
|
+
const { credentialId } = req.body;
|
|
630
|
+
if (!credentialId) return res.status(400).json({ message: '缺少凭证ID' });
|
|
631
|
+
|
|
632
|
+
const result = getCurrentUser(req);
|
|
633
|
+
if (!result) return res.status(401).json({ message: '未登录' });
|
|
634
|
+
if (!result.user) return res.status(500).json({ message: '用户数据异常' });
|
|
635
|
+
|
|
636
|
+
const credentials = result.user.webauthnCredentials ?? [], index = credentials.findIndex(c => c.id === credentialId);
|
|
637
|
+
if (index === -1) return res.status(404).json({ message: '凭证不存在' });
|
|
638
|
+
|
|
639
|
+
credentials.splice(index, 1);
|
|
640
|
+
if (credentials.length === 0) result.user.webauthnEnabled = false;
|
|
641
|
+
result.user.webauthnCredentials = credentials;
|
|
642
|
+
touchAndSaveUser(result.users, result.user), res.json({ success: true, message: '设备已删除' });
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// 切换硬件验证启用状态
|
|
646
|
+
app.post('/api/webauthn/toggle', authLimiter, async (req, res) => {
|
|
647
|
+
const result = getCurrentUser(req);
|
|
648
|
+
if (!result) return res.status(401).json({ message: '未登录' });
|
|
649
|
+
if (!result.user.webauthnEnabled && result.user.webauthnCredentials.length === 0)
|
|
650
|
+
return res.status(400).json({ message: '没有可用的硬件凭证,请先添加设备' });
|
|
651
|
+
|
|
652
|
+
result.user.webauthnEnabled = !result.user.webauthnEnabled, touchAndSaveUser(result.users, result.user);
|
|
653
|
+
res.json({ success: true, enabled: result.user.webauthnEnabled });
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// 开始硬件验证登录(生成断言选项)
|
|
657
|
+
app.post('/api/webauthn/login/begin', authLimiter, async (req, res) => {
|
|
658
|
+
try {
|
|
659
|
+
const { username } = req.body;
|
|
660
|
+
if (!username) return res.status(400).json({ message: '缺少用户名' });
|
|
661
|
+
const users = readUsers(), user = users.find(u => u.username === username || u.email === username);
|
|
662
|
+
if (!user || !user.webauthnEnabled || user.webauthnCredentials.length === 0)
|
|
663
|
+
return res.status(400).json({ message: '未启用硬件验证或无凭证' });
|
|
664
|
+
|
|
665
|
+
const rpID = getRpId(req), origin = getOrigin(req),
|
|
666
|
+
options = await generateAuthenticationOptions({
|
|
667
|
+
rpID,
|
|
668
|
+
allowCredentials: user.webauthnCredentials.map(cred => ({
|
|
669
|
+
id: cred.id,
|
|
670
|
+
type: 'public-key',
|
|
671
|
+
transports: cred.transports,
|
|
672
|
+
})),
|
|
673
|
+
userVerification: 'preferred', timeout: 60000,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
req.session.webauthnLoginChallenge = options.challenge, req.session.webauthnLoginUserId = user.id;
|
|
677
|
+
req.session.webauthnRpID = rpID, req.session.webauthnOrigin = origin, res.json(options);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
return res.status(400).json({ message: err.message });
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// 完成硬件验证登录
|
|
684
|
+
app.post('/api/webauthn/login/complete', authLimiter, async (req, res) => {
|
|
685
|
+
try {
|
|
686
|
+
const { assertionResponse } = req.body, { webauthnLoginChallenge, webauthnLoginUserId, webauthnRpID,
|
|
687
|
+
webauthnOrigin } = req.session;
|
|
688
|
+
|
|
689
|
+
if (!webauthnLoginChallenge || !webauthnLoginUserId || !webauthnRpID || !webauthnOrigin)
|
|
690
|
+
return res.status(400).json({ message: '会话无效或已过期' });
|
|
691
|
+
|
|
692
|
+
const users = readUsers(), user = users.find(u => u.id === webauthnLoginUserId);
|
|
693
|
+
if (!user) return res.status(404).json({ message: '用户不存在' });
|
|
694
|
+
|
|
695
|
+
let credential = user.webauthnCredentials.find(c => c.id === assertionResponse.id);
|
|
696
|
+
if (!credential) {
|
|
697
|
+
credential = user.webauthnCredentials.find(c => c.id.toLowerCase() === assertionResponse.id.toLowerCase());
|
|
698
|
+
if (!credential) return res.status(400).json({ message: '凭证不匹配' });
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const webauthnCredential = {
|
|
702
|
+
id: credential.id,
|
|
703
|
+
publicKey: toBuffer(credential.publicKey),
|
|
704
|
+
counter: Number(credential.counter),
|
|
705
|
+
},
|
|
706
|
+
verification = await verifyAuthenticationResponse({
|
|
707
|
+
response: assertionResponse,
|
|
708
|
+
expectedChallenge: webauthnLoginChallenge,
|
|
709
|
+
expectedOrigin: webauthnOrigin,
|
|
710
|
+
expectedRPID: webauthnRpID,
|
|
711
|
+
credential: webauthnCredential,
|
|
712
|
+
requireUserVerification: false,
|
|
713
|
+
}), { verified, authenticationInfo } = verification;
|
|
714
|
+
if (!verified) return res.status(400).json({ message: '签名验证失败' });
|
|
715
|
+
|
|
716
|
+
credential.counter = authenticationInfo.newCounter, touchAndSaveUser(users, user);
|
|
717
|
+
['webauthnLoginChallenge', 'webauthnLoginUserId', 'webauthnRpID', 'webauthnOrigin']
|
|
718
|
+
.forEach(key => delete req.session[key]);
|
|
719
|
+
req.session.userId = user.id, req.session.username = user.username, req.session.loginTime = Date.now();
|
|
720
|
+
recentPasswordResets.delete(user.email), res.json({ success: true });
|
|
721
|
+
} catch (err) {
|
|
722
|
+
return res.status(400).json({ message: err.message });
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
console.log('✅ 认证路由已加载(account.js)');
|
|
726
|
+
}
|