diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php index 607676f..48f8798 100644 --- a/app/Http/Controllers/PasswordResetController.php +++ b/app/Http/Controllers/PasswordResetController.php @@ -108,21 +108,28 @@ class PasswordResetController extends Controller ], 403); } - $email = trim((string) $request->input('email', '')); + $inputEmail = 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; + $user = User::query()->where('username', $username)->first(); + if (! $user || empty($user->email)) { + return response()->json([ + 'status' => 'error', + 'message' => '找不到绑定了邮箱的账号。', + ], 422); } + // 强行双向比对核对(忽略大小写和前后空白) + if (strcasecmp($user->email, $inputEmail) !== 0) { + return response()->json([ + 'status' => 'error', + 'message' => '输入的完整邮箱地址与该账号绑定的邮箱不一致,二次确认失败。', + ], 422); + } + + $email = $user->email; + // 1. IP 级别发信限流:限制单个 IP 每分钟最多请求 2 次 $ipKey = 'pw-email:ip:'.$ip; if (RateLimiter::tooManyAttempts($ipKey, 2)) { diff --git a/app/Http/Requests/SendPasswordResetLinkRequest.php b/app/Http/Requests/SendPasswordResetLinkRequest.php index 397827c..cc0dac2 100644 --- a/app/Http/Requests/SendPasswordResetLinkRequest.php +++ b/app/Http/Requests/SendPasswordResetLinkRequest.php @@ -31,8 +31,8 @@ class SendPasswordResetLinkRequest extends FormRequest public function rules(): array { return [ - 'email' => ['required_without:username', 'nullable', 'email', 'max:255'], - 'username' => ['required_without:email', 'nullable', 'string', 'max:100'], + 'email' => ['required', 'email', 'max:255'], + 'username' => ['required', 'string', 'max:100'], ]; } @@ -44,9 +44,10 @@ class SendPasswordResetLinkRequest extends FormRequest public function messages(): array { return [ + 'email.required' => '请输入绑定邮箱地址以进行二次确认。', 'email.email' => '邮箱格式不正确,请重新输入。', 'email.max' => '邮箱长度不能超过 255 个字符。', - 'email.required_without' => '请输入绑定邮箱或用户昵称。', + 'username.required' => '用户昵称参数缺失。', ]; } } diff --git a/resources/js/password-forgot.js b/resources/js/password-forgot.js index 29b99d0..25536ba 100644 --- a/resources/js/password-forgot.js +++ b/resources/js/password-forgot.js @@ -139,6 +139,9 @@ async function submitAccountDetect(event) { const hasEmail = body.has_email; const hasWechat = body.has_wechat; + const hintLabel = document.getElementById("masked-email-hint"); + const emailInput = document.getElementById("email"); + if (hasEmail && hasWechat) { summary.innerHTML = `检测到账号 ${currentUsername} 已同时绑定了微信和邮箱。您可以选择以下任意一种方式找回密码:`; tabs.style.display = "grid"; @@ -149,15 +152,24 @@ async function submitAccountDetect(event) { wechatTabBtn.classList.add("active"); } panelWechat.style.display = "block"; + + if (hintLabel) { + hintLabel.innerHTML = `📬 绑定的邮箱提示:${body.masked_email}`; + } + if (emailInput instanceof HTMLInputElement) { + emailInput.value = ""; + } } 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 (hintLabel) { + hintLabel.innerHTML = `📬 绑定的邮箱提示:${body.masked_email}`; + } if (emailInput instanceof HTMLInputElement) { - emailInput.value = body.masked_email; + emailInput.value = ""; } } else { summary.innerHTML = `检测到账号 ${currentUsername} 尚未绑定微信或邮箱找回途径:`; @@ -195,6 +207,9 @@ async function submitPasswordRecovery(event) { hideAlert(); try { + const emailInput = document.getElementById("email"); + const emailVal = emailInput instanceof HTMLInputElement ? emailInput.value.trim() : ""; + const response = await fetch(form.getAttribute("data-password-email-url") ?? "", { method: "POST", headers: { @@ -204,6 +219,7 @@ async function submitPasswordRecovery(event) { }, body: JSON.stringify({ username: currentUsername, + email: emailVal, }), }); const body = await response.json(); diff --git a/resources/views/password-forgot.blade.php b/resources/views/password-forgot.blade.php index 7cb6464..3c59224 100644 --- a/resources/views/password-forgot.blade.php +++ b/resources/views/password-forgot.blade.php @@ -351,21 +351,23 @@
diff --git a/tests/Feature/PasswordResetRateLimitTest.php b/tests/Feature/PasswordResetRateLimitTest.php index f067937..38ee5bf 100644 --- a/tests/Feature/PasswordResetRateLimitTest.php +++ b/tests/Feature/PasswordResetRateLimitTest.php @@ -131,7 +131,31 @@ class PasswordResetRateLimitTest extends TestCase } /** - * 测试发送链接的双层安全控频防御 (防止疯狂邮件轰炸他人) + * 测试当用户输入的邮箱地址与绑定的真实邮箱不匹配时,二次核验拦截报错 + */ + public function test_store_link_fails_when_masked_email_mismatch(): void + { + Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']); + + User::factory()->create([ + 'username' => 'mismatch_user', + 'email' => 'real.mail@example.com', + ]); + + $response = $this->postJson(route('password.email'), [ + 'username' => 'mismatch_user', + 'email' => 'wrong.mail@example.com', // 错误的手输邮箱 + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'status' => 'error', + 'message' => '输入的完整邮箱地址与该账号绑定的邮箱不一致,二次确认失败。', + ]); + } + + /** + * 测试发送链接的双层安全控频防御 (防止发信轰炸他人) */ public function test_store_link_prevents_email_bombing(): void { @@ -146,14 +170,16 @@ class PasswordResetRateLimitTest extends TestCase $targetKey = 'pw-email:target:'.md5($email); RateLimiter::clear($targetKey); - // 模拟第一次发送成功 + // 模拟第一次发送 $response1 = $this->postJson(route('password.email'), [ 'username' => 'bomb_user', + 'email' => 'bomb.target@example.com', ]); - // 第二次在 3 分钟内再次连续发送同一个账号邮件,应该直接拦截返回 429,防止邮箱轰炸 + // 第二次在 3 分钟内再次连续发送,触发 429 节流 $response2 = $this->postJson(route('password.email'), [ 'username' => 'bomb_user', + 'email' => 'bomb.target@example.com', ]); $response2->assertStatus(429);