登录上线
This commit is contained in:
1
accounts/__init__.py
Normal file
1
accounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Accounts app for secure login flow."""
|
||||
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal 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
38
accounts/es_client.py
Normal 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'),
|
||||
}
|
||||
123
accounts/static/accounts/login.js
Normal file
123
accounts/static/accounts/login.js
Normal 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;
|
||||
}
|
||||
});
|
||||
40
accounts/templates/accounts/login.html
Normal file
40
accounts/templates/accounts/login.html
Normal 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
12
accounts/urls.py
Normal 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
146
accounts/views.py
Normal 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
|
||||
Reference in New Issue
Block a user