ip(); if (RateLimiter::tooManyAttempts($key, 5)) { $sec = RateLimiter::availableIn($key); return [ 'ok' => false, 'status' => 429, 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요.", ]; } RateLimiter::hit($key, 600); // ✅ 가입자 확인(DB) $member = $this->members->findByEmailAndName($emailLower, $name); if (!$member) { return [ 'ok' => false, 'status' => 404, 'code' => 'NOT_MATCHED', 'message' => '입력하신 이메일/성명 정보가 일치하지 않습니다.', 'step' => 1, ]; } $memNo = (int)$member->mem_no; // ✅ DB 저장 없이 signed URL만 생성 $nonce = bin2hex(random_bytes(10)); $link = URL::temporarySignedRoute( 'web.auth.find_password.verify', now()->addMinutes($expiresMinutes), [ 'mem_no' => $memNo, 'n' => $nonce, ] ); // ✅ 메일 발송 try { $this->mail->sendTemplate( $emailLower, '[PIN FOR YOU] 비밀번호 재설정 링크 안내', 'mail.auth.reset_password', [ 'email' => $emailLower, 'name' => $name, 'link' => $link, 'expires_min' => $expiresMinutes, 'accent' => '#E4574B', 'brand' => 'PIN FOR YOU', 'siteUrl' => config('app.url'), ], queue: false ); } catch (\Throwable $e) { Log::error('FindPassword sendResetMail failed', [ 'mem_no' => $memNo, 'email' => $emailLower, 'error' => $e->getMessage(), ]); return [ 'ok' => false, 'status' => 500, 'message' => '메일 발송 중 오류가 발생했습니다.', ]; } // 컨트롤러가 세션에 저장할 payload를 서비스에서 만들어줌 return [ 'ok' => true, 'status' => 200, 'message' => '재설정 메일을 발송했습니다. 메일함을 확인해 주세요.', 'expires_in' => $expiresMinutes * 60, 'step' => 2, 'session' => [ 'sent' => true, 'verified' => false, 'mem_no' => $memNo, 'email' => $emailLower, 'name' => $name, 'sent_at' => now()->timestamp, ], ]; } /** * 링크 클릭 검증(서명/만료 + 회원존재) * - DB 조회는 여기서 처리 * - 컨트롤러는 redirect만 담당 */ public function verifyResetLink(Request $request, int $memNo): array { // signed 미들웨어를 라우트에 붙이면 여기 체크는 사실상 안전장치 if (!$request->hasValidSignature()) { return [ 'ok' => false, 'message' => '링크가 유효하지 않거나 만료되었습니다. 다시 진행해 주세요.', ]; } if ($memNo <= 0) { return [ 'ok' => false, 'message' => '잘못된 접근입니다.', ]; } $member = $this->members->findByMemNo($memNo); if (!$member) { return [ 'ok' => false, 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.', ]; } return [ 'ok' => true, 'message' => '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.', 'session' => [ 'sent' => true, 'verified' => true, 'verified_at' => now()->timestamp, 'mem_no' => $memNo, 'email' => (string)($member->email ?? ''), 'name' => (string)($member->name ?? ''), ], ]; } public function resetPasswordFinal(Request $request, string $newPassword): array { $sess = (array) $request->session()->get('find_pw', []); // 0) 세션 체크: 이메일 링크 인증 완료 상태인지 if (empty($sess['verified']) || empty($sess['mem_no'])) { return [ 'ok' => false, 'status' => 403, 'message' => '이메일 인증이 필요합니다. 메일 링크를 통해 다시 진행해 주세요.', ]; } $memNo = (int) ($sess['mem_no'] ?? 0); if ($memNo <= 0) { $request->session()->forget('find_pw'); return [ 'ok' => false, 'status' => 403, 'message' => '세션 정보가 올바르지 않습니다. 다시 진행해 주세요.', ]; } // 1) 인증 후 유효시간 정책 (예: 인증 후 10분 내 변경) $verifiedAt = (int) ($sess['verified_at'] ?? 0); if ($verifiedAt <= 0 || (now()->timestamp - $verifiedAt) > (10 * 60)) { $request->session()->forget('find_pw'); return [ 'ok' => false, 'status' => 403, 'message' => '인증이 만료되었습니다. 비밀번호 찾기를 다시 진행해 주세요.', ]; } // 2) 레이트리밋 (비번 변경 시도) $key = 'findpw:reset:' . $memNo . ':' . (string)$request->ip(); if (RateLimiter::tooManyAttempts($key, 10)) { $sec = RateLimiter::availableIn($key); return [ 'ok' => false, 'status' => 429, 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요.", ]; } RateLimiter::hit($key, 600); // 3) 방어적 비번 정책 체크(컨트롤러에서 검증하지만 한번 더) if (!is_string($newPassword) || $newPassword === '') { return ['ok'=>false,'status'=>422,'message'=>'새 비밀번호를 입력해 주세요.']; } if (strlen($newPassword) < 8 || strlen($newPassword) > 20) { return ['ok'=>false,'status'=>422,'message'=>'비밀번호는 8~20자리로 입력해 주세요.']; } if (!preg_match('/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/', $newPassword)) { return ['ok'=>false,'status'=>422,'message'=>'비밀번호는 영문+숫자+특수문자를 포함해야 합니다.']; } // 4) 회원 존재 확인 (DB는 Repo 통해서) $member = $this->members->findByMemNo($memNo); if (!$member) { $request->session()->forget('find_pw'); return [ 'ok' => false, 'status' => 404, 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.', ]; } // 5) 비밀번호 저장 (mem_st_ring str_0~2) + 성공 로그 $this->members->updatePasswordOnly($memNo, $newPassword); $this->members->logPasswordResetSuccess( $memNo, (string) $request->ip(), (string) $request->userAgent() ); // 6) 세션 정리 $request->session()->forget('find_pw'); $request->session()->save(); return [ 'ok' => true, 'status' => 200, 'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.', 'redirect_url' => route('web.auth.login'), ]; } }