277 lines
10 KiB
PHP
277 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Web\Auth;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\SmsService;
|
|
use App\Support\LegacyCrypto\CiSeedCrypto;
|
|
use App\Models\MemInfo;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class FindIdController extends Controller
|
|
{
|
|
public function show(Request $request)
|
|
{
|
|
// 세션에 결과가 있으면 3단계로 시작, 인증대기면 2단계, 아니면 1단계
|
|
$sess = $request->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;
|
|
}
|
|
|
|
}
|