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 @@
+
+
+
+
+
+
+ | code |
+ keys |
+ manage_keys |
+ 创建时间 |
+ 过期时间 |
+ 状态 |
+ 操作 |
+
+
+
+
+
+