@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
|
@@ -5,28 +5,24 @@
|
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
7
|
<title>双因素验证处理页</title>
|
|
8
|
-
|
|
9
|
-
<link rel="stylesheet" href="/static/
|
|
10
|
-
<link rel="stylesheet" href="/static/
|
|
11
|
-
<link rel="stylesheet" href="/static/
|
|
12
|
-
<link rel="stylesheet" href="/static/topImg.css" /> <!-- 返回顶部图标 -->
|
|
13
|
-
|
|
8
|
+
<link rel="stylesheet" href="/static/constants.css" />
|
|
9
|
+
<link rel="stylesheet" href="/static/public.css" />
|
|
10
|
+
<link rel="stylesheet" href="/static/themeImg.css" />
|
|
11
|
+
<link rel="stylesheet" href="/static/topImg.css" />
|
|
14
12
|
<style>
|
|
15
|
-
/* 覆盖 body 布局:本页需要居中卡片,而非默认的纵向弹性布局 */
|
|
16
13
|
body {
|
|
17
14
|
display: flex;
|
|
18
15
|
align-items: center;
|
|
19
16
|
justify-content: center;
|
|
20
17
|
padding: 20px;
|
|
21
|
-
/* 背景由公共变量接管,自动适配深浅色 */
|
|
22
18
|
background-color: var(--bg-color);
|
|
23
19
|
background-image: var(--body-bg);
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
/* 卡片容器 */
|
|
27
22
|
.card {
|
|
28
23
|
background: var(--container-bg);
|
|
29
24
|
border-radius: 12px;
|
|
25
|
+
border: 1px solid #2e3135;
|
|
30
26
|
box-shadow: 0 20px 40px var(--content-shadow);
|
|
31
27
|
width: 100%;
|
|
32
28
|
max-width: 400px;
|
|
@@ -34,7 +30,6 @@
|
|
|
34
30
|
transition: background 0.5s ease, box-shadow 0.5s ease;
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
/* 卡片标题 */
|
|
38
33
|
.card h2 {
|
|
39
34
|
color: var(--h2-color);
|
|
40
35
|
margin-bottom: 20px;
|
|
@@ -43,18 +38,10 @@
|
|
|
43
38
|
font-size: 28px;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
/* 提示文字 */
|
|
47
|
-
.card p {
|
|
48
|
-
text-align: center;
|
|
49
|
-
margin-bottom: 20px;
|
|
50
|
-
color: var(--p-color);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
41
|
.form-group {
|
|
54
42
|
margin-bottom: 20px;
|
|
55
43
|
}
|
|
56
44
|
|
|
57
|
-
/* 验证码输入框 */
|
|
58
45
|
.form-group input {
|
|
59
46
|
width: 100%;
|
|
60
47
|
padding: 12px 16px;
|
|
@@ -62,20 +49,20 @@
|
|
|
62
49
|
border-radius: 8px;
|
|
63
50
|
font-size: 16px;
|
|
64
51
|
text-align: center;
|
|
65
|
-
letter-spacing: 4px;
|
|
66
52
|
background: var(--li-bg);
|
|
67
53
|
color: var(--text-color);
|
|
68
|
-
transition: border-color 0.3s ease, background 0.5s ease, color 0.5s ease;
|
|
69
54
|
}
|
|
70
55
|
|
|
71
56
|
.form-group input:focus {
|
|
72
57
|
border-color: var(--link-color);
|
|
73
58
|
}
|
|
74
59
|
|
|
75
|
-
|
|
60
|
+
.totp-input {
|
|
61
|
+
letter-spacing: 4px;
|
|
62
|
+
}
|
|
63
|
+
|
|
76
64
|
.btn {
|
|
77
65
|
width: 100%;
|
|
78
|
-
display: block;
|
|
79
66
|
padding: 14px;
|
|
80
67
|
background: var(--btn-bg);
|
|
81
68
|
color: var(--text-color);
|
|
@@ -84,7 +71,6 @@
|
|
|
84
71
|
font-size: 16px;
|
|
85
72
|
font-weight: 600;
|
|
86
73
|
cursor: pointer;
|
|
87
|
-
margin-top: 0;
|
|
88
74
|
box-shadow: 0 4px 12px var(--content-shadow);
|
|
89
75
|
transition: background 0.3s ease, transform 0.2s ease;
|
|
90
76
|
}
|
|
@@ -101,7 +87,15 @@
|
|
|
101
87
|
transform: none;
|
|
102
88
|
}
|
|
103
89
|
|
|
104
|
-
|
|
90
|
+
.btn-secondary {
|
|
91
|
+
background: var(--btn-secondary-bg, #6c757d);
|
|
92
|
+
margin-top: 10px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.btn-secondary:hover {
|
|
96
|
+
background: var(--btn-secondary-hover, #5a6268);
|
|
97
|
+
}
|
|
98
|
+
|
|
105
99
|
.error {
|
|
106
100
|
color: #e53e3e;
|
|
107
101
|
font-size: 14px;
|
|
@@ -109,22 +103,35 @@
|
|
|
109
103
|
text-align: center;
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
|
|
113
|
-
.backup-link {
|
|
106
|
+
.backup-panel {
|
|
114
107
|
margin-top: 20px;
|
|
108
|
+
padding-top: 15px;
|
|
109
|
+
border-top: 1px solid var(--content-border);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.hardware-loader {
|
|
115
113
|
text-align: center;
|
|
116
|
-
|
|
114
|
+
margin: 20px 0;
|
|
117
115
|
color: var(--text-color);
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
text-decoration: none;
|
|
118
|
+
[hidden] {
|
|
119
|
+
display: none !important;
|
|
123
120
|
}
|
|
124
121
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
@media (max-width: 640px) {
|
|
123
|
+
.card {
|
|
124
|
+
padding: 28px 20px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.card h2 {
|
|
128
|
+
font-size: 24px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.form-group input {
|
|
132
|
+
font-size: 16px;
|
|
133
|
+
padding: 12px;
|
|
134
|
+
}
|
|
128
135
|
}
|
|
129
136
|
</style>
|
|
130
137
|
</head>
|
|
@@ -132,52 +139,187 @@
|
|
|
132
139
|
<body>
|
|
133
140
|
<div class="card">
|
|
134
141
|
<h2>双因素验证</h2>
|
|
135
|
-
|
|
136
|
-
|
|
142
|
+
|
|
143
|
+
<!-- TOTP 模式区域 -->
|
|
144
|
+
<div id="totpSection">
|
|
145
|
+
<p id="promptText">请输入身份验证器中的6位数字验证码</p>
|
|
137
146
|
<div class="form-group">
|
|
138
|
-
<input type="text" id="
|
|
139
|
-
autocomplete="one-time-code">
|
|
147
|
+
<input type="text" id="tokenInput" placeholder="6位验证码" maxlength="6" autofocus
|
|
148
|
+
autocomplete="one-time-code" class="totp-input">
|
|
149
|
+
</div>
|
|
150
|
+
<button type="button" class="btn" id="verifyTotpBtn">验证</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- 硬件验证专用区域 -->
|
|
154
|
+
<div id="webauthnSection" hidden>
|
|
155
|
+
<p>正在请求硬件验证,请按提示操作...</p>
|
|
156
|
+
<div class="hardware-loader">
|
|
157
|
+
<button type="button" class="btn btn-secondary" id="retryHardwareBtn">重新尝试硬件验证</button>
|
|
140
158
|
</div>
|
|
141
|
-
<button type="button" class="btn" id="verifyBtn">验证</button>
|
|
142
|
-
</form>
|
|
143
|
-
<div class="error" id="message"></div>
|
|
144
|
-
<div class="backup-link">
|
|
145
|
-
<a href="#" id="useBackup">使用备用码</a>
|
|
146
159
|
</div>
|
|
160
|
+
|
|
161
|
+
<!-- 备份码面板(异常时动态显示) -->
|
|
162
|
+
<div id="backupPanel" class="backup-panel" hidden>
|
|
163
|
+
<div class="form-group">
|
|
164
|
+
<input type="text" id="backupCodeInput" placeholder="请输入10位备用码" maxlength="10" autocomplete="off">
|
|
165
|
+
</div>
|
|
166
|
+
<button type="button" class="btn" id="submitBackupBtn">登录</button>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="error" id="message"></div>
|
|
147
170
|
</div>
|
|
148
171
|
|
|
149
|
-
<!--
|
|
150
|
-
<script src="/static/themeModule.js" defer></script
|
|
151
|
-
<script src="/static/mouseOrTouch.js" defer></script
|
|
152
|
-
<script src="/static/themeImg.js" defer></script>
|
|
153
|
-
<script src="/static/topImg.js" defer></script>
|
|
172
|
+
<!-- 公共脚本 -->
|
|
173
|
+
<script src="/static/themeModule.js" defer></script>
|
|
174
|
+
<script src="/static/mouseOrTouch.js" defer></script>
|
|
175
|
+
<script src="/static/themeImg.js" defer></script>
|
|
176
|
+
<script src="/static/topImg.js" defer></script>
|
|
177
|
+
<script src="/static/utils/browser.js"></script>
|
|
154
178
|
<script>
|
|
155
|
-
const [
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
179
|
+
const [totpSection, webauthnSection, backupPanel, messageDiv, tokenInput, verifyTotpBtn, backupCodeInput, submitBackupBtn,
|
|
180
|
+
retryHardwareBtn] = ['totpSection', 'webauthnSection', 'backupPanel', 'message', 'tokenInput',
|
|
181
|
+
'verifyTotpBtn', 'backupCodeInput', 'submitBackupBtn', 'retryHardwareBtn']
|
|
182
|
+
.map(id => document.getElementById(id)),
|
|
183
|
+
urlParams = new URLSearchParams(window.location.search), method = urlParams.get('method'),
|
|
184
|
+
urlUsername = urlParams.get('username') || '', maxNum = 3; // 失败次数达到3次时显示备用码面板
|
|
185
|
+
|
|
186
|
+
let hardwareRunning = false, currentUsername = urlUsername;
|
|
187
|
+
const setMessage = msg => messageDiv.textContent = msg, clearMessage = () => messageDiv.textContent = '',
|
|
188
|
+
// 禁用/启用表单控件
|
|
189
|
+
setFormDisabled = disabled => {
|
|
190
|
+
const inputs = [tokenInput, backupCodeInput, verifyTotpBtn, submitBackupBtn, retryHardwareBtn];
|
|
191
|
+
inputs.forEach(el => { if (el) el.disabled = disabled; });
|
|
192
|
+
},
|
|
193
|
+
// 显示备份码面板
|
|
194
|
+
showBackupPanel = optionalMsg => {
|
|
195
|
+
if (optionalMsg) setMessage(optionalMsg);
|
|
196
|
+
totpSection.hidden = true, webauthnSection.hidden = true, backupPanel.hidden = false;
|
|
197
|
+
backupCodeInput.value = '', backupCodeInput.focus();
|
|
159
198
|
},
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
199
|
+
// 统一处理验证响应(TOTP 和备份码共用)
|
|
200
|
+
handleVerifyResponse = async input => {
|
|
201
|
+
if (!input) {
|
|
202
|
+
setMessage('请输入验证码/备用码');
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const isToken = input.length === 6, isBackup = input.length === 10;
|
|
206
|
+
if (!isToken && !isBackup) {
|
|
207
|
+
setMessage('验证码必须为6位数字,备用码为10位');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 临时禁用提交按钮防止重复请求
|
|
212
|
+
const activeBtn = isToken ? verifyTotpBtn : submitBackupBtn;
|
|
213
|
+
activeBtn.disabled = true, clearMessage();
|
|
214
|
+
|
|
164
215
|
try {
|
|
165
216
|
const response = await fetch('/api/verify-2fa', {
|
|
166
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({ input })
|
|
167
219
|
}), data = await response.json();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
220
|
+
|
|
221
|
+
if (response.ok) {
|
|
222
|
+
window.location.href = '/';
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 处理锁定情况
|
|
227
|
+
if (data.locked) {
|
|
228
|
+
setMessage(data.message), setFormDisabled(true);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 处理普通失败(包含 remainingAttempts)
|
|
233
|
+
if (data.remainingAttempts !== undefined) {
|
|
234
|
+
const failuresSoFar = 9 - data.remainingAttempts;
|
|
235
|
+
setMessage(`${data.message} (已失败 ${failuresSoFar} 次)`);
|
|
236
|
+
if (failuresSoFar >= maxNum && isToken) showBackupPanel(`连续失败 ${failuresSoFar} 次,请使用备用码登录`);
|
|
237
|
+
}
|
|
238
|
+
else setMessage(data.message);
|
|
239
|
+
|
|
240
|
+
// 清空输入框,准备下次尝试
|
|
241
|
+
tokenInput?.value = '', backupCodeInput?.value = '', activeBtn.disabled = false;
|
|
242
|
+
|
|
243
|
+
// 判断焦点归属
|
|
244
|
+
if (isBackup) backupCodeInput.focus();
|
|
245
|
+
else if (isToken) tokenInput.focus();
|
|
246
|
+
return false;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
setMessage(err.message), activeBtn.disabled = false;
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
// TOTP验证和备份码提交入口
|
|
253
|
+
performTotpVerification = () => handleVerifyResponse(tokenInput.value.trim()),
|
|
254
|
+
submitBackupCode = () => handleVerifyResponse(backupCodeInput.value.trim()),
|
|
255
|
+
// 硬件验证核心
|
|
256
|
+
performHardwareVerification = async () => {
|
|
257
|
+
if (!window.PublicKeyCredential) {
|
|
258
|
+
showBackupPanel('当前浏览器不支持硬件安全验证,请使用备用码登录');
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
if (!currentUsername) {
|
|
262
|
+
showBackupPanel('无法获取用户身份,请使用备用码登录');
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
if (retryHardwareBtn && !retryHardwareBtn.hasListener)
|
|
266
|
+
retryHardwareBtn.addEventListener('click', () => performHardwareVerification()), retryHardwareBtn.hasListener = true;
|
|
267
|
+
if (hardwareRunning) return false;
|
|
268
|
+
|
|
269
|
+
hardwareRunning = true, clearMessage();
|
|
270
|
+
try {
|
|
271
|
+
let platformAvailable = false;
|
|
272
|
+
if (window.PublicKeyCredential) {
|
|
273
|
+
try {
|
|
274
|
+
platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
275
|
+
} catch (err) { console.warn(err); }
|
|
276
|
+
}
|
|
277
|
+
if (!platformAvailable) throw new Error('当前环境不支持平台硬件验证');
|
|
278
|
+
|
|
279
|
+
const beginRes = await fetch('/api/webauthn/login/begin', {
|
|
280
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({ username: currentUsername })
|
|
282
|
+
}), options = await beginRes.json();
|
|
283
|
+
|
|
284
|
+
if (!beginRes.ok) throw new Error(options.message);
|
|
285
|
+
const assertionResp = await flunWebAuthnBrowser.startAuthentication(options),
|
|
286
|
+
completeRes = await fetch('/api/webauthn/login/complete', {
|
|
287
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
288
|
+
body: JSON.stringify({ assertionResponse: assertionResp })
|
|
289
|
+
}), completeData = await completeRes.json();
|
|
290
|
+
|
|
291
|
+
if (completeRes.ok) {
|
|
292
|
+
window.location.href = '/';
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
else throw new Error(completeData.message);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
let errorMsg = err.message;
|
|
298
|
+
if (err.name === 'NotAllowedError') errorMsg = '硬件验证已取消';
|
|
299
|
+
showBackupPanel(`${errorMsg},请使用备用码登录`);
|
|
300
|
+
return false;
|
|
301
|
+
} finally { hardwareRunning = false }
|
|
302
|
+
},
|
|
303
|
+
// 页面初始化
|
|
304
|
+
initPage = () => {
|
|
305
|
+
backupPanel.hidden = true;
|
|
306
|
+
if (method === 'webauthn')
|
|
307
|
+
totpSection.hidden = true, webauthnSection.hidden = false, performHardwareVerification().catch(e => console.warn(e));
|
|
308
|
+
else totpSection.hidden = false, webauthnSection.hidden = true, tokenInput.maxLength = 6, tokenInput.focus();
|
|
309
|
+
|
|
310
|
+
// 事件监听
|
|
311
|
+
submitBackupBtn.addEventListener('click', submitBackupCode);
|
|
312
|
+
verifyTotpBtn.addEventListener('click', performTotpVerification);
|
|
313
|
+
backupCodeInput.addEventListener('keypress', e => {
|
|
314
|
+
if (e.key === 'Enter') submitBackupCode();
|
|
315
|
+
});
|
|
316
|
+
tokenInput.addEventListener('keypress', e => {
|
|
317
|
+
if (e.key === 'Enter') performTotpVerification();
|
|
318
|
+
});
|
|
319
|
+
[backupCodeInput, tokenInput].forEach(el => el.addEventListener('input', clearMessage));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
initPage();
|
|
181
323
|
</script>
|
|
182
324
|
</body>
|
|
183
325
|
|
|
@@ -18,16 +18,15 @@
|
|
|
18
18
|
align-items: center;
|
|
19
19
|
justify-content: center;
|
|
20
20
|
padding: 20px;
|
|
21
|
-
/* 背景由公共变量接管,自动适配深浅色 */
|
|
22
21
|
background-color: var(--bg-color);
|
|
23
22
|
background-image: var(--body-bg);
|
|
24
|
-
/* 保留页面原有的字体偏好,覆盖 public.css 中的通用字体 */
|
|
25
23
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
.card {
|
|
29
27
|
background: var(--container-bg);
|
|
30
28
|
border-radius: 12px;
|
|
29
|
+
border: 1px solid #2e3135;
|
|
31
30
|
box-shadow: 0 20px 40px var(--content-shadow);
|
|
32
31
|
width: 100%;
|
|
33
32
|
max-width: 400px;
|
|
@@ -129,8 +128,58 @@
|
|
|
129
128
|
text-decoration: underline;
|
|
130
129
|
}
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
display: none;
|
|
131
|
+
[hidden] {
|
|
132
|
+
display: none !important;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ==================== 移动端样式适配 (≤640px) ==================== */
|
|
136
|
+
@media (max-width: 640px) {
|
|
137
|
+
body {
|
|
138
|
+
padding: 16px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.card {
|
|
142
|
+
max-width: 100%;
|
|
143
|
+
padding: 28px 20px;
|
|
144
|
+
border-radius: 16px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.card h2 {
|
|
148
|
+
font-size: 24px;
|
|
149
|
+
margin-bottom: 8px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.card p {
|
|
153
|
+
font-size: 13px;
|
|
154
|
+
margin-bottom: 24px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.form-group input {
|
|
158
|
+
font-size: 16px;
|
|
159
|
+
/* 防止iOS缩放 */
|
|
160
|
+
padding: 12px 14px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.btn {
|
|
164
|
+
padding: 12px;
|
|
165
|
+
font-size: 16px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.links {
|
|
169
|
+
font-size: 13px;
|
|
170
|
+
margin-top: 20px;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* 极小屏幕 (≤480px) 微调 */
|
|
175
|
+
@media (max-width: 480px) {
|
|
176
|
+
.card {
|
|
177
|
+
padding: 24px 16px;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.card h2 {
|
|
181
|
+
font-size: 22px;
|
|
182
|
+
}
|
|
134
183
|
}
|
|
135
184
|
</style>
|
|
136
185
|
</head>
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
.card {
|
|
31
31
|
background: var(--container-bg);
|
|
32
32
|
border-radius: 12px;
|
|
33
|
+
border: 1px solid #2e3135;
|
|
33
34
|
box-shadow: 0 20px 40px var(--content-shadow);
|
|
34
35
|
width: 100%;
|
|
35
36
|
max-width: 400px;
|
|
@@ -127,6 +128,68 @@
|
|
|
127
128
|
color: var(--link-hover);
|
|
128
129
|
text-decoration: underline;
|
|
129
130
|
}
|
|
131
|
+
|
|
132
|
+
/* ==================== 移动端样式适配 (≤640px) ==================== */
|
|
133
|
+
@media (max-width: 640px) {
|
|
134
|
+
body {
|
|
135
|
+
padding: 16px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.card {
|
|
139
|
+
max-width: 100%;
|
|
140
|
+
padding: 28px 20px;
|
|
141
|
+
border-radius: 16px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.card h2 {
|
|
145
|
+
font-size: 24px;
|
|
146
|
+
margin-bottom: 24px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.form-group {
|
|
150
|
+
margin-bottom: 18px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.form-group label {
|
|
154
|
+
font-size: 13px;
|
|
155
|
+
margin-bottom: 6px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.form-group input {
|
|
159
|
+
font-size: 16px;
|
|
160
|
+
padding: 12px 14px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.btn {
|
|
164
|
+
padding: 12px;
|
|
165
|
+
font-size: 16px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.links {
|
|
169
|
+
margin-top: 20px;
|
|
170
|
+
font-size: 13px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.links a {
|
|
174
|
+
margin: 0 8px;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@media (max-width: 480px) {
|
|
179
|
+
.card {
|
|
180
|
+
padding: 24px 16px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.card h2 {
|
|
184
|
+
font-size: 22px;
|
|
185
|
+
margin-bottom: 20px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.links a {
|
|
189
|
+
display: inline-block;
|
|
190
|
+
margin: 4px 6px;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
130
193
|
</style>
|
|
131
194
|
</head>
|
|
132
195
|
|
|
@@ -153,77 +216,44 @@
|
|
|
153
216
|
</div>
|
|
154
217
|
|
|
155
218
|
<!-- 公共逻辑 -->
|
|
156
|
-
<script src="/static/themeModule.js" defer></script
|
|
157
|
-
<script src="/static/mouseOrTouch.js" defer></script
|
|
158
|
-
<script src="/static/themeImg.js" defer></script>
|
|
159
|
-
<script src="/static/topImg.js" defer></script>
|
|
160
|
-
<script src="/static/utils/browser.js"></script> <!-- 引入@flun前端硬件验证模块 -->
|
|
219
|
+
<script src="/static/themeModule.js" defer></script>
|
|
220
|
+
<script src="/static/mouseOrTouch.js" defer></script>
|
|
221
|
+
<script src="/static/themeImg.js" defer></script>
|
|
222
|
+
<script src="/static/topImg.js" defer></script>
|
|
161
223
|
<script>
|
|
162
|
-
const [usernameEl, passwordEl, messageDiv, loginBtn] = ['username', 'password', 'message', 'loginBtn']
|
|
224
|
+
const [usernameEl, passwordEl, messageDiv, loginBtn,] = ['username', 'password', 'message', 'loginBtn',]
|
|
163
225
|
.map(id => document.getElementById(id)),
|
|
164
226
|
handleError = msg => {
|
|
165
227
|
messageDiv.textContent = msg, loginBtn.disabled = false;
|
|
166
228
|
};
|
|
167
229
|
|
|
230
|
+
// 登录主逻辑(统一跳转到2fa页面,携带对应验证方式)
|
|
168
231
|
loginBtn.addEventListener('click', async () => {
|
|
169
232
|
loginBtn.disabled = true, messageDiv.textContent = '';
|
|
233
|
+
|
|
170
234
|
const [username, password] = [usernameEl, passwordEl].map(input => input.value.trim());
|
|
171
235
|
if (!username || !password) return handleError('请输入用户名和密码');
|
|
172
|
-
|
|
173
236
|
try {
|
|
174
|
-
// 密码验证
|
|
175
237
|
const response = await fetch('/api/login', {
|
|
176
|
-
method: 'POST',
|
|
177
|
-
headers: { 'Content-Type': 'application/json' },
|
|
238
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
178
239
|
body: JSON.stringify({ username, password })
|
|
179
240
|
}), data = await response.json();
|
|
180
241
|
|
|
181
242
|
if (!response.ok) return handleError(data.message);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
188
|
-
} catch (err) { console.warn(err) };
|
|
189
|
-
}
|
|
190
|
-
if (!platformAvailable)
|
|
191
|
-
return handleError('当前环境不支持硬件验证,请使用HTTPS或localhost访问,并确保浏览器支持指纹/人脸功能;');
|
|
192
|
-
try {
|
|
193
|
-
// 获取 WebAuthn 登录选项
|
|
194
|
-
const beginRes = await fetch('/api/webauthn/login/begin', {
|
|
195
|
-
method: 'POST',
|
|
196
|
-
headers: { 'Content-Type': 'application/json' },
|
|
197
|
-
body: JSON.stringify({ username })
|
|
198
|
-
}), options = await beginRes.json();
|
|
199
|
-
if (!beginRes.ok) return handleError(options.message);
|
|
200
|
-
|
|
201
|
-
// 调用浏览器硬件验证
|
|
202
|
-
const assertionResp = await flunWebAuthnBrowser.startAuthentication(options),
|
|
203
|
-
completeRes = await fetch('/api/webauthn/login/complete', {
|
|
204
|
-
method: 'POST',
|
|
205
|
-
headers: { 'Content-Type': 'application/json' },
|
|
206
|
-
body: JSON.stringify({ assertionResponse: assertionResp })
|
|
207
|
-
}), completeData = await completeRes.json();
|
|
208
|
-
|
|
209
|
-
if (completeRes.ok) window.location.href = '/';
|
|
210
|
-
else return handleError(completeData.message);
|
|
211
|
-
} catch (webauthnErr) {
|
|
212
|
-
let msg = webauthnErr.message;
|
|
213
|
-
if (webauthnErr.name === 'NotAllowedError') msg = '认证操作已取消';
|
|
214
|
-
handleError(msg);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
else if (data.require2FA) window.location.href = '/2fa';
|
|
218
|
-
else if (data.success) window.location.href = '/';
|
|
243
|
+
if (data.requireWebAuthn)
|
|
244
|
+
window.location.href = `/2fa?method=webauthn&username=${encodeURIComponent(username)}`;
|
|
245
|
+
else if (data.require2FA)
|
|
246
|
+
window.location.href = `/2fa?method=totp&username=${encodeURIComponent(username)}`;
|
|
247
|
+
else if (data.success) window.location.href = '/'; // 无二次验证,直接进入首页
|
|
219
248
|
else handleError(data.message);
|
|
220
|
-
} catch (err) { handleError(
|
|
249
|
+
} catch (err) { handleError(err.message) }
|
|
221
250
|
});
|
|
222
251
|
|
|
252
|
+
// 输入时清除错误消息
|
|
253
|
+
[usernameEl, passwordEl].forEach(el => el.addEventListener('input', () => messageDiv.textContent = ''));
|
|
223
254
|
passwordEl.addEventListener('keypress', e => {
|
|
224
255
|
if (e.key === 'Enter') loginBtn.click();
|
|
225
256
|
});
|
|
226
|
-
[usernameEl, passwordEl].forEach(el => el.addEventListener('input', () => messageDiv.textContent = ''));
|
|
227
257
|
</script>
|
|
228
258
|
</body>
|
|
229
259
|
|