From f93286a5febc19a5e2b61a8517de3ef13b31ade0 Mon Sep 17 00:00:00 2001 From: spdis Date: Mon, 17 Nov 2025 15:33:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=99=BB=E5=BD=95=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8RSA-OAEP=20=E5=8C=85?= =?UTF-8?q?=E8=A3=B9=E6=AF=8F=E4=BC=9A=E8=AF=9D=E7=8B=AC=E7=AB=8B=20AES-GC?= =?UTF-8?q?M=20=E5=AF=86=E9=92=A5=20+=20=E5=8A=A0=E5=AF=86=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/crypto.py | 75 ++++++++++++++++++- accounts/es_client.py | 9 +-- accounts/static/accounts/login.js | 117 ++++++++++-------------------- accounts/urls.py | 5 +- accounts/views.py | 105 +++++++++++---------------- elastic/documents.py | 5 +- elastic/es_connect.py | 30 +++++--- elastic/indexes.py | 4 +- 8 files changed, 188 insertions(+), 162 deletions(-) diff --git a/accounts/crypto.py b/accounts/crypto.py index b3d22e7..eed1f27 100644 --- a/accounts/crypto.py +++ b/accounts/crypto.py @@ -1,5 +1,23 @@ import hashlib import hmac +import os +import base64 +from typing import Tuple + +try: + from cryptography.hazmat.primitives.asymmetric import rsa, padding + from cryptography.hazmat.primitives import serialization, hashes + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.backends import default_backend +except Exception: + rsa = None + padding = None + serialization = None + hashes = None + Cipher = None + algorithms = None + modes = None + default_backend = None def salt_for_username(username: str) -> bytes: @@ -17,4 +35,59 @@ def derive_password(password_plain: str, salt: bytes, iterations: int = 100_000, def hmac_sha256(key: bytes, message: bytes) -> bytes: """Compute HMAC-SHA256 signature for the given message using key bytes.""" - return hmac.new(key, message, hashlib.sha256).digest() \ No newline at end of file + return hmac.new(key, message, hashlib.sha256).digest() + + +_RSA_PRIVATE = None +_RSA_PUBLIC = None + +def _ensure_rsa_keys(): + global _RSA_PRIVATE, _RSA_PUBLIC + if _RSA_PRIVATE is None: + if rsa is None: + raise RuntimeError("cryptography library is required for RSA operations") + _RSA_PRIVATE = rsa.generate_private_key(public_exponent=65537, key_size=2048) + _RSA_PUBLIC = _RSA_PRIVATE.public_key() + +def get_public_key_spki_b64() -> str: + _ensure_rsa_keys() + spki = _RSA_PUBLIC.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo) + return base64.b64encode(spki).decode('ascii') + +def rsa_oaep_decrypt_b64(ciphertext_b64: str) -> bytes: + _ensure_rsa_keys() + ct = base64.b64decode(ciphertext_b64) + return _RSA_PRIVATE.decrypt(ct, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)) + +def aes_gcm_decrypt_b64(key_bytes: bytes, iv_b64: str, ciphertext_b64: str) -> bytes: + if Cipher is None: + raise RuntimeError("cryptography library is required for AES operations") + iv = base64.b64decode(iv_b64) + data = base64.b64decode(ciphertext_b64) + if len(data) < 16: + raise ValueError("ciphertext too short") + ct = data[:-16] + tag = data[-16:] + decryptor = Cipher(algorithms.AES(key_bytes), modes.GCM(iv, tag), backend=default_backend()).decryptor() + pt = decryptor.update(ct) + decryptor.finalize() + return pt + +def gen_salt(length: int = 16) -> bytes: + return os.urandom(length) + +def hash_password_with_salt(password_plain: str, salt: bytes, iterations: int = 200_000, dklen: int = 32) -> bytes: + return hashlib.pbkdf2_hmac('sha256', password_plain.encode('utf-8'), salt, iterations, dklen=dklen) + +def hash_password_random_salt(password_plain: str) -> Tuple[str, str]: + salt = gen_salt(16) + h = hash_password_with_salt(password_plain, salt) + return base64.b64encode(salt).decode('ascii'), base64.b64encode(h).decode('ascii') + +def verify_password(password_plain: str, salt_b64: str, hash_b64: str) -> bool: + try: + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(hash_b64) + actual = hash_password_with_salt(password_plain, salt) + return hmac.compare_digest(actual, expected) + except Exception: + return False \ No newline at end of file diff --git a/accounts/es_client.py b/accounts/es_client.py index a13ce71..785e826 100644 --- a/accounts/es_client.py +++ b/accounts/es_client.py @@ -1,19 +1,14 @@ import base64 from elastic.es_connect import get_user_by_username as es_get_user_by_username -from .crypto import salt_for_username, derive_password def get_user_by_username(username: str): - """ - 期望ES中存储的是明文密码,登录时按用户名盐派生后对nonce做HMAC验证。 - """ es_user = es_get_user_by_username(username) if es_user: - salt = salt_for_username(username) - derived = derive_password(es_user.get('password', ''), salt) return { 'user_id': es_user.get('user_id', 0), 'username': es_user.get('username', ''), - 'password': base64.b64encode(derived).decode('ascii'), + 'password_hash': es_user.get('password_hash'), + 'password_salt': es_user.get('password_salt'), 'permission': es_user.get('permission', 1), } return None \ No newline at end of file diff --git a/accounts/static/accounts/login.js b/accounts/static/accounts/login.js index 9de32aa..969a83c 100644 --- a/accounts/static/accounts/login.js +++ b/accounts/static/accounts/login.js @@ -1,64 +1,39 @@ -// Utility: read cookie value function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); } -// Convert base64 string to ArrayBuffer 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); - } + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } -// ArrayBuffer to base64 function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } -async function deriveKey(password, saltBytes, iterations = 100000, length = 32) { - const encoder = new TextEncoder(); - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - encoder.encode(password), - { name: 'PBKDF2' }, - false, - ['deriveBits'] - ); - - const derivedBits = await window.crypto.subtle.deriveBits( - { - name: 'PBKDF2', - salt: saltBytes, - iterations, - hash: 'SHA-256' - }, - keyMaterial, - length * 8 - ); - - return new Uint8Array(derivedBits); +async function importRsaPublicKey(spkiBytes) { + return window.crypto.subtle.importKey('spki', spkiBytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']); } -async function hmacSha256(keyBytes, messageBytes) { - const key = await window.crypto.subtle.importKey( - 'raw', - keyBytes, - { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, - ['sign'] - ); - const signature = await window.crypto.subtle.sign('HMAC', key, messageBytes); - return new Uint8Array(signature); +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); } document.getElementById('loginForm').addEventListener('submit', async (e) => { @@ -68,53 +43,41 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { const username = document.getElementById('username').value.trim(); const password = document.getElementById('password').value; - if (!username || !password) { - errorEl.textContent = '请输入账户与密码'; - return; - } + if (!username || !password) { errorEl.textContent = '请输入账户与密码'; return; } const btn = document.getElementById('loginBtn'); btn.disabled = true; try { - // Step 1: get challenge (nonce + salt) const csrftoken = getCookie('csrftoken'); - const chalResp = await fetch('/accounts/challenge/', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrftoken || '' - }, - body: JSON.stringify({ username }) + 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 }) }); - if (!chalResp.ok) { - throw new Error('获取挑战失败'); - } - const chal = await chalResp.json(); - const nonceBytes = new Uint8Array(base64ToArrayBuffer(chal.nonce)); - const saltBytes = new Uint8Array(base64ToArrayBuffer(chal.salt)); + const setKeyJson = await setKeyResp.json(); + if (!setKeyResp.ok || !setKeyJson.ok) throw new Error('设置会话密钥失败'); - // Step 2: derive secret and compute HMAC - const derived = await deriveKey(password, saltBytes, 100000, 32); - const hmac = await hmacSha256(derived, nonceBytes); - const hmacB64 = arrayBufferToBase64(hmac); + const aesKey = await importAesKey(aesKeyRaw); + const iv = new Uint8Array(12); window.crypto.getRandomValues(iv); + const payload = new TextEncoder().encode(JSON.stringify({ username, password })); + const ct = await aesGcmEncrypt(aesKey, iv, payload); + const ctB64 = arrayBufferToBase64(ct); + const ivB64 = arrayBufferToBase64(iv); - // Step 3: submit login with username and hmac - const submitResp = await fetch('/accounts/login/submit/', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrftoken || '' - }, - body: JSON.stringify({ username, hmac: hmacB64 }) + 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(); - if (!submitResp.ok || !submitJson.ok) { - throw new Error(submitJson.message || '登录失败'); - } - // Redirect to home with user_id + if (!submitResp.ok || !submitJson.ok) throw new Error(submitJson.message || '登录失败'); window.location.href = submitJson.redirect_url; } catch (err) { console.error(err); diff --git a/accounts/urls.py b/accounts/urls.py index 769cbda..6f99548 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,7 +4,8 @@ app_name = "accounts" urlpatterns = [ path("login/", views.login_page, name="login"), - path("challenge/", views.challenge, name="challenge"), - path("login/submit/", views.login_submit, name="login_submit"), + path("pubkey/", views.pubkey, name="pubkey"), + path("session-key/", views.set_session_key, name="set_session_key"), + path("login/secure-submit/", views.secure_login_submit, name="secure_login_submit"), path("logout/", views.logout, name="logout"), ] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index a294282..d9501ec 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,7 +1,6 @@ import base64 import json import os -import hmac from django.http import JsonResponse, HttpResponseBadRequest from django.shortcuts import render, redirect @@ -10,7 +9,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 salt_for_username, hmac_sha256 +from .crypto import get_public_key_spki_b64, rsa_oaep_decrypt_b64, aes_gcm_decrypt_b64, verify_password @require_http_methods(["GET"]) @@ -19,90 +18,72 @@ def login_page(request): return render(request, "accounts/login.html") -@require_http_methods(["POST"]) -@csrf_protect -def challenge(request): - try: - payload = json.loads(request.body.decode("utf-8")) - except json.JSONDecodeError: - return HttpResponseBadRequest("Invalid JSON") - - username = payload.get("username", "").strip() - if not username: - return HttpResponseBadRequest("Username required") - - # Generate nonce and compute per-username salt - nonce = os.urandom(16) - salt = salt_for_username(username) - - # Persist challenge in session to prevent replay with mismatched user - request.session["challenge_nonce"] = base64.b64encode(nonce).decode("ascii") - request.session["challenge_username"] = username - - return JsonResponse({ - "nonce": base64.b64encode(nonce).decode("ascii"), - "salt": base64.b64encode(salt).decode("ascii"), - }) +@require_http_methods(["GET"]) +@ensure_csrf_cookie +def pubkey(request): + pk_b64 = get_public_key_spki_b64() + return JsonResponse({"public_key_spki": pk_b64}) @require_http_methods(["POST"]) @csrf_protect -def login_submit(request): +def set_session_key(request): try: payload = json.loads(request.body.decode("utf-8")) except json.JSONDecodeError: return HttpResponseBadRequest("Invalid JSON") - - username = payload.get("username", "").strip() - client_hmac_b64 = payload.get("hmac", "") - if not username or not client_hmac_b64: + enc_key_b64 = payload.get("encrypted_key", "") + if not enc_key_b64: return HttpResponseBadRequest("Missing fields") + try: + key_bytes = rsa_oaep_decrypt_b64(enc_key_b64) + except Exception: + return HttpResponseBadRequest("Decrypt error") + request.session["session_enc_key_b64"] = base64.b64encode(key_bytes).decode("ascii") + return JsonResponse({"ok": True}) - # Validate challenge stored in session - session_username = request.session.get("challenge_username") - nonce_b64 = request.session.get("challenge_nonce") - if not session_username or not nonce_b64 or session_username != username: - return HttpResponseBadRequest("Challenge not found or mismatched user") - - # Lookup user in ES (placeholder) +@require_http_methods(["POST"]) +@csrf_protect +def secure_login_submit(request): + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return HttpResponseBadRequest("Invalid JSON") + iv_b64 = payload.get("iv", "") + ct_b64 = payload.get("ciphertext", "") + if not iv_b64 or not ct_b64: + return HttpResponseBadRequest("Missing fields") + key_b64 = request.session.get("session_enc_key_b64") + if not key_b64: + return HttpResponseBadRequest("Session key missing") + try: + key_bytes = base64.b64decode(key_b64) + pt = aes_gcm_decrypt_b64(key_bytes, iv_b64, ct_b64) + obj = json.loads(pt.decode("utf-8")) + except Exception: + return HttpResponseBadRequest("Decrypt error") + username = (obj.get("username") or "").strip() + password = (obj.get("password") or "") + if not username or not password: + return HttpResponseBadRequest("Missing credentials") user = get_user_by_username(username) if not user: return JsonResponse({"ok": False, "message": "User not found"}, status=401) - - # Server-side HMAC verification - try: - nonce = base64.b64decode(nonce_b64) - stored_derived_b64 = user.get("password", "") - stored_derived = base64.b64decode(stored_derived_b64) - server_hmac_b64 = base64.b64encode(hmac_sha256(stored_derived, nonce)).decode("ascii") - except Exception: - return HttpResponseBadRequest("Verification error") - - if not hmac.compare_digest(server_hmac_b64, client_hmac_b64): + if not verify_password(password, user.get("password_salt") or "", user.get("password_hash") or ""): return JsonResponse({"ok": False, "message": "Invalid credentials"}, status=401) - - # Successful login: rotate session key and set user session try: request.session.cycle_key() except Exception: pass - request.session["user_id"] = user["user_id"] request.session["username"] = user["username"] try: request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1 except Exception: request.session["permission"] = 1 - - # Clear challenge to prevent reuse - for k in ("challenge_username", "challenge_nonce"): - if k in request.session: - del request.session[k] - - return JsonResponse({ - "ok": True, - "redirect_url": f"/main/home/?user_id={user['user_id']}", - }) + if "session_enc_key_b64" in request.session: + del request.session["session_enc_key_b64"] + return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"}) @require_http_methods(["GET"]) diff --git a/elastic/documents.py b/elastic/documents.py index 2e02ee7..7a96c5a 100644 --- a/elastic/documents.py +++ b/elastic/documents.py @@ -34,8 +34,9 @@ class UserDocument(Document): """用户数据文档映射""" user_id = fields.LongField() username = fields.KeywordField() - password = fields.KeywordField() # 还是2种权限,0为管理员,1为用户(区别在于0有全部权限,1在数据管理页面有搜索框,但是索引到的录入信息要根据其用户id查询其key,若其中之一与用户的manage_key字段匹配就显示否则不显示) - permission = fields.IntegerField() + password_hash = fields.KeywordField() + password_salt = fields.KeywordField() + permission = fields.IntegerField() # 还是2种权限,0为管理员,1为用户(区别在于0有全部权限,1在数据管理页面有搜索框,但是索引到的录入信息要根据其用户id查询其key,若其中之一与用户的manage_key字段匹配就显示否则不显示) key = fields.IntegerField() #表示该用户的关键字,举个例子:学生A的key为"2024届人工智能1班","2024届","计算机与人工智能学院" 班导师B的key为"计算机与人工智能学院" manage_key = fields.IntegerField() #表示该用户管理的关键字(非管理员)班导师B的manage_key为"2024届人工智能1班" #那么学生A就可以在数据管理页面搜索到自己的获奖数据,而班导师B就可以在数据管理页面搜索到所有人工智能1班的获奖数据。也就是说学生A和班导师B都其实只有用户权限 diff --git a/elastic/es_connect.py b/elastic/es_connect.py index 908f31c..01aca71 100644 --- a/elastic/es_connect.py +++ b/elastic/es_connect.py @@ -6,6 +6,7 @@ from elasticsearch import Elasticsearch from elasticsearch_dsl import connections import os from .documents import AchievementDocument, UserDocument, GlobalDocument +from accounts.crypto import hash_password_random_salt from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME import hashlib import time @@ -63,10 +64,12 @@ def create_index_with_mapping(): # --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)--- # 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断 + _salt_b64, _hash_b64 = hash_password_random_salt("admin") admin_user = { "user_id": 0, "username": "admin", - "password": "admin", # ⚠️ 生产环境务必加密! + "password_hash": _hash_b64, + "password_salt": _salt_b64, "permission": 0 } # 可选:检查 admin 是否已存在(根据 user_id 或 username) @@ -513,10 +516,17 @@ def write_user_data(user_data): perm_val = int(user_data.get('permission', 1)) except Exception: perm_val = 1 + pwd = str(user_data.get('password') or '').strip() + pwd_hash_b64 = user_data.get('password_hash') + pwd_salt_b64 = user_data.get('password_salt') + if pwd: + salt_b64, hash_b64 = hash_password_random_salt(pwd) + pwd_hash_b64, pwd_salt_b64 = hash_b64, salt_b64 user = UserDocument( user_id=user_data.get('user_id'), username=user_data.get('username'), - password=user_data.get('password'), + password_hash=pwd_hash_b64, + password_salt=pwd_salt_b64, permission=perm_val ) user.save() @@ -535,11 +545,10 @@ def get_user_by_id(user_id): if response.hits: hit = response.hits[0] return { - "user_id": hit.user_id, - "username": hit.username, - "password": hit.password, - "permission": hit.permission - } + "user_id": hit.user_id, + "username": hit.username, + "permission": hit.permission + } return None except Exception as e: @@ -566,7 +575,8 @@ def get_user_by_username(username): return { "user_id": hit.user_id, "username": hit.username, - "password": hit.password, + "password_hash": getattr(hit, 'password_hash', None), + "password_salt": getattr(hit, 'password_salt', None), "permission": int(hit.permission) } return None @@ -639,7 +649,9 @@ def update_user_by_id(user_id, username=None, permission=None, password=None): if permission is not None: doc.permission = int(permission) if password is not None: - doc.password = password + salt_b64, hash_b64 = hash_password_random_salt(str(password)) + doc.password_hash = hash_b64 + doc.password_salt = salt_b64 doc.save() return True return False diff --git a/elastic/indexes.py b/elastic/indexes.py index 6c75be1..1a37881 100644 --- a/elastic/indexes.py +++ b/elastic/indexes.py @@ -1,5 +1,5 @@ INDEX_NAME = "wordsearch2666661" -USER_NAME = "users11111" +USER_NAME = "users1111166" ACHIEVEMENT_INDEX_NAME = INDEX_NAME USER_INDEX_NAME = USER_NAME -GLOBAL_INDEX_NAME = "global11111111211" +GLOBAL_INDEX_NAME = "global1111111121"