Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧', 'smtpEnabled' => $this->isPasswordResetMailEnabled(), ]); } /** * 账号检测接口:根据昵称检测是否绑定微信或邮箱,并提供分流依据(支持 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, ]); } /** * 发送邮箱找回密码链接。 */ public function storeLink(SendPasswordResetLinkRequest $request): JsonResponse { if (! $this->isPasswordResetMailEnabled()) { return response()->json([ 'status' => 'error', 'message' => '系统暂未开启邮箱发信服务,当前无法通过邮箱找回密码。', ], 403); } $inputEmail = trim((string) $request->input('email', '')); $username = trim((string) $request->input('username', '')); $ip = $request->ip(); $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)) { $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) { return response()->json([ 'status' => 'error', 'message' => '该邮箱绑定了多个账号,暂不支持自助找回,请联系管理员处理。', ], 422); } // 记录发信请求频率 RateLimiter::hit($ipKey, 60); RateLimiter::hit($targetKey, 180); $status = Password::sendResetLink(['email' => $email]); if ($status === Password::RESET_LINK_SENT) { return response()->json([ 'status' => 'success', 'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。链接 60 分钟内有效,请注意查收。', ]); } if ($status === Password::RESET_THROTTLED) { return response()->json([ 'status' => 'error', 'message' => '发送过于频繁,请稍后再试。', ], 429); } if ($status === Password::INVALID_USER) { return response()->json([ 'status' => 'success', 'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。请检查收件箱与垃圾邮件箱。', ]); } return response()->json([ 'status' => 'error', 'message' => '找回密码邮件发送失败,请稍后重试。', ], 500); } /** * 展示独立的重置密码页面。 */ public function edit(Request $request, string $token): View { return view('password-reset', [ 'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧', 'token' => $token, 'email' => (string) $request->query('email', ''), ]); } /** * 提交新的登录密码并完成重置。 */ public function update(ResetPasswordRequest $request): RedirectResponse { $credentials = $request->validated(); $status = Password::reset( $credentials, function (User $user, #[\SensitiveParameter] string $password): void { // 重置成功后同步刷新 remember_token,避免旧设备继续沿用旧令牌。 $user->forceFill([ 'password' => Hash::make($password), 'remember_token' => Str::random(60), ])->save(); event(new PasswordReset($user)); } ); if ($status === Password::PASSWORD_RESET) { return redirect()->route('home')->with('status', '密码已重置成功,请使用原昵称和新密码重新登录。'); } return back() ->withInput($request->except('password', 'password_confirmation')) ->withErrors([ 'email' => $this->resolveResetFailureMessage($status), ]); } /** * 判断系统是否已开启邮箱发信服务。 */ private function isPasswordResetMailEnabled(): bool { return Sysparam::where('alias', 'smtp_enabled')->value('body') === '1'; } /** * 将 Laravel 密码重置状态码转换为中文错误提示。 */ private function resolveResetFailureMessage(string $status): string { return match ($status) { Password::INVALID_TOKEN => '重置链接无效或已过期,请重新申请邮箱找回。', Password::INVALID_USER => '该邮箱未绑定可重置的账号,请确认后再试。', default => '密码重置失败,请重新获取重置链接后再试。', }; } }