clearSession($request, 'signup'); $this->clearSession($request, 'register'); return view('web.auth.register'); } public function showTerms(Request $request) { // Step0 스킵 방지 (최소) if (($request->session()->get('signup.step') ?? 0) < 1) { return redirect()->route('web.auth.register'); } return view('web.auth.register_terms'); } public function postPhoneCheck(Request $request, MemberAuthRepository $repo) { $v = Validator::make($request->all(), [ 'phone' => ['required', 'string', 'max:20'], 'carrier' => ['required', 'in:01,02,03,04,05,06'], //01:SKT,02:KT,03:LGU+,04:SKT(알뜰폰),05:KT(알뜰폰),06:LGU+(알뜰폰)' 'g-recaptcha-response' => ['required', new RecaptchaV3Rule('register_phone_check')], ], [ 'phone.required' => '휴대폰 번호를 입력해 주세요.', 'carrier.required' => '통신사를 선택해 주세요.', 'carrier.in' => '통신사 선택 값이 올바르지 않습니다.', 'g-recaptcha-response.required' => '보안 검증에 실패했습니다. 다시 시도해 주세요.', ]); if ($v->fails()) { return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); } /* 회원가입 필터 아이피로 차단 및 관리자 알림*/ $ip = $request->ip(); $ip4c = implode('.', array_slice(explode('.', $ip), 0, 3)); //C-CLASS CHECK (210.96.177) if (!$request->session()->has('signup.ipf_result')) { $ipCheck = $repo->precheckJoinIpFilterAndLog([ 'mem_no' => 0, 'cell_corp' => $request->input('carrier'), 'cell_phone' => $request->input('phone'), 'email' => '-', 'ip4' => $ip, 'ip4_c' => $ip4c, ]); $request->session()->put('signup.ipf_result', $ipCheck['result']); // A/S/P $request->session()->put('signup.ipf_seq', (int)($ipCheck['seq'] ?? 0)); if ($ipCheck['result'] === 'A') { // 가입 차단 return response()->json([ 'ok' => false, 'message' => '회원가입에 문제가 발생하였습니다. 고객센터에 문의하세요!', ], 403); } } /* 회원가입 필터 아이피로 차단 및 관리자 알림*/ $map = [ //다날로 보내는 값 '01' => 'SKT', '02' => 'KTF', '03' => 'LGT', '04' => 'MVNO','05' => 'MVNO','06' => 'MVNO', ]; $result = $repo->step0PhoneCheck( (string) $request->input('phone'), (string) $request->input('carrier'), ); $danalCarrier = $map[$result['carrier']] ?? null; if (!($result['ok'] ?? false)) { $reason = $result['reason'] ?? 'error'; // blocked류만 403, 그 외는 422(원하면 already_member는 200으로 바꿔도 됨) $status = in_array($reason, ['blocked', 'blocked_ip'], true) ? 403 : 422; return response()->json([ 'ok' => false, 'reason' => $reason, 'message' => $result['message'] ?? '처리에 실패했습니다.', 'redirect' => $result['redirect'] ?? null, ], $status); } $request->session()->put('signup.step', 1); $request->session()->put('signup.phone', $result['phone']); $request->session()->put('signup.carrier', $danalCarrier); $request->session()->save(); return response()->json([ 'ok' => true, 'reason' => $result['reason'] ?? 'ok', 'message' => $result['message'] ?? '', 'redirect' => $result['redirect'] ?? null, 'phone' => $result['phone'] ?? null, 'carrier' => $danalCarrier, ], 200); } public function termsSubmit(Request $request) { $v = Validator::make($request->all(), [ 'agree_terms' => ['required', 'in:1'], 'agree_privacy' => ['required', 'in:1'], 'agree_age' => ['required', 'in:1'], 'agree_marketing' => ['nullable', 'in:1'], ], [ 'agree_terms.required' => '이용약관에 동의해 주세요.', 'agree_privacy.required' => '개인정보 수집·이용에 동의해 주세요.', 'agree_age.required' => '만 14세 이상만 가입할 수 있습니다.', ]); if ($v->fails()) { // AJAX면 JSON으로 if ($request->expectsJson()) { return response()->json([ 'ok' => false, 'message' => $v->errors()->first(), 'errors' => $v->errors(), ], 422); } // 일반 submit이면 기존처럼 return back()->withErrors($v)->withInput(); } // 약관 동의값 저장(세션) $request->session()->put('register.terms', [ 'agree_terms' => true, 'agree_privacy' => true, 'agree_age' => true, 'agree_marketing' => (bool)$request->input('agree_marketing'), 'agreed_at' => now()->toDateTimeString(), 'ip' => $request->ip(), 'ua' => substr((string)$request->userAgent(), 0, 500), ]); $request->session()->save(); // AJAX면: 다날 준비값 생성 후 popup 정보 반환 if ($request->expectsJson()) { $danal = app(\App\Services\Danal\DanalAuthtelService::class)->prepare([ 'targetUrl' => route('web.auth.register.danal.result'), 'backUrl' => route('web.auth.register.terms'), 'cpTitle' => request()->getHost(), ]); if (!($danal['ok'] ?? false)) { return response()->json([ 'ok' => false, 'message' => $danal['message'] ?? '본인인증 준비에 실패했습니다. 잠시 후 다시 시도해 주세요.', ], 500); } // 필요하면 트랜잭션 정보 세션 저장 $request->session()->put('register.danal', [ 'txid' => $danal['txid'] ?? null, 'created_at' => now()->toDateTimeString(), ]); $request->session()->save(); return response()->json([ 'ok' => true, 'reason' => 'danal_ready', 'popup' => [ 'url' => route('web.auth.register.danal.start'), 'fields' => $danal['fields'], // Start.php로 보낼 hidden inputs ], ]); } return redirect()->route('web.auth.register.terms') ->with('ui_dialog', [ 'type' => 'alert', 'title' => '완료', 'message' => "약관 동의가 저장되었습니다.\n\n다음 단계(본인인증)를 진행합니다.", ]); } public function danalStart(Request $request) { // fields는 JSON으로 받을 것 $fieldsJson = (string) $request->input('fields', ''); $fields = json_decode($fieldsJson, true); if (!is_array($fields) || empty($fields)) { abort(400, 'Invalid Danal fields'); } $platform = strtolower((string) $request->input('platform', ($fields['platform'] ?? ''))); $isMobile = false; if ($platform === 'mobile') { $isMobile = true; } elseif ($platform === 'web') { $isMobile = false; } else { $ua = (string) $request->header('User-Agent', ''); $isMobile = (bool) preg_match('/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i', $ua); } $fields['IsDstAddr'] = $request->session()->get('signup.phone'); $fields['IsCarrier'] = $request->session()->get('signup.carrier'); $action = $isMobile ? 'https://wauth.teledit.com/Danal/WebAuth/Mobile/Start.php' : 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php'; unset($fields['platform']); return view('web.auth.danal_autosubmit', [ 'action' => $action, 'fields' => $fields, 'isMobile' => $isMobile, ]); } public function danalResult(Request $request, DanalAuthtelService $danal, MemberAuthRepository $repo) { $payload = $request->all(); if (config('app.debug')) { Log::info('[DANAL][RESULT] payload keys', [ 'method' => $request->method(), 'url' => $request->fullUrl(), 'keys' => array_keys($payload), ]); Log::info('[DANAL][RESULT] payload', $this->maskDanalPayloadForLog($payload)); } $tid = (string)($payload['TID'] ?? ''); if ($tid === '') { return response()->view('web.auth.danal_finish_top', [ 'ok' => false, 'message' => 'TID가 없습니다.', 'redirect' => route('web.auth.register.terms'), ]); } // CI와 동일: TID로 CONFIRM 호출해서 RETURNCODE를 받는다 (dndata 사용 안함) $res = $danal->confirm($tid, 0, 1); if (config('app.debug')) { Log::info('[DANAL][CONFIRM] keys', ['keys' => array_keys($res)]); Log::info('[DANAL][CONFIRM] res', $this->maskDanalPayloadForLog($res)); } //repo로 로그 1차 저장” (성공/실패 무조건 저장) $logSeq = $repo->insertDanalAuthLog('J', (array)$res); if ($logSeq > 0) { $request->session()->put('register.danal_log_seq', $logSeq); } $ok = (($res['RETURNCODE'] ?? '') === '0000'); if (!$ok) { return response()->view('web.auth.danal_finish_top', [ 'ok' => false, 'message' => ($res['RETURNMSG'] ?? '본인인증에 실패했습니다.') . ' (' . ($res['RETURNCODE'] ?? 'NO_CODE') . ')', 'redirect' => route('web.auth.register.terms'), ]); } /* 다날 통신 성공 후 체크*/ $normPhone = function (?string $p) { $p = preg_replace('/\D+/', '', (string)$p); return $p; }; $signupPhone = $normPhone(data_get($request->session()->get('signup', []), 'phone')); //처음 입력 전화번호 $passPhone = $normPhone($res['PHONE'] ?? null); //인증받은 전화번호 // signup.phone vs PASS PHONE 비교 if ($signupPhone === '' || $passPhone === '' || $signupPhone !== $passPhone) { $request->session()->flush(); $request->session()->save(); return response()->view('web.auth.danal_finish_top', [ 'ok' => false, 'message' => '인증에 사용한 휴대폰 번호가 가입 진행 번호와 일치하지 않습니다. 다시 진행해 주세요.', 'redirect' => route('web.auth.register'), ]); } // 통신사 제한 $carrier = (string)($res['CARRIER'] ?? ''); $blockedCarriers = ['SKT_MVNO', 'KT_MVNO', 'LGT_MVNO']; // 알뜰폰 if (in_array($carrier, $blockedCarriers, true)) { $request->session()->flush(); $request->session()->save(); return response()->view('web.auth.danal_finish_top', [ 'ok' => false, 'message' => '죄송합니다. 알뜰폰(또는 일부 통신사) 휴대전화는 가입할 수 없습니다.', 'redirect' => route('web.auth.register'), ]); } // 성공: 다음 단계에서 쓸 데이터 세션 저장 $request->session()->put('register.pass_verified', true); $request->session()->put('register.pass_payload', $res); $request->session()->save(); return response()->view('web.auth.danal_finish_top', [ 'ok' => true, 'message' => '본인인증이 완료되었습니다.', 'redirect' => route('web.auth.register.profile'), ]); } public function showProfileForm(Request $request) { if (!$request->session()->get('register.pass_verified')) { return redirect()->route('web.auth.register.terms') ->with('ui_dialog', [ 'type'=>'alert', 'title'=>'안내', 'message'=>'본인인증 후 진행해 주세요.', ]); } return view('web.auth.profile'); } public function checkLoginId(Request $request, MemberAuthRepository $repo) { $v = Validator::make($request->all(), [ 'login_id' => ['required', 'string', 'max:60', 'email'], ], [ 'login_id.required' => '아이디(이메일)를 입력해 주세요.', 'login_id.email' => '이메일 형식으로 입력해 주세요.', ]); if ($v->fails()) { return response()->json([ 'ok' => false, 'message' => $v->errors()->first(), ], 422); } $email = (string) $request->input('login_id'); // repo에서 mem_info.email 중복 체크 $exists = $repo->existsEmail($email); return response()->json([ 'ok' => !$exists, 'message' => $exists ? '이미 사용 중인 아이디입니다.' : '사용 가능한 아이디입니다.', ]); } public function submitProfile(Request $request, MemberAuthRepository $repo) { // 0) PASS 인증 안됐으면 컷 if (!$request->session()->get('register.pass_verified')) { return redirect()->route('web.auth.register.terms') ->with('ui_dialog', [ 'type' => 'alert', 'title' => '안내', 'message' => '본인인증 후 진행해 주세요.', ]); } // 1) 입력값 검증 (아이디=이메일) $v = Validator::make($request->all(), [ 'login_id' => ['required', 'string', 'email', 'max:60'], 'password' => [ 'required', 'string', 'min:8', 'max:20', 'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/' ], 'password_confirmation' => ['required', 'same:password'], 'pin2' => ['required', 'digits:4'], 'pin2_confirmation' => ['required', 'same:pin2'], ], [ 'login_id.required' => '아이디(이메일)를 입력해 주세요.', 'login_id.email' => '아이디는 이메일 형식이어야 합니다.', 'password.required' => '비밀번호를 입력해 주세요.', 'password.min' => '비밀번호는 8자리 이상이어야 합니다.', 'password.max' => '비밀번호는 20자리를 초과할 수 없습니다.', 'password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.', 'password_confirmation.same' => '비밀번호 확인이 일치하지 않습니다.', 'pin2.required' => '2차 비밀번호(숫자 4자리)를 입력해 주세요.', 'pin2.digits' => '2차 비밀번호는 숫자 4자리여야 합니다.', 'pin2_confirmation.same' => '2차 비밀번호 확인이 일치하지 않습니다.', ]); if ($v->fails()) { return back()->withErrors($v)->withInput(); } // 2) 아이디(=이메일) 정규화 $email = strtolower(trim((string)$request->input('login_id'))); // 3) 중복 체크는 Repository로만 if ($repo->existsEmail($email)) { return back()->withErrors(['login_id' => '이미 사용 중인 아이디입니다.'])->withInput(); } // 4) PASS payload + signup 세션값 가져오기 $pv = (array) $request->session()->get('register.pass_payload', []); $signupPhone = (string) $request->session()->get('signup.phone', ''); $signupCarrier = (string) $request->session()->get('signup.carrier', ''); if ($signupPhone !== $pv['PHONE'] || $signupCarrier !== $pv['CARRIER']) { return back()->withErrors(['login_id' => '처음등록한 전화번호와 인증받은 전화번호 정보가 일치 하지 않습니다..'])->withInput(); } // 5) 가공(서버에서 최종 사용 데이터 구성) $final = [ // 회원 입력 'email' => $email, 'password_plain' => (string)$request->input('password'), // 실제 저장은 Hash로 (아래에서 처리) 'pin2_plain' => (string) $request->input('pin2'), // PASS 인증 결과 'pass' => [ 'return_code' => $pv['RETURNCODE'] ?? null, 'tid' => $pv['TID'] ?? null, 'name' => $pv['NAME'] ?? null, 'dob' => $pv['DOB'] ?? null, 'sex' => $pv['SEX'] ?? null, 'foreigner' => $pv['FOREIGNER'] ?? null, 'phone' => $pv['PHONE'] ?? $signupPhone, 'carrier' => $pv['CARRIER'] ?? $signupCarrier, 'ci' => $pv['CI'] ?? null, 'di' => $pv['DI'] ?? null, ], 'meta' => [ 'ip' => $request->ip(), 'ua' => substr((string)$request->userAgent(), 0, 200), ], ]; // 비밀번호는 마스킹/제거 $logFinal = $final; $logFinal['password_plain'] = '***masked***'; $logFinal['pin2_plain'] = '***masked***'; // 6) 컨트롤러에서 "넘어온 값" 확인: 로그로 보기 Log::info('[register.profile.submit] payload', $logFinal); // 7) 여기부터 실제 저장 로직 (Repository로 위임) try { $res = $repo->createMemberFromSignup($final, $request->session()->all()); // 성공 처리 후 세션 정리/이동 $this->clearSession($request, 'signup'); $this->clearSession($request, 'register'); return redirect()->route('web.auth.login') ->with('ui_dialog', [ 'type'=>'alert', 'title'=>'완료', 'message'=>'가입이 완료되었습니다. 로그인해 주세요.', ]); } catch (\Throwable $e) { Log::error('[register.profile.submit] commit failed', [ 'email' => $final['email'] ?? null, 'err' => $e->getMessage(), 'trace' => substr($e->getTraceAsString(), 0, 2000), ]); // PASS 세션은 살아있게 두고(=register/pass 유지), // 입력값은 withInput()으로 다시 채워서 profile로 복귀 return redirect()->route('web.auth.register.profile') ->withInput() ->with('ui_dialog', [ 'type' => 'alert', 'title' => '가입 실패', 'message' => '저장 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', ]); } } private function maskDanalPayloadForLog(array $payload): array { $masked = $payload; $sensitiveKeys = [ 'CI','DI','NAME','BIRTH','SEX','GENDER','TEL','PHONE','MOBILE', 'TID','TXID','TOKEN','ENC','CERT','SSN' ]; foreach ($masked as $k => $v) { $keyUpper = strtoupper((string)$k); foreach ($sensitiveKeys as $sk) { if (str_contains($keyUpper, $sk)) { $masked[$k] = '***'; break; } } } return $masked; } private function clearSession(Request $request, string $key = 'signup'): void { $request->session()->forget($key); } }