session()->get('find_id', []); $step = 1; if (!empty($sess['result_email_masked'])) $step = 3; else if (!empty($sess['sent'])) $step = 2; return view('web.auth.find_id', [ 'initialStep' => $step, 'maskedEmail' => $sess['result_email_masked'] ?? null, 'phone' => $sess['phone'] ?? null, ]); } public function sendCode(Request $request) { logger()->info('HIT sendCode', ['path' => request()->path(), 'host' => request()->getHost()]); $v = Validator::make($request->all(), [ 'phone' => ['required', 'string', 'max:20'], ], [ 'phone.required' => '휴대폰 번호를 입력해 주세요.', ]); if ($v->fails()) { return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); } $phoneRaw = $request->input('phone'); $phone = $this->normalizeKoreanPhone($phoneRaw); if (!$phone) { return response()->json(['ok' => false, 'message' => '휴대폰 번호 형식이 올바르지 않습니다.'], 422); } // ✅ 0) DB에 가입된 휴대폰인지 먼저 확인 /** @var CiSeedCrypto $seed */ $seed = app(CiSeedCrypto::class); // DB에 저장된 방식(동일)으로 암호화해서 비교 $phoneEnc = $seed->encrypt($phone); $exists = MemInfo::query() ->whereNotNull('email') ->where('email', '<>', '') ->where('cell_phone', $phoneEnc) ->exists(); if (!$exists) { // ✅ 세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공 return response()->json([ 'ok' => false, 'code' => 'PHONE_NOT_FOUND', 'message' => '해당 휴대폰 번호로 가입된 계정을 찾을 수 없습니다. 번호를 다시 확인해 주세요.', 'step' => 1, ], 404); } // 레이트리밋(휴대폰 기준) - 과도한 발송 방지 $key = 'findid:send:' . $phone; if (RateLimiter::tooManyAttempts($key, 5)) { // 10분에 5회 예시 $sec = RateLimiter::availableIn($key); return response()->json(['ok' => false, 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); } RateLimiter::hit($key, 600); // 6자리 OTP 생성 $code = (string) random_int(100000, 999999); // 세션 저장(보안: 실제로는 해시 저장 권장, 여기선 간단 구현) $request->session()->put('find_id', [ 'sent' => true, 'phone' => $phone, 'code' => password_hash($code, PASSWORD_DEFAULT), 'code_expires_at' => now()->addMinutes(3)->timestamp, 'verified' => false, 'result_email_masked' => null, ]); // SMS 발송 try { // $smsPayload = [ // 'from_number' => config('services.sms.from', '1833-4856'), // 기본 발신번호 // 'to_number' => $phone, // 'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. (3분 이내)", // 'sms_type' => 'sms', // 짧으니 sms 고정(원하면 자동 판단으로 빼도 됨) // // 'country' => '82', // 필요 시 강제 // ]; // // $ok = app(SmsService::class)->send($smsPayload, 'lguplus'); // // if (!$ok) { // // 실패 시 세션 정리(인증 진행 꼬임 방지) // $request->session()->forget('find_id'); // return response()->json([ // 'ok' => false, // 'message' => '문자 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.', // ], 500); // } } catch (\Throwable $e) { $request->session()->forget('find_id'); // 운영에서만 로그 남기기(개발 중엔 디버깅 가능) Log::error('FindId SMS send failed', [ 'phone' => $phone, 'error' => $e->getMessage(), ]); return response()->json([ 'ok' => false, 'message' => '문자 발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', ], 500); } $isLocal = app()->environment(['local', 'development', 'testing']); return response()->json([ 'ok' => true, 'message' => '인증번호를 발송했습니다.', 'expires_in' => 180, 'step' => 2, 'dev_code' => $isLocal ? $code : null, ]); } public function verify(Request $request) { $v = Validator::make($request->all(), [ 'code' => ['required', 'digits:6'], ], [ 'code.required' => '인증번호를 입력해 주세요.', 'code.digits' => '인증번호 6자리를 입력해 주세요.', ]); if ($v->fails()) { return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); } $sess = $request->session()->get('find_id'); if (!$sess || empty($sess['sent']) || empty($sess['phone'])) { return response()->json(['ok' => false, 'message' => '먼저 인증번호를 요청해 주세요.'], 400); } if (empty($sess['code_expires_at']) || now()->timestamp > (int)$sess['code_expires_at']) { return response()->json(['ok' => false, 'message' => '인증번호가 만료되었습니다. 다시 요청해 주세요.'], 400); } // 검증 시도 레이트리밋 $key = 'findid:verify:' . $sess['phone']; if (RateLimiter::tooManyAttempts($key, 10)) { // 10분 10회 예시 $sec = RateLimiter::availableIn($key); return response()->json(['ok' => false, 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); } RateLimiter::hit($key, 600); $code = $request->input('code'); $ok = password_verify($code, $sess['code'] ?? ''); if (!$ok) { return response()->json(['ok' => false, 'message' => '인증번호가 일치하지 않습니다.'], 422); } // 인증 성공: 휴대폰 번호로 가입된 "아이디(이메일)"들 조회 (여러개면 전부) $phone = (string) ($sess['phone'] ?? ''); $phone = trim($phone); if ($phone === '') { return response()->json(['ok' => false, 'message' => '휴대폰 번호가 비어 있습니다.'], 422); } /** @var CiSeedCrypto $crypto */ $crypto = app(CiSeedCrypto::class); // 키를 넘기지 말고, CiSeedCrypto가 생성자 주입된 userKey로 처리하게 통일 $phoneEnc = $crypto->encrypt($phone); $emails = MemInfo::query() ->whereNotNull('email') ->where('email', '<>', '') ->where('cell_phone', $phoneEnc) //DB에 저장된 암호문과 동일하게 매칭됨 ->orderByDesc('mem_no') ->pluck('email') ->unique() ->values() ->all(); if (empty($emails)) { // 운영에서는 암호문 노출 절대 금지 (지금은 디버그용이면 로그로만) // Log::debug('find-id phoneEnc', ['phoneEnc' => $phoneEnc, 'phone' => $phone]); return response()->json(['ok' => false, 'message' => '해당 번호로 가입된 계정을 찾을 수 없습니다.'], 404); } // 마스킹해서 여러개 내려주기 $maskedEmails = array_map(fn($e) => $this->maskEmail($e), $emails); $request->session()->forget('find_id'); $request->session()->save(); return response()->json([ 'ok' => true, 'message' => '인증이 완료되었습니다.', 'count' => count($maskedEmails), 'masked_emails' => $maskedEmails, //'emails' => $emails, ]); } public function reset(Request $request) { $request->session()->forget('find_id'); return response()->json(['ok' => true]); } private function normalizeKoreanPhone(string $input): ?string { $digits = preg_replace('/\D+/', '', $input); if (!$digits) return null; // 010XXXXXXXX (11), 01XXXXXXXXX (10) 정도만 허용 예시 if (Str::startsWith($digits, '010') && strlen($digits) === 11) return $digits; if (preg_match('/^01[0-9]{8,9}$/', $digits)) return $digits; // 필요시 범위 조정 return null; } private function maskEmail(string $email): string { $email = trim($email); if (!str_contains($email, '@')) { return $email; } [$local, $domain] = explode('@', $email, 2); $localLen = mb_strlen($local, 'UTF-8'); // 너무 짧은 로컬파트는 규칙 완화 if ($localLen <= 2) { // ab@ -> a*@ 정도 $head = mb_substr($local, 0, 1, 'UTF-8'); return $head . '*' . '@' . $domain; } if ($localLen <= 5) { // abcde -> ab*** (끝 1자리만 힌트) $head = mb_substr($local, 0, 2, 'UTF-8'); $tail = mb_substr($local, -1, 1, 'UTF-8'); return $head . str_repeat('*', max(1, $localLen - 3)) . $tail . '@' . $domain; } // ✅ 기본 규칙: 앞 3글자 + ***** + 뒤 2글자 $head = mb_substr($local, 0, 3, 'UTF-8'); $tail = mb_substr($local, -2, 2, 'UTF-8'); // 별 개수: 최소 5개, 너무 길면 로컬 길이에 맞게 조정 $stars = max(5, $localLen - 5); // head3 + tail2 = 5 return $head . str_repeat('*', $stars) . $tail . '@' . $domain; } }