220 lines
8.2 KiB
JavaScript
220 lines
8.2 KiB
JavaScript
((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);
|