diff --git a/accounts/static/accounts/login.js b/accounts/static/accounts/login.js index 969a83c..76aa71d 100644 --- a/accounts/static/accounts/login.js +++ b/accounts/static/accounts/login.js @@ -36,6 +36,20 @@ async function aesGcmEncrypt(aesKey, ivBytes, dataBytes) { return new Uint8Array(ct); } +let needCaptcha = false; + +async function loadCaptcha() { + const csrftoken = getCookie('csrftoken'); + const resp = await fetch('/accounts/captcha/', { method: 'GET', credentials: 'same-origin', headers: { 'X-CSRFToken': csrftoken || '' } }); + const data = await resp.json(); + if (resp.ok && data.ok) { + const img = document.getElementById('captchaImg'); + const box = document.getElementById('captchaBox'); + img.src = 'data:image/png;base64,' + data.image_b64; + box.style.display = 'block'; + } +} + document.getElementById('loginForm').addEventListener('submit', async (e) => { e.preventDefault(); const errorEl = document.getElementById('error'); @@ -68,7 +82,9 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { const aesKey = await importAesKey(aesKeyRaw); const iv = new Uint8Array(12); window.crypto.getRandomValues(iv); - const payload = new TextEncoder().encode(JSON.stringify({ username, password })); + const obj = { username, password }; + if (needCaptcha) obj.captcha = (document.getElementById('captcha').value || '').trim(); + const payload = new TextEncoder().encode(JSON.stringify(obj)); const ct = await aesGcmEncrypt(aesKey, iv, payload); const ctB64 = arrayBufferToBase64(ct); const ivB64 = arrayBufferToBase64(iv); @@ -77,7 +93,10 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { 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 || '登录失败'); + if (!submitResp.ok || !submitJson.ok) { + if (submitJson && submitJson.captcha_required) { needCaptcha = true; await loadCaptcha(); } + throw new Error(submitJson.message || '登录失败'); + } window.location.href = submitJson.redirect_url; } catch (err) { console.error(err); @@ -85,4 +104,9 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { } finally { btn.disabled = false; } +}); + +document.getElementById('refreshCaptcha').addEventListener('click', async () => { + needCaptcha = true; + await loadCaptcha(); }); \ No newline at end of file diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html index de921b8..43aa079 100644 --- a/accounts/templates/accounts/login.html +++ b/accounts/templates/accounts/login.html @@ -30,6 +30,15 @@ + +
diff --git a/accounts/urls.py b/accounts/urls.py index 6f99548..0325672 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -5,6 +5,7 @@ app_name = "accounts" urlpatterns = [ path("login/", views.login_page, name="login"), path("pubkey/", views.pubkey, name="pubkey"), + path("captcha/", views.captcha, name="captcha"), 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"), diff --git a/accounts/views.py b/accounts/views.py index d9501ec..ca00532 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,6 +1,9 @@ import base64 import json import os +import io +import random +import string from django.http import JsonResponse, HttpResponseBadRequest from django.shortcuts import render, redirect @@ -24,6 +27,22 @@ def pubkey(request): pk_b64 = get_public_key_spki_b64() return JsonResponse({"public_key_spki": pk_b64}) +@require_http_methods(["GET"]) +@ensure_csrf_cookie +def captcha(request): + try: + from captcha.image import ImageCaptcha + except Exception: + return JsonResponse({"ok": False, "message": "captcha unavailable"}, status=500) + code = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(5)) + request.session["captcha_code"] = code + img = ImageCaptcha(width=160, height=60) + image = img.generate_image(code) + buf = io.BytesIO() + image.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return JsonResponse({"ok": True, "image_b64": b64}) + @require_http_methods(["POST"]) @csrf_protect @@ -66,11 +85,18 @@ def secure_login_submit(request): password = (obj.get("password") or "") if not username or not password: return HttpResponseBadRequest("Missing credentials") + if bool(request.session.get("login_failed_once")): + ans = (obj.get("captcha") or "").strip() + code = request.session.get("captcha_code") + if not ans or not code or ans.lower() != str(code).lower(): + return JsonResponse({"ok": False, "message": "验证码错误", "captcha_required": True}, status=401) user = get_user_by_username(username) if not user: - return JsonResponse({"ok": False, "message": "User not found"}, status=401) + request.session["login_failed_once"] = True + return JsonResponse({"ok": False, "message": "用户不存在", "captcha_required": True}, status=401) if not verify_password(password, user.get("password_salt") or "", user.get("password_hash") or ""): - return JsonResponse({"ok": False, "message": "Invalid credentials"}, status=401) + request.session["login_failed_once"] = True + return JsonResponse({"ok": False, "message": "账户或密码错误", "captcha_required": True}, status=401) try: request.session.cycle_key() except Exception: @@ -83,6 +109,10 @@ def secure_login_submit(request): request.session["permission"] = 1 if "session_enc_key_b64" in request.session: del request.session["session_enc_key_b64"] + if "login_failed_once" in request.session: + del request.session["login_failed_once"] + if "captcha_code" in request.session: + del request.session["captcha_code"] return JsonResponse({"ok": True, "redirect_url": f"/main/home/?user_id={user['user_id']}"})