giftcon_dev/app/Services/FindPasswordService.php

249 lines
8.6 KiB
PHP

<?php
namespace App\Services;
use App\Repositories\Member\MemberAuthRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Log;
class FindPasswordService
{
public function __construct(
private readonly MemberAuthRepository $members,
private readonly MailService $mail,
) {}
/**
* 이메일+성명 검증 후 재설정 링크 메일 발송
* - DB 조회는 여기서 처리
* - 컨트롤러는 결과만 받아서 JSON 응답
*/
public function sendResetMail(Request $request, string $email, string $name, int $expiresMinutes = 30): array
{
$emailLower = mb_strtolower(trim($email));
$name = trim($name);
// 레이트리밋 (email+ip) - DB 아니지만 정책/로직이므로 서비스에서 처리
$key = 'findpw:mail:' . $emailLower . ':' . (string)$request->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(),
'E'
);
// 6) 세션 정리
$request->session()->forget('find_pw');
$request->session()->save();
return [
'ok' => true,
'status' => 200,
'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.',
'redirect_url' => route('web.auth.login'),
];
}
}