修改登录逻辑,使用RSA-OAEP 包裹每会话独立 AES-GCM 密钥 + 加密提交凭据

This commit is contained in:
2025-11-17 15:33:40 +08:00
parent dc57d88779
commit f93286a5fe
8 changed files with 188 additions and 162 deletions

View File

@@ -1,5 +1,23 @@
import hashlib import hashlib
import hmac 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: 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: def hmac_sha256(key: bytes, message: bytes) -> bytes:
"""Compute HMAC-SHA256 signature for the given message using key bytes.""" """Compute HMAC-SHA256 signature for the given message using key bytes."""
return hmac.new(key, message, hashlib.sha256).digest() 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

View File

@@ -1,19 +1,14 @@
import base64 import base64
from elastic.es_connect import get_user_by_username as es_get_user_by_username 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): def get_user_by_username(username: str):
"""
期望ES中存储的是明文密码登录时按用户名盐派生后对nonce做HMAC验证。
"""
es_user = es_get_user_by_username(username) es_user = es_get_user_by_username(username)
if es_user: if es_user:
salt = salt_for_username(username)
derived = derive_password(es_user.get('password', ''), salt)
return { return {
'user_id': es_user.get('user_id', 0), 'user_id': es_user.get('user_id', 0),
'username': es_user.get('username', ''), '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), 'permission': es_user.get('permission', 1),
} }
return None return None

View File

