feat: add QR code generation support and rate limiting for known device probes

This commit is contained in:
shuaiplus
2026-02-27 22:07:37 +08:00
committed by Shuai
parent ceb4bef9e4
commit 930f4f86cc
8 changed files with 81 additions and 5 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
</head>
<body>
<div id="app"></div>
<script defer src="/web/vendor/qrcode-generator.min.js"></script>
<script type="module" src="/web/runtime-config.js"></script>
</body>
</html>
+21 -2
View File
@@ -214,6 +214,25 @@ export function startNodewardenApp(runtimeConfig) {
function randomBase32Secret(len){ var a='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; var b=crypto.getRandomValues(new Uint8Array(len)); var o=''; for(var i=0;i<b.length;i++) o+=a[b[i]%a.length]; return o; }
function currentTotpSecret(){ if(!state.totpSetupSecret) state.totpSetupSecret=randomBase32Secret(32); return state.totpSetupSecret; }
function buildTotpUri(secret){ var issuer='NodeWarden'; var account=state.profile&&state.profile.email?state.profile.email:'account'; return 'otpauth://totp/'+encodeURIComponent(issuer+':'+account)+'?secret='+encodeURIComponent(secret)+'&issuer='+encodeURIComponent(issuer)+'&algorithm=SHA1&digits=6&period=30'; }
function renderTotpSetupQr(){
if(state.phase!=='app'||state.tab!=='settings') return;
var box=document.getElementById('totp-qr-box');
if(!box) return;
var secret=currentTotpSecret();
var otpUri=buildTotpUri(secret);
try{
var qrf=window.qrcode;
if(typeof qrf!=='function') throw new Error('qrcode-generator unavailable');
var qr=qrf(0,'M');
qr.addData(otpUri);
qr.make();
var dataUrl=qr.createDataURL(4,2);
if(!dataUrl) throw new Error('qrcode data url unavailable');
box.innerHTML='<img src="'+esc(dataUrl)+'" alt="TOTP QR" width="180" height="180" style="display:block; width:180px; height:180px;" />';
}catch(_){
box.innerHTML='<div class="totp-qr-fallback"><div>QR unavailable</div><div class="tiny">Use secret key below</div></div>';
}
}
function buildSymmetricKeyBytes(){
if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return null;
try{
@@ -833,13 +852,12 @@ export function startNodewardenApp(runtimeConfig) {
function renderSettingsTab(){
var p=state.profile||{};
var secret=currentTotpSecret();
var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret));
return ''
+ renderMsg()
+ '<h2 style="margin:0 0 24px 0; font-size:24px; font-weight:600; color:var(--text-primary);">'+t('settings')+'</h2>'
+ '<div class="panel"><h3 style="margin-top:0;">'+t('profile')+'</h3><form id="profileForm"><div style="display:flex; gap:16px; margin-bottom:16px;"><div class="form-group" style="flex:1;"><label class="form-label">'+t('name')+'</label><input class="form-input" name="name" value="'+esc(p.name||'')+'" /></div><div class="form-group" style="flex:1;"><label class="form-label">'+t('email')+'</label><input class="form-input" type="email" name="email" value="'+esc(p.email||'')+'" required /></div></div><button class="btn btn-primary" type="submit">'+t('saveProfile')+'</button></form></div>'
+ '<div class="panel"><h3 style="margin-top:0;">'+t('changePwd')+'</h3><form id="passwordForm"><div class="form-group"><label class="form-label">'+t('currentPwd')+'</label><input class="form-input" type="password" name="currentPassword" required /></div><div style="display:flex; gap:16px; margin-bottom:16px;"><div class="form-group" style="flex:1;"><label class="form-label">'+t('newPwd')+'</label><input class="form-input" type="password" name="newPassword" minlength="12" required /></div><div class="form-group" style="flex:1;"><label class="form-label">'+t('confirmPwd')+'</label><input class="form-input" type="password" name="newPassword2" minlength="12" required /></div></div><button class="btn btn-danger" type="submit">'+t('changePwd')+'</button><div class="tiny" style="margin-top:8px;">After success, current sessions are revoked and you must log in again.</div></form></div>'
+ '<div class="panel"><h3 style="margin-top:0;">'+t('totpSetup')+'</h3><div style="display:flex; gap:24px; margin-bottom:24px; flex-wrap:wrap;"><div style="background:#fff; padding:16px; border:1px solid var(--border-color); border-radius:8px;"><img src="'+esc(qr)+'" alt="TOTP QR" style="display:block;" /></div><div style="flex:1; min-width:250px;"><form id="totpEnableForm"><div class="form-group"><label class="form-label">'+t('secret')+'</label><input class="form-input" name="secret" value="'+esc(secret)+'" /></div><div class="form-group"><label class="form-label">'+t('verifyCode')+'</label><input class="form-input" name="token" maxlength="6" value="'+esc(state.totpSetupToken)+'" /></div><div style="display:flex; gap:8px;"><button class="btn btn-primary" type="submit">'+t('enableTotp')+'</button><button class="btn btn-secondary" type="button" data-action="totp-secret-refresh">Regenerate</button><button class="btn btn-secondary" type="button" data-action="totp-secret-copy">Copy Secret</button></div></form></div></div><button class="btn btn-danger" type="button" data-action="totp-disable">'+t('disableTotp')+'</button><div class="tiny" style="margin-top:8px;">Disable action prompts for master password.</div></div>';
+ '<div class="panel"><h3 style="margin-top:0;">'+t('totpSetup')+'</h3><div style="display:flex; gap:24px; margin-bottom:24px; flex-wrap:wrap;"><div class="totp-qr-card"><div id="totp-qr-box"><div class="totp-qr-fallback"><div>QR loading...</div><div class="tiny">Use secret key below</div></div></div></div><div style="flex:1; min-width:250px;"><form id="totpEnableForm"><div class="form-group"><label class="form-label">'+t('secret')+'</label><input class="form-input" name="secret" value="'+esc(secret)+'" /></div><div class="form-group"><label class="form-label">'+t('verifyCode')+'</label><input class="form-input" name="token" maxlength="6" value="'+esc(state.totpSetupToken)+'" /></div><div style="display:flex; gap:8px;"><button class="btn btn-primary" type="submit">'+t('enableTotp')+'</button><button class="btn btn-secondary" type="button" data-action="totp-secret-refresh">Regenerate</button><button class="btn btn-secondary" type="button" data-action="totp-secret-copy">Copy Secret</button></div></form></div></div><button class="btn btn-danger" type="button" data-action="totp-disable">'+t('disableTotp')+'</button><div class="tiny" style="margin-top:8px;">Disable action prompts for master password.</div></div>';
}
function renderTotpDisableModal(){
if(!state.totpDisableOpen) return '';
@@ -937,6 +955,7 @@ export function startNodewardenApp(runtimeConfig) {
if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; }
app.innerHTML=renderApp();
updateLiveTotpDisplay();
renderTotpSetupQr();
}
async function init(){
+13 -1
View File
@@ -1,5 +1,17 @@
import { startNodewardenApp } from './app.js';
async function ensureQrLibrary() {
if (typeof window.qrcode === 'function') return;
await new Promise((resolve) => {
const s = document.createElement('script');
s.src = '/web/vendor/qrcode-generator.min.js';
s.async = true;
s.onload = () => resolve(null);
s.onerror = () => resolve(null);
document.head.appendChild(s);
});
}
async function loadRuntimeConfig() {
try {
const resp = await fetch('/api/web/config', { method: 'GET' });
@@ -10,6 +22,6 @@ async function loadRuntimeConfig() {
}
}
await ensureQrLibrary();
const cfg = await loadRuntimeConfig();
startNodewardenApp(cfg || { defaultKdfIterations: 600000 });
+24 -1
View File
@@ -166,6 +166,30 @@
}
.alert-success { background: var(--success-bg); color: var(--success); border-color: #BADBCC; }
.alert-danger { background: var(--danger-bg); color: var(--danger); border-color: #F5C2C7; }
.totp-qr-card {
background:#fff;
padding:16px;
border:1px solid var(--border-color);
border-radius:8px;
width: 200px;
min-height: 200px;
display:flex;
align-items:center;
justify-content:center;
}
.totp-qr-fallback {
width:100%;
min-height:168px;
border:1px dashed var(--border-color);
border-radius:8px;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
color:var(--text-secondary);
text-align:center;
padding:8px;
}
/* App Layout */
.navbar {
@@ -561,4 +585,3 @@
.vault-grid { grid-template-columns: 1fr; }
}
File diff suppressed because one or more lines are too long
+3
View File
@@ -35,6 +35,9 @@
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// /api/devices/knowndevice probe budget per IP per minute.
// /api/devices/knowndevice 每 IP 每分钟探测配额。
knownDeviceRequestsPerMinute: 10,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
+7
View File
@@ -235,6 +235,13 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Known device check (no auth required)
if (path === '/api/devices/knowndevice' && method === 'GET') {
const rateLimit = new RateLimitService(env.DB);
const clientIp = getClientIdentifier(request);
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientIp + ':known-device');
if (!probeLimit.allowed) {
// Keep compatibility simple: do not error, just answer "unknown device".
return jsonResponse(false);
}
return handleKnownDevice(request, env);
}
+11
View File
@@ -15,6 +15,8 @@ const CONFIG = {
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
// Dedicated budget for GET /api/devices/knowndevice probes.
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
@@ -222,6 +224,15 @@ export class RateLimitService {
CONFIG.API_WINDOW_SECONDS
);
}
// Probe budget for GET /api/devices/knowndevice.
async consumeKnownDeviceProbeBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.KNOWN_DEVICE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
}
export function getClientIdentifier(request: Request): string {