修改登录逻辑,使用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 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()
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
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

View File

@@ -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);

View File

@@ -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"),
]

View File

@@ -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"])

View File

@@ -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都其实只有用户权限

View File

@@ -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

View File

@@ -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"