登录上线

This commit is contained in:
2025-11-09 20:31:37 +08:00
parent e650a087ca
commit aba94c074a
35 changed files with 675 additions and 5919 deletions

1
accounts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Accounts app for secure login flow."""

6
accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

38
accounts/es_client.py Normal file
View File

@@ -0,0 +1,38 @@
import base64
import hashlib
def _salt_for_username(username: str) -> bytes:
return hashlib.sha256(username.encode('utf-8')).digest()
def _derive_password(password_plain: str, salt: bytes) -> bytes:
return hashlib.pbkdf2_hmac('sha256', password_plain.encode('utf-8'), salt, 100_000, dklen=32)
def get_user_by_username(username: str):
"""
Placeholder for ES lookup. Returns fixed JSON for a demo user.
In production this should query ES with the given mapping.
Demo user:
- username: admin
- password: Password123! (stored as PBKDF2-derived secret only)
- user_id: 1
- premission: 0 (admin)
"""
if username != 'admin':
return None
salt = _salt_for_username(username)
# Demo: derive and store secret from a known password for the placeholder
derived = _derive_password('Password123!', salt)
return {
'user_id': 1,
'username': 'admin',
# Store only the derived secret, not the plaintext password
'password': base64.b64encode(derived).decode('ascii'),
'premission': 0,
# Expose salt to the client during challenge so both sides derive consistently
'salt': base64.b64encode(salt).decode('ascii'),
}

View File

@@ -0,0 +1,123 @@
// 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);
}
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]);
}
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 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);
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('error');
errorEl.textContent = '';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
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',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ username })
});
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));
// 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);
// Step 3: submit login with username and hmac
const submitResp = await fetch('/accounts/login/submit/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken || ''
},
body: JSON.stringify({ username, hmac: hmacB64 })
});
const submitJson = await submitResp.json();
if (!submitResp.ok || !submitJson.ok) {
throw new Error(submitJson.message || '登录失败');
}
// Redirect to home with user_id
window.location.href = submitJson.redirect_url;
} catch (err) {
console.error(err);
errorEl.textContent = err.message || '发生错误';
} finally {
btn.disabled = false;
}
});

View File

@@ -0,0 +1,40 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全登录</title>
<link rel="preload" href="{% static 'accounts/login.js' %}" as="script">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #f5f6fa; }
.container { max-width: 360px; margin: 12vh auto; padding: 24px; background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
h1 { font-size: 20px; margin: 0 0 16px; }
label { display: block; margin: 12px 0 6px; color: #333; }
input { width: 100%; padding: 10px 12px; border: 1px solid #dcdde1; border-radius: 6px; }
button { width: 100%; margin-top: 16px; padding: 10px 12px; background: #2d8cf0; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
button:disabled { background: #9bbcf0; cursor: not-allowed; }
.error { color: #d93025; margin-top: 10px; min-height: 20px; }
.hint { color: #888; font-size: 12px; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<h1>登录到系统</h1>
<form id="loginForm">
{% csrf_token %}
<label for="username">账户</label>
<input id="username" name="username" type="text" autocomplete="username" required />
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
<button id="loginBtn" type="submit">登录</button>
<div id="error" class="error"></div>
</form>
</div>
<script src="{% static 'accounts/login.js' %}"></script>
</body>
</html>

12
accounts/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path
from . import views
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("logout/", views.logout, name="logout"),
]

146
accounts/views.py Normal file
View File

@@ -0,0 +1,146 @@
import base64
import json
import os
from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_protect
from django.conf import settings
from .es_client import get_user_by_username, _salt_for_username
@require_http_methods(["GET"])
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(["POST"])
@csrf_protect
def login_submit(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:
return HttpResponseBadRequest("Missing fields")
# 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)
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)
# HMAC-SHA256: server computes with stored derived secret
import hmac, hashlib
server_hmac = hmac.new(stored_derived, nonce, hashlib.sha256).digest()
server_hmac_b64 = base64.b64encode(server_hmac).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)
# 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"]
request.session["premission"] = user["premission"]
# 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']}",
})
@require_http_methods(["GET"])
def home(request):
# Minimal placeholder page per requirement
# Ensure user_id is passed via query and session contains id
user_id = request.GET.get("user_id")
session_user_id = request.session.get("user_id")
context = {
"user_id": user_id or session_user_id,
}
return render(request, "accounts/home.html", context)
@require_http_methods(["POST"])
@csrf_protect
def logout(request):
# Flush the session to clear all data and rotate the key
try:
request.session.flush()
except Exception:
pass
# Return a response that also deletes cookies client-side
resp = JsonResponse({"ok": True, "redirect_url": "/accounts/login/"})
try:
# Delete session cookie
resp.delete_cookie(
settings.SESSION_COOKIE_NAME,
path='/',
samesite=settings.SESSION_COOKIE_SAMESITE,
secure=settings.SESSION_COOKIE_SECURE,
)
# Optionally delete CSRF cookie to satisfy "清除cookie" 的要求
resp.delete_cookie(
settings.CSRF_COOKIE_NAME,
path='/',
samesite=settings.CSRF_COOKIE_SAMESITE,
secure=settings.CSRF_COOKIE_SECURE,
)
except Exception:
pass
return resp