diff --git a/accounts/crypto.py b/accounts/crypto.py index eed1f27..3fe11c7 100644 --- a/accounts/crypto.py +++ b/accounts/crypto.py @@ -90,4 +90,26 @@ def verify_password(password_plain: str, salt_b64: str, hash_b64: str) -> bool: actual = hash_password_with_salt(password_plain, salt) return hmac.compare_digest(actual, expected) except Exception: - return False \ No newline at end of file + return False + +def generate_rsa_private_pem_b64() -> str: + if rsa is None or serialization is None: + raise RuntimeError("cryptography library is required for RSA operations") + priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = priv.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()) + return base64.b64encode(pem).decode('ascii') + +def public_spki_b64_from_private_pem_b64(private_pem_b64: str) -> str: + if serialization is None: + raise RuntimeError("cryptography library is required for RSA operations") + priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None) + pub = priv.public_key() + spki = pub.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo) + return base64.b64encode(spki).decode('ascii') + +def rsa_oaep_decrypt_b64_with_private_pem(private_pem_b64: str, ciphertext_b64: str) -> bytes: + if serialization is None or padding is None or hashes is None: + raise RuntimeError("cryptography library is required for RSA operations") + priv = serialization.load_pem_private_key(base64.b64decode(private_pem_b64), password=None) + ct = base64.b64decode(ciphertext_b64) + return priv.decrypt(ct, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)) \ No newline at end of file diff --git a/accounts/static/accounts/login.js b/accounts/static/accounts/login.js index 76aa71d..e856ed1 100644 --- a/accounts/static/accounts/login.js +++ b/accounts/static/accounts/login.js @@ -77,8 +77,20 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { 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 setKeyJson = await setKeyResp.json(); - if (!setKeyResp.ok || !setKeyJson.ok) throw new Error('设置会话密钥失败'); + 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); @@ -92,7 +104,19 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { 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 submitJson = await submitResp.json(); + 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 || '登录失败'); diff --git a/accounts/views.py b/accounts/views.py index 5dbdfa3..b98592f 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -12,7 +12,7 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie from django.conf import settings from .es_client import get_user_by_username -from .crypto import get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password +from .crypto import get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password, generate_rsa_private_pem_b64, public_spki_b64_from_private_pem_b64, rsa_oaep_decrypt_b64_with_private_pem from elastic.es_connect import get_registration_code, get_user_by_username as es_get_user_by_username, get_all_users as es_get_all_users, write_user_data @@ -25,7 +25,11 @@ def login_page(request): @require_http_methods(["GET"]) @ensure_csrf_cookie def pubkey(request): - pk_b64 = get_public_key_spki_b64() + pem_b64 = request.session.get("rsa_private_pem_b64") + if not pem_b64: + pem_b64 = generate_rsa_private_pem_b64() + request.session["rsa_private_pem_b64"] = pem_b64 + pk_b64 = public_spki_b64_from_private_pem_b64(pem_b64) return JsonResponse({"public_key_spki": pk_b64}) @require_http_methods(["GET"]) @@ -56,7 +60,10 @@ def set_session_key(request): if not enc_key_b64: return HttpResponseBadRequest("Missing fields") try: - key_bytes = rsa_oaep_decrypt_b64(enc_key_b64) + pem_b64 = request.session.get("rsa_private_pem_b64") + if not pem_b64: + return HttpResponseBadRequest("Decrypt error") + key_bytes = rsa_oaep_decrypt_b64_with_private_pem(pem_b64, enc_key_b64) except Exception: return HttpResponseBadRequest("Decrypt error") request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii") @@ -110,6 +117,8 @@ def secure_login_submit(request): request.session["permission"] = 1 if "session_enc_key_b64" in request.session: del request.session["session_enc_key_b64"] + if "rsa_private_pem_b64" in request.session: + del request.session["rsa_private_pem_b64"] if "login_failed_once" in request.session: del request.session["login_failed_once"] if "captcha_code" in request.session: diff --git a/elastic/es_connect.py b/elastic/es_connect.py index 9811560..9abac50 100644 --- a/elastic/es_connect.py +++ b/elastic/es_connect.py @@ -197,6 +197,55 @@ def get_registration_code(code: str): except Exception: return None +def list_registration_codes(): + try: + search = RegistrationCodeDocument.search() + body = { + "sort": [{"created_at": {"order": "desc"}}], + "query": {"exists": {"field": "code"}} + } + search = search.update_from_dict(body) + resp = search.execute() + out = [] + now = datetime.now(timezone.utc) + for hit in resp: + try: + if not getattr(hit, 'code', None): + continue + except Exception: + continue + exp = getattr(hit, 'expires_at', None) + try: + if hasattr(exp, 'isoformat'): + exp_dt = exp + else: + exp_dt = datetime.fromisoformat(str(exp)) + except Exception: + exp_dt = None + active = bool(exp_dt and exp_dt > now) + out.append({ + "code": getattr(hit, 'code', ''), + "keys": list(getattr(hit, 'keys', []) or []), + "manage_keys": list(getattr(hit, 'manage_keys', []) or []), + "created_at": getattr(hit, 'created_at', None), + "expires_at": getattr(hit, 'expires_at', None), + "created_by": getattr(hit, 'created_by', None), + "active": active, + }) + return out + except Exception: + return [] + +def revoke_registration_code(code: str): + try: + doc = RegistrationCodeDocument.get(id=str(code)) + now = datetime.now(timezone.utc).isoformat() + doc.expires_at = now + doc.save() + return True + except Exception: + return False + def get_doc_id(data): """ 根据数据内容生成唯一ID(用于去重) diff --git a/elastic/templates/elastic/registration_codes.html b/elastic/templates/elastic/registration_codes.html index 18c9e80..19146de 100644 --- a/elastic/templates/elastic/registration_codes.html +++ b/elastic/templates/elastic/registration_codes.html @@ -23,6 +23,14 @@ .notice.success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; } .notice.error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; } .code-box { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding:12px; border:1px solid #e5e7eb; border-radius:8px; background:#fafafa; margin-top:10px; } + .overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); display:flex; align-items:center; justify-content:center; z-index:2000; } + .spinner { width:42px; height:42px; border:4px solid #cbd5e1; border-top-color:#4f46e5; border-radius:50%; animation:spin 0.8s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .fade-in { animation: fadeUp 0.25s ease-out; } + @keyframes fadeUp { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } } + table tr:hover { background-color:#f3f4f6; transition: background-color 0.2s ease; } + .btn { transition: transform 0.1s ease, box-shadow 0.2s ease; } + .btn:hover { transform: translateY(-1px); box-shadow:0 6px 16px rgba(31,35,40,0.12); } {% csrf_token %} +
-
+

管理注册码

@@ -116,6 +149,30 @@
+
+
+
+

已生成的注册码

+
+ +
+
+ + + + + + + + + + + + + +
codekeysmanage_keys创建时间过期时间状态操作
+
+