From 5f950a4420a81bea84a5e986044bbdfba3356896 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Wed, 28 Jan 2026 14:35:38 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20sms=EB=B0=9C=EC=86=A1=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EA=B0=80=EC=9E=85=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Web/Auth/FindIdController.php | 38 +- .../Web/Auth/FindPasswordController.php | 2 +- .../Controllers/Web/Auth/LoginController.php | 111 ++++ .../Web/Auth/RegisterController.php | 245 +++++++- app/Http/Middleware/LegacyAuth.php | 29 + app/Http/Middleware/LegacyGuest.php | 22 + app/Models/AdminUser.php | 1 + app/Models/MemInfo.php | 2 - app/Models/Member/MemAddress.php | 6 +- app/Models/Member/MemAuth.php | 3 +- app/Models/Member/MemAuthInfo.php | 3 +- app/Models/Member/MemAuthLog.php | 3 +- app/Models/Member/MemInfo.php | 113 +++- app/Models/Member/MemJoinFilter.php | 1 + app/Models/Member/MemJoinLog.php | 3 +- app/Models/Member/MemLoginRecent.php | 3 +- app/Models/Member/MemLoginYear.php | 3 +- app/Models/Member/MemModLog.php | 3 +- app/Models/Member/MemPasswdModify.php | 3 +- app/Models/Member/MemStRing.php | 44 +- .../Member/MemStRingRepository.php | 64 ++ .../Member/MemberAuthRepository.php | 344 +++++++++-- app/Services/MemInfoService.php | 386 +++++++++++- app/Support/AuthSession.php | 10 + app/Support/Legacy/Carrier.php | 23 + app/Support/LegacyCrypto/CiPassword.php | 50 ++ bootstrap/app.php | 7 + config/legacy.php | 43 +- resources/css/web.css | 358 ++++++++++- .../views/web/auth/debug_payload.blade.php | 61 ++ resources/views/web/auth/login.blade.php | 119 +++- resources/views/web/auth/profile.blade.php | 570 +++++++++++++++++- resources/views/web/auth/register.blade.php | 208 ++++++- .../views/web/auth/register_terms.blade.php | 165 ++++- resources/views/web/company/header.blade.php | 59 +- .../views/web/main/notice-support.blade.php | 2 +- .../web/partials/mobile-drawer.blade.php | 101 ++-- routes/web.php | 96 +-- 38 files changed, 3018 insertions(+), 286 deletions(-) create mode 100644 app/Http/Controllers/Web/Auth/LoginController.php create mode 100644 app/Http/Middleware/LegacyAuth.php create mode 100644 app/Http/Middleware/LegacyGuest.php create mode 100644 app/Repositories/Member/MemStRingRepository.php create mode 100644 app/Support/AuthSession.php create mode 100644 app/Support/Legacy/Carrier.php create mode 100644 app/Support/LegacyCrypto/CiPassword.php create mode 100644 resources/views/web/auth/debug_payload.blade.php diff --git a/app/Http/Controllers/Web/Auth/FindIdController.php b/app/Http/Controllers/Web/Auth/FindIdController.php index 172a4b8..c3ea323 100644 --- a/app/Http/Controllers/Web/Auth/FindIdController.php +++ b/app/Http/Controllers/Web/Auth/FindIdController.php @@ -5,7 +5,7 @@ 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 App\Models\Member\MemInfo; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; @@ -95,24 +95,24 @@ class FindIdController extends Controller // 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); -// } + $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'); diff --git a/app/Http/Controllers/Web/Auth/FindPasswordController.php b/app/Http/Controllers/Web/Auth/FindPasswordController.php index f2be313..8fae1b8 100644 --- a/app/Http/Controllers/Web/Auth/FindPasswordController.php +++ b/app/Http/Controllers/Web/Auth/FindPasswordController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Web\Auth; use App\Services\MailService; use App\Http\Controllers\Controller; -use App\Models\MemInfo; +use App\Models\Member\MemInfo; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; diff --git a/app/Http/Controllers/Web/Auth/LoginController.php b/app/Http/Controllers/Web/Auth/LoginController.php new file mode 100644 index 0000000..5ad5d36 --- /dev/null +++ b/app/Http/Controllers/Web/Auth/LoginController.php @@ -0,0 +1,111 @@ + ['required', 'string', 'email', 'max:60'], + 'mem_pw' => ['required', 'string', 'max:100'], + 'return_url'=> ['nullable', 'string', 'max:2000'], + ]; + + if (app()->environment('production')) { + $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')]; + } + + $v = Validator::make($request->all(), $rules, [ + 'mem_email.required' => '아이디 혹은 비밀번호가 일치하지 않습니다.', + 'mem_email.email' => '아이디 혹은 비밀번호가 일치하지 않습니다.', + 'mem_pw.required' => '아이디 혹은 비밀번호가 일치하지 않습니다.', + 'g-recaptcha-response.required' => '올바른 접근이 아닙니다.', + ]); + + if ($v->fails()) { + return back()->withErrors($v)->withInput(); + } + + $email = strtolower(trim((string)$request->input('mem_email'))); + $pw = (string)$request->input('mem_pw'); + + // return_url: 오픈리다이렉트 방지 (내부 path만 허용) + $returnUrl = (string)($request->input('return_url') ?? '/'); + if ($returnUrl === '' || str_starts_with($returnUrl, 'http://') || str_starts_with($returnUrl, 'https://') || str_starts_with($returnUrl, '//')) { + $returnUrl = '/'; + } + if (!str_starts_with($returnUrl, '/')) { + $returnUrl = '/'; + } + + $res = $memInfoService->attemptLegacyLogin([ + 'email' => $email, + 'password' => $pw, + 'ip' => $request->ip(), + 'ua' => substr((string)$request->userAgent(), 0, 500), + 'return_url' => $returnUrl, + ]); + + if (!$res['ok']) { + // UI 처리 방식은 프로젝트 스타일에 맞춰 + // (일단 errors로 처리) + return back() + ->withErrors(['login' => $res['message']]) + ->withInput(['mem_email' => $email]); + } + + // 세션 저장 + AuthSession::putMember($res['session']); + + + return redirect()->to($res['redirect'] ?? $returnUrl); + } + + /** + * (옵션) 휴면 해제 링크 처리 - 최소 골격 + * 실제 로직은 다음 단계에서 dormancy 테이블 검증/만료/상태변경까지 붙이면 됨 + */ + public function dormancyPrc(Request $request) + { + // TODO: Crypt::decryptString(authnum) -> "auth_key|seq" + // TODO: mem_dormancy 검증/만료/인증완료 처리 + return redirect()->route('web.auth.login') + ->withErrors(['login' => '휴면 해제 처리는 다음 단계에서 연결합니다.']); + } + + public function logout(Request $request) + { + $request->session()->forget('_sess'); + + // (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가 + $request->session()->forget('signup'); + $request->session()->forget('register'); + + // (선택) 디버그 세션 정리 + $request->session()->forget('debug'); + + // ✅ 세션 저장 + $request->session()->save(); + + return redirect()->route('web.home') + ->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => '로그아웃 되었습니다.', + ]); + } +} diff --git a/app/Http/Controllers/Web/Auth/RegisterController.php b/app/Http/Controllers/Web/Auth/RegisterController.php index a1f57c5..290f607 100644 --- a/app/Http/Controllers/Web/Auth/RegisterController.php +++ b/app/Http/Controllers/Web/Auth/RegisterController.php @@ -12,8 +12,11 @@ use Illuminate\Support\Facades\Log; class RegisterController extends Controller { - public function showStep0() + public function showStep0(Request $request) { + //기존 인증 받다가 중지된 정보가 있다면 세션값 지우기 + $this->clearSession($request, 'signup'); + $this->clearSession($request, 'register'); return view('web.auth.register'); } @@ -32,9 +35,12 @@ class RegisterController extends Controller { $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' => '보안 검증에 실패했습니다. 다시 시도해 주세요.', ]); @@ -42,8 +48,43 @@ class RegisterController extends Controller return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); } - $ip4 = $request->ip() ?: ''; - $result = $repo->step0PhoneCheck((string)$request->input('phone'), $ip4); + /* 회원가입 필터 아이피로 차단 및 관리자 알림*/ + $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'; @@ -59,7 +100,8 @@ class RegisterController extends Controller } $request->session()->put('signup.step', 1); - $request->session()->put('signup.phone', $result['phone']); // 필요하면 + $request->session()->put('signup.phone', $result['phone']); + $request->session()->put('signup.carrier', $danalCarrier); $request->session()->save(); return response()->json([ @@ -68,6 +110,7 @@ class RegisterController extends Controller 'message' => $result['message'] ?? '', 'redirect' => $result['redirect'] ?? null, 'phone' => $result['phone'] ?? null, + 'carrier' => $danalCarrier, ], 200); } @@ -154,21 +197,42 @@ class RegisterController extends Controller public function danalStart(Request $request) { // fields는 JSON으로 받을 것 - $fieldsJson = (string)$request->input('fields', ''); + $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' => 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php', + 'action' => $action, 'fields' => $fields, + 'isMobile' => $isMobile, ]); } - public function danalResult(Request $request, DanalAuthtelService $danal) + public function danalResult(Request $request, DanalAuthtelService $danal, MemberAuthRepository $repo) { $payload = $request->all(); @@ -198,6 +262,12 @@ class RegisterController extends Controller 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) { @@ -243,8 +313,6 @@ class RegisterController extends Controller ]); } - - // 성공: 다음 단계에서 쓸 데이터 세션 저장 $request->session()->put('register.pass_verified', true); $request->session()->put('register.pass_payload', $res); @@ -271,6 +339,160 @@ class RegisterController extends Controller 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 { @@ -294,5 +516,10 @@ class RegisterController extends Controller return $masked; } + private function clearSession(Request $request, string $key = 'signup'): void + { + $request->session()->forget($key); + } + } diff --git a/app/Http/Middleware/LegacyAuth.php b/app/Http/Middleware/LegacyAuth.php new file mode 100644 index 0000000..325dfaf --- /dev/null +++ b/app/Http/Middleware/LegacyAuth.php @@ -0,0 +1,29 @@ +session()->get('_sess._login_', false); + + if (!$loggedIn) { + // 로그인 성공 후 원래 가려던 곳으로 보내기 위해 intended 저장 + // (Laravel auth 안 쓰더라도 이 키는 redirect()->intended()가 알아서 씀) + $request->session()->put('url.intended', $request->fullUrl()); + + return redirect()->route('web.auth.login') + ->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => '로그인 필요합니다.', + ]); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/LegacyGuest.php b/app/Http/Middleware/LegacyGuest.php new file mode 100644 index 0000000..7290c8f --- /dev/null +++ b/app/Http/Middleware/LegacyGuest.php @@ -0,0 +1,22 @@ +session()->get('_sess._login_', false); + + if ($loggedIn) { + // 이미 로그인 상태면 auth 페이지 접근 막고 홈(또는 intended)으로 + $to = $request->session()->pull('url.intended', '/'); + return redirect($to ?: '/'); + } + + return $next($request); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 3690afa..5b984cc 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -10,6 +10,7 @@ class AdminUser extends Authenticatable use Notifiable; protected $table = 'admin_users'; + public $timestamps = false; protected $fillable = [ 'email','password', diff --git a/app/Models/MemInfo.php b/app/Models/MemInfo.php index ea74894..50996e7 100644 --- a/app/Models/MemInfo.php +++ b/app/Models/MemInfo.php @@ -12,8 +12,6 @@ class MemInfo extends Model protected $primaryKey = 'mem_no'; public $incrementing = true; protected $keyType = 'int'; - - // mem_info는 created_at/updated_at 컬럼이 dt_reg/dt_mod 라서 기본 timestamps 안 씀 public $timestamps = false; /** diff --git a/app/Models/Member/MemAddress.php b/app/Models/Member/MemAddress.php index fa3a815..4568bdc 100644 --- a/app/Models/Member/MemAddress.php +++ b/app/Models/Member/MemAddress.php @@ -7,13 +7,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAddress extends Model -{ - - - protected $table = 'mem_address'; +{ protected $table = 'mem_address'; protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemAuth.php b/app/Models/Member/MemAuth.php index b2f31a7..2668684 100644 --- a/app/Models/Member/MemAuth.php +++ b/app/Models/Member/MemAuth.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAuth extends Model { - + protected $table = 'mem_auth'; @@ -17,6 +17,7 @@ class MemAuth extends Model protected $primaryKey = null; public $incrementing = false; protected $keyType = 'string'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemAuthInfo.php b/app/Models/Member/MemAuthInfo.php index a00465d..0dbc889 100644 --- a/app/Models/Member/MemAuthInfo.php +++ b/app/Models/Member/MemAuthInfo.php @@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAuthInfo extends Model { - + protected $table = 'mem_auth_info'; protected $primaryKey = 'mem_no'; public $incrementing = false; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemAuthLog.php b/app/Models/Member/MemAuthLog.php index b640842..80d08ac 100644 --- a/app/Models/Member/MemAuthLog.php +++ b/app/Models/Member/MemAuthLog.php @@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAuthLog extends Model { - + protected $table = 'mem_auth_log'; protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemInfo.php b/app/Models/Member/MemInfo.php index d80931e..9f2d42a 100644 --- a/app/Models/Member/MemInfo.php +++ b/app/Models/Member/MemInfo.php @@ -2,24 +2,44 @@ namespace App\Models\Member; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; - +use Illuminate\Support\Carbon; class MemInfo extends Model { - // - protected $table = 'mem_info'; protected $primaryKey = 'mem_no'; protected $keyType = 'int'; public $incrementing = true; + public $timestamps = false; - // 테이블이 legacy라 fillable 대신 guarded 추천 (내부에서만 쓰면 []도 가능) - protected $guarded = []; + /** + * ✅ 보안: guarded=[](전부 허용) 는 위험하니, + * 기존 App\Models\MemInfo 의 allowlist(fillable) 방식 유지. + */ + protected $fillable = [ + 'stat_1','stat_2','stat_3','stat_4','stat_5', + 'name','name_first','name_mid','name_last', + 'birth','gender','native', + 'cell_corp','cell_phone','email','pv_sns', + 'bank_code','bank_name','bank_act_num','bank_vact_num', + 'rcv_email','rcv_sms','rcv_push', + 'login_fail_cnt', + 'dt_login','dt_reg','dt_mod', + 'dt_rcv_email','dt_rcv_sms','dt_rcv_push', + 'dt_stat_1','dt_stat_2','dt_stat_3','dt_stat_4','dt_stat_5', + 'ip_reg','ci_v','ci','di', + 'country_code','country_name', + 'admin_memo','modify_log', + ]; - // zero-date 때문에 datetime/date cast는 걸지 않음 (필요시 별도 accessor로 안전 파싱) + /** + * ✅ 레거시 zero-date(0000-00-00 ...)가 있으면 datetime/date cast는 예외/오작동 가능. + * 안전하게 JSON 컬럼만 cast (나머지는 safe accessor로 뽑아 쓰자) + */ protected $casts = [ 'admin_memo' => 'array', 'modify_log' => 'array', @@ -36,7 +56,7 @@ class MemInfo extends Model public function authRows(): HasMany { - // mem_auth 복합키 테이블이지만 조회 관계는 문제 없음 + // mem_auth 복합키 테이블이어도 조회 관계는 가능 return $this->hasMany(MemAuth::class, 'mem_no', 'mem_no'); } @@ -71,17 +91,90 @@ class MemInfo extends Model } /* ===================== - * Helpers (optional) + * Scopes (기존 App\Models\MemInfo에서 가져옴) * ===================== */ + public function scopeActive(Builder $q): Builder + { + // CI에서 stat_3 == 3 접근금지 / 4 탈퇴신청 / 5 탈퇴완료 + return $q->whereNotIn('stat_3', ['3','4','5']); + } + + public function scopeByEmail(Builder $q, string $email): Builder + { + return $q->where('email', strtolower($email)); + } + + public function scopeByPhoneLookup(Builder $q, string $phoneNormalized): Builder + { + // TODO: cell_phone이 암호화면 단순 where 비교 불가 + // 추천: cell_phone_hash 같은 정규화+해시 컬럼 만들어 lookup + return $q; + } + + /* ===================== + * Helpers (둘 모델 통합) + * ===================== */ + + public function isBlocked(): bool + { + return (string) $this->stat_3 === '3'; + } + + public function isWithdrawnOrRequested(): bool + { + return in_array((string) $this->stat_3, ['4','5'], true); + } + public function isWithdrawn(): bool { - // legacy: dt_out 기본값이 0000-00-00... 이므로 문자열 비교로 처리 - return isset($this->attributes['dt_out']) && $this->attributes['dt_out'] !== '0000-00-00 00:00:00'; + // legacy: dt_out 기본값이 0000-00-00 00:00:00 일 수 있음 + $v = $this->attributes['dt_out'] ?? null; + return !empty($v) && $v !== '0000-00-00 00:00:00'; } public function hasEmail(): bool { return !empty($this->attributes['email']); } + + public function isFirstLogin(): bool + { + $dtLogin = $this->dt_login_at(); + $dtReg = $this->dt_reg_at(); + + if (!$dtLogin || !$dtReg) return false; + return $dtLogin->equalTo($dtReg); + } + + /* ===================== + * Safe datetime accessors + * ===================== */ + + private function safeCarbon(?string $value): ?Carbon + { + if (!$value) return null; + if ($value === '0000-00-00 00:00:00' || $value === '0000-00-00') return null; + + try { + return Carbon::parse($value); + } catch (\Throwable $e) { + return null; + } + } + + public function dt_login_at(): ?Carbon + { + return $this->safeCarbon($this->attributes['dt_login'] ?? null); + } + + public function dt_reg_at(): ?Carbon + { + return $this->safeCarbon($this->attributes['dt_reg'] ?? null); + } + + public function dt_mod_at(): ?Carbon + { + return $this->safeCarbon($this->attributes['dt_mod'] ?? null); + } } diff --git a/app/Models/Member/MemJoinFilter.php b/app/Models/Member/MemJoinFilter.php index cf2cddd..b539b08 100644 --- a/app/Models/Member/MemJoinFilter.php +++ b/app/Models/Member/MemJoinFilter.php @@ -13,6 +13,7 @@ class MemJoinFilter extends Model protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemJoinLog.php b/app/Models/Member/MemJoinLog.php index 995589a..b561eea 100644 --- a/app/Models/Member/MemJoinLog.php +++ b/app/Models/Member/MemJoinLog.php @@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemJoinLog extends Model { - + protected $table = 'mem_join_log'; protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemLoginRecent.php b/app/Models/Member/MemLoginRecent.php index add44c7..9791db5 100644 --- a/app/Models/Member/MemLoginRecent.php +++ b/app/Models/Member/MemLoginRecent.php @@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemLoginRecent extends Model { - + protected $table = 'mem_login_recent'; protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemLoginYear.php b/app/Models/Member/MemLoginYear.php index 1ba558a..521cfbe 100644 --- a/app/Models/Member/MemLoginYear.php +++ b/app/Models/Member/MemLoginYear.php @@ -11,11 +11,12 @@ use Illuminate\Database\Eloquent\Model; */ class MemLoginYear extends Model { - + protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemModLog.php b/app/Models/Member/MemModLog.php index e6a95cd..5ff5893 100644 --- a/app/Models/Member/MemModLog.php +++ b/app/Models/Member/MemModLog.php @@ -7,12 +7,11 @@ use Illuminate\Database\Eloquent\Model; class MemModLog extends Model { - - protected $table = 'mem_mod_log'; protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; } diff --git a/app/Models/Member/MemPasswdModify.php b/app/Models/Member/MemPasswdModify.php index 668ea6a..450ba8d 100644 --- a/app/Models/Member/MemPasswdModify.php +++ b/app/Models/Member/MemPasswdModify.php @@ -7,12 +7,13 @@ use Illuminate\Database\Eloquent\Model; class MemPasswdModify extends Model { - + protected $table = 'mem_passwd_modify'; protected $primaryKey = 'seq'; public $incrementing = true; protected $keyType = 'int'; + public $timestamps = false; protected $guarded = []; diff --git a/app/Models/Member/MemStRing.php b/app/Models/Member/MemStRing.php index cc5e54c..0a36a54 100644 --- a/app/Models/Member/MemStRing.php +++ b/app/Models/Member/MemStRing.php @@ -1,24 +1,34 @@ belongsTo(MemInfo::class, 'mem_no', 'mem_no'); + $col = 'str_' . (int)$set; // str_0 / str_1 / str_2 + + $row = DB::table('mem_st_ring') + ->select($col) + ->where('mem_no', $memNo) + ->first(); + + if (!$row) return null; + + $val = $row->{$col} ?? null; + $val = is_string($val) ? trim($val) : null; + + return $val !== '' ? $val : null; + } + + public function getLegacyPass2(int $memNo): ?string + { + $val = DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->value('passwd2'); + + $val = is_string($val) ? trim($val) : null; + return $val !== '' ? $val : null; } } diff --git a/app/Repositories/Member/MemStRingRepository.php b/app/Repositories/Member/MemStRingRepository.php new file mode 100644 index 0000000..56c2bbd --- /dev/null +++ b/app/Repositories/Member/MemStRingRepository.php @@ -0,0 +1,64 @@ +toDateTimeString(); + + [$str0, $str1, $str2] = CiPassword::makeAll($plainPassword); + $pass2 = CiPassword::makePass2($pin2); + + DB::statement( + "INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg, passwd2, passwd2_reg) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + str_0 = ?, str_1 = ?, str_2 = ?, dt_reg = ?, passwd2 = ?, passwd2_reg = ?", + [ + $memNo, $str0, $str1, $str2, $dt, $pass2, $dt, + $str0, $str1, $str2, $dt, $pass2, $dt, + ] + ); + } + + /** + * CI3 modify_type == "1_passwd" 대응 (1차만) + */ + public function upsertPassword1(int $memNo, string $plainPassword, ?string $dtNow = null): void + { + $dt = $dtNow ?: Carbon::now()->toDateTimeString(); + [$str0, $str1, $str2] = CiPassword::makeAll($plainPassword); + + DB::statement( + "INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE str_0=?, str_1=?, str_2=?, dt_reg=?", + [$memNo, $str0, $str1, $str2, $dt, $str0, $str1, $str2, $dt] + ); + } + + /** + * CI3 modify_type == "2_passwd" 대응 (2차만) + */ + public function upsertPassword2(int $memNo, string $pin2, ?string $dtNow = null): void + { + $dt = $dtNow ?: Carbon::now()->toDateTimeString(); + $pass2 = CiPassword::makePass2($pin2); + + DB::statement( + "INSERT INTO mem_st_ring (mem_no, passwd2, passwd2_reg) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE passwd2=?, passwd2_reg=?", + [$memNo, $pass2, $dt, $pass2, $dt] + ); + } +} diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php index 444f7ba..0b7baf9 100644 --- a/app/Repositories/Member/MemberAuthRepository.php +++ b/app/Repositories/Member/MemberAuthRepository.php @@ -8,12 +8,22 @@ use App\Models\Member\MemAuthLog; use App\Models\Member\MemInfo; use App\Models\Member\MemJoinFilter; use App\Models\Member\MemJoinLog; +use App\Support\Legacy\Carrier; +use App\Services\SmsService; +use App\Services\MemInfoService; use App\Support\LegacyCrypto\CiSeedCrypto; +use App\Support\LegacyCrypto\CiPassword; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Carbon; + class MemberAuthRepository { + public function __construct(private readonly MemInfoService $memInfoService) + { + } + /* ========================================================= * mem_auth (기존) * ========================================================= */ @@ -99,15 +109,6 @@ class MemberAuthRepository ->value('auth_state'); } - public function isVerified(int $memNo, string $authType): bool - { - return $this->getState($memNo, $authType) === MemAuth::STATE_Y; - } - - /* ========================================================= - * Step0: phone check + join_filter + join_log - * ========================================================= */ - private function normalizeKoreanPhone(string $raw): ?string { $digits = preg_replace('/\D+/', '', $raw ?? ''); @@ -156,25 +157,10 @@ class MemberAuthRepository return ['hit' => true, 'block' => false, 'gubun' => $r->gubun ?? 'filter_hit', 'row' => $r]; } - public function writeJoinLog(array $data): void - { - MemJoinLog::query()->create([ - 'gubun' => $data['gubun'] ?? null, - 'mem_no' => (int)($data['mem_no'] ?? 0), - 'cell_corp' => $data['cell_corp'] ?? 'n', - 'cell_phone' => $data['cell_phone'] ?? '', - 'email' => $data['email'] ?? null, - 'ip4' => $data['ip4'] ?? '', - 'ip4_c' => $data['ip4_c'] ?? '', - 'error_code' => $data['error_code'] ?? '', - 'dt_reg' => Carbon::now()->toDateTimeString(), - ]); - } - /** * Step0 통합 처리 */ - public function step0PhoneCheck(string $rawPhone, string $ip4 = ''): array + public function step0PhoneCheck(string $rawPhone, string $carrier = '', string $ip4 = ''): array { $base = [ 'ok' => false, @@ -182,6 +168,7 @@ class MemberAuthRepository 'message' => '', 'redirect' => null, 'phone' => null, + 'carrier' => null, 'filter' => null, 'admin_phones' => [], ]; @@ -190,6 +177,9 @@ class MemberAuthRepository $ip4c = $this->ipToCClass($ip4); $ipHit = $this->checkJoinFilterByIp($ip4, $ip4c); + $carrier = trim($carrier); + $base['carrier'] = $carrier ?: null; + if ($ipHit) { $base['filter'] = $ipHit; $base['admin_phones'] = $ipHit['admin_phones'] ?? []; @@ -215,16 +205,17 @@ class MemberAuthRepository $base['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, // 필요하면 유지 - ]); - } + $member = $this->findMemberByPhone($phone, $carrier); +// 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, +// 'matched_cell_corp' => $member->cell_corp ?? null, // ✅ 필요시 +// ]); +// } // 3) 기존 phone+ip 필터 $filter = $this->checkJoinFilter($phone, $ip4, $ip4c); @@ -247,9 +238,7 @@ class MemberAuthRepository ]); } - - - private function findMemberByPhone(string $phone): ?object + private function findMemberByPhone(string $phone, string $carrier): ?object { /** @var CiSeedCrypto $seed */ $seed = app(CiSeedCrypto::class); @@ -259,10 +248,21 @@ class MemberAuthRepository return DB::table('mem_info') ->select('mem_no', 'cell_phone') ->where('cell_phone', $phoneEnc) + ->where('cell_corp', $carrier) ->limit(1) ->first(); } + public function existsEmail(string $email): bool + { + $email = trim($email); + if ($email === '') return false; + + return DB::table('mem_info') + ->where('email', $email) // ✅ mem_info.email + ->exists(); + } + public function ipToCClass(string $ip): string { @@ -274,13 +274,12 @@ class MemberAuthRepository } 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']) @@ -338,6 +337,271 @@ class MemberAuthRepository $parts = array_map('trim', explode(',', $s)); return array_values(array_filter($parts)); } + + public function createMemberFromSignup(array $final, array $sessionAll = []): array + { + // 0) 필수값 최소 체크(컨트롤러에서 검증했지만 방어) + $email = strtolower(trim((string)($final['email'] ?? ''))); + $pwPlain = (string)($final['password_plain'] ?? ''); + $pin2 = (string)($final['pin2_plain'] ?? ''); + + if ($email === '' || $pwPlain === '' || !preg_match('/^\d{4}$/', $pin2)) { + throw new \RuntimeException('invalid_payload'); + } + + $pass = (array)($final['pass'] ?? []); + $name = (string)($pass['name'] ?? ''); + $carrier = Carrier::toCode((string)($pass['carrier'] ?? 'n')); //통신사 코드로 변경 + $phone = preg_replace('/\D+/', '', (string)($pass['phone'] ?? '')); + $ci = $pass['ci'] ?? null; + $di = $pass['di'] ?? null; + + // DOB: YYYYMMDD -> YYYY-MM-DD + $dob = preg_replace('/\D+/', '', (string)($pass['dob'] ?? '')); + $birth = '0000-00-00'; + if (strlen($dob) >= 8) { + $birth = substr($dob, 0, 4).'-'.substr($dob, 4, 2).'-'.substr($dob, 6, 2); + } + + // foreigner/native/country + $foreigner = (string)($pass['foreigner'] ?? '0'); // 값 정의는 네 PASS 규격대로 + $native = ($foreigner === '1') ? '2' : '1'; // 외국인=2, 내국인=1 + $countryCode = ($native === '1') ? '82' : ''; + $countryName = ($native === '1') ? 'Republic of Korea(대한민국)' : ''; + + // gender mapping (프로젝트 규칙에 맞춰 조정) + $sex = (string)($pass['sex'] ?? ''); + $gender = match (strtoupper($sex)) { + '1', 'M', 'MALE' => '1', + '0', 'F', 'FEMALE', '2' => '0', + default => '0', + }; + + $ip = (string)($final['meta']['ip'] ?? request()->ip()); + $promotion = (string)(data_get($sessionAll, 'signup.promotion', '')) === 'on'; + + + return DB::transaction(function () use ( + $email, $pwPlain, $pin2, + $name, $birth, $carrier, $phone, + $native, $countryCode, $countryName, $gender, + $ci, $di, $ip, $promotion, $sessionAll + ) { + // 1) 접근금지회원 체크(간단 버전) + // prohibit_access() 정확한 조건이 있으면 그 조건대로 바꾸면 됨) + if (!empty($ci)) { + $blocked = DB::table('mem_info') + ->where('ci', $ci) + ->whereIn('stat_3', ['3','4','5']) + ->exists(); + + if ($blocked) { + throw new \RuntimeException('prohibit_access'); + } + } + + // 2) mem_info 생성 (휴대폰 암호화 포함은 MemInfoService::register가 처리) + $mem = $this->memInfoService->register([ + 'email' => $email, + 'name' => $name, + 'pv_sns' => 'self', + 'promotion' => $promotion, + 'ip_reg' => $ip, + 'country_code' => $countryCode, + 'country_name' => $countryName, + 'birth' => $birth, + 'cell_corp' => $carrier, + 'cell_phone' => $phone, // 평문 → register()에서 암호화 + 'native' => $native, + 'gender' => $gender, + 'ci' => $ci, + 'di' => $di, + 'ci_v' => '1', + ]); + + $memNo = (int)$mem->mem_no; + $now = Carbon::now()->format('Y-m-d H:i:s'); + + //다날 로그 업데이트 + $danalLogSeq = (int) data_get($sessionAll, 'register.danal_log_seq', 0); + if ($danalLogSeq > 0) { + $this->updateDanalAuthLogMemNo($danalLogSeq, $memNo); + } + + /*회원가입 아이피 필터 업데이트*/ + $ipfResult = (string) data_get($sessionAll, 'signup.ipf_result', ''); + $ipfSeq = (int) data_get($sessionAll, 'signup.ipf_seq', 0); + +// if ($ipfResult === 'A') { +// // 이 케이스는 원래 step0에서 막혀야 정상이지만, 방어적으로 한번 더 +// throw new \RuntimeException('회원가입이 제한된 IP입니다.'); +// } + if ($ipfResult === 'S' && $ipfSeq > 0) { + $this->updateJoinLogAfterSignup($ipfSeq, $memNo, $email); + } + /*회원가입 아이피 필터 업데이트*/ + + // 3) mem_st_ring 비번 저장 (CI3 macro->pass + sha512(pin2)와 동일) + [$str0, $str1, $str2] = CiPassword::makeAll($pwPlain); + $passwd2 = CiPassword::makePass2($pin2); + + DB::statement( + "INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg, passwd2, passwd2_reg) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE str_0=VALUES(str_0), str_1=VALUES(str_1), str_2=VALUES(str_2), + dt_reg=VALUES(dt_reg), passwd2=VALUES(passwd2), passwd2_reg=VALUES(passwd2_reg)", + [$memNo, $str0, $str1, $str2, $now, $passwd2, $now] + ); + + // 4) mem_auth 휴대폰 인증 처리 + DB::table('mem_auth')->updateOrInsert( + ['mem_no' => $memNo, 'auth_type' => 'cell'], + ['auth_state' => 'Y', 'auth_date' => date('Y-m-d')] + ); + + // 5) mem_auth_log 가입 로그 저장 (CI3처럼 세션 스냅샷) + DB::table('mem_auth_log')->insert([ + 'mem_no' => $memNo, + 'type' => 'signup', + 'state' => 'S', + 'info' => json_encode($sessionAll, JSON_UNESCAPED_UNICODE), + 'rgdate' => $now, + ]); + + // 6) (TODO) email_auth init + 메일 발송은 다음 단계에서 연결 + // 지금은 우선 회원 저장까지 완성 + + return ['ok' => true, 'mem_no' => $memNo]; + }); + } + + public function insertDanalAuthLog(string $gubun, array $res): int + { + // gubun: 'J' (회원가입), 'M'(정보수정) + try { + return (int) DB::table('mem_danalauthtel_log')->insertGetId([ + 'gubun' => $gubun, + 'TID' => (string)($res['TID'] ?? ''), + 'res_code' => (string)($res['RETURNCODE'] ?? ''), + 'mem_no' => null, + 'info' => json_encode($res, JSON_UNESCAPED_UNICODE), + 'rgdate' => now()->format('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + Log::error('[danal] insert log failed', [ + 'err' => $e->getMessage(), + ]); + return 0; // 로그 실패해도 플로우는 계속 + } + } + + public function updateDanalAuthLogMemNo(int $logSeq, int $memNo): void + { + if ($logSeq <= 0 || $memNo <= 0) return; + + try { + DB::table('mem_danalauthtel_log') + ->where('seq', $logSeq) + ->update(['mem_no' => $memNo]); + } catch (\Throwable $e) { + Log::error('[danal] update log mem_no failed', [ + 'seq' => $logSeq, + 'mem_no' => $memNo, + 'err' => $e->getMessage(), + ]); + // 여기서 throw 할지 말지는 정책인데, + // 레거시 흐름대로면 "가입은 살리고 로그만 실패"가 맞음. + } + } + + public function precheckJoinIpFilterAndLog(array $userInfo): array + { + // $userInfo keys: + // mem_no(없으면 0), cell_corp, cell_phone(암호화), email('-'), ip4, ip4_c(앞 3옥텟), dt_reg(optional) + + $ip4 = (string)($userInfo['ip4'] ?? ''); + $ip4c = (string)($userInfo['ip4_c'] ?? ''); + if ($ip4 === '' || $ip4c === '') { + return ['result' => 'P', 'seq' => 0, 'admin_phones' => []]; + } + + // join_block 우선순위 A > S + $row = DB::table('mem_join_filter') + ->whereIn('join_block', ['A', 'S']) + ->whereIn('gubun_code', ['01', '02']) + ->where(function ($q) use ($ip4, $ip4c) { + $q->where('filter', $ip4c) + ->orWhere('filter', $ip4); + }) + ->orderByRaw("CASE join_block WHEN 'A' THEN 0 WHEN 'S' THEN 1 ELSE 9 END") + ->orderByDesc('seq') + ->first(); + + if (!$row) { + return ['result' => 'P', 'seq' => 0, 'admin_phones' => []]; + } + + $result = (string)($row->join_block ?? 'P'); // 'A' or 'S' + $gubun = (string)($row->gubun_code ?? ''); + + // admin_phone JSON decode + $adminPhones = []; + $raw = $row->admin_phone ?? null; + if (is_string($raw) && $raw !== '') { + $j = json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($j)) { + $adminPhones = array_values(array_filter(array_map('trim', $j))); + } + } + + // 관리자 SMS (S만 보낼지, A도 보낼지는 정책인데) + // CI3 코드는 S에서만 발송했지만, 주석/정책상 A도 발송하는게 더 안전해서 A도 발송하도록 권장. + if (in_array($result, ['S','A'], true) && !empty($adminPhones)) { + foreach ($adminPhones as $phone) { + $smsPayload = [ + 'from_number' => config('services.sms.from', '1833-4856'), + 'to_number' => $phone, + 'message' => '[PIN FOR YOU] 회원가입필터 IP에서 가입 시도됨! 관리자 확인요망 '.date('m-d H:i'), + 'sms_type' => 'sms', + ]; + app(SmsService::class)->send($smsPayload, 'lguplus'); + } + } + + // ✅ mem_join_log 기록 (CI3: S 또는 A 일때만 기록) + $seq = (int) DB::table('mem_join_log')->insertGetId([ + 'gubun' => $gubun, // CI3: gubun_code를 gubun에 저장 + 'mem_no' => (int)($userInfo['mem_no'] ?? 0), // 가입 전이면 0 + 'cell_corp' => (string)($userInfo['cell_corp'] ?? 'n'), + 'cell_phone' => (string)($userInfo['cell_phone'] ?? ''), + 'email' => (string)($userInfo['email'] ?? '-'), + 'ip4' => $ip4, + 'ip4_c' => $ip4c, + 'error_code' => $result, // ✅ join_block을 error_code에 저장(추적용) + 'dt_reg' => $userInfo['dt_reg'] ?? date('Y-m-d H:i:s'), + ]); + + return [ + 'result' => $result, // 'A' or 'S' + 'seq' => $seq, + 'admin_phones' => $adminPhones, + ]; + } + + /** + * 가입 성공 후 mem_no/email 업데이트 (CI3 ip_check_update 대응) + */ + public function updateJoinLogAfterSignup(int $seq, int $memNo, string $email): void + { + DB::table('mem_join_log') + ->where('seq', $seq) + ->update([ + 'mem_no' => $memNo, + 'email' => $email, + ]); + } + + } diff --git a/app/Services/MemInfoService.php b/app/Services/MemInfoService.php index 1e77e3a..ebe31c4 100644 --- a/app/Services/MemInfoService.php +++ b/app/Services/MemInfoService.php @@ -2,9 +2,14 @@ namespace App\Services; -use App\Models\MemInfo; +use App\Models\Member\MemInfo; +use App\Support\LegacyCrypto\CiSeedCrypto; +use App\Support\LegacyCrypto\CiPassword; +use App\Support\Legacy\LoginReason; use Illuminate\Support\Facades\DB; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; class MemInfoService { @@ -49,13 +54,12 @@ class MemInfoService /** * CI: mem_reg() (간소화 버전) * - 실제로는 validation은 FormRequest에서 처리 권장 - * - stat_3=3 (1969 이전 출생 접근금지) 룰 포함 */ public function register(array $data): MemInfo { return DB::transaction(function () use ($data) { - $email = strtolower($data['email']); + $email = strtolower((string)($data['email'] ?? '')); // 중복 체크 + 잠금 (CI for update) $exists = MemInfo::query() @@ -68,11 +72,12 @@ class MemInfoService } $now = Carbon::now()->format('Y-m-d H:i:s'); + $notnull = "1000-01-01 00:00:00"; $mem = new MemInfo(); $mem->email = $email; - $mem->name = $data['name'] ?? ''; - $mem->pv_sns = $data['pv_sns'] ?? 'self'; + $mem->name = (string)($data['name'] ?? ''); + $mem->pv_sns = (string)($data['pv_sns'] ?? 'self'); $promotion = !empty($data['promotion']) ? 'y' : 'n'; $mem->rcv_email = $promotion; @@ -85,26 +90,42 @@ class MemInfoService $mem->dt_rcv_email = $now; $mem->dt_rcv_sms = $now; $mem->dt_rcv_push = $now; - $mem->dt_stat_1 = $now; $mem->dt_stat_2 = $now; $mem->dt_stat_3 = $now; $mem->dt_stat_4 = $now; $mem->dt_stat_5 = $now; - $mem->ip_reg = $data['ip_reg'] ?? request()->ip(); + $mem->dt_mod = $notnull; + $mem->dt_vact = $notnull; + $mem->dt_dor = $notnull; + $mem->dt_ret_dor = $notnull; + $mem->dt_req_out = "1000-01-01"; + $mem->dt_out = $notnull; + + $mem->ip_reg = (string)($data['ip_reg'] ?? request()->ip()); // 국가/본인인증 값들 - $mem->country_code = $data['country_code'] ?? ''; - $mem->country_name = $data['country_name'] ?? ''; - $mem->birth = $data['birth'] ?? '0000-00-00'; - $mem->cell_corp = $data['cell_corp'] ?? 'n'; - $mem->cell_phone = $data['cell_phone'] ?? ''; // ⚠️ 암호화 저장이라면 여기서 암호화해서 넣어야 함 - $mem->native = $data['native'] ?? 'n'; + $mem->country_code = (string)($data['country_code'] ?? ''); + $mem->country_name = (string)($data['country_name'] ?? ''); + $mem->birth = (string)($data['birth'] ?? '0000-00-00'); + $mem->cell_corp = (string)($data['cell_corp'] ?? 'n'); + + // 휴대폰 암호화 (빈값이면 빈값) + $rawPhone = (string)($data['cell_phone'] ?? ''); + if ($rawPhone !== '') { + /** @var CiSeedCrypto $seed */ + $seed = app(CiSeedCrypto::class); + $mem->cell_phone = (string)$seed->encrypt($rawPhone); + } else { + $mem->cell_phone = ''; + } + + $mem->native = (string)($data['native'] ?? 'n'); $mem->ci = $data['ci'] ?? null; - $mem->ci_v = $data['ci_v'] ?? ''; + $mem->ci_v = (string)($data['ci_v'] ?? ''); $mem->di = $data['di'] ?? null; - $mem->gender = $data['gender'] ?? 'n'; + $mem->gender = (string)($data['gender'] ?? 'n'); // 1969년 이전 출생 접근금지(stat_3=3) $birthY = (int)substr((string)$mem->birth, 0, 4); @@ -118,9 +139,6 @@ class MemInfoService }); } - /** - * CI: last_login() - */ public function updateLastLogin(int $memNo): void { MemInfo::query() @@ -132,9 +150,6 @@ class MemInfoService ]); } - /** - * CI: fail_count() - */ public function incrementLoginFail(int $memNo): void { MemInfo::query() @@ -144,4 +159,333 @@ class MemInfoService 'dt_mod' => Carbon::now()->format('Y-m-d H:i:s'), ]); } + + private function dt6(): string + { + return Carbon::now()->format('Y-m-d H:i:s.u'); + } + + private function ip4c(string $ip): string + { + $oct = explode('.', $ip); + if (count($oct) >= 3) { + return $oct[0].'.'.$oct[1].'.'.$oct[2]; + } + return $ip; + } + + private function parseUa(string $ua): array + { + $platform = ''; + if (stripos($ua, 'Windows') !== false) $platform = 'Windows'; + elseif (stripos($ua, 'Mac OS X') !== false) $platform = 'macOS'; + elseif (stripos($ua, 'Android') !== false) $platform = 'Android'; + elseif (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) $platform = 'iOS'; + elseif (stripos($ua, 'Linux') !== false) $platform = 'Linux'; + + $browser = 'Unknown'; + $version = ''; + $candidates = [ + 'Edg/' => 'Edge', + 'Chrome/' => 'Chrome', + 'Firefox/' => 'Firefox', + 'Safari/' => 'Safari', + ]; + + foreach ($candidates as $needle => $name) { + $pos = stripos($ua, $needle); + if ($pos !== false) { + $browser = $name; + $sub = substr($ua, $pos + strlen($needle)); + $version = preg_split('/[^0-9\.]/', $sub)[0] ?? ''; + // Safari는 Chrome UA에도 같이 끼므로 Chrome 우선순위를 위에서 처리 + break; + } + } + + return [$platform, trim($browser), trim($version)]; + } + + /** + * mem_auth 기반 레벨 계산 (CI3 get_mem_level 이식) + */ + private function getMemLevel(int $memNo): array + { + $rows = DB::table('mem_auth') + ->select(['auth_type','auth_state']) + ->where('mem_no', $memNo) + ->where('auth_state', 'Y') + ->limit(50) + ->get(); + + $state = ['email'=>false,'cell'=>false,'account'=>false,'otp'=>false,'ars'=>false]; + + foreach ($rows as $r) { + $t = strtolower((string)$r->auth_type); + if (array_key_exists($t, $state)) $state[$t] = true; + } + + $level = 0; + if ($state['email']) $level = 1; + if ($level === 1 && $state['cell']) $level = 2; + if ($level === 2 && $state['account']) $level = 3; + if ($level === 3 && $state['otp']) $level = 4; + if ($level === 2 && $state['ars']) $level = 5; + + return ['level'=>$level, 'auth_state'=>$state]; + } + + /** + * 연도별 로그인 테이블 자동 생성 (B 선택) + * - mem_login_recent 스키마를 그대로 복제 + */ + private function ensureLoginYearlyTable(int $year): string + { + $year = (int)$year; + if ($year < 2000 || $year > 2100) { + // 안전장치 + return 'mem_login_recent'; + } + + $table = "mem_login_{$year}"; + + // DB마다 동작이 달라서 가장 단순하고 확실한 DDL로 처리 + // CREATE TABLE IF NOT EXISTS mem_login_YYYY LIKE mem_login_recent + DB::statement("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `mem_login_recent`"); + + return $table; + } + + private function insertLoginLog(string $table, array $d): void + { + DB::statement( + "INSERT INTO `{$table}` + SET mem_no=?, + sf=?, + conn=?, + ip4_aton=inet_aton(?), + ip4=?, + ip4_c=SUBSTRING_INDEX(?,'.',3), + dt_reg=?, + platform=?, + browser=?, + pattern=?, + error_code=?", + [ + $d['mem_no'], + $d['sf'], + $d['conn'], + $d['ip4'], + $d['ip4'], + $d['ip4'], + $d['dt_reg'], + $d['platform'], + $d['browser'], + $d['pattern'], + $d['error_code'], + ] + ); + } + + + public function attemptLegacyLogin(array $in): array + { + $email = strtolower(trim((string)($in['email'] ?? ''))); + $pw = (string)($in['password'] ?? ''); + $ip = (string)($in['ip'] ?? request()->ip()); + $ua = (string)($in['ua'] ?? ''); + $returnUrl = (string)($in['return_url'] ?? '/'); + + if ($email === '' || $pw === '') { + return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.']; + } + + $dtNow6 = $this->dt6(); + $dtY = (int)substr($dtNow6, 0, 4); + + // UA 파싱 + [$platform, $browser, $version] = $this->parseUa($ua); + $browserFull = trim($browser.' '.$version); + + //$yearTable = $this->ensureLoginYearlyTable((int)$dtY); + + + return DB::transaction(function () use ($email, $pw, $ip, $ua, $returnUrl, $dtNow6, $dtY, $platform, $browserFull) { + $yearTable = "mem_login_".(int)$dtY; + + /** @var MemInfo|null $mem */ + $mem = MemInfo::query() + ->select([ + 'mem_no','email','name','cell_phone','cell_corp', + 'dt_login','dt_reg','login_fail_cnt', + 'pv_sns', + 'stat_1','stat_2','stat_3','stat_4','stat_5', + 'native','country_code' + ]) + ->where('email', $email) + ->lockForUpdate() + ->first(); + + // 아이디 없음 -> mem_no가 없으니 fail_count/log 저장 불가. 메시지만 통일. + if (!$mem) { + return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.1']; + } + + // stat_3 차단 로직 (CI3 id_exists 반영) + if ((string)$mem->stat_3 === '3') { + return ['ok'=>false, 'message'=>"접근금지 계정입니다.

