diff --git a/app/Http/Controllers/Web/Auth/RegisterController.php b/app/Http/Controllers/Web/Auth/RegisterController.php index de35131..a1f57c5 100644 --- a/app/Http/Controllers/Web/Auth/RegisterController.php +++ b/app/Http/Controllers/Web/Auth/RegisterController.php @@ -4,9 +4,11 @@ namespace App\Http\Controllers\Web\Auth; use App\Http\Controllers\Controller; use App\Repositories\Member\MemberAuthRepository; +use App\Services\Danal\DanalAuthtelService; use App\Rules\RecaptchaV3Rule; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Facades\Log; class RegisterController extends Controller { @@ -43,30 +45,254 @@ class RegisterController extends Controller $ip4 = $request->ip() ?: ''; $result = $repo->step0PhoneCheck((string)$request->input('phone'), $ip4); - if (!$result['ok']) { - $status = ($result['reason'] ?? '') === 'blocked' ? 403 : 422; - return response()->json(['ok' => false, 'message' => $result['message'] ?? '처리 실패'], $status); - } + if (!($result['ok'] ?? false)) { + $reason = $result['reason'] ?? 'error'; - if (($result['reason'] ?? '') === 'already_member') { + // blocked류만 403, 그 외는 422(원하면 already_member는 200으로 바꿔도 됨) + $status = in_array($reason, ['blocked', 'blocked_ip'], true) ? 403 : 422; return response()->json([ - 'ok' => true, - 'reason' => 'already_member', - 'redirect' => route('web.auth.find_id'), - ]); + 'ok' => false, + 'reason' => $reason, + 'message' => $result['message'] ?? '처리에 실패했습니다.', + 'redirect' => $result['redirect'] ?? null, + ], $status); } - $request->session()->put('signup.phone', $result['phone']); $request->session()->put('signup.step', 1); - $request->session()->put('signup.ip4', $ip4); - $request->session()->put('signup.ip4_c', $repo->ipToCClass($ip4)); - $request->session()->put('signup.checked_at', now()->toDateTimeString()); + $request->session()->put('signup.phone', $result['phone']); // 필요하면 + $request->session()->save(); return response()->json([ 'ok' => true, - 'reason' => 'ok', - 'redirect' => route('web.auth.register.terms'), + 'reason' => $result['reason'] ?? 'ok', + 'message' => $result['message'] ?? '', + 'redirect' => $result['redirect'] ?? null, + 'phone' => $result['phone'] ?? null, + ], 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'); + } + + return view('web.auth.danal_autosubmit', [ + 'action' => 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php', + 'fields' => $fields, ]); } + + public function danalResult(Request $request, DanalAuthtelService $danal) + { + $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)); + } + + $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'); + } + + + 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; + } + } + diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php index ad4e5cd..444f7ba 100644 --- a/app/Repositories/Member/MemberAuthRepository.php +++ b/app/Repositories/Member/MemberAuthRepository.php @@ -8,6 +8,7 @@ use App\Models\Member\MemAuthLog; use App\Models\Member\MemInfo; use App\Models\Member\MemJoinFilter; use App\Models\Member\MemJoinLog; +use App\Support\LegacyCrypto\CiSeedCrypto; use Illuminate\Support\Facades\DB; use Illuminate\Support\Carbon; @@ -107,39 +108,18 @@ class MemberAuthRepository * Step0: phone check + join_filter + join_log * ========================================================= */ - public function normalizeKoreanPhone(string $raw): ?string + private function normalizeKoreanPhone(string $raw): ?string { $digits = preg_replace('/\D+/', '', $raw ?? ''); - if (!$digits) return null; - // 82 국제형 → 0 시작으로 변환 - if (str_starts_with($digits, '82')) { - $digits = '0' . substr($digits, 2); + if (strlen($digits) !== 11) { + return null; } - - // 010/011/016/017/018/019 + 10~11자리 - if (!preg_match('/^01[016789]\d{7,8}$/', $digits)) { + if (substr($digits, 0, 3) !== '010') { return null; } - return $digits; - } - - public function ipToCClass(string $ip): string - { - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - return ''; - } - $p = explode('.', $ip); - return count($p) === 4 ? ($p[0] . '.' . $p[1] . '.' . $p[2] . '.0') : ''; - } - - public function isAlreadyMemberByPhone(string $phone): bool - { - return MemInfo::query() - ->where('cell_phone', $phone) - ->where('dt_out', '0000-00-00 00:00:00') - ->exists(); + return $digits; // ✅ 무조건 11자리 숫자만 리턴 } /** @@ -196,62 +176,169 @@ class MemberAuthRepository */ public function step0PhoneCheck(string $rawPhone, string $ip4 = ''): array { + $base = [ + 'ok' => false, + 'reason' => '', + 'message' => '', + 'redirect' => null, + 'phone' => null, + 'filter' => null, + 'admin_phones' => [], + ]; + + // 0) IP 필터 먼저 + $ip4c = $this->ipToCClass($ip4); + $ipHit = $this->checkJoinFilterByIp($ip4, $ip4c); + + if ($ipHit) { + $base['filter'] = $ipHit; + $base['admin_phones'] = $ipHit['admin_phones'] ?? []; + } + + if ($ipHit && ($ipHit['join_block'] ?? '') === 'A') { + return array_merge($base, [ + 'ok' => false, + 'reason' => 'blocked_ip', + 'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.', + ]); + } + + // 1) 전화번호 검증 $phone = $this->normalizeKoreanPhone($rawPhone); if (!$phone) { - return [ + return array_merge($base, [ 'ok' => false, 'reason' => 'invalid_phone', 'message' => '휴대폰 번호 형식이 올바르지 않습니다.', - ]; - } - - $ip4c = $this->ipToCClass($ip4); - - // already member - if ($this->isAlreadyMemberByPhone($phone)) { - $this->writeJoinLog([ - 'gubun' => 'already_member', - 'mem_no' => 0, - 'cell_phone' => $phone, - 'ip4' => $ip4, - 'ip4_c' => $ip4c, - 'error_code' => 'J2', ]); + } + $base['phone'] = $phone; - return ['ok' => true, 'reason' => 'already_member', 'phone' => $phone]; + // 2) 이미 회원인지 체크 + $member = $this->findMemberByPhone($phone); + if ($member && !empty($member->mem_no)) { + return array_merge($base, [ + 'ok' => false, + 'reason' => 'already_member', + 'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?", + 'redirect' => route('web.auth.find_id'), // ✅ 이미가입이면 아이디 찾기 + 'matched_mem_no' => (int)$member->mem_no, // 필요하면 유지 + ]); } - // join filter + // 3) 기존 phone+ip 필터 $filter = $this->checkJoinFilter($phone, $ip4, $ip4c); if ($filter && ($filter['block'] ?? false) === true) { - $this->writeJoinLog([ - 'gubun' => $filter['gubun'] ?? 'filter_block', - 'mem_no' => 0, - 'cell_phone' => $phone, - 'ip4' => $ip4, - 'ip4_c' => $ip4c, - 'error_code' => 'J1', - ]); - - return [ + return array_merge($base, [ 'ok' => false, 'reason' => 'blocked', - 'phone' => $phone, 'filter' => $filter, 'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.', - ]; + ]); } - // pass - $this->writeJoinLog([ - 'gubun' => $filter['gubun'] ?? 'ok', - 'mem_no' => 0, - 'cell_phone' => $phone, - 'ip4' => $ip4, - 'ip4_c' => $ip4c, - 'error_code' => 'J0', + // 4) 성공(가입 가능) + return array_merge($base, [ + 'ok' => true, + 'reason' => 'ok', + 'filter' => $filter ?: $ipHit, + 'message' => "회원가입 가능한 전화번호 입니다.\n\n약관동의 페이지로 이동합니다.", + 'redirect' => route('web.auth.register.terms'), ]); + } - return ['ok' => true, 'reason' => 'ok', 'phone' => $phone, 'filter' => $filter]; + + + private function findMemberByPhone(string $phone): ?object + { + /** @var CiSeedCrypto $seed */ + $seed = app(CiSeedCrypto::class); + $phoneEnc = $seed->encrypt($phone); + + // 실제로 매칭되는 회원 1건을 가져옴 + return DB::table('mem_info') + ->select('mem_no', 'cell_phone') + ->where('cell_phone', $phoneEnc) + ->limit(1) + ->first(); + } + + + public function ipToCClass(string $ip): string + { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return ''; + } + $p = explode('.', $ip); + return count($p) === 4 ? ($p[0] . '.' . $p[1] . '.' . $p[2] . '.0') : ''; + } + + private function checkJoinFilterByIp(string $ip4, string $ip4c): ?array + {$ip4 = "19dd.0"; + // IPv4 아니면 필터 적용 안 함 + if (!filter_var($ip4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return null; + } + + + // 우선순위: A(차단) > S(관리자알림) > N(무시) + $row = DB::table('mem_join_filter') + ->whereIn('join_block', ['A', 'S']) + ->where(function ($q) use ($ip4, $ip4c) { + // ✅ C class (보통 gubun_code='01', filter='162.168.0.0') + $q->where(function ($q2) use ($ip4c) { + $q2->where('gubun_code', '01') + ->where('filter', $ip4c); + }); + + // ✅ D class (보통 gubun_code='02', filter='162.168.0.2') + $q->orWhere(function ($q2) use ($ip4) { + $q2->where('gubun_code', '02') + ->where('filter', $ip4); + }); + + // ✅ (레거시) gubun_code가 01인데도 filter에 단일 IP가 들어있는 케이스 방어 + $q->orWhere(function ($q2) use ($ip4) { + $q2->where('gubun_code', '01') + ->where('filter', $ip4); + }); + }) + ->orderByRaw("CASE join_block WHEN 'A' THEN 0 WHEN 'S' THEN 1 ELSE 9 END") + ->limit(1) + ->first(); + + if (!$row) return null; + + $adminPhones = $this->parseAdminPhones($row->admin_phone ?? ''); + + return [ + 'gubun_code' => $row->gubun_code ?? null, + 'join_block' => $row->join_block ?? null, + 'filter' => $row->filter ?? null, + 'admin_phones' => $adminPhones, + 'ip' => $ip4, + 'ip4c' => $ip4c, + ]; + } + + private function parseAdminPhones($value): array + { + // admin_phone: ["010-3682-8958", ...] 형태(=JSON 배열)라고 했으니 JSON 우선 + if (is_array($value)) return $value; + + $s = trim((string)$value); + if ($s === '') return []; + + $json = json_decode($s, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($json)) { + return array_values(array_filter(array_map('trim', $json))); + } + + // 혹시 "010...,010..." 같은 문자열이면 콤마 분리 fallback + $parts = array_map('trim', explode(',', $s)); + return array_values(array_filter($parts)); } } + + + diff --git a/app/Services/Danal/DanalAuthtelService.php b/app/Services/Danal/DanalAuthtelService.php new file mode 100644 index 0000000..6cc0f40 --- /dev/null +++ b/app/Services/Danal/DanalAuthtelService.php @@ -0,0 +1,222 @@ +serviceUrl = (string) Arr::get($cfg, 'service_url', ''); + $this->cpid = (string) Arr::get($cfg, 'cpid', ''); + $this->cppwd = (string) Arr::get($cfg, 'cppwd', ''); + $this->charset = (string) Arr::get($cfg, 'charset', 'UTF-8'); + $this->connectTimeout = (int) Arr::get($cfg, 'connect_timeout', 5); + $this->timeout = (int) Arr::get($cfg, 'timeout', 30); + $this->debug = (bool) Arr::get($cfg, 'debug', false); + } + + public function prepare(array $opt): array + { + // 필수 설정 체크 + if ($this->cpid === '' || $this->cppwd === '' || $this->serviceUrl === '') { + return ['ok' => false, 'message' => 'DANAL 설정(CPID/CPPWD/SERVICE_URL)이 없습니다.']; + } + + $targetUrl = (string) ($opt['targetUrl'] ?? ''); + $backUrl = (string) ($opt['backUrl'] ?? ''); + + if ($targetUrl === '') return ['ok' => false, 'message' => 'targetUrl 누락']; + if ($backUrl === '') return ['ok' => false, 'message' => 'backUrl 누락']; + + // 기본 타이틀 + $cpTitle = (string) ($opt['cpTitle'] ?? request()->getHost()); + + // TransR + $trans = [ + 'TXTYPE' => 'ITEMSEND', + 'SERVICE' => 'UAS', + 'AUTHTYPE' => '36', + 'CPID' => $this->cpid, + 'CPPWD' => $this->cppwd, + 'TARGETURL' => $targetUrl, + 'CPTITLE' => $cpTitle, + ]; + + // 다날 서버 통신 + $res = $this->callTrans($trans); + + if (($res['RETURNCODE'] ?? '') !== '0000') { + return [ + 'ok' => false, + 'message' => $this->formatReturnMsg($res), + ]; + } + + // CI3의 ByPassValue + $byPass = [ + // CI의 GetBgColor(0~10 => "00"~"10") 느낌 유지 + 'BgColor' => $this->getBgColor($this->getRandom(0, 10)), + 'BackURL' => $backUrl, + 'IsCharSet' => $this->charset, + 'ByBuffer' => 'This value bypass to CPCGI Page', + 'ByAnyName' => 'AnyValue', + ]; + + // Start.php로 보낼 hidden fields 구성 + // - 여기서는 절대 e()/htmlspecialchars 같은 escape를 하지 마세요. + // - 프론트에서 input.value로 넣으면 브라우저가 처리합니다. + $fields = array_merge( + $this->makeFormFields($res, ['RETURNCODE', 'RETURNMSG']), + $this->makeFormFields($byPass) + ); + + return [ + 'ok' => true, + 'txid' => $res['TID'] ?? $res['TXID'] ?? null, + 'fields' => $fields, + ]; + } + + /** + * 다날 서버 통신 + * @return array + */ + public function callTrans(array $reqData): array + { + // x-www-form-urlencoded + $body = http_build_query($reqData, '', '&', PHP_QUERY_RFC3986); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_POST, 1); + + // SSL 검증은 끄지 마세요 (운영 보안) + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->connectTimeout); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + curl_setopt($ch, CURLOPT_URL, $this->serviceUrl); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/x-www-form-urlencoded; charset=' . $this->charset, + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + $resStr = curl_exec($ch); + + if (($errno = curl_errno($ch)) !== 0) { + $err = curl_error($ch) ?: 'curl error'; + curl_close($ch); + + return [ + 'RETURNCODE' => '-1', + 'RETURNMSG' => 'NETWORK ERROR(' . $errno . ':' . $err . ')', + ]; + } + + curl_close($ch); + + // 다날 응답 파싱 + return $this->str2data((string) $resStr); + } + + /** @return array */ + private function str2data(string $str): array + { + $data = []; + foreach (explode('&', $str) as $line) { + $kv = explode('=', $line, 2); + if (count($kv) === 2) { + $data[$kv[0]] = $kv[1]; + } + } + return $data; + } + + /** + * hidden fields 만들기 (escape 하지 않음) + * @param array $arr + * @param array $excludeKeys + * @return array + */ + private function makeFormFields(array $arr, array $excludeKeys = [], string $prefix = ''): array + { + $out = []; + $preLen = strlen(trim($prefix)); + + foreach ($arr as $key => $value) { + $key = (string) $key; + if ($key === '' || trim($key) === '') continue; + + if (in_array($key, $excludeKeys, true)) continue; + + if ($preLen > 0 && substr($key, 0, $preLen) !== $prefix) continue; + + // 다날은 value에 urlencoded가 들어갈 수 있음 + // 그대로 보내는 게 원칙이지만, 필요 시 여기서 정책적으로 urldecode/encode 조절 가능 + $out[$key] = is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE); + } + + return $out; + } + + private function getBgColor($bgColor): string + { + $color = 0; + $i = (int) $bgColor; + if ($i > 0 && $i < 11) $color = $i; + return sprintf('%02d', $color); + } + + private function getRandom(int $min, int $max): int + { + return random_int($min, $max); + } + + /** @param array $res */ + private function formatReturnMsg(array $res): string + { + $msg = (string)($res['RETURNMSG'] ?? 'DANAL ERROR'); + $code = (string)($res['RETURNCODE'] ?? 'NO_CODE'); + return $msg . ' (' . $code . ')'; + } + + public function confirm(string $tid, int $confirmOption = 0, int $idenOption = 1): array + { + $req = [ + 'TXTYPE' => 'CONFIRM', + 'TID' => $tid, + 'CONFIRMOPTION' => $confirmOption, + 'IDENOPTION' => $idenOption, + ]; + + // CI 주석: CONFIRMOPTION=1이면 CPID/ORDERID 필수 + if ($confirmOption === 1) { + $req['CPID'] = $this->cpid; + } + + return $this->callTrans($req); + } + + +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 81bf5bf..f324506 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,7 +22,7 @@ return Application::configure(basePath: dirname(__DIR__)) }, ) - // (B) Middleware: Reverse Proxy/Host 신뢰 정책 + // (B) Middleware: Reverse Proxy/Host 신뢰 정책 + CSRF 예외 ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: [ '192.168.100.0/24', @@ -35,6 +35,13 @@ return Application::configure(basePath: dirname(__DIR__)) 'four.syye.net', 'shot.syye.net', ]); + + // CSRF 예외 처리 + // - 도메인 제외, path만 + // - 네 라우트 정의 기준: POST register/danal/result + $middleware->validateCsrfTokens(except: [ + 'auth/register/danal/result', //다날 PASS 콜백 (외부 서버가 호출) + ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/danal.php b/config/danal.php new file mode 100644 index 0000000..208abe5 --- /dev/null +++ b/config/danal.php @@ -0,0 +1,15 @@ + [ + // CI3 CallTrans가 호출하던 다날 "CPCGI" URL + 'service_url' => env('DANAL_AUTHTEL_SERVICE_URL', ''), + 'charset' => env('DANAL_AUTHTEL_CHARSET', 'UTF-8'), + 'connect_timeout' => env('DANAL_AUTHTEL_CONNECT_TIMEOUT', 5), + 'timeout' => env('DANAL_AUTHTEL_TIMEOUT', 30), + 'debug' => env('DANAL_AUTHTEL_DEBUG', false), + + 'cpid' => env('DANAL_AUTHTEL_CPID', ''), + 'cppwd' => env('DANAL_AUTHTEL_CPPWD', ''), + ], +]; diff --git a/public/assets/images/web/member/idpwfind.png b/public/assets/images/web/member/idpwfind.png new file mode 100644 index 0000000..6f5b32c Binary files /dev/null and b/public/assets/images/web/member/idpwfind.png differ diff --git a/public/assets/images/web/member/idpwfind.webp b/public/assets/images/web/member/idpwfind.webp new file mode 100644 index 0000000..ce04e96 Binary files /dev/null and b/public/assets/images/web/member/idpwfind.webp differ diff --git a/public/assets/images/web/member/login.png b/public/assets/images/web/member/login.png new file mode 100644 index 0000000..b77b5a1 Binary files /dev/null and b/public/assets/images/web/member/login.png differ diff --git a/public/assets/images/web/member/login.webp b/public/assets/images/web/member/login.webp new file mode 100644 index 0000000..e82c34f Binary files /dev/null and b/public/assets/images/web/member/login.webp differ diff --git a/public/assets/images/web/member/register.png b/public/assets/images/web/member/register.png new file mode 100644 index 0000000..f670624 Binary files /dev/null and b/public/assets/images/web/member/register.png differ diff --git a/public/assets/images/web/member/register.webp b/public/assets/images/web/member/register.webp new file mode 100644 index 0000000..be8009f Binary files /dev/null and b/public/assets/images/web/member/register.webp differ diff --git a/resources/css/ui-dialog.css b/resources/css/ui-dialog.css new file mode 100644 index 0000000..36fdfb3 --- /dev/null +++ b/resources/css/ui-dialog.css @@ -0,0 +1,53 @@ +.ui-dialog { position: fixed; inset: 0; z-index: 9999; display: none; } +.ui-dialog.is-open { display: block; } + +.ui-dialog__backdrop { + position: absolute; inset: 0; + background: rgba(0,0,0,.55); + backdrop-filter: blur(2px); +} + +.ui-dialog__panel { + position: relative; + width: min(520px, calc(100% - 32px)); + margin: 12vh auto 0; + background: #111; + color: #fff; + border: 1px solid rgba(255,255,255,.12); + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0,0,0,.55); + overflow: hidden; +} +.ui-dialog { position: fixed; inset: 0; z-index: 200000; display: none; } +.ui-dialog__header { display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.10); } +.ui-dialog__title { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.2px; } +.ui-dialog__x { + width: 36px; height: 36px; border: 0; background: transparent; color: rgba(255,255,255,.8); + font-size: 22px; cursor: pointer; border-radius: 10px; +} +.ui-dialog__x:hover { background: rgba(255,255,255,.08); } + +.ui-dialog__body { padding: 16px; } +.ui-dialog__message { margin: 0; white-space: pre-line; line-height: 1.6; color: rgba(255,255,255,.92); } + +.ui-dialog__footer { display:flex; gap: 10px; justify-content:flex-end; padding: 14px 16px; border-top: 1px solid rgba(255,255,255,.10); } + +.ui-dialog__btn { + padding: 10px 14px; + border-radius: 12px; + border: 1px solid rgba(255,255,255,.16); + background: rgba(255,255,255,.06); + color: #fff; + cursor: pointer; + font-weight: 600; +} +.ui-dialog__btn:hover { background: rgba(255,255,255,.10); } + +.ui-dialog__btn--ok { background: rgba(99,102,241,.22); border-color: rgba(99,102,241,.45); } +.ui-dialog__btn--ok:hover { background: rgba(99,102,241,.30); } + +.ui-dialog__btn--danger { background: rgba(239,68,68,.20); border-color: rgba(239,68,68,.45); } +.ui-dialog__btn--danger:hover { background: rgba(239,68,68,.28); } + +.ui-dialog__btn[disabled] { opacity: .6; cursor: not-allowed; } + diff --git a/resources/js/ui/dialog.js b/resources/js/ui/dialog.js new file mode 100644 index 0000000..b9630f2 --- /dev/null +++ b/resources/js/ui/dialog.js @@ -0,0 +1,243 @@ +class UiDialog { + constructor(rootId = "uiDialog") { + this.root = document.getElementById(rootId); + if (!this.root) return; + + this.titleEl = this.root.querySelector("#uiDialogTitle"); + this.msgEl = this.root.querySelector("#uiDialogMessage"); + this.okBtn = this.root.querySelector("#uiDialogOk"); + this.cancelBtn = this.root.querySelector("#uiDialogCancel"); + + this._resolver = null; + this._type = "alert"; + this._lastFocus = null; + + // close triggers + this.root.addEventListener("click", (e) => { + if (!e.target?.dataset?.uidialogClose) return; + + // 어떤 close인지 구분 (backdrop / x) + const isBackdrop = !!e.target.closest(".ui-dialog__backdrop"); + const isX = !!e.target.closest(".ui-dialog__x"); + + // 정책에 따라 차단 (기본: 모두 차단) + if (isBackdrop && !this._closePolicy?.backdrop) return; + if (isX && !this._closePolicy?.x) return; + + this._resolve(false); + }); + + + // keyboard + document.addEventListener("keydown", (e) => { + if (!this.isOpen()) return; + + if (e.key === "Escape") { + if (this._closePolicy?.esc) this._resolve(false); + return; + } + if (e.key === "Enter") { + // Enter는 OK로 처리 (기본 true) + if (this._closePolicy?.enter !== false) this._resolve(true); + return; + } + }); + this.okBtn.addEventListener("click", () => this._resolve(true)); + this.cancelBtn.addEventListener("click", () => this._resolve(false)); + } + + isOpen() { + return !!this.root && this.root.classList.contains("is-open"); + } + + alert(message, options = {}) { + return this._open("alert", message, options); + } + + confirm(message, options = {}) { + return this._open("confirm", message, options); + } + + _open(type, message, options) { + if (!this.root) return Promise.resolve(false); + + const { + title = type === "confirm" ? "확인" : "알림", + okText = "확인", + cancelText = "취소", + dangerous = false, + + // ✅ 기본: 밖 클릭/닫기(X)/ESC로 닫기 금지 + closeOnBackdrop = false, + closeOnX = false, + closeOnEsc = false, + closeOnEnter = true, + } = options; + + this._closePolicy = { + backdrop: !!closeOnBackdrop, + x: !!closeOnX, + esc: !!closeOnEsc, + enter: closeOnEnter !== false, + }; + + this._type = type; + this._lastFocus = document.activeElement; + + this.titleEl.textContent = title; + this.msgEl.textContent = message ?? ""; + + this.okBtn.textContent = okText; + this.cancelBtn.textContent = cancelText; + + // alert면 cancel 숨김 + if (type === "alert") { + this.cancelBtn.style.display = "none"; + } else { + this.cancelBtn.style.display = ""; + } + + // danger 스타일 + this.okBtn.classList.toggle("ui-dialog__btn--danger", !!dangerous); + + // open + this.root.classList.add("is-open"); + this.root.setAttribute("aria-hidden", "false"); + document.documentElement.style.overflow = "hidden"; + + // focus + setTimeout(() => this.okBtn.focus(), 0); + + return new Promise((resolve) => { + this._resolver = resolve; + }); + } + + _resolve(ok) { + if (!this.isOpen()) return; + + // close + this.root.classList.remove("is-open"); + this.root.setAttribute("aria-hidden", "true"); + document.documentElement.style.overflow = ""; + + const r = this._resolver; + this._resolver = null; + + // focus restore + try { this._lastFocus?.focus?.(); } catch (e) {} + + if (typeof r === "function") r(!!ok); + } +} + +// 전역으로 노출 (모든 페이지에서 바로 호출) +window.uiDialog = new UiDialog(); + + +// ====================================================== +// ✅ Global showMsg / clearMsg (공통 사용) +// - 페이지에서는 showMsg/clearMsg만 호출 +// - await showMsg(...) 하면 버튼 클릭 전까지 다음 코드 진행이 멈춤 +// ====================================================== +(function () { + let cachedHelpEl = null; + + function getHelpEl(opt = {}) { + // opt.helpId를 주면 그걸 우선 사용 + if (opt.helpId) { + const el = document.getElementById(opt.helpId); + if (el) return el; + } + // 기본 fallback id (너가 쓰는 reg_phone_help) + if (cachedHelpEl && document.contains(cachedHelpEl)) return cachedHelpEl; + cachedHelpEl = document.getElementById("reg_phone_help"); + return cachedHelpEl; + } + + /** + * showMsg(msg, opt) + * opt: { + * type:'alert'|'confirm', + * title, okText, cancelText, dangerous, + * redirect, helpId + * } + * return: Promise (confirm: true/false, alert: true) + */ + window.showMsg = async function (msg, opt = {}) { + const d = Object.assign({ + type: "alert", + title: "알림", + okText: "확인", + cancelText: "취소", + dangerous: false, + redirect: "", + helpId: "", + }, opt || {}); + + // ✅ 모달이 있으면 모달로 (여기서 await로 멈춤) + if (window.uiDialog && typeof window.uiDialog[d.type] === "function") { + const ok = await window.uiDialog[d.type](msg || "", d); + + if (d.redirect) { + // alert: OK(true)일 때만 / confirm: OK(true)일 때만 + if (ok) { + window.location.href = d.redirect; + } + } + + return d.type === "confirm" ? !!ok : true; + } + + // ✅ fallback (모달이 없으면 help 영역에 표시) + const helpEl = getHelpEl(d); + if (helpEl) { + helpEl.style.display = "block"; + helpEl.textContent = msg || ""; + return true; + } + + // 마지막 fallback + alert(msg || ""); + return true; + }; + + /** + * clearMsg(helpId?) + */ + window.clearMsg = function (helpId = "") { + const el = helpId ? document.getElementById(helpId) : getHelpEl({}); + if (!el) return; + el.style.display = "none"; + el.textContent = ""; + }; +})(); + + +// ====================================================== +// 서버 flash 자동 실행 (기존 유지) +// ====================================================== +document.addEventListener("DOMContentLoaded", async () => { + const flashEl = document.getElementById("uiDialogFlash"); + if (!flashEl || !window.uiDialog) return; + + let payload = null; + try { payload = JSON.parse(flashEl.textContent || "{}"); } catch (e) {} + + if (!payload) return; + + /** + * payload 예시: + * { type:'alert'|'confirm', message:'...', title:'...', okText:'...', cancelText:'...', redirect:'/...' } + */ + const type = payload.type || "alert"; + const ok = await window.uiDialog[type](payload.message || "", payload); + + // confirm이고 OK일 때만 redirect 같은 후속 처리 + if (type === "confirm") { + if (ok && payload.redirect) window.location.href = payload.redirect; + } else { + if (payload.redirect) window.location.href = payload.redirect; + } +}); + diff --git a/resources/js/web.js b/resources/js/web.js index 6b6a1d9..15b0ba3 100644 --- a/resources/js/web.js +++ b/resources/js/web.js @@ -1,3 +1,6 @@ +import "../css/ui-dialog.css"; +import "./ui/dialog"; + document.addEventListener('DOMContentLoaded', () => { // --- Hero Carousel Logic --- diff --git a/resources/views/web/auth/danal_autosubmit.blade.php b/resources/views/web/auth/danal_autosubmit.blade.php new file mode 100644 index 0000000..25fef0c --- /dev/null +++ b/resources/views/web/auth/danal_autosubmit.blade.php @@ -0,0 +1,28 @@ +{{-- resources/views/web/auth/danal_autosubmit.blade.php --}} + + + + + + 본인인증 이동 + + +
+ 본인인증 페이지로 이동 중입니다. 잠시만 기다려 주세요… +
+ +
+ @foreach(($fields ?? []) as $k => $v) + + @endforeach + + +
+ + + + diff --git a/resources/views/web/auth/danal_finish_top.blade.php b/resources/views/web/auth/danal_finish_top.blade.php new file mode 100644 index 0000000..515a243 --- /dev/null +++ b/resources/views/web/auth/danal_finish_top.blade.php @@ -0,0 +1,32 @@ + + + + + + 처리 결과 + + + + + + + diff --git a/resources/views/web/auth/find_id.blade.php b/resources/views/web/auth/find_id.blade.php index 039e0f6..f5ed92e 100644 --- a/resources/views/web/auth/find_id.blade.php +++ b/resources/views/web/auth/find_id.blade.php @@ -12,6 +12,13 @@ @section('auth_content')
{{-- STEP 1 --}} +
diff --git a/resources/views/web/auth/login.blade.php b/resources/views/web/auth/login.blade.php index 60cf007..045c2a9 100644 --- a/resources/views/web/auth/login.blade.php +++ b/resources/views/web/auth/login.blade.php @@ -12,6 +12,14 @@ @section('auth_content') + +
diff --git a/resources/views/web/auth/pass_redirect.blade.php b/resources/views/web/auth/pass_redirect.blade.php new file mode 100644 index 0000000..a3eed5e --- /dev/null +++ b/resources/views/web/auth/pass_redirect.blade.php @@ -0,0 +1,25 @@ +@extends('web.layouts.auth') + +@section('title', '본인인증 이동 | PIN FOR YOU') + +@section('content') +
+

본인인증으로 이동 중입니다…

+

잠시만 기다려 주세요.

+ + + {{-- 다날로 POST할 때 CSRF는 "우리 사이트용"이라 필요 없음 --}} + @foreach($inputs as $k => $v) + + @endforeach + + + +
+ + +@endsection diff --git a/resources/views/web/auth/profile.blade.php b/resources/views/web/auth/profile.blade.php new file mode 100644 index 0000000..8e6bf9c --- /dev/null +++ b/resources/views/web/auth/profile.blade.php @@ -0,0 +1,36 @@ +@extends('web.layouts.auth') + +@section('title', '회원정보 입력 | PIN FOR YOU') + +@section('content') +
+

회원정보 입력

+

계정 생성을 위해 필요한 정보를 입력해 주세요.

+ +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+@endsection diff --git a/resources/views/web/auth/register.blade.php b/resources/views/web/auth/register.blade.php index bdd9f45..4064afa 100644 --- a/resources/views/web/auth/register.blade.php +++ b/resources/views/web/auth/register.blade.php @@ -5,51 +5,23 @@ @section('canonical', url('/auth/register')) @section('h1', '회원가입') -@section('desc', '휴대폰 번호 확인 후 본인인증을 진행합니다.') +@section('desc', '휴대폰 번호로 가입 여부를 먼저 확인한 뒤, PASS 본인인증을 진행합니다.') @section('card_aria', '회원가입 Step0 - 휴대폰 확인') @section('show_cs_links', true) @section('auth_content') {{-- Step0 Hero Image + 안내문구 --}} - - + +
@csrf @@ -91,15 +63,6 @@ const help = document.getElementById('reg_phone_help'); const btn = document.getElementById('reg_next_btn'); - function showMsg(msg) { - help.style.display = 'block'; - help.textContent = msg; - } - function clearMsg() { - help.style.display = 'none'; - help.textContent = ''; - } - // 숫자만 남기고 010-0000-0000 형태로 포맷 function formatPhone(value) { const digits = (value || '').replace(/\D/g, '').slice(0, 11); @@ -137,12 +100,12 @@ const phoneDigits = toDigits(input.value); if (!phoneDigits) { - showMsg('휴대폰 번호를 입력해 주세요.'); + await showMsg('휴대폰 번호를 입력해 주세요.', { type:'alert', title:'입력오류' }); input.focus(); return; } if (phoneDigits.length < 10) { - showMsg('휴대폰 번호를 끝까지 입력해 주세요.'); + await showMsg('휴대폰 번호를 끝까지 입력해 주세요.', { type:'alert', title:'입력오류' }); input.focus(); return; } @@ -166,21 +129,49 @@ }) }); - const data = await res.json().catch(() => ({})); + // 422/403에서도 JSON 잘 파싱되게 안전 처리 + const raw = await res.text(); + let data = {}; + try { data = JSON.parse(raw || "{}"); } catch (e) { data = {}; } - if (!res.ok || data.ok === false) { - showMsg(data.message || '처리에 실패했습니다.'); + // 공통 메시지 + const msg = data.message || '처리에 실패했습니다.'; + + // 1) 이미 가입(=confirm) + if (data.reason === 'already_member') { + await showMsg(msg, { + type: 'confirm', + title: '안내', + okText: '이동', + cancelText: '취소', + redirect: data.redirect, + }); return; } - if (data.redirect) { - window.location.href = data.redirect; + // 2) 성공(=confirm) + if (data.ok === true) { + await showMsg(msg || '회원가입 가능한 전화번호 입니다.\n\n인증페이지로 이동합니다.', { + type: 'confirm', + title: '안내', + okText: '이동', + cancelText: '취소', + redirect: data.redirect, + }); return; } - showMsg('처리에 실패했습니다.'); + // 3) 나머지 전부 실패(=alert) + await showMsg(msg, { + type: 'alert', + title: '안내', + }); + } catch (e) { - showMsg('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'); + await showMsg("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", { + type:'alert', + title:'네트워크오류' + }); } finally { btn.disabled = false; } diff --git a/resources/views/web/auth/register_terms.blade.php b/resources/views/web/auth/register_terms.blade.php index d41f950..8c0a36a 100644 --- a/resources/views/web/auth/register_terms.blade.php +++ b/resources/views/web/auth/register_terms.blade.php @@ -5,18 +5,796 @@ @section('canonical', url('/auth/register/terms')) @section('h1', '약관 동의') -@section('desc', '다음 단계에서 본인인증을 진행합니다.') +@section('desc', '필수 약관에 동의하시면 다음 단계(본인인증)로 진행합니다.') @section('card_aria', '회원가입 Step1 - 약관 동의') @section('show_cs_links', true) @section('auth_content') -
임시 페이지
-

- Step0(휴대폰 확인) 통과했습니다.
- 다음 단계에서 약관 UI와 저장 로직을 붙일 예정입니다. -

+
+ {{-- 진행 단계 --}} +
+
약관 동의
+
본인인증
+
가입정보
+
-
- 처음으로 +{{-- --}} + + @csrf + + {{-- 전체 동의 --}} +
+ +
+ + {{-- 약관 리스트 --}} +
+ {{-- [필수] 이용약관 --}} +
+ + + +
+ + +
+ + {{-- [필수] 개인정보 --}} +
+ + + +
+ + +
+ + {{-- [필수] 14세 --}} +
+ +
+ +
+ + {{-- [선택] 마케팅 --}} +
+ + + +
+ +
+ + + +
+ + 이전 +
+ +

+ 다음 단계에서 PASS 본인인증을 진행합니다. +

+
+ + {{-- 페이지 전용 스타일(이쁘게) --}} + + + {{-- 페이지 전용 JS --}} + + @endsection + diff --git a/resources/views/web/layouts/layout.blade.php b/resources/views/web/layouts/layout.blade.php index 8c2f520..504a5e7 100644 --- a/resources/views/web/layouts/layout.blade.php +++ b/resources/views/web/layouts/layout.blade.php @@ -69,5 +69,9 @@ {{-- 페이지별 스크립트 추가용 --}} @stack('recaptcha') @stack('scripts') +@include('web.partials.ui-dialog') + +{{--@php 개발모드에서만 세션표시--}} +@include('web.partials.dev_session_overlay') diff --git a/resources/views/web/partials/auth/danal_autosubmit.blade.php b/resources/views/web/partials/auth/danal_autosubmit.blade.php new file mode 100644 index 0000000..e3243b5 --- /dev/null +++ b/resources/views/web/partials/auth/danal_autosubmit.blade.php @@ -0,0 +1,12 @@ + + + + +
+ @foreach($fields as $k => $v) + + @endforeach +
+ + + diff --git a/resources/views/web/partials/auth/danal_finish.blade.php b/resources/views/web/partials/auth/danal_finish.blade.php new file mode 100644 index 0000000..cb5247b --- /dev/null +++ b/resources/views/web/partials/auth/danal_finish.blade.php @@ -0,0 +1,16 @@ + + + + + + + diff --git a/resources/views/web/partials/dev_session_overlay.blade.php b/resources/views/web/partials/dev_session_overlay.blade.php new file mode 100644 index 0000000..14e29b3 --- /dev/null +++ b/resources/views/web/partials/dev_session_overlay.blade.php @@ -0,0 +1,218 @@ +{{-- resources/views/web/partials/dev_session_overlay.blade.php --}} +@php + // ✅ 개발 모드에서만 노출 + $show = config('app.debug') || app()->environment('local'); + + // ✅ 이 overlay 자체가 세션을 수정하는 "dev action" 처리(컨트롤러/라우트 없이) + if ($show && request()->isMethod('post') && request()->has('_dev_sess_action')) { + // CSRF는 web 미들웨어에 걸려있으니 토큰 포함된 요청만 처리됨. + $action = request()->input('_dev_sess_action'); + + if ($action === 'flush') { + session()->flush(); + session()->save(); + } + + if ($action === 'put') { + $k = (string) request()->input('_dev_sess_key', ''); + $v = (string) request()->input('_dev_sess_value', ''); + + // 빈 키 방지 + if ($k !== '') { + // "a.b.c" 점 표기 지원 + data_set(session()->all(), $k, $v); + + // data_set은 배열에만 반영되므로 세션에 직접 put + session()->put($k, $v); + session()->save(); + } + } + + // ✅ POST 재전송 방지 + 현재 페이지로 되돌리기 + $redir = url()->current(); + $qs = request()->query(); + if (!empty($qs)) $redir .= '?' . http_build_query($qs); + header('Location: ' . $redir, true, 302); + exit; + } + + // ✅ 세션 전체 + $sess = session()->all(); + + // ✅ 민감값 마스킹 + $maskKeys = []; +// $maskKeys = [ +// 'password', 'passwd', 'pw', 'token', 'access_token', 'refresh_token', +// 'api_key', 'secret', 'authorization', 'csrf', '_token', +// 'g-recaptcha-response', 'recaptcha', 'otp', +// 'ci', 'di', 'phone', 'mobile', 'email', +// ]; + + $mask = function ($key, $val) use ($maskKeys) { + $k = strtolower((string)$key); + foreach ($maskKeys as $mk) { + if (str_contains($k, $mk)) return '***'; + } + return $val; + }; + + // ✅ key:value 라인 생성(재귀) + $lines = []; + $dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) { + foreach ((array)$data as $k => $v) { + $key = $prefix . (string)$k; + + if (is_array($v)) { + $lines[] = $key . ' : ['; + $dump($v, $prefix . ' '); + $lines[] = $prefix . ']'; + } else { + if (is_bool($v)) $v = $v ? 'true' : 'false'; + if ($v === null) $v = 'null'; + + $vv = is_string($v) ? (mb_strlen($v) > 260 ? mb_substr($v, 0, 260) . '…' : $v) : (string)$v; + $vv = $mask($k, $vv); + + $lines[] = $key . ' : ' . $vv; + } + } + }; + $dump($sess); + $text = implode("\n", $lines); +@endphp + +@if($show) +
+
+
+ SESSION + ({{ count($sess) }} keys) +
+
+ + +
+
+ +
+
+ {{ request()->method() }} {{ request()->path() }} +
+ + {{-- ✅ Controls --}} +
+
+ @csrf + + + + +
+ +
+ + @csrf + + + + + +
+
+ +
+
{!! e($text) !!}
+
+
+
+ + +@endif diff --git a/resources/views/web/partials/ui-dialog.blade.php b/resources/views/web/partials/ui-dialog.blade.php new file mode 100644 index 0000000..5ffc99a --- /dev/null +++ b/resources/views/web/partials/ui-dialog.blade.php @@ -0,0 +1,28 @@ +{{-- 공통 Alert/Confirm Dialog (no bootstrap dependency) --}} + + +{{-- 서버 flash로 띄우고 싶을 때: session('ui_dialog') 사용 --}} +@if (session()->has('ui_dialog')) + +@endif + diff --git a/routes/web.php b/routes/web.php index 01220aa..1f392b5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -58,12 +58,21 @@ Route::prefix('auth')->name('web.auth.')->group(function () { // 정적 페이지 Route::view('login', 'web.auth.login')->name('login'); - // ✅ 회원가입 Step0 + // 회원가입 Step0 Route::get('register', [RegisterController::class, 'showStep0'])->name('register'); Route::post('register/phone-check', [RegisterController::class, 'postPhoneCheck'])->name('register.phone_check'); - // ✅ Step1(약관) - 지금은 임시 화면 + // Step1(약관) - 지금은 임시 화면 Route::get('register/terms', [RegisterController::class, 'showTerms'])->name('register.terms'); + Route::post('register/terms', [RegisterController::class, 'termsSubmit'])->name('register.terms.submit'); + + // Step2 다날 인증 시작 화면 + Route::post('register/danal/start', [RegisterController::class, 'danalStart'])->name('register.danal.start'); // iframe에서 로드될 페이지 + Route::post('register/danal/result', [RegisterController::class, 'danalResult'])->name('register.danal.result'); // 다날 pass 결과 리턴 + + // Step3 회원정보 입력 + Route::get('register/profile', [RegisterController::class, 'showProfileForm'])->name('register.profile'); + Route::post('register/profile', [RegisterController::class, 'submitProfile'])->name('register.profile.submit'); // 아이디 찾기 (컨트롤러) @@ -109,4 +118,67 @@ if (app()->environment(['local', 'development', 'testing']) }); } + + + + +/* 개발용 페이지 세션 보기*/ +use Illuminate\Http\Request; + +Route::post('_dev/session', function (Request $request) { + abort_unless(config('app.debug') || app()->environment('local'), 404); + + $action = (string) $request->input('_dev_sess_action', ''); + + // ✅ 자동 타입 변환(익명 함수라 재선언 문제 없음) + $parse = function (string $raw) { + $s = trim($raw); + $lower = strtolower($s); + + if ($lower === 'true') return true; + if ($lower === 'false') return false; + if ($lower === 'null') return null; + + // 정수 + if (preg_match('/^-?\d+$/', $s)) { + // 앞자리 0이 있는 값(예: 00123)은 문자열 유지하고 싶으면 아래 조건 추가 + // if (strlen($s) > 1 && $s[0] === '0') return $raw; + + $int = (int) $s; + if ((string)$int === $s) return $int; + } + + // 실수 + if (preg_match('/^-?\d+\.\d+$/', $s)) { + return (float) $s; + } + + // JSON + if ($s !== '' && (str_starts_with($s, '{') || str_starts_with($s, '['))) { + $j = json_decode($s, true); + if (json_last_error() === JSON_ERROR_NONE) return $j; + } + + return $raw; + }; + + if ($action === 'flush') { + session()->flush(); + session()->save(); + } elseif ($action === 'put') { + $k = trim((string) $request->input('_dev_sess_key', '')); + $raw = (string) $request->input('_dev_sess_value', ''); + + if ($k !== '') { + session()->put($k, $parse($raw)); + session()->save(); + } + } + + return redirect()->to((string) $request->input('_dev_return', '/')); +})->name('dev.session'); + +/* 개발용 페이지 세션 보기*/ + Route::fallback(fn () => abort(404)); +