136 lines
5.6 KiB
JavaScript
136 lines
5.6 KiB
JavaScript
function getCookie(name) {
|
|
const value = `; ${document.cookie}`;
|
|
const parts = value.split(`; ${name}=`);
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
}
|
|
|
|
function base64ToArrayBuffer(b64) {
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes.buffer;
|
|
}
|
|
|
|
function arrayBufferToBase64(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
|
return btoa(binary);
|
|
}
|
|
|
|
async function importRsaPublicKey(spkiBytes) {
|
|
return window.crypto.subtle.importKey('spki', spkiBytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
|
}
|
|
|
|
async function rsaOaepEncrypt(publicKey, dataBytes) {
|
|
const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, dataBytes);
|
|
return new Uint8Array(encrypted);
|
|
}
|
|
|
|
async function importAesKey(keyBytes) {
|
|
return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
|
}
|
|
|
|
async function aesGcmEncrypt(aesKey, ivBytes, dataBytes) {
|
|
const ct = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, aesKey, dataBytes);
|
|
return new Uint8Array(ct);
|
|
}
|
|
|
|
let needCaptcha = false;
|
|
|
|
async function loadCaptcha() {
|
|
const csrftoken = getCookie('csrftoken');
|
|
const resp = await fetch('/accounts/captcha/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
|
|
const data = await resp.json();
|
|
if (resp.ok && data.ok) {
|
|
const img = document.getElementById('captchaImg');
|
|
const box = document.getElementById('captchaBox');
|
|
img.src = 'data:image/png;base64,' + data.image_b64;
|
|
box.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errorEl = document.getElementById('error');
|
|
errorEl.textContent = '';
|
|
|
|
const username = document.getElementById('username').value.trim();
|
|
const password = document.getElementById('password').value;
|
|
if (!username || !password) { errorEl.textContent = '请输入账户与密码'; return; }
|
|
|
|
const btn = document.getElementById('loginBtn');
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const csrftoken = getCookie('csrftoken');
|
|
const pkResp = await fetch('/accounts/pubkey/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
|
|
if (!pkResp.ok) throw new Error('获取公钥失败');
|
|
const pkJson = await pkResp.json();
|
|
const spkiBytes = new Uint8Array(base64ToArrayBuffer(pkJson.public_key_spki));
|
|
const pubKey = await importRsaPublicKey(spkiBytes);
|
|
|
|
const aesKeyRaw = new Uint8Array(32); window.crypto.getRandomValues(aesKeyRaw);
|
|
const encAesKey = await rsaOaepEncrypt(pubKey, aesKeyRaw);
|
|
const encAesKeyB64 = arrayBufferToBase64(encAesKey);
|
|
|
|
const setKeyResp = await fetch('/accounts/session-key/', {
|
|
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ encrypted_key: encAesKeyB64 })
|
|
});
|
|
const setKeySnapshot = await (async () => {
|
|
const clone = setKeyResp.clone();
|
|
const txt = await clone.text();
|
|
let parsed = null;
|
|
try { parsed = await setKeyResp.json(); } catch (_) {}
|
|
return { txt, parsed };
|
|
})();
|
|
if (!setKeySnapshot.parsed) {
|
|
const msg = (setKeySnapshot.txt || '').trim();
|
|
const mapped = msg.toLowerCase().includes('decrypt error') ? '会话密钥解密失败,请刷新页面后重试' : (msg || '设置会话密钥失败');
|
|
throw new Error(mapped);
|
|
}
|
|
const setKeyJson = setKeySnapshot.parsed;
|
|
if (!setKeyResp.ok || !setKeyJson.ok) throw new Error(setKeyJson.message || '设置会话密钥失败');
|
|
|
|
const aesKey = await importAesKey(aesKeyRaw);
|
|
const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
|
|
const obj = { username, password };
|
|
if (needCaptcha) obj.captcha = (document.getElementById('captcha').value || '').trim();
|
|
const payload = new TextEncoder().encode(JSON.stringify(obj));
|
|
const ct = await aesGcmEncrypt(aesKey, iv, payload);
|
|
const ctB64 = arrayBufferToBase64(ct);
|
|
const ivB64 = arrayBufferToBase64(iv);
|
|
|
|
const submitResp = await fetch('/accounts/login/secure-submit/', {
|
|
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
|
|
});
|
|
const submitSnapshot = await (async () => {
|
|
const clone = submitResp.clone();
|
|
const txt = await clone.text();
|
|
let parsed = null;
|
|
try { parsed = await submitResp.json(); } catch (_) {}
|
|
return { txt, parsed };
|
|
})();
|
|
if (!submitSnapshot.parsed) {
|
|
const msg = (submitSnapshot.txt || '').trim();
|
|
const mapped = msg.toLowerCase().includes('decrypt error') ? '解密失败,请刷新页面后重试' : (msg || '服务器响应异常');
|
|
throw new Error(mapped);
|
|
}
|
|
const submitJson = submitSnapshot.parsed;
|
|
if (!submitResp.ok || !submitJson.ok) {
|
|
if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); }
|
|
throw new Error(submitJson.message || '登录失败');
|
|
}
|
|
window.location.href = submitJson.redirect_url;
|
|
} catch (err) {
|
|
console.error(err);
|
|
errorEl.textContent = err.message || '发生错误';
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById('refreshCaptcha').addEventListener('click', async () => {
|
|
needCaptcha = true;
|
|
await loadCaptcha();
|
|
}); |