@@ -1,64 +1,39 @@
// Utility: read cookie value
function getCookie(name) { function getCookie(name) {
const value = `; ${document.cookie}`; const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`); const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift(); if (parts.length === 2) return parts.pop().split(';').shift();
} }
// Convert base64 string to ArrayBuffer
function base64ToArrayBuffer(b64) { function base64ToArrayBuffer(b64) {
const binary = atob(b64); const binary = atob(b64);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer; return bytes.buffer;
} }
// ArrayBuffer to base64
function arrayBufferToBase64(buffer) { function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ''; let binary = '';
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary); return btoa(binary);
} }
async function deriveKey(password, saltBytes, iterations = 100000, length = 32) { async function importRsaPublicKey(spkiBytes) {
const encoder = new TextEncoder(); return window.crypto.subtle.importKey('spki', spkiBytes, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
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 hmacSha256(keyBytes, messageBytes) { async function rsaOaepEncrypt(publicKey, dataBytes) {
const key = await window.crypto.subtle.importKey( const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, dataBytes);
'raw', return new Uint8Array(encrypted);
keyBytes, }
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false, async function importAesKey(keyBytes) {
['sign'] return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
); }
const signature = await window.crypto.subtle.sign('HMAC', key, messageBytes);
return new Uint8Array(signature); 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) => { 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 username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
if (!username || !password) { if (!username || !password) { errorEl.textContent = '请输入账户与密码'; return; }
errorEl.textContent = '请输入账户与密码';
return;
}
const btn = document.getElementById('loginBtn'); const btn = document.getElementById('loginBtn');
btn.disabled = true; btn.disabled = true;
try { try {
// Step 1: get challenge (nonce + salt)
const csrftoken = getCookie('csrftoken'); const csrftoken = getCookie('csrftoken');
const chalResp = await fetch('/accounts/challenge/', { const pkResp = await fetch('/accounts/pubkey/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } });
method: 'POST', if (!pkResp.ok) throw new Error('获取公钥失败');
credentials: 'same-origin', const pkJson = await pkResp.json();
headers: { const spkiBytes = new Uint8Array(base64ToArrayBuffer(pkJson.public_key_spki));
'Content-Type': 'application/json', const pubKey = await importRsaPublicKey(spkiBytes);
'X-CSRFToken': csrftoken || ''
}, const aesKeyRaw = new Uint8Array(32); window.crypto.getRandomValues(aesKeyRaw);
body: JSON.stringify({ username }) 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) { const setKeyJson = await setKeyResp.json();
throw new Error('获取挑战失败'); if (!setKeyResp.ok || !setKeyJson.ok) throw new Error('设置会话密钥失败');
}
const chal = await chalResp.json();
const nonceBytes = new Uint8Array(base64ToArrayBuffer(chal.nonce));
const saltBytes = new Uint8Array(base64ToArrayBuffer(chal.salt));
// Step 2: derive secret and compute HMAC const aesKey = await importAesKey(aesKeyRaw);
const derived = await deriveKey(password, saltBytes, 100000, 32); const iv = new Uint8Array(12); window.crypto.getRandomValues(iv);
const hmac = await hmacSha256(derived, nonceBytes); const payload = new TextEncoder().encode(JSON.stringify({ username, password }));
const hmacB64 = arrayBufferToBase64(hmac); 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/secure-submit/', {
const submitResp = await fetch('/accounts/login/submit/', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' }, body: JSON.stringify({ iv: ivB64, ciphertext: ctB64 })
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ username, hmac: hmacB64 })
}); });
const submitJson = await submitResp.json(); const submitJson = await submitResp.json();
if (!submitResp.ok || !submitJson.ok) { if (!submitResp.ok || !submitJson.ok) throw new Error(submitJson.message || '登录失败');
throw new Error(submitJson.message || '登录失败');
}
// Redirect to home with user_id
window.location.href = submitJson.redirect_url; window.location.href = submitJson.redirect_url;
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -4,7 +4,8 @@ app_name = "accounts"
urlpatterns = [ urlpatterns = [
path("login/", views.login_page, name="login"), path("login/", views.login_page, name="login"),
path("challenge/", views.challenge, name="challenge"), path("pubkey/", views.pubkey, name="pubkey"),
path("login/submit/", views.login_submit, name="login_submit"), 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"), path("logout/", views.logout, name="logout"),
] ]

View File

@@ -1,7 +1,6 @@
import base64 import base64
import json import json
import os import os
import hmac
from django.http import JsonResponse, HttpResponseBadRequest from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect 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 django.conf import settings
from .es_client import get_user_by_username 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"]) @require_http_methods(["GET"])
@@ -19,90 +18,72 @@ def login_page(request):
return render(request, "accounts/login.html") return render(request, "accounts/login.html")
@require_http_methods(["POST"]) @require_http_methods(["GET"])
@csrf_protect @ensure_csrf_cookie
def challenge(request): def pubkey(request):
try: pk_b64 = get_public_key_spki_b64()
payload = json.loads(request.body.decode("utf-8")) return JsonResponse({"public_key_spki": pk_b64})
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(["POST"]) @require_http_methods(["POST"])
@csrf_protect @csrf_protect
def login_submit(request): def set_session_key(request):
try: try:
payload = json.loads(request.body.decode("utf-8")) payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError: except json.JSONDecodeError:
return HttpResponseBadRequest("Invalid JSON") return HttpResponseBadRequest("Invalid JSON")
enc_key_b64 = payload.get("encrypted_key", "")
username = payload.get("username", "").strip() if not enc_key_b64:
client_hmac_b64 = payload.get("hmac", "")
if not username or not client_hmac_b64:
return HttpResponseBadRequest("Missing fields") 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 @require_http_methods(["POST"])
session_username = request.session.get("challenge_username") @csrf_protect
nonce_b64 = request.session.get("challenge_nonce") def secure_login_submit(request):
if not session_username or not nonce_b64 or session_username != username: try:
return HttpResponseBadRequest("Challenge not found or mismatched user") payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
# Lookup user in ES (placeholder) 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) user = get_user_by_username(username)
if not user: if not user:
return JsonResponse({"ok": False, "message": "User not found"}, status=401) return JsonResponse({"ok": False, "message": "User not found"}, status=401)
if not verify_password(password, user.get("password_salt") or "", user.get("password_hash") or ""):
# 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):
return JsonResponse({"ok": False, "message": "Invalid credentials"}, status=401) return JsonResponse({"ok": False, "message": "Invalid credentials"}, status=401)
# Successful login: rotate session key and set user session
try: try:
request.session.cycle_key() request.session.cycle_key()
except Exception: except Exception:
pass pass
request.session["user_id"] = user["user_id"] request.session["user_id"] = user["user_id"]
request.session["username"] = user["username"] request.session["username"] = user["username"]
try: try:
request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1 request.session["permission"] = int(user["permission"]) if user.get("permission") is not None else 1
except Exception: except Exception:
request.session["permission"] = 1 request.session["permission"] = 1
if "session_enc_key_b64" in request.session:
# Clear challenge to prevent reuse del request.session["session_enc_key_b64"]
for k in ("challenge_username", "challenge_nonce"): return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"})
if k in request.session:
del request.session[k]
return JsonResponse({
"ok": True,
"redirect_url": f"/main/home/?user_id={user['user_id']}",
})
@require_http_methods(["GET"]) @require_http_methods(["GET"])

View File

@@ -34,8 +34,9 @@ class UserDocument(Document):
"""用户数据文档映射""" """用户数据文档映射"""
user_id = fields.LongField() user_id = fields.LongField()
username = fields.KeywordField() username = fields.KeywordField()
password = fields.KeywordField() # 还是2种权限0为管理员1为用户区别在于0有全部权限1在数据管理页面有搜索框但是索引到的录入信息要根据其用户id查询其key若其中之一与用户的manage_key字段匹配就显示否则不显示 password_hash = fields.KeywordField()
permission = fields.IntegerField() 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为"计算机与人工智能学院" key = fields.IntegerField() #表示该用户的关键字举个例子学生A的key为"2024届人工智能1班","2024届""计算机与人工智能学院" 班导师B的key为"计算机与人工智能学院"
manage_key = fields.IntegerField() #表示该用户管理的关键字非管理员班导师B的manage_key为"2024届人工智能1班" manage_key = fields.IntegerField() #表示该用户管理的关键字非管理员班导师B的manage_key为"2024届人工智能1班"
#那么学生A就可以在数据管理页面搜索到自己的获奖数据而班导师B就可以在数据管理页面搜索到所有人工智能1班的获奖数据。也就是说学生A和班导师B都其实只有用户权限 #那么学生A就可以在数据管理页面搜索到自己的获奖数据而班导师B就可以在数据管理页面搜索到所有人工智能1班的获奖数据。也就是说学生A和班导师B都其实只有用户权限

View File

@@ -6,6 +6,7 @@ from elasticsearch import Elasticsearch
from elasticsearch_dsl import connections from elasticsearch_dsl import connections
import os import os
from .documents import AchievementDocument, UserDocument, GlobalDocument 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 from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
import hashlib import hashlib
import time import time
@@ -63,10 +64,12 @@ def create_index_with_mapping():
# --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)--- # --- 4. 创建默认管理员用户(可选:也可检查用户是否已存在)---
# 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断 # 这里简单处理:每次初始化都写入(可能重复),建议加唯一性判断
_salt_b64, _hash_b64 = hash_password_random_salt("admin")
admin_user = { admin_user = {
"user_id": 0, "user_id": 0,
"username": "admin", "username": "admin",
"password": "admin", # ⚠️ 生产环境务必加密! "password_hash": _hash_b64,
"password_salt": _salt_b64,
"permission": 0 "permission": 0
} }
# 可选:检查 admin 是否已存在(根据 user_id 或 username # 可选:检查 admin 是否已存在(根据 user_id 或 username
@@ -513,10 +516,17 @@ def write_user_data(user_data):
perm_val = int(user_data.get('permission', 1)) perm_val = int(user_data.get('permission', 1))
except Exception: except Exception:
perm_val = 1 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 = UserDocument(
user_id=user_data.get('user_id'), user_id=user_data.get('user_id'),
username=user_data.get('username'), username=user_data.get('username'),
password=user_data.get('password'), password_hash=pwd_hash_b64,
password_salt=pwd_salt_b64,
permission=perm_val permission=perm_val
) )
user.save() user.save()
@@ -535,11 +545,10 @@ def get_user_by_id(user_id):
if response.hits: if response.hits:
hit = response.hits[0] hit = response.hits[0]
return { return {
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"password": hit.password, "permission": hit.permission
"permission": hit.permission }
}
return None return None
except Exception as e: except Exception as e:
@@ -566,7 +575,8 @@ def get_user_by_username(username):
return { return {
"user_id": hit.user_id, "user_id": hit.user_id,
"username": hit.username, "username": hit.username,
"password": hit.password, "password_hash": getattr(hit, 'password_hash', None),
"password_salt": getattr(hit, 'password_salt', None),
"permission": int(hit.permission) "permission": int(hit.permission)
} }
return None return None
@@ -639,7 +649,9 @@ def update_user_by_id(user_id, username=None, permission=None, password=None):
if permission is not None: if permission is not None:
doc.permission = int(permission) doc.permission = int(permission)
if password is not None: 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() doc.save()
return True return True
return False return False

View File

@@ -1,5 +1,5 @@
INDEX_NAME = "wordsearch2666661" INDEX_NAME = "wordsearch2666661"
USER_NAME = "users11111" USER_NAME = "users1111166"
ACHIEVEMENT_INDEX_NAME = INDEX_NAME ACHIEVEMENT_INDEX_NAME = INDEX_NAME
USER_INDEX_NAME = USER_NAME USER_INDEX_NAME = USER_NAME
GLOBAL_INDEX_NAME = "global11111111211" GLOBAL_INDEX_NAME = "global1111111121"