고객센터 1833-4856로 문의 하세요"]; + } + if ((string)$mem->stat_3 === '4') { + return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.2']; + } + if ((string)$mem->stat_3 === '5') { + return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.3']; + } + + // 휴면(stat_3=6) 처리: 지금은 테이블 저장 + 안내까지만 (메일 연결은 다음 단계에서) + if ((string)$mem->stat_3 === '6') { + // TODO: mem_dormancy insert + authnum 생성 + 메일 발송 연결 + return ['ok'=>false, 'message'=>'회원님 계정은 휴면계정입니다. 이메일 인증 후 이용 가능합니다.4']; + } + + // mem_st_ring 비번 로드 + $ring = DB::table('mem_st_ring')->where('mem_no', $mem->mem_no)->first(); + if (!$ring || empty($ring->str_0)) { + $reason = config('legacy.login_reason.L_NOT_EXISTS_PASS'); + // 실패 카운트 + 실패 로그 + $this->incrementLoginFail((int)$mem->mem_no); + + $log = [ + 'mem_no' => (int)$mem->mem_no, + 'sf' => 'f', + 'conn' => '1', + 'ip4' => $ip, + 'ip4_c' => $this->ip4c($ip), + 'dt_reg' => $dtNow6, + 'platform' => $platform, + 'browser' => $browserFull, + 'pattern' => 'self', + 'error_code' => $reason, + ]; + + + $this->insertLoginLog('mem_login_recent', $log); + $this->insertLoginLog($yearTable, $log); + + return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.5']; + } + + // 비번 검증 (PASS_SET=0) + $try = CiPassword::make($pw, 0); + $dbPass = (string)$ring->str_0; + + if ($try === '' || strcmp($try, $dbPass) !== 0) { + $reason = config('legacy.login_reason.L_INCORRECT_PASS'); + $failCnt = (int)$mem->login_fail_cnt; + + // 5회 실패 안내(>=4면 이번이 5회) + if ($failCnt >= 4) { + $reason = config('legacy.login_reason.L_LOGIN_FAIL'); + } + + // 실패 카운트 + 실패 로그 + $this->incrementLoginFail((int)$mem->mem_no); + + $log = [ + 'mem_no' => (int)$mem->mem_no, + 'sf' => 'f', + 'conn' => '1', + 'ip4' => $ip, + 'ip4_c' => $this->ip4c($ip), + 'dt_reg' => $dtNow6, + 'platform' => $platform, + 'browser' => $browserFull, + 'pattern' => 'self', + 'error_code' => $reason, + ]; + + $this->insertLoginLog('mem_login_recent', $log); + $this->insertLoginLog($yearTable, $log); + + if ($failCnt >= 4) { + return ['ok'=>false, 'message'=>"5회이상 실패시 비밀번호찾기 후 이용 바랍니다.(클릭)"]; + } + + return ['ok'=>false, 'message'=>"비밀번호가 일치하지 않습니다.\n비밀번호 실패횟수 : ".($failCnt+1)."\n5회 이상 실패시 인증을 다시받아야 합니다."]; + } + + // 레벨 체크 (email 인증 필수) + $levelInfo = $this->getMemLevel((int)$mem->mem_no); + if (($levelInfo['level'] ?? 0) < 1 || empty($levelInfo['auth_state']['email'])) { + return [ + 'ok'=>false, + 'message'=>"
이메일 인증 완료후 이용가능합니다. \n이메일주소(".$mem->email.") 메일을 확인하세요\n", + ]; + } + + // 로그인 차단 IP 대역 체크 + $ip4c = $this->ip4c($ip); + $blocked = DB::table('filter_login_ip_reject') + ->where('mem_no', $mem->mem_no) + ->where('ip4_c', $ip4c) + ->exists(); + + if ($blocked) { + return ['ok'=>false, 'message'=>"회원님의 설정에 의해 접속이 차단되었습니다.6"]; + } + + // 최근 로그인 업데이트(성공 시 fail_count reset) + $this->updateLastLogin((int)$mem->mem_no); + + // 성공 로그 저장(최근 + 연도별) + $log = [ + 'mem_no' => (int)$mem->mem_no, + 'sf' => 's', + 'conn' => '1', + 'ip4' => $ip, + 'ip4_c' => $ip4c, + 'dt_reg' => $dtNow6, + 'platform' => $platform, + 'browser' => $browserFull, + 'pattern' => 'self', + 'error_code' => '', + ]; + + $this->insertLoginLog('mem_login_recent', $log); + $this->insertLoginLog($yearTable, $log); + + // 첫 로그인 여부 + $login1st = 'n'; + if ((string)$mem->dt_login === (string)$mem->dt_reg) { + $login1st = 'y'; + } + + // ✅ 세션 payload (CI3 키 유지) + $session = [ + '_login_' => true, + '_mid' => (string)$mem->email, + '_mno' => (int)$mem->mem_no, + '_mname' => (string)$mem->name, + '_mstat_1' => (string)$mem->stat_1, + '_mstat_2' => (string)$mem->stat_2, + '_mstat_3' => (string)$mem->stat_3, + '_mstat_4' => (string)$mem->stat_4, + '_mstat_5' => (string)$mem->stat_5, + '_mcell' => (string)$mem->cell_phone, + '_mpv_sns' => (string)$mem->pv_sns, + '_mnative' => (string)$mem->native, + '_mcountry_code' => (string)$mem->country_code, + '_ip' => $ip, + '_login_1st' => $login1st, + '_dt_reg' => (string)$mem->dt_reg, + 'auth_ars' => !empty($levelInfo['auth_state']['ars']) ? 'Y' : 'N', + ]; + + return [ + 'ok' => true, + 'session' => $session, + 'redirect' => $returnUrl ?: '/', + ]; + }); + } } diff --git a/app/Support/AuthSession.php b/app/Support/AuthSession.php new file mode 100644 index 0000000..61f0881 --- /dev/null +++ b/app/Support/AuthSession.php @@ -0,0 +1,10 @@ +put('_sess', $data); // 예시 (너희 세션 구조에 맞게) + } +} diff --git a/app/Support/Legacy/Carrier.php b/app/Support/Legacy/Carrier.php new file mode 100644 index 0000000..e4d4110 --- /dev/null +++ b/app/Support/Legacy/Carrier.php @@ -0,0 +1,23 @@ +validateCsrfTokens(except: [ 'auth/register/danal/result', //다날 PASS 콜백 (외부 서버가 호출) ]); + + //페이지 접근권한 미들웨어 등록 + $middleware->alias([ + 'legacy.auth' => \App\Http\Middleware\LegacyAuth::class, //로그인후 접근가능 + 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, //게스트 접근가능페이지 + ]); + }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/legacy.php b/config/legacy.php index c713a79..85cf179 100644 --- a/config/legacy.php +++ b/config/legacy.php @@ -6,5 +6,46 @@ return [ 'inner_encoding' => 'UTF-8', 'block' => 16, 'iv' => [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], -]; + //로그인 오류 분류 + 'login_reason' => [ + 'L_NOT_EXISTS_ID' => 'E1', # 아이디 없음 + 'L_NOT_EXISTS_PASS' => 'E2', # 패스워드 없음 + 'L_INCORRECT_PASS' => 'E3', # 패스워드 불일치 + 'L_REJECT' => 'E4', # 회원설정 접속차단 + 'L_LOGIN_TIME' => 'E5', # 최근로그인일시 업데이트실패 + 'L_LOGIN_LOG_RECENT' => 'E6', # 로그인성공 로그 기록 실패 + 'L_LOGIN_LOG_YYYY' => 'E7', # 로그인성공 로그 기록 실패 + 'L_NOT_SNS_REG' => 'E8', # SNS 연동 가입 아님 + 'L_INCORRECT_SNS' => 'E9', # 아이디는 같지만 가입 sns 틀림 + 'L_LOGIN_FAIL' => 'E0', # 로그인5회오류 + ], + + //통신사. + 'carrier' => [ + 'default' => 'n', + + // 표준 코드 + 'codes' => [ + 'SKT' => '01', + 'KT' => '02', + 'LGU+' => '03', + 'LGU' => '03', + 'LGT' => '03', + + // 알뜰폰(표기 케이스 방어) + 'SKT_MVNO' => '04', + 'MVNO_SKT' => '04', + + 'KT_MVNO' => '05', + 'MVNO_KT' => '05', + + 'LGU_MVNO' => '06', + 'LGU+_MVNO' => '06', + 'MVNO_LGU' => '06', + ], + + // 이미 코드로 들어온 경우 허용 + 'allowed_codes' => ['01','02','03','04','05','06','n'], + ], +]; diff --git a/resources/css/web.css b/resources/css/web.css index f1a48bd..fa91502 100644 --- a/resources/css/web.css +++ b/resources/css/web.css @@ -152,15 +152,6 @@ img { display: block; } -/* Header layout fix */ -.site-header .container{ - height: 100%; - display: flex; - align-items: center; /* 전체 세로 가운데 */ - justify-content: space-between; - gap: 16px; -} - .header-left{ display: flex; align-items: center; /* 로고+메뉴 세로 가운데 */ @@ -298,11 +289,12 @@ h1, h2, h3, h4, h5, h6 { background-color: var(--color-bg-tint); } -/* Header */ .site-header { - position: sticky; + position: fixed; top: 0; - z-index: 50; + left: 0; + right: 0; + z-index: 9999; background-color: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid var(--color-border); @@ -314,6 +306,20 @@ h1, h2, h3, h4, h5, h6 { height: var(--header-height-scroll); } +/* 헤더에 가려지지 않게 상단 여백 확보 */ +body { + padding-top: var(--header-height-desktop); +} + +/* Header layout fix */ +.site-header .container{ + height: 100%; + display: flex; + align-items: center; /* 전체 세로 가운데 */ + justify-content: space-between; + gap: 16px; +} + .nav-link { font-size: 17px; font-weight: 600; @@ -4039,3 +4045,331 @@ body.is-drawer-open{ .pagination-wrap { margin-top: 18px; } .pg-btn, .pg-page, .pg-ellipsis { height: 32px; font-size: 12px; } } + +/* ========================================================= + Header Profile Dropdown (Tailwind-less fallback) + - Works with the exact Blade markup you pasted + ========================================================= */ + +/* wrapper positioning */ +.header-profile { + position: relative; + display: inline-flex; + align-items: center; +} + +/* button (btn btn-ghost fallback) */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid transparent; + border-radius: 10px; + padding: 8px 12px; + font-size: 14px; + line-height: 1; + cursor: pointer; + user-select: none; + text-decoration: none; + white-space: nowrap; +} + +.btn-ghost { + background: transparent; + color: rgba(15, 23, 42, 0.85); /* slate-ish */ +} + +.btn-ghost:hover { + background: rgba(2, 6, 23, 0.06); + border-color: rgba(2, 6, 23, 0.08); +} + +.btn-ghost:active { + background: rgba(2, 6, 23, 0.10); +} + +/* truncate + max width (truncate max-w-[120px]) */ +.header-profile .truncate { + display: inline-block; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* dropdown panel wrapper: + markup uses: absolute right-0 mt-2 w-48 hidden group-hover:block + We'll style the dropdown container and show on hover */ +.header-profile .profile-dropdown { + position: absolute; + right: 0; + top: 100%; /* 버튼 바로 아래에 붙임 */ + margin-top: 0; /* ✅ gap 제거 */ + width: 192px; + display: none; + z-index: 50; +} + +.profile-dropdown .profile-card { + margin-top: 10px; /* ✅ 시각적 간격은 카드에만 */ +} + +/* show on hover (group-hover:block replacement) */ +.header-profile:hover .profile-dropdown { + display: block; +} + +.header-profile:hover .profile-dropdown, +.header-profile .profile-dropdown:hover { + display: block; +} + +.header-profile .profile-dropdown::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -12px; /* 버튼 아래 ~ 메뉴 위 연결 */ + height: 12px; +} + +/* dropdown card (rounded-xl border bg-white shadow-lg overflow-hidden) */ +.profile-dropdown .profile-card { + background: #fff; + border: 1px solid rgba(2, 6, 23, 0.10); + border-radius: 14px; /* rounded-xl 느낌 */ + box-shadow: 0 12px 30px rgba(2, 6, 23, 0.12); + overflow: hidden; + min-width: 192px; +} + +/* top email line */ +.profile-dropdown .profile-meta { + padding: 10px 14px; + font-size: 12px; + color: rgba(2, 6, 23, 0.55); + background: rgba(2, 6, 23, 0.02); +} + +/* separators */ +.profile-dropdown .profile-sep { + height: 1px; + background: rgba(2, 6, 23, 0.08); +} + +/* menu links */ +.profile-dropdown a, +.profile-dropdown button { + display: block; + width: 100%; + text-align: left; + padding: 10px 14px; + font-size: 14px; + color: rgba(15, 23, 42, 0.90); + text-decoration: none; + background: transparent; + border: 0; +} + +.profile-dropdown a:hover, +.profile-dropdown button:hover { + background: rgba(2, 6, 23, 0.04); +} + +.profile-dropdown a:active, +.profile-dropdown button:active { + background: rgba(2, 6, 23, 0.07); +} + +/* logout button emphasis */ +.profile-dropdown .logout-btn { + color: rgba(220, 38, 38, 0.92); /* red */ +} + +/* accessibility: keyboard focus */ +.profile-dropdown a:focus, +.profile-dropdown button:focus, +.btn:focus { + outline: none; +} + +.profile-dropdown a:focus-visible, +.profile-dropdown button:focus-visible, +.btn:focus-visible { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25); + border-radius: 10px; +} + +/* Optional: small arrow pointer */ +.profile-dropdown .profile-card::before { + content: ""; + position: absolute; + right: 18px; + top: -7px; + width: 14px; + height: 14px; + background: #fff; + border-left: 1px solid rgba(2, 6, 23, 0.10); + border-top: 1px solid rgba(2, 6, 23, 0.10); + transform: rotate(45deg); +} + +/* ensure ::before positions correctly */ +.profile-dropdown { + position: absolute; +} +.profile-dropdown .profile-card { + position: relative; +} + +/* Mobile: open on tap is JS territory, but at least keep it usable */ +@media (max-width: 640px) { + .header-profile .truncate { max-width: 90px; } + .header-profile .profile-dropdown { width: 180px; } +} + +/* Mobile Drawer User Card (v2) */ +.m-usercard2{ + border: 1px solid var(--color-border); + background: rgba(255,255,255,.92); + backdrop-filter: blur(10px); + border-radius: 16px; + padding: 14px; + display: grid; + gap: 12px; + margin: 15px; +} + +/* head */ +.m-usercard2__head{ + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; +} + +.m-usercard2__badge{ + font-size: 11px; + font-weight: 800; + letter-spacing: .06em; + padding: 6px 8px; + border-radius: 999px; + border: 1px solid var(--color-border); + background: rgba(0,0,0,.03); +} + +.m-usercard2__title{ + font-weight: 800; + font-size: 14px; + line-height: 1.2; +} + +.m-usercard2__sub{ + margin-top: 4px; + font-size: 12px; + color: var(--color-muted, #6b7280); + font-weight: 500; +} + +.m-usercard2__icon{ + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 10px; + border: 1px solid var(--color-border); + background: rgba(0,0,0,.03); + text-decoration: none; +} + +/* profile */ +.m-usercard2__profile{ + display: grid; + grid-template-columns: 44px 1fr; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 14px; + border: 1px solid var(--color-border); + background: rgba(255,255,255,.65); +} + +.m-usercard2__avatar{ + width: 44px; + height: 44px; + border-radius: 14px; + display: grid; + place-items: center; + font-weight: 900; + background: rgba(0,0,0,.06); +} + +.m-usercard2__avatar--ghost{ + background: rgba(0,0,0,.03); +} + +.m-usercard2__info{ + min-width: 0; +} + +.m-usercard2__name{ + font-weight: 900; + font-size: 14px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.m-usercard2__meta{ + margin-top: 4px; + font-size: 12px; + color: var(--color-muted, #6b7280); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* actions */ +.m-usercard2__actions{ + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + align-items: center; +} + +/* button system (drawer-only) */ +.m-btn2{ + height: 42px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 800; + text-decoration: none; + border: 1px solid transparent; + background: var(--color-primary, #ff6a00); + color: #fff; +} + +.m-btn2--ghost{ + background: transparent; + border-color: var(--color-border); + color: var(--color-text, #111827); +} + +.m-btn2--text{ + background: transparent; + border-color: transparent; + color: var(--color-muted, #6b7280); +} + +/* 로그인 상태: 로그아웃은 2칸을 차지해서 아래로 명확히 */ +.m-usercard2.is-login .m-usercard2__logout{ + grid-column: 1 / -1; +} + +.m-usercard2__logout{ margin: 0; } +.m-usercard2__logout button{ width: 100%; } diff --git a/resources/views/web/auth/debug_payload.blade.php b/resources/views/web/auth/debug_payload.blade.php new file mode 100644 index 0000000..04ab297 --- /dev/null +++ b/resources/views/web/auth/debug_payload.blade.php @@ -0,0 +1,61 @@ +@extends('web.layouts.auth') + +@section('title', '회원가입 Debug | PIN FOR YOU') + +@section('auth_content') +
+

회원가입 Debug

+ +
+
Payload(session)
+
+{{ json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT) }}
+        
+
+ +
+
+ @csrf + + +
+ +
+ @csrf + + +
+ +
+ @csrf + + +
+ +
+ @csrf + + +
+
+ + @if(!empty($last)) +
+ +
마지막 실행 결과
+
+ step: {{ $last['step'] ?? '-' }} / + mode: {{ !empty($last['commit']) ? 'COMMIT' : 'DRY RUN(ROLLBACK)' }} / + ok: {{ !empty($last['ok']) ? 'true' : 'false' }} +
+
{{ $last['message'] ?? '' }}
+ +
+
Captured Queries
+
+{{ json_encode($last['queries'] ?? [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT) }}
+            
+
+ @endif +
+@endsection diff --git a/resources/views/web/auth/login.blade.php b/resources/views/web/auth/login.blade.php index 045c2a9..ca3326d 100644 --- a/resources/views/web/auth/login.blade.php +++ b/resources/views/web/auth/login.blade.php @@ -10,8 +10,20 @@ @section('subheadline', '로그인 후 구매/문의 내역을 빠르게 확인할 수 있어요.') @section('card_aria', '로그인 폼') +{{-- ✅ reCAPTCHA 스크립트/공통함수는 이 페이지에서만 로드 --}} +@push('recaptcha') + + + +@endpush + @section('auth_content') -
+ + @csrf + + + + - +
- +
-
-
-
회원가입 @@ -60,3 +72,94 @@ 카카오 상담
@endsection + +@push('scripts') + @push('scripts') + + @endpush + +@endpush diff --git a/resources/views/web/auth/profile.blade.php b/resources/views/web/auth/profile.blade.php index 8e6bf9c..3ebdfa3 100644 --- a/resources/views/web/auth/profile.blade.php +++ b/resources/views/web/auth/profile.blade.php @@ -1,36 +1,562 @@ @extends('web.layouts.auth') @section('title', '회원정보 입력 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 회원정보 입력 단계입니다.') +@section('canonical', url('/auth/register/profile')) -@section('content') -
-

회원정보 입력

-

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

+@section('h1', '회원정보 입력') +@section('desc', '본인인증 정보는 수정할 수 없으며, 아이디/비밀번호 등 가입 정보를 입력해 주세요.') +@section('card_aria', '회원가입 Step2 - 가입정보 입력') +@section('show_cs_links', true) - - @csrf +@section('auth_content') + + @csrf -
- - + {{-- 진행 단계 --}} +
+
+
가입정보확인
+
약관/인증 확인
+
가입정보입력
+
-
- - + + {{-- PASS 인증 정보(수정 불가) --}} +
+
본인인증 정보
+ + @php + $pv = (array) session('register.pass_payload', []); + + $pvName = $pv['NAME'] ?? '-'; + $pvBirth = $pv['DOB'] ?? null; // YYYYMMDD + $pvSex = $pv['SEX'] ?? null; // 1/2 등 + $pvNation = $pv['FOREIGNER'] ?? null; // 0/1 + $pvTelco = $pv['CARRIER'] ?? (session('signup.carrier') ?? '-'); // SKT/KT/LGU+ 또는 코드 + $pvPhone = $pv['PHONE'] ?? (session('signup.phone') ?? '-'); + + // 표시용 변환(성별/내외국인) + $pvSexText = match((string)$pvSex) { + '1' => '남', + '2' => '여', + default => ($pvSex ?? '-'), + }; + + $pvNationText = match((string)$pvNation) { + '0' => '내국인', + '1' => '외국인', + default => ($pvNation ?? '-'), + }; + + // 생년월일 포맷 + $pvBirthText = '-'; + if (is_string($pvBirth) && strlen($pvBirth) >= 8) { + $pvBirthText = substr($pvBirth,0,4).'년 '.substr($pvBirth,4,2).'월 '.substr($pvBirth,6,2).'일'; + } elseif (!empty($pvBirth)) { + $pvBirthText = (string)$pvBirth; + } + + // 휴대폰 포맷(표시용) + $pvPhoneText = (string)$pvPhone; + if (is_string($pvPhone) && preg_match('/^\d{10,11}$/', $pvPhone)) { + $pvPhoneText = substr($pvPhone,0,3).'-'.substr($pvPhone,3,4).'-'.substr($pvPhone,7); + } + @endphp + +
+
+
성명
+
{{ $pvName}}
+
+
+
생년월일
+
{{ $pvBirthText }}
+
+
+
성별
+
{{ $pvSexText }}
+
+
+
내외국인
+
{{ $pvNationText}}
+
+
+
통신사
+
{{ $pvTelco }}
+
+
+
휴대전화번호
+
{{ $pvPhoneText}}
+
- -
- - +
+ 위 정보가 다르면 인증을 중단하고 처음부터 다시 진행해 주세요.
+
-
- - + {{-- 아이디(이메일) --}} +
+ + + +
+ + {{-- 비밀번호(6자리 숫자) --}} +
+ + + +
+ +
+ + + +
+ + + {{-- 2차 비밀번호(4자리 숫자) --}} +
+
+ + +
+
+ + + +
+
+ +
+ + 처음으로 +
+ + + + + + - - -
@endsection diff --git a/resources/views/web/auth/register.blade.php b/resources/views/web/auth/register.blade.php index 4064afa..649a414 100644 --- a/resources/views/web/auth/register.blade.php +++ b/resources/views/web/auth/register.blade.php @@ -12,15 +12,13 @@ @section('auth_content') {{-- Step0 Hero Image + 안내문구 --}} - - +
+
+
가입정보확인
+
약관/인증 확인
+
가입정보입력
+
+
@@ -29,6 +27,25 @@ {{-- ✅ hidden input만 생성(토큰은 JS에서 발급 후 payload에 포함) --}} +
+ + + {{-- 서버 전송용 hidden --}} + + +
+ + + +{{-- --}} +{{-- --}} +{{-- --}} +
+ + +
+ {{-- ✅ reCAPTCHA 스크립트/공통함수는 이 페이지에서만 로드 --}} @push('recaptcha') @@ -58,10 +162,16 @@ + @endsection @section('auth_bottom') diff --git a/resources/views/web/auth/register_terms.blade.php b/resources/views/web/auth/register_terms.blade.php index 8c0a36a..a6e9f74 100644 --- a/resources/views/web/auth/register_terms.blade.php +++ b/resources/views/web/auth/register_terms.blade.php @@ -13,9 +13,9 @@
{{-- 진행 단계 --}}
-
약관 동의
-
본인인증
-
가입정보
+
가입정보확인
+
약관/인증 확인
+
가입정보입력
{{--
--}} @@ -453,11 +453,90 @@ {{-- 페이지 전용 스타일(이쁘게) --}}