From b098639db58fadf2448e48a43a0596326148ea2a Mon Sep 17 00:00:00 2001 From: pllx Date: Wed, 1 Jul 2026 11:11:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BF=98=E8=AE=B0=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=B8=BA=E6=99=BA=E8=83=BD=E5=88=86=E6=B5=81?= =?UTF-8?q?=E5=90=91=E5=AF=BC=E5=B9=B6=E9=83=A8=E7=BD=B2IP/=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=8F=8C=E5=B1=82=E9=98=B2=E6=89=AB=E9=98=B2=E8=BD=B0?= =?UTF-8?q?=E7=82=B8=E9=99=90=E6=B5=81=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/PasswordResetController.php | 100 ++++++++- .../Requests/SendPasswordResetLinkRequest.php | 5 +- resources/js/password-forgot.js | 205 +++++++++++++++-- resources/views/password-forgot.blade.php | 206 +++++++++++++++--- routes/web.php | 1 + tests/Feature/PasswordResetRateLimitTest.php | 165 ++++++++++++++ 6 files changed, 633 insertions(+), 49 deletions(-) create mode 100644 tests/Feature/PasswordResetRateLimitTest.php diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php index 193d819..607676f 100644 --- a/app/Http/Controllers/PasswordResetController.php +++ b/app/Http/Controllers/PasswordResetController.php @@ -19,6 +19,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; /** @@ -37,6 +38,64 @@ class PasswordResetController extends Controller ]); } + /** + * 账号检测接口:根据昵称检测是否绑定微信或邮箱,并提供分流依据(支持 IP 防扫限流)。 + */ + public function checkAccount(Request $request): JsonResponse + { + $request->validate([ + 'username' => 'required|string|max:100', + ]); + + $username = trim((string) $request->input('username')); + $ip = $request->ip(); + + // IP 防扫描节流限制:每个 IP 每 1 分钟最多请求 5 次 + $ipKey = 'pw-check:ip:'.$ip; + if (RateLimiter::tooManyAttempts($ipKey, 5)) { + $seconds = RateLimiter::availableIn($ipKey); + + return response()->json([ + 'status' => 'error', + 'message' => "账号检测请求过于频繁,请在 {$seconds} 秒后重试。", + ], 429); + } + RateLimiter::hit($ipKey, 60); + + $user = User::query()->where('username', $username)->first(); + if (! $user) { + return response()->json([ + 'status' => 'not_found', + 'message' => '抱歉,没有找到该昵称对应的账号。请确认后再试。', + ]); + } + + $hasEmail = ! empty($user->email); + $hasWechat = ! empty($user->wxid); + + // 对邮箱地址进行安全脱敏(如 pllx@ay.lc -> p***x@ay.lc) + $maskedEmail = ''; + if ($hasEmail) { + $parts = explode('@', $user->email); + $name = $parts[0] ?? ''; + $domain = $parts[1] ?? ''; + $len = strlen($name); + if ($len <= 2) { + $maskedEmail = substr($name, 0, 1).'*'.'@'.$domain; + } else { + $maskedEmail = substr($name, 0, 1).str_repeat('*', $len - 2).substr($name, -1).'@'.$domain; + } + } + + return response()->json([ + 'status' => 'success', + 'username' => $user->username, + 'has_email' => $hasEmail, + 'has_wechat' => $hasWechat, + 'masked_email' => $maskedEmail, + ]); + } + /** * 发送邮箱找回密码链接。 */ @@ -49,7 +108,42 @@ class PasswordResetController extends Controller ], 403); } - $email = trim((string) $request->string('email')); + $email = trim((string) $request->input('email', '')); + $username = trim((string) $request->input('username', '')); + $ip = $request->ip(); + + if ($username !== '') { + $user = User::query()->where('username', $username)->first(); + if (! $user || empty($user->email)) { + return response()->json([ + 'status' => 'error', + 'message' => '找不到绑定了邮箱的账号。', + ], 422); + } + $email = $user->email; + } + + // 1. IP 级别发信限流:限制单个 IP 每分钟最多请求 2 次 + $ipKey = 'pw-email:ip:'.$ip; + if (RateLimiter::tooManyAttempts($ipKey, 2)) { + $seconds = RateLimiter::availableIn($ipKey); + + return response()->json([ + 'status' => 'error', + 'message' => "发送验证链接过于频繁,请在 {$seconds} 秒后重试。", + ], 429); + } + + // 2. 账号邮箱级别冷却锁:同一个邮箱每 3 分钟限发 1 次,防止狂刷邮件轰炸他人 + $targetKey = 'pw-email:target:'.md5($email); + if (RateLimiter::tooManyAttempts($targetKey, 1)) { + $seconds = RateLimiter::availableIn($targetKey); + + return response()->json([ + 'status' => 'error', + 'message' => "该账号申请重置链接过于频繁,请在 {$seconds} 秒后重试。", + ], 429); + } // 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。 if (User::query()->where('email', $email)->count() > 1) { @@ -59,6 +153,10 @@ class PasswordResetController extends Controller ], 422); } + // 记录发信请求频率 + RateLimiter::hit($ipKey, 60); + RateLimiter::hit($targetKey, 180); + $status = Password::sendResetLink(['email' => $email]); if ($status === Password::RESET_LINK_SENT) { diff --git a/app/Http/Requests/SendPasswordResetLinkRequest.php b/app/Http/Requests/SendPasswordResetLinkRequest.php index 7a9beec..397827c 100644 --- a/app/Http/Requests/SendPasswordResetLinkRequest.php +++ b/app/Http/Requests/SendPasswordResetLinkRequest.php @@ -31,7 +31,8 @@ class SendPasswordResetLinkRequest extends FormRequest public function rules(): array { return [ - 'email' => ['required', 'email', 'max:255'], + 'email' => ['required_without:username', 'nullable', 'email', 'max:255'], + 'username' => ['required_without:email', 'nullable', 'string', 'max:100'], ]; } @@ -43,9 +44,9 @@ class SendPasswordResetLinkRequest extends FormRequest public function messages(): array { return [ - 'email.required' => '请输入已绑定账号的邮箱地址。', 'email.email' => '邮箱格式不正确,请重新输入。', 'email.max' => '邮箱长度不能超过 255 个字符。', + 'email.required_without' => '请输入绑定邮箱或用户昵称。', ]; } } diff --git a/resources/js/password-forgot.js b/resources/js/password-forgot.js index c5727c7..29b99d0 100644 --- a/resources/js/password-forgot.js +++ b/resources/js/password-forgot.js @@ -1,6 +1,7 @@ -// 邮箱找回密码页交互入口,负责 AJAX 发送重置链接和页面提示。 +// 忘记密码页面交互逻辑:智能账号检测与分流引导 let passwordForgotControlsBound = false; +let currentUsername = ""; /** * 读取 CSRF 令牌,供找回密码请求使用。 @@ -30,33 +31,73 @@ function showAlert(message, type) { } /** - * 提交邮箱找回密码请求。 + * 隐藏找回密码页面提示。 + * + * @returns {void} + */ +function hideAlert() { + const alertBox = document.getElementById("alert-box"); + if (alertBox) { + alertBox.style.display = "none"; + } +} + +/** + * 绑定双绑定状态下的选项卡切换事件。 + * + * @returns {void} + */ +function bindTabEvents() { + const tabsContainer = document.getElementById("channel-tabs"); + if (!tabsContainer) { + return; + } + + tabsContainer.addEventListener("click", (event) => { + const btn = event.target.closest(".tab-btn"); + if (!btn) { + return; + } + + // 清除所有高亮 + tabsContainer.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + + // 切换显示面板 + const targetId = btn.getAttribute("data-target"); + document.querySelectorAll(".channel-pane").forEach((pane) => { + if (pane.id === targetId) { + pane.style.display = "block"; + } else { + pane.style.display = "none"; + } + }); + }); +} + +/** + * 第一步:提交昵称检测账号绑定状态。 * * @param {SubmitEvent} event * @returns {Promise} */ -async function submitPasswordRecovery(event) { +async function submitAccountDetect(event) { event.preventDefault(); - const form = event.target; - const submitButton = document.getElementById("submit-btn"); - const alertBox = document.getElementById("alert-box"); + const detectBtn = document.getElementById("detect-btn"); if (!(form instanceof HTMLFormElement)) { return; } - if (submitButton instanceof HTMLButtonElement) { - submitButton.disabled = true; - submitButton.innerText = "发送中..."; - } - if (alertBox) { - alertBox.style.display = "none"; + if (detectBtn instanceof HTMLButtonElement) { + detectBtn.disabled = true; + detectBtn.innerText = "账号检索中..."; } + hideAlert(); try { - const response = await fetch(form.getAttribute("data-password-email-url") ?? form.action, { + const response = await fetch(form.action, { method: "POST", - credentials: "same-origin", headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": getCsrfToken(), @@ -66,16 +107,121 @@ async function submitPasswordRecovery(event) { }); const body = await response.json(); - if (response.status === 200 && body.status === "success") { - showAlert(body.message, "success"); - form.reset(); + if (response.status === 429) { + showAlert(body.message || "请求过于频繁,请稍后再试。", "error"); return; } - const errorMessage = body.message || (body.errors ? Object.values(body.errors)[0][0] : "邮件发送失败,请稍后重试。"); + if (body.status === "not_found") { + showAlert(body.message, "error"); + return; + } + + if (body.status === "success") { + currentUsername = body.username; + document.getElementById("step-detect").style.display = "none"; + document.getElementById("step-result").style.display = "block"; + document.getElementById("recovery-title").innerText = "密码找回建议"; + document.getElementById("forgot-footer").style.display = "none"; + + const summary = document.getElementById("detect-summary"); + const tabs = document.getElementById("channel-tabs"); + const panelWechat = document.getElementById("panel-wechat"); + const panelEmail = document.getElementById("panel-email"); + const panelNone = document.getElementById("panel-none"); + + // 默认隐藏所有特定面板 + tabs.style.display = "none"; + panelWechat.style.display = "none"; + panelEmail.style.display = "none"; + panelNone.style.display = "none"; + + const hasEmail = body.has_email; + const hasWechat = body.has_wechat; + + if (hasEmail && hasWechat) { + summary.innerHTML = `检测到账号 ${currentUsername} 已同时绑定了微信和邮箱。您可以选择以下任意一种方式找回密码:`; + tabs.style.display = "grid"; + // 默认选中微信面板 + tabs.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); + const wechatTabBtn = tabs.querySelector('[data-target="panel-wechat"]'); + if (wechatTabBtn) { + wechatTabBtn.classList.add("active"); + } + panelWechat.style.display = "block"; + } else if (hasWechat) { + summary.innerHTML = `检测到账号 ${currentUsername} 仅绑定了微信。系统不支持邮箱找回,建议您使用微信助手进行重置:`; + panelWechat.style.display = "block"; + } else if (hasEmail) { + summary.innerHTML = `检测到账号 ${currentUsername} 仅绑定了邮箱。建议您使用邮箱发送重置链接找回:`; + panelEmail.style.display = "block"; + const emailInput = document.getElementById("email"); + if (emailInput instanceof HTMLInputElement) { + emailInput.value = body.masked_email; + } + } else { + summary.innerHTML = `检测到账号 ${currentUsername} 尚未绑定微信或邮箱找回途径:`; + panelNone.style.display = "block"; + } + } + } catch { + showAlert("网络或服务器连接异常,请稍后再试。", "error"); + } finally { + if (detectBtn instanceof HTMLButtonElement) { + detectBtn.disabled = false; + detectBtn.innerText = "下一步 (检测账号)"; + } + } +} + +/** + * 第二步:提交发送重置链接邮件请求。 + * + * @param {SubmitEvent} event + * @returns {Promise} + */ +async function submitPasswordRecovery(event) { + event.preventDefault(); + const form = event.target; + const submitButton = document.getElementById("submit-btn"); + if (!(form instanceof HTMLFormElement)) { + return; + } + + if (submitButton instanceof HTMLButtonElement) { + submitButton.disabled = true; + submitButton.innerText = "发送重置链接中..."; + } + hideAlert(); + + try { + const response = await fetch(form.getAttribute("data-password-email-url") ?? "", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-TOKEN": getCsrfToken(), + Accept: "application/json", + }, + body: JSON.stringify({ + username: currentUsername, + }), + }); + const body = await response.json(); + + if (response.status === 429) { + showAlert(body.message || "请求发送过于频繁,请稍后再试。", "error"); + return; + } + + if (body.status === "success") { + showAlert(body.message, "success"); + return; + } + + const errorMessage = body.message || (body.errors ? Object.values(body.errors)[0][0] : "重置邮件发送失败,请稍后重试。"); showAlert(errorMessage, "error"); } catch { - showAlert("网络或服务器异常,请稍后再试。", "error"); + showAlert("网络发送异常,请稍后再试。", "error"); } finally { if (submitButton instanceof HTMLButtonElement) { submitButton.disabled = false; @@ -85,7 +231,7 @@ async function submitPasswordRecovery(event) { } /** - * 绑定邮箱找回密码页提交事件。 + * 绑定智能找回向导控制事件。 * * @returns {void} */ @@ -93,11 +239,28 @@ function bindPasswordForgotControls() { if (passwordForgotControlsBound || typeof document === "undefined") { return; } - passwordForgotControlsBound = true; + + // 第一步表单提交 + document.getElementById("account-detect-form")?.addEventListener("submit", (event) => { + void submitAccountDetect(event); + }); + + // 第二步发信表单提交 document.getElementById("password-recovery-form")?.addEventListener("submit", (event) => { void submitPasswordRecovery(event); }); + + // 返回按钮事件 + document.getElementById("back-detect-btn")?.addEventListener("click", () => { + hideAlert(); + document.getElementById("step-detect").style.display = "block"; + document.getElementById("step-result").style.display = "none"; + document.getElementById("recovery-title").innerText = "忘记密码"; + document.getElementById("forgot-footer").style.display = "block"; + }); + + bindTabEvents(); } bindPasswordForgotControls(); diff --git a/resources/views/password-forgot.blade.php b/resources/views/password-forgot.blade.php index ddac50b..7cb6464 100644 --- a/resources/views/password-forgot.blade.php +++ b/resources/views/password-forgot.blade.php @@ -182,6 +182,101 @@ line-height: 1.7; } + /* ── 智能引导 Tab 及微信向导样式 ── */ + .tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + margin-bottom: 20px; + border: 1px solid var(--border); + background: rgba(255, 248, 236, 0.02); + padding: 2px; + } + + .tab-btn { + height: 46px; + background: transparent; + border: none; + color: var(--text-soft); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + } + + .tab-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.02); + } + + .tab-btn.active { + color: var(--gold); + background: rgba(198, 163, 91, 0.08); + font-weight: 700; + } + + .wechat-guide { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 18px; + } + + .guide-step { + display: flex; + align-items: flex-start; + gap: 12px; + background: rgba(255, 248, 236, 0.02); + border: 1px solid rgba(198, 163, 91, 0.08); + padding: 12px 14px; + } + + .step-num { + width: 22px; + height: 22px; + background: var(--gold); + color: var(--bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + font-size: 12px; + flex-shrink: 0; + box-shadow: 0 0 10px rgba(198, 163, 91, 0.2); + } + + .step-desc { + font-size: 13.5px; + line-height: 1.6; + color: var(--text-soft); + } + + .step-desc strong { + color: var(--text); + } + + .cmd-badge { + display: inline-block; + padding: 2px 8px; + background: var(--red); + color: #fff; + font-weight: 700; + font-size: 12px; + border-radius: 3px; + margin: 0 4px; + } + + .wechat-tip { + border-left: 3px solid var(--gold); + background: rgba(198, 163, 91, 0.03); + padding: 12px; + font-size: 13px; + line-height: 1.6; + color: var(--text-soft); + } + @media (max-width: 640px) { .content { padding: 22px 18px 18px; @@ -198,41 +293,102 @@
PASSWORD RECOVERY
-

邮箱找回密码

-

请输入账号已绑定的邮箱地址。系统会把重置密码链接发送到邮箱,您再进入独立页面设置新密码。

+

忘记密码

-
- - -
仅支持已绑定唯一邮箱的账号自助找回。重置链接默认 60 分钟内有效。
+ +
+

请输入您的聊天室用户昵称,小助手将智能查询并为您提供最安全的重置方案。

+ + + +
+ + 返回首页 +
+ +
-
- + + -
- diff --git a/routes/web.php b/routes/web.php index 43fcb65..9d9af66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,7 @@ Route::post('/login', [AuthController::class, 'login']) Route::middleware('guest')->group(function () { Route::get('/forgot-password', [PasswordResetController::class, 'create'])->name('password.request'); Route::post('/forgot-password', [PasswordResetController::class, 'storeLink'])->name('password.email'); + Route::post('/forgot-password/check-account', [PasswordResetController::class, 'checkAccount'])->name('password.check_account'); Route::get('/reset-password/{token}', [PasswordResetController::class, 'edit'])->name('password.reset'); Route::post('/reset-password', [PasswordResetController::class, 'update'])->name('password.update'); }); diff --git a/tests/Feature/PasswordResetRateLimitTest.php b/tests/Feature/PasswordResetRateLimitTest.php new file mode 100644 index 0000000..f067937 --- /dev/null +++ b/tests/Feature/PasswordResetRateLimitTest.php @@ -0,0 +1,165 @@ +postJson(route('password.check_account'), [ + 'username' => 'non_existing_user_abc', + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'status' => 'not_found', + 'message' => '抱歉,没有找到该昵称对应的账号。请确认后再试。', + ]); + } + + /** + * 测试账号检测分流状态与脱敏邮箱输出 + */ + public function test_check_account_shows_correct_channels(): void + { + // 1. 双绑定用户 + $bothBoundUser = User::factory()->create([ + 'username' => 'both_user', + 'email' => 'both.test@example.com', + 'wxid' => 'wxid_both', + ]); + + $response1 = $this->postJson(route('password.check_account'), [ + 'username' => 'both_user', + ]); + + $response1->assertJson([ + 'status' => 'success', + 'has_email' => true, + 'has_wechat' => true, + 'masked_email' => 'b*******t@example.com', + ]); + + // 2. 仅绑定微信用户 + $wechatUser = User::factory()->create([ + 'username' => 'wx_user', + 'email' => null, + 'wxid' => 'wxid_single', + ]); + + $response2 = $this->postJson(route('password.check_account'), [ + 'username' => 'wx_user', + ]); + + $response2->assertJson([ + 'status' => 'success', + 'has_email' => false, + 'has_wechat' => true, + ]); + + // 3. 均未绑定用户 + $noneUser = User::factory()->create([ + 'username' => 'none_user', + 'email' => null, + 'wxid' => null, + ]); + + $response3 = $this->postJson(route('password.check_account'), [ + 'username' => 'none_user', + ]); + + $response3->assertJson([ + 'status' => 'success', + 'has_email' => false, + 'has_wechat' => false, + ]); + } + + /** + * 测试 IP 防扫描限流限制 (每个 IP 1分钟限制 5 次检测) + */ + public function test_check_account_rate_limiting(): void + { + $ip = '127.0.0.1'; + + // 连续请求 5 次都应该正常响应 + for ($i = 0; $i < 5; $i++) { + $response = $this->postJson(route('password.check_account'), [ + 'username' => 'non_existing_user', + ]); + $response->assertStatus(200); + } + + // 第 6 次应该被 RateLimiter 节流拦截返回 429 + $response = $this->postJson(route('password.check_account'), [ + 'username' => 'non_existing_user', + ]); + + $response->assertStatus(429); + $response->assertJson([ + 'status' => 'error', + ]); + $this->assertStringContainsString('请求过于频繁', $response->json('message')); + } + + /** + * 测试发送链接的双层安全控频防御 (防止疯狂邮件轰炸他人) + */ + public function test_store_link_prevents_email_bombing(): void + { + Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']); + + $user = User::factory()->create([ + 'username' => 'bomb_user', + 'email' => 'bomb.target@example.com', + ]); + + $email = $user->email; + $targetKey = 'pw-email:target:'.md5($email); + RateLimiter::clear($targetKey); + + // 模拟第一次发送成功 + $response1 = $this->postJson(route('password.email'), [ + 'username' => 'bomb_user', + ]); + + // 第二次在 3 分钟内再次连续发送同一个账号邮件,应该直接拦截返回 429,防止邮箱轰炸 + $response2 = $this->postJson(route('password.email'), [ + 'username' => 'bomb_user', + ]); + + $response2->assertStatus(429); + $response2->assertJson([ + 'status' => 'error', + ]); + $this->assertStringContainsString('重置链接过于频繁', $response2->json('message')); + } +}