Files
Traffic-information_Trackin…/assets/auth.js
T

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);