feat: 忘记密码增设脱敏邮箱二次手动输入一致性核对安全锁
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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' => '用户昵称参数缺失。',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 已同时绑定了微信和邮箱。您可以选择以下任意一种方式找回密码:`;
|
||||
tabs.style.display = "grid";
|
||||
@@ -149,15 +152,24 @@ async function submitAccountDetect(event) {
|
||||
wechatTabBtn.classList.add("active");
|
||||
}
|
||||
panelWechat.style.display = "block";
|
||||
|
||||
if (hintLabel) {
|
||||
hintLabel.innerHTML = `📬 绑定的邮箱提示:<span style="color:#fff;">${body.masked_email}</span>`;
|
||||
}
|
||||
if (emailInput instanceof HTMLInputElement) {
|
||||
emailInput.value = "";
|
||||
}
|
||||
} else if (hasWechat) {
|
||||
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 仅绑定了微信。系统不支持邮箱找回,建议您使用微信助手进行重置:`;
|
||||
panelWechat.style.display = "block";
|
||||
} else if (hasEmail) {
|
||||
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 仅绑定了邮箱。建议您使用邮箱发送重置链接找回:`;
|
||||
panelEmail.style.display = "block";
|
||||
const emailInput = document.getElementById("email");
|
||||
if (hintLabel) {
|
||||
hintLabel.innerHTML = `📬 绑定的邮箱提示:<span style="color:#fff;">${body.masked_email}</span>`;
|
||||
}
|
||||
if (emailInput instanceof HTMLInputElement) {
|
||||
emailInput.value = body.masked_email;
|
||||
emailInput.value = "";
|
||||
}
|
||||
} else {
|
||||
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 尚未绑定微信或邮箱找回途径:`;
|
||||
@@ -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();
|
||||
|
||||
@@ -351,21 +351,23 @@
|
||||
|
||||
<!-- 模块二:邮箱重置发送表单 -->
|
||||
<div id="panel-email" class="channel-pane" style="display: none;">
|
||||
<!-- 脱敏提示标签 -->
|
||||
<div id="masked-email-hint" style="margin-bottom: 14px; font-size: 13.5px; color: var(--gold); font-weight: bold; line-height: 1.6; padding: 10px; border: 1px dashed rgba(198,163,91,0.3); background: rgba(198,163,91,0.02);"></div>
|
||||
|
||||
<form id="password-recovery-form" data-password-email-url="{{ route('password.email') }}">
|
||||
<label for="email">已绑定邮箱</label>
|
||||
<label for="email">二次安全确认:请输入绑定的完整邮箱</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
maxlength="255"
|
||||
placeholder="账号绑定的邮箱地址"
|
||||
autocomplete="email"
|
||||
readonly
|
||||
placeholder="请输入绑定的完整邮箱地址以进行二次确认"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<div class="tip" style="margin-bottom: 15px;">出于安全目的,此邮箱仅作脱敏验证。系统将向该邮箱投递密码重置链接。</div>
|
||||
<div class="tip" style="margin-top: 10px; margin-bottom: 15px;">为保障安全,邮箱已作脱敏隐藏。请手动输入完全一致的完整绑定邮箱,否则无法发送重置链接。</div>
|
||||
|
||||
<button id="submit-btn" type="submit" style="width: 100%;" {{ $smtpEnabled ? '' : 'disabled' }}>发送重置邮件</button>
|
||||
<button id="submit-btn" type="submit" style="width: 100%;" {{ $smtpEnabled ? '' : 'disabled' }}>二次验证并发送重置邮件</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user