250 lines
8.7 KiB
PHP
250 lines
8.7 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,
|
|
$member->email,
|
|
(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'),
|
|
];
|
|
}
|
|
}
|