((root) => { const users = root.APP_USERS || []; const PROFILE_KEY = 'traffic_auth_profiles_v1'; const SESSION_KEY = 'traffic_auth_session_v1'; const SESSION_DURATION = 8 * 60 * 60 * 1000; const encoder = new TextEncoder(); const decoder = new TextDecoder(); const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; function findUser(username) { return users.find((user) => user.username === username) || null; } function readProfiles() { try { const value = JSON.parse(localStorage.getItem(PROFILE_KEY) || '{}'); return value && typeof value === 'object' ? value : {}; } catch (_) { return {}; } } function writeProfiles(profiles) { try { localStorage.setItem(PROFILE_KEY, JSON.stringify(profiles)); } catch (_) { throw new Error('浏览器无法保存本机绑定,请检查存储权限'); } } function bytesToBase64(bytes) { let binary = ''; bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); return btoa(binary); } function base64ToBytes(value) { const binary = atob(value); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } function base32Encode(bytes) { let bits = 0, value = 0, output = ''; bytes.forEach((byte) => { value = (value << 8) | byte; bits += 8; while (bits >= 5) { output += BASE32[(value >>> (bits - 5)) & 31]; bits -= 5; } }); if (bits > 0) output += BASE32[(value << (5 - bits)) & 31]; return output; } function base32Decode(input) { const clean = input.toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const output = []; for (const char of clean) { const index = BASE32.indexOf(char); if (index < 0) continue; value = (value << 5) | index; bits += 5; if (bits >= 8) { output.push((value >>> (bits - 8)) & 255); bits -= 8; } } return new Uint8Array(output); } async function deriveKey(password, salt) { const material = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name:'PBKDF2', salt, iterations:210000, hash:'SHA-256' }, material, { name:'AES-GCM', length:256 }, false, ['encrypt', 'decrypt'] ); } async function encryptSecret(secret, password) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(password, salt); const encrypted = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key, encoder.encode(secret)); return { salt:bytesToBase64(salt), iv:bytesToBase64(iv), cipher:bytesToBase64(new Uint8Array(encrypted)) }; } async function decryptSecret(profile, password) { try { const salt = base64ToBytes(profile.salt); const iv = base64ToBytes(profile.iv); const key = await deriveKey(password, salt); const decrypted = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, base64ToBytes(profile.cipher)); return decoder.decode(decrypted); } catch (_) { throw new Error('密码不正确'); } } function counterBytes(counter) { const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setUint32(0, Math.floor(counter / 0x100000000)); view.setUint32(4, counter >>> 0); return buffer; } async function generateTotp(secret, timestamp = Date.now()) { const key = await crypto.subtle.importKey('raw', base32Decode(secret), { name:'HMAC', hash:'SHA-1' }, false, ['sign']); const counter = Math.floor(timestamp / 30000); const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes(counter))); const offset = signature[signature.length - 1] & 15; const binary = ((signature[offset] & 127) << 24) | (signature[offset + 1] << 16) | (signature[offset + 2] << 8) | signature[offset + 3]; return String(binary % 1000000).padStart(6, '0'); } async function verifyTotp(secret, code, timestamp = Date.now()) { const normalized = String(code).replace(/\D/g, ''); if (normalized.length !== 6) return false; for (const step of [-1, 0, 1]) { if (await generateTotp(secret, timestamp + step * 30000) === normalized) return true; } return false; } function createEnrollment(username) { const user = findUser(username); if (!user) throw new Error('用户不存在'); const secret = base32Encode(crypto.getRandomValues(new Uint8Array(20))); const label = `Traffic Intelligence:${username}`; const otpauthUrl = `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent('Traffic Intelligence')}&algorithm=SHA1&digits=6&period=30`; return { secret, otpauthUrl }; } function setSession(user, revision) { const session = { username:user.username, role:user.role, revision, expiresAt:Date.now() + SESSION_DURATION }; sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)); return session; } function currentUser() { try { const session = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null'); const user = session && findUser(session.username); const profile = user && readProfiles()[user.username]; if (!user || !profile?.revision || profile.revision !== session.revision || session.expiresAt <= Date.now()) { sessionStorage.removeItem(SESSION_KEY); return null; } return { ...user, expiresAt:session.expiresAt }; } catch (_) { return null; } } function isEnrolled(username) { return Boolean(readProfiles()[username]); } async function enroll(username, password, secret, code) { const user = findUser(username); if (!user) throw new Error('用户不存在'); if (String(password).length < 8) throw new Error('密码至少需要 8 位'); if (!await verifyTotp(secret, code)) throw new Error('动态验证码不正确,请确认手机时间准确'); const encrypted = await encryptSecret(secret, password); const profiles = readProfiles(); const revision = bytesToBase64(crypto.getRandomValues(new Uint8Array(12))); profiles[username] = { ...encrypted, revision, createdAt:new Date().toISOString() }; writeProfiles(profiles); return setSession(user, revision); } async function login(username, password, code) { const user = findUser(username); const profile = readProfiles()[username]; if (!user || !profile) throw new Error('该账号尚未在本机完成首次设置'); const secret = await decryptSecret(profile, password); if (!await verifyTotp(secret, code)) throw new Error('动态验证码不正确'); return setSession(user, profile.revision); } function logout() { sessionStorage.removeItem(SESSION_KEY); } function clearEnrollment(username) { const profiles = readProfiles(); delete profiles[username]; writeProfiles(profiles); if (currentUser()?.username === username) logout(); } function requireUser() { const user = currentUser(); if (user) return user; const page = location.pathname.split('/').pop() || 'link-generator.html'; const next = ['link-generator.html', 'link-history.html'].includes(page) ? page : 'link-generator.html'; location.replace(`login.html?next=${encodeURIComponent(next)}`); return null; } function watchSession(onInvalid) { let redirected = false; const check = () => { if (redirected || currentUser()) return; redirected = true; onInvalid(); }; const timer = setInterval(check, 30000); const onStorage = (event) => { if (event.key === PROFILE_KEY) check(); }; const onVisibility = () => { if (document.visibilityState === 'visible') check(); }; addEventListener('storage', onStorage); document.addEventListener('visibilitychange', onVisibility); return () => { clearInterval(timer); removeEventListener('storage', onStorage); document.removeEventListener('visibilitychange', onVisibility); }; } root.TrafficAuth = { users, PROFILE_KEY, findUser, createEnrollment, generateTotp, verifyTotp, isEnrolled, enroll, login, logout, clearEnrollment, currentUser, requireUser, watchSession }; })(typeof window === 'undefined' ? globalThis : window);