2
0

PasswordResetService.php 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Exceptions\BusinessException;
  4. use App\Mail\V3\PasswordResetMail;
  5. use App\Models\UserInfo;
  6. use Carbon\CarbonInterface;
  7. use Illuminate\Support\Facades\Date;
  8. use Illuminate\Support\Facades\Log;
  9. use Illuminate\Support\Facades\Mail;
  10. use Illuminate\Support\Str;
  11. use Throwable;
  12. /**
  13. * 找回密码:发重置邮件、凭 token 查用户、凭 token 设新密码。
  14. *
  15. * 与 v2 的 `ForgotPasswordController` / `ResetPasswordController` 共用
  16. * `user_infos.reset_password_token` / `reset_password_sent_at` 两列,但不共用代码
  17. * (CLAUDE.md 铁律 2)。两边的差别:
  18. *
  19. * - 库里只存 token 的 sha256,明文只出现在邮件里;v2 存明文 uuid。
  20. * 两边写同一列,后写的覆盖先写的,这正是「重新申请一次」应有的语义。
  21. * - token 60 分钟过期(v2 永不过期)。
  22. * - 邮箱不存在时同样「成功」,不泄露某个邮箱是否注册过(v2 回 404)。
  23. * - 重置链接用服务端配置 `mint.server.dashboard_v6_base_path` 拼,不收客户端传的站点地址。
  24. */
  25. class PasswordResetService
  26. {
  27. /** token 有效期(分钟) */
  28. public const TTL_MINUTES = 60;
  29. /** 存在对应模板的语言(`resources/views/emails/reset_password/*`),其余回落 en */
  30. private const MAIL_LOCALES = ['en', 'en-US', 'zh-Hans', 'zh-Hant'];
  31. public function __construct(private readonly PasswordHasher $hasher) {}
  32. /**
  33. * 给该邮箱的用户发重置邮件;邮箱未注册时什么都不做。
  34. *
  35. * @throws BusinessException 邮件发送失败(503)
  36. */
  37. public function request(string $email, ?string $lang): void
  38. {
  39. $user = UserInfo::where('email', $email)->first();
  40. if (! $user) {
  41. return;
  42. }
  43. $token = Str::random(64);
  44. $user->reset_password_token = $this->digest($token);
  45. $user->reset_password_sent_at = now();
  46. $user->save();
  47. $locale = in_array($lang, self::MAIL_LOCALES, true) ? $lang : 'en';
  48. $url = rtrim(config('mint.server.dashboard_v6_base_path'), '/').'/anonymous/reset-password/'.$token;
  49. try {
  50. Mail::to($user->email)->send(new PasswordResetMail($url, $locale));
  51. } catch (Throwable $e) {
  52. Log::error('v3 password reset mail failed', ['message' => $e->getMessage()]);
  53. // 用 BusinessException 而不是让它冒成 500:兜底处理器会抹掉 5xx 的 detail,
  54. // 而这条文案得让用户看到(稍后重试,而不是干等一封不会来的邮件)
  55. throw new BusinessException(__('messages.mail_send_failed'), 503, 'mail-send-failed');
  56. }
  57. }
  58. /**
  59. * 凭 token 找到待重置的用户,token 不存在或已过期时 404。
  60. *
  61. * @return array{username: string, expires_at: CarbonInterface}
  62. */
  63. public function find(string $token): array
  64. {
  65. $user = $this->userByToken($token);
  66. return [
  67. 'username' => $user->username,
  68. 'expires_at' => $this->expiresAt($user),
  69. ];
  70. }
  71. /**
  72. * 设新密码并作废 token(一次性)。
  73. */
  74. public function reset(string $token, string $password): void
  75. {
  76. $user = $this->userByToken($token);
  77. $user->password = $this->hasher->hash($password);
  78. $user->reset_password_token = null;
  79. $user->reset_password_sent_at = null;
  80. $user->save();
  81. }
  82. private function userByToken(string $token): UserInfo
  83. {
  84. $user = UserInfo::where('reset_password_token', $this->digest($token))
  85. ->whereNotNull('reset_password_sent_at')
  86. ->first();
  87. // 不存在与已过期同样回 404:不给调用方区分两者的信号
  88. if (! $user || $this->expiresAt($user)->isPast()) {
  89. abort(404, __('passwords.token'));
  90. }
  91. return $user;
  92. }
  93. private function expiresAt(UserInfo $user): CarbonInterface
  94. {
  95. // UserInfo 是 v2/v3 共享模型,没给这列配 cast,这里自己解析,不去改共享模型
  96. return Date::parse($user->reset_password_sent_at)->addMinutes(self::TTL_MINUTES);
  97. }
  98. private function digest(string $token): string
  99. {
  100. return hash('sha256', $token);
  101. }
  102. }