diff --git a/app/Http/Controllers/Web/Auth/EmailVerificationController.php b/app/Http/Controllers/Web/Auth/EmailVerificationController.php new file mode 100644 index 0000000..5265e9a --- /dev/null +++ b/app/Http/Controllers/Web/Auth/EmailVerificationController.php @@ -0,0 +1,83 @@ +getAuthUserFromSession($request); + if (!$au) { + return redirect()->route('web.home') + ->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => '잘못된 접근입니다.', + ]); + } + + return view('web.auth.email_required', [ + 'email' => $au['email'], + 'memNo' => $au['mem_no'], + ]); + } + + public function sendVerify(Request $request, EmailVerificationService $svc) + { + $au = $svc->getAuthUserFromSession($request); + if (!$au) { + return redirect()->route('web.home') + ->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => '잘못된 접근입니다.', + ]); + } + + try { + $res = $svc->sendVerifyMail($request, (int)$au['mem_no'], (string)$au['email']); + + return back()->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => $res['message'] ?? '인증메일을 발송했습니다. 메일함을 확인해 주세요.', + ]); + } catch (\Throwable $e) { + \Log::error('Email verify send failed', [ + 'mem_no' => $au['mem_no'], + 'email' => $au['email'], + 'error' => $e->getMessage(), + ]); + return back()->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '오류', + 'message' => '인증메일 발송 중 오류가 발생했습니다.', + ]); + } + } + + + public function verify(Request $request, EmailVerificationService $svc) + { + $memNo = (int)$request->query('mem_no', 0); + $k = (string)$request->query('k', ''); + + $res = $svc->verifySignedLink($request, $memNo, $k); + + if (!($res['ok'] ?? false)) { + return redirect('/')->with('alert', $res['message'] ?? '잘못된 접근입니다.'); + } + + // 인증 완료 후 임시세션 제거 + $request->session()->forget('auth_user'); + + return view('web.auth.email_verified', [ + 'email' => (string)($res['email'] ?? ''), + 'loginUrl' => route('web.auth.login'), + ]); + } +} diff --git a/app/Http/Controllers/Web/Auth/FindIdController.php b/app/Http/Controllers/Web/Auth/FindIdController.php index c3ea323..46274b7 100644 --- a/app/Http/Controllers/Web/Auth/FindIdController.php +++ b/app/Http/Controllers/Web/Auth/FindIdController.php @@ -3,9 +3,10 @@ namespace App\Http\Controllers\Web\Auth; use App\Http\Controllers\Controller; -use App\Services\SmsService; +use App\Repositories\Member\MemberAuthRepository; use App\Support\LegacyCrypto\CiSeedCrypto; -use App\Models\Member\MemInfo; +use App\Rules\RecaptchaV3Rule; +use App\Services\SmsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; @@ -33,37 +34,41 @@ class FindIdController extends Controller { logger()->info('HIT sendCode', ['path' => request()->path(), 'host' => request()->getHost()]); - $v = Validator::make($request->all(), [ + $rules = [ 'phone' => ['required', 'string', 'max:20'], - ], [ + ]; + + if (app()->environment('production')) { + $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('find_id')]; + } + + $v = Validator::make($request->all(), $rules, [ 'phone.required' => '휴대폰 번호를 입력해 주세요.', + 'g-recaptcha-response.required' => '올바른 접근이 아닙니다.', ]); if ($v->fails()) { return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); } - $phoneRaw = $request->input('phone'); + $phoneRaw = (string) $request->input('phone'); $phone = $this->normalizeKoreanPhone($phoneRaw); + if (!$phone) { return response()->json(['ok' => false, 'message' => '휴대폰 번호 형식이 올바르지 않습니다.'], 422); } - // ✅ 0) DB에 가입된 휴대폰인지 먼저 확인 /** @var CiSeedCrypto $seed */ $seed = app(CiSeedCrypto::class); // DB에 저장된 방식(동일)으로 암호화해서 비교 $phoneEnc = $seed->encrypt($phone); - $exists = MemInfo::query() - ->whereNotNull('email') - ->where('email', '<>', '') - ->where('cell_phone', $phoneEnc) - ->exists(); + $repo = app(MemberAuthRepository::class); + $exists = $repo->existsByEncryptedPhone($phoneEnc); if (!$exists) { - // ✅ 세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공 + //\세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공 return response()->json([ 'ok' => false, 'code' => 'PHONE_NOT_FOUND', @@ -80,7 +85,7 @@ class FindIdController extends Controller } RateLimiter::hit($key, 600); - // 6자리 OTP 생성 + // 6자리 난수 생성 $code = (string) random_int(100000, 999999); // 세션 저장(보안: 실제로는 해시 저장 권장, 여기선 간단 구현) @@ -134,7 +139,7 @@ class FindIdController extends Controller 'message' => '인증번호를 발송했습니다.', 'expires_in' => 180, 'step' => 2, - 'dev_code' => $isLocal ? $code : null, + //'dev_code' => $isLocal ? $code : null, ]); } @@ -189,15 +194,8 @@ class FindIdController extends Controller // 키를 넘기지 말고, CiSeedCrypto가 생성자 주입된 userKey로 처리하게 통일 $phoneEnc = $crypto->encrypt($phone); - $emails = MemInfo::query() - ->whereNotNull('email') - ->where('email', '<>', '') - ->where('cell_phone', $phoneEnc) //DB에 저장된 암호문과 동일하게 매칭됨 - ->orderByDesc('mem_no') - ->pluck('email') - ->unique() - ->values() - ->all(); + $repo = app(MemberAuthRepository::class); + $emails = $repo->getEmailsByEncryptedPhone($phoneEnc); if (empty($emails)) { // 운영에서는 암호문 노출 절대 금지 (지금은 디버그용이면 로그로만) @@ -231,8 +229,6 @@ class FindIdController extends Controller { $digits = preg_replace('/\D+/', '', $input); if (!$digits) return null; - - // 010XXXXXXXX (11), 01XXXXXXXXX (10) 정도만 허용 예시 if (Str::startsWith($digits, '010') && strlen($digits) === 11) return $digits; if (preg_match('/^01[0-9]{8,9}$/', $digits)) return $digits; // 필요시 범위 조정 return null; diff --git a/app/Http/Controllers/Web/Auth/FindPasswordController.php b/app/Http/Controllers/Web/Auth/FindPasswordController.php index 8fae1b8..f8e0f39 100644 --- a/app/Http/Controllers/Web/Auth/FindPasswordController.php +++ b/app/Http/Controllers/Web/Auth/FindPasswordController.php @@ -1,22 +1,18 @@ session()->get('find_pw', []); + $sess = (array) $request->session()->get('find_pw', []); $step = 1; if (!empty($sess['verified'])) $step = 3; @@ -24,238 +20,120 @@ class FindPasswordController extends Controller return view('web.auth.find_password', [ 'initialStep' => $step, - 'email' => $sess['email'] ?? null, + 'email' => $sess['email'] ?? null, + 'name' => $sess['name'] ?? null, ]); } - public function sendCode(Request $request) + public function sendMail(Request $request, FindPasswordService $svc) { - $v = Validator::make($request->all(), [ + $rules = [ 'email' => ['required', 'email', 'max:120'], - ], [ + 'name' => ['required', 'string', 'max:50'], + ]; + + if (app()->environment('production')) { + $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('find_pass')]; + } + + $v = Validator::make($request->all(), $rules, [ 'email.required' => '이메일을 입력해 주세요.', 'email.email' => '이메일 형식이 올바르지 않습니다.', + 'name.required' => '성명을 입력해 주세요.', + 'g-recaptcha-response.required' => '올바른 접근이 아닙니다.', ]); if ($v->fails()) { - return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); - } - - $email = mb_strtolower(trim((string) $request->input('email'))); - - // 0) 가입된 이메일인지 확인 (MemInfo 기준) - $exists = MemInfo::query() - ->whereNotNull('email') - ->where('email', '<>', '') - ->whereRaw('LOWER(email) = ?', [$email]) - ->exists(); - - if (!$exists) { return response()->json([ 'ok' => false, - 'code' => 'EMAIL_NOT_FOUND', - 'message' => '해당 이메일로 가입된 계정을 찾을 수 없습니다. 이메일을 다시 확인해 주세요.', - 'step' => 1, - ], 404); + 'message' => $v->errors()->first(), + ], 422); } - try { - app(MailService::class)->sendTemplate( - $email, - '[PIN FOR YOU] 비밀번호 재설정 인증번호', - 'mail.legacy.noti_email_auth_1', // CI 템플릿명에 맞춰 선택 - [ - 'code' => $code, - 'expires_min' => 3, - 'email' => $email, - ], - queue: true - ); - } catch (\Throwable $e) { - $request->session()->forget('find_pw'); - Log::error('FindPassword sendCode failed', ['email' => $email, 'error' => $e->getMessage()]); - return response()->json(['ok'=>false,'message'=>'인증번호 발송 중 오류가 발생했습니다.'], 500); + $email = (string) $request->input('email'); + $name = (string) $request->input('name'); + + $res = $svc->sendResetMail($request, $email, $name, 30); + + if (!($res['ok'] ?? false)) { + return response()->json([ + 'ok' => false, + 'code' => $res['code'] ?? null, + 'message' => $res['message'] ?? '요청 처리 중 오류가 발생했습니다.', + 'step' => $res['step'] ?? 1, + ], (int)($res['status'] ?? 400)); } - // 1) 레이트리밋(이메일 기준) - $key = 'findpw:send:' . $email; - if (RateLimiter::tooManyAttempts($key, 5)) { // 10분 5회 예시 - $sec = RateLimiter::availableIn($key); - return response()->json(['ok' => false, 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); + if (!empty($res['session'])) { + $request->session()->put('find_pw', $res['session']); } - RateLimiter::hit($key, 600); - // 2) OTP 생성 - $code = (string) random_int(100000, 999999); - - // 3) 세션 저장 (코드는 해시로) - $request->session()->put('find_pw', [ - 'sent' => true, - 'email' => $email, - 'code' => password_hash($code, PASSWORD_DEFAULT), - 'code_expires_at' => now()->addMinutes(3)->timestamp, - 'verified' => false, - 'verified_at' => null, + return response()->json([ + 'ok' => true, + 'message' => $res['message'] ?? '인증메일을 발송했습니다.', + 'expires_in' => $res['expires_in'] ?? 1800, + 'step' => $res['step'] ?? 2, ]); + } - // 4) 실제 발송 연결 (메일/SMS 등) - try { - // TODO: 프로젝트에 맞게 구현 - // 예: Mail::to($email)->send(new PasswordOtpMail($code)); - } catch (\Throwable $e) { - $request->session()->forget('find_pw'); - Log::error('FindPassword sendCode failed', [ - 'email' => $email, - 'error' => $e->getMessage(), + + public function verifyLink(Request $request, FindPasswordService $svc) + { + $memNo = (int) $request->query('mem_no', 0); + + $res = $svc->verifyResetLink($request, $memNo); + + if (!($res['ok'] ?? false)) { + return redirect()->route('web.auth.find_password.show') + ->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => $res['message'] ?? '잘못된 접근입니다.', + ]); + } + + // ✅ 인증완료 세션 세팅 + if (!empty($res['session'])) { + $request->session()->put('find_pw', $res['session']); + } + + return redirect()->route('web.auth.find_password.show') + ->with('ui_dialog', [ + 'type' => 'alert', + 'title' => '안내', + 'message' => $res['message'] ?? '인증이 완료되었습니다.', ]); - return response()->json([ - 'ok' => false, - 'message' => '인증번호 발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - ], 500); - } - - $isLocal = app()->environment(['local', 'development', 'testing']); - - return response()->json([ - 'ok' => true, - 'message' => '인증번호를 발송했습니다.', - 'expires_in' => 180, - 'step' => 2, - 'dev_code' => $isLocal ? $code : null, - ]); } - public function verify(Request $request) + public function reset(Request $request, FindPasswordService $svc) { $v = Validator::make($request->all(), [ - 'code' => ['required', 'digits:6'], - ], [ - 'code.required' => '인증번호를 입력해 주세요.', - 'code.digits' => '인증번호 6자리를 입력해 주세요.', - ]); - - if ($v->fails()) { - return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); - } - - $sess = $request->session()->get('find_pw'); - if (!$sess || empty($sess['sent']) || empty($sess['email'])) { - return response()->json(['ok' => false, 'message' => '먼저 인증번호를 요청해 주세요.'], 400); - } - - if (empty($sess['code_expires_at']) || now()->timestamp > (int)$sess['code_expires_at']) { - return response()->json(['ok' => false, 'message' => '인증번호가 만료되었습니다. 다시 요청해 주세요.'], 400); - } - - // 검증 시도 레이트리밋 - $key = 'findpw:verify:' . $sess['email']; - if (RateLimiter::tooManyAttempts($key, 10)) { // 10분 10회 예시 - $sec = RateLimiter::availableIn($key); - return response()->json(['ok' => false, 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); - } - RateLimiter::hit($key, 600); - - $code = (string) $request->input('code'); - $ok = password_verify($code, $sess['code'] ?? ''); - - if (!$ok) { - return response()->json(['ok' => false, 'message' => '인증번호가 일치하지 않습니다.'], 422); - } - - // 인증 성공 → step3 허용 - $sess['verified'] = true; - $sess['verified_at'] = now()->timestamp; - $request->session()->put('find_pw', $sess); - - return response()->json([ - 'ok' => true, - 'message' => '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.', - 'step' => 3, - ]); - } - - public function reset(Request $request) - { - $v = Validator::make($request->all(), [ - 'new_password' => ['required', 'string', 'min:8', 'max:72', 'confirmed'], + 'new_password' => [ + 'required', 'string', 'min:8', 'max:20', + 'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/' + ], + 'new_password_confirmation' => ['required', 'same:new_password'], ], [ 'new_password.required' => '새 비밀번호를 입력해 주세요.', - 'new_password.min' => '비밀번호는 8자 이상으로 입력해 주세요.', - 'new_password.confirmed' => '비밀번호 확인이 일치하지 않습니다.', + 'new_password.min' => '비밀번호는 8자리 이상이어야 합니다.', + 'new_password.max' => '비밀번호는 20자리를 초과할 수 없습니다.', + 'new_password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.', + 'new_password_confirmation.same' => '비밀번호 확인이 일치하지 않습니다.', ]); if ($v->fails()) { return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); } - $sess = $request->session()->get('find_pw'); - if (!$sess || empty($sess['email']) || empty($sess['verified'])) { - return response()->json(['ok' => false, 'message' => '인증이 필요합니다.'], 403); - } + $pw = (string) $request->input('new_password'); - $email = (string) $sess['email']; - - // (선택) 인증 후 너무 오래 지나면 재인증 요구 - $verifiedAt = (int)($sess['verified_at'] ?? 0); - if ($verifiedAt > 0 && now()->timestamp - $verifiedAt > 10 * 60) { // 10분 예시 - $request->session()->forget('find_pw'); - return response()->json(['ok' => false, 'message' => '인증이 만료되었습니다. 다시 진행해 주세요.'], 403); - } - - $newPassword = (string) $request->input('new_password'); - - // 실제 비밀번호 저장 컬럼은 프로젝트마다 다를 수 있어 안전하게 처리 - $member = MemInfo::query() - ->whereNotNull('email') - ->where('email', '<>', '') - ->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]) - ->orderByDesc('mem_no') - ->first(); - - if (!$member) { - $request->session()->forget('find_pw'); - return response()->json(['ok' => false, 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.'], 404); - } - - // ✅ 여기서부터가 “진짜 저장 로직” - // MemInfo의 실제 컬럼명에 맞게 1개만 쓰면 됩니다. - // - password 컬럼을 쓰면 아래처럼 - // - 레거시 passwd 컬럼이면 passwd로 교체 - try { - if (isset($member->password)) { - $member->password = Hash::make($newPassword); - } elseif (isset($member->passwd)) { - $member->passwd = Hash::make($newPassword); // 레거시 규격이면 여기를 교체 - } else { - // 컬럼을 모르면 여기서 명시적으로 막는게 안전 - return response()->json([ - 'ok' => false, - 'message' => '비밀번호 저장 컬럼 설정이 필요합니다. (MemInfo password/passwd 확인)', - ], 500); - } - - $member->save(); - } catch (\Throwable $e) { - Log::error('FindPassword reset failed', [ - 'email' => $email, - 'error' => $e->getMessage(), - ]); - return response()->json(['ok' => false, 'message' => '비밀번호 변경 중 오류가 발생했습니다.'], 500); - } - - $request->session()->forget('find_pw'); - $request->session()->save(); + $res = $svc->resetPasswordFinal($request, $pw); return response()->json([ - 'ok' => true, - 'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.', - 'redirect_url' => route('web.auth.login'), - ]); + 'ok' => (bool)($res['ok'] ?? false), + 'message' => $res['message'] ?? '', + 'redirect_url' => $res['redirect_url'] ?? null, + ], (int)($res['status'] ?? 200)); } - public function resetSession(Request $request) - { - $request->session()->forget('find_pw'); - return response()->json(['ok' => true]); - } } diff --git a/app/Http/Controllers/Web/Auth/LoginController.php b/app/Http/Controllers/Web/Auth/LoginController.php index 5ad5d36..a031844 100644 --- a/app/Http/Controllers/Web/Auth/LoginController.php +++ b/app/Http/Controllers/Web/Auth/LoginController.php @@ -27,6 +27,8 @@ final class LoginController extends Controller if (app()->environment('production')) { $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')]; + var_dump('google'); + exit; } $v = Validator::make($request->all(), $rules, [ @@ -60,11 +62,25 @@ final class LoginController extends Controller 'return_url' => $returnUrl, ]); - if (!$res['ok']) { - // UI 처리 방식은 프로젝트 스타일에 맞춰 - // (일단 errors로 처리) + if (!($res['ok'] ?? false)) { + + // 이메일 미인증이면 confirm 페이지로 이동 + if (($res['reason'] ?? null) === 'email_unverified') { + // 세션 고정 공격 방지 (중요) + $request->session()->regenerate(); + $request->session()->put('auth_user', [ + 'mem_no' => (int)($res['mem_no'] ?? 0), + 'email' => (string)($res['email'] ?? $email), + 'issued_at' => now()->timestamp, + 'expires_at' => now()->addMinutes(30)->timestamp, // auth_user 세션 유효기간 + ]); + + return redirect()->route('web.auth.email.required'); + } + + // 그 외 실패는 기존 방식 유지 return back() - ->withErrors(['login' => $res['message']]) + ->withErrors(['login' => $res['message'] ?? '로그인에 실패했습니다.']) ->withInput(['mem_email' => $email]); } @@ -89,18 +105,18 @@ final class LoginController extends Controller public function logout(Request $request) { - $request->session()->forget('_sess'); - - // (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가 - $request->session()->forget('signup'); - $request->session()->forget('register'); - - // (선택) 디버그 세션 정리 - $request->session()->forget('debug'); - - // ✅ 세션 저장 - $request->session()->save(); - +// $request->session()->forget('_sess'); +// +// // (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가 +// $request->session()->forget('signup'); +// $request->session()->forget('register'); +// +// // (선택) 디버그 세션 정리 +// $request->session()->forget('debug'); +// +// $request->session()->save(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); return redirect()->route('web.home') ->with('ui_dialog', [ 'type' => 'alert', diff --git a/app/Http/Controllers/Web/Mypage/InfoGateController.php b/app/Http/Controllers/Web/Mypage/InfoGateController.php new file mode 100644 index 0000000..c842d27 --- /dev/null +++ b/app/Http/Controllers/Web/Mypage/InfoGateController.php @@ -0,0 +1,335 @@ +session()->get('mypage_gate', []); + $ok = (bool) ($gate['ok'] ?? false); + $at = (int) ($gate['at'] ?? 0); + $ttlSeconds = 5 * 60; + $isValid = $ok && $at > 0 && (time() - $at) <= $ttlSeconds; + if ($isValid) { + return redirect()->to('/mypage/info_renew'); + } + + return view('web.mypage.info.gate'); + } + + + public function info_renew(Request $request) + { + // ✅ gate (기존 그대로) + $gate = (array) $request->session()->get('mypage_gate', []); + $gateOk = (bool) Arr::get($gate, 'ok', false); + $gateAt = (int) Arr::get($gate, 'at', 0); + + $ttlSec = 5 * 60; + $nowTs = now()->timestamp; + $expireTs = ($gateOk && $gateAt > 0) ? ($gateAt + $ttlSec) : 0; + $remainSec = ($expireTs > 0) ? max(0, $expireTs - $nowTs) : 0; + $isGateValid = ($remainSec > 0); + + // ✅ 회원정보: _sess 기반 + $sess = (array) $request->session()->get('_sess', []); + + $memberName = (string) Arr::get($sess, '_mname', ''); + $memberEmail = (string) Arr::get($sess, '_mid', ''); + $dtReg = (string) Arr::get($sess, '_dt_reg', ''); + + // ✅ 전화번호 복호 (인증 완료 상태라 마스킹 제외) + $rawPhone = (string) Arr::get($sess, '_mcell', ''); + $memberPhone = (string) $this->seed->decrypt($rawPhone); + $user = $request->user(); + $withdrawBankName = (string)($user->withdraw_bank_name ?? $user->bank_name ?? ''); + $withdrawAccount = (string)($user->withdraw_account ?? $user->bank_account ?? ''); + $hasWithdrawAccount = ($withdrawBankName !== '' && $withdrawAccount !== ''); + + $agreeEmail = (string)($user->agree_marketing_email ?? $user->agree_email ?? 'n'); + $agreeSms = (string)($user->agree_marketing_sms ?? $user->agree_sms ?? 'n'); + + return view('web.mypage.info.renew', [ + // gate + 'ttlSec' => $ttlSec, + 'expireTs' => (int) $expireTs, + 'remainSec' => (int) $remainSec, + 'isGateValid' => (bool) $isGateValid, + + // member (sess) + 'memberName' => $memberName, + 'memberEmail' => $memberEmail, + 'memberPhone' => $memberPhone, + 'memberDtReg' => $dtReg, + + // etc + 'hasWithdrawAccount' => (bool) $hasWithdrawAccount, + 'agreeEmail' => $agreeEmail, + 'agreeSms' => $agreeSms, + ]); + } + + + /** + * 재인증을 위한 세셔초기화 + */ + public function gateReset(Request $request) + { + // 게이트 인증 세션만 초기화 + $request->session()->forget('mypage_gate'); + + // (선택) reauth도 같이 초기화하고 싶으면 + $request->session()->forget('mypage.reauth.at'); + $request->session()->forget('mypage.reauth.until'); + + $request->session()->save(); + + // 게이트 페이지로 이동 + return redirect()->route('web.mypage.info.index'); + } + + /** + * 비밀번호 재인증 처리 + */ + public function verify(Request $request, MemInfoService $memInfoService) + { + $request->validate([ + 'password' => ['required', 'string'], + ], [ + 'password.required' => '비밀번호를 입력해 주세요.', + ]); + + $sess = (array) $request->session()->get('_sess', []); + $isLogin = (bool) ($sess['_login_'] ?? false); + $email = (string) ($sess['_mid'] ?? ''); + + if (!$isLogin || $email === '') { + return redirect()->route('web.auth.login', ['return_url' => url('/mypage/info')]); + } + + $pw = (string) $request->input('password'); + + $res = $memInfoService->attemptLegacyLogin([ + 'email' => $email, + 'password' => $pw, + 'ip' => $request->ip(), + 'ua' => substr((string) $request->userAgent(), 0, 500), + 'return_url' => url('/mypage/info'), + ]); + + $ok = (bool)($res['ok'] ?? $res['success'] ?? false); + + if (!$ok) { + $msg = (string)($res['message'] ?? '비밀번호가 일치하지 않습니다.'); + return back() + ->withErrors(['password' => $msg]) // 레이어 알림 스크립트가 이걸 잡음 + ->withInput($request->except('password')); + } + + // 게이트 통과 세션 (예: 30분) + $request->session()->put('mypage_gate', [ + 'ok' => true, + 'email' => $email, + 'at' => time(), + ]); + + return redirect()->route('web.mypage.info.renew'); + } + + public function passReady(Request $request) + { + // 목적 저장 (result에서 분기용) + $purpose = (string) $request->input('purpose', 'mypage_phone_change'); + $request->session()->put('mypage.pass_purpose', $purpose); + $request->session()->save(); + + $danal = app(\App\Services\Danal\DanalAuthtelService::class)->prepare([ + 'targetUrl' => route('web.mypage.info.danal.result'), + 'backUrl' => route('web.mypage.info.renew'), // 취소/뒤로가기 + 'cpTitle' => request()->getHost(), + ]); + + if (!($danal['ok'] ?? false)) { + return response()->json([ + 'ok' => false, + 'message' => $danal['message'] ?? '본인인증 준비에 실패했습니다. 잠시 후 다시 시도해 주세요.', + ], 500); + } + + // 필요하면 txid 저장 (회원가입과 동일) + $request->session()->put('mypage.danal', [ + 'txid' => $danal['txid'] ?? null, + 'created_at' => now()->toDateTimeString(), + 'purpose' => $purpose, + ]); + $request->session()->save(); + + return response()->json([ + 'ok' => true, + 'reason' => 'danal_ready', + 'popup' => [ + 'url' => route('web.mypage.info.danal.start'), + 'fields' => $danal['fields'], + ], + ]); + } + + public function danalStart(Request $request) + { + $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); + } + + $action = $isMobile + ? 'https://wauth.teledit.com/Danal/WebAuth/Mobile/Start.php' + : 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php'; + + unset($fields['platform']); + + // 기존 autosubmit 뷰 재사용 OK + return view('web.auth.danal_autosubmit', [ + 'action' => $action, + 'fields' => $fields, + 'isMobile' => $isMobile, + ]); + } + + + public function danalResult( + Request $request, + DanalAuthtelService $danal, + MemberAuthRepository $repo + ) { + $payload = $request->all(); + + if (config('app.debug')) { + Log::info('[MYPAGE][DANAL][RESULT] keys', [ + 'method' => $request->method(), + 'url' => $request->fullUrl(), + 'keys' => array_keys($payload), + ]); + } + + $tid = (string)($payload['TID'] ?? ''); + if ($tid === '') { + return response()->view('web.auth.danal_finish_top', [ + 'ok' => false, + 'message' => 'TID가 없습니다.', + 'redirect' => route('web.mypage.info.index'), + ]); + } + + // CI와 동일: TID로 CONFIRM + $res = $danal->confirm($tid, 0, 1); + + // 로그 저장 (성공/실패 무조건) + $logSeq = $repo->insertDanalAuthLog('M', (array) $res); + if ($logSeq > 0) { + $request->session()->put('mypage.pass.danal_log_seq', $logSeq); + } + + $ok = (($res['RETURNCODE'] ?? '') === '0000'); + if (!$ok) { + return response()->view('web.auth.danal_finish_top', [ + 'ok' => false, + 'message' => ($res['RETURNMSG'] ?? '본인인증에 실패했습니다.') . ' (' . ($res['RETURNCODE'] ?? 'NO_CODE') . ')', + 'redirect' => route('web.mypage.info.index'), + ]); + } + + // ✅ 목적이 마이페이지 연락처 변경일 때만 추가 검증 + $purpose = (string) $request->session()->get('mypage.pass_purpose', ''); + if ($purpose === 'mypage_phone_change') { + + $sess = (array) $request->session()->get('_sess', []); + $memNo = (int) ($sess['_mno'] ?? 0); + + if ($memNo <= 0) { + return response()->view('web.auth.danal_finish_top', [ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.', + 'redirect' => route('web.auth.login'), + ]); + } + + $svc = app(\App\Services\MypageInfoService::class); + + //연락처 검증 + $check = $svc->validatePassPhoneChange($sess, (array) $res); + if (!($check['ok'] ?? false)) { + return response()->view('web.auth.danal_finish_top', [ + 'ok' => false, + 'message' => $check['message'] ?? '연락처 변경 검증에 실패했습니다.', + 'redirect' => route('web.mypage.info.index'), + ]); + } + + //전화번호 저장 + $check = $svc->commitPhoneChange($memNo, (array) $res); + if (!($check['ok'] ?? false)) { + return response()->view('web.auth.danal_finish_top', [ + 'ok' => false, + 'message' => $check['message'] ?? '연락처 저장에 실패했습니다.', + 'redirect' => route('web.mypage.info.index'), + ]); + } + + $request->session()->put('_sess._mcell', $check['_cell'] ?? ''); //전화번호 변경 + + }else{ + return response()->view('web.auth.danal_finish_top', [ + 'ok' => false, + 'message' => $check['message'] ?? '연락처 변경에 실패했습니다.\n\n관리자에게 문의하세요!', + 'redirect' => route('web.mypage.info.index'), + ]); + } + + // 성공: 마이페이지 인증 플래그 세션 저장 + $request->session()->forget('mypage.pass'); + $request->session()->forget('mypage.danal '); + + $request->session()->save(); + + return response()->view('web.auth.danal_finish_top', [ + 'ok' => true, + 'message' => '본인인증이 완료되었습니다.', + 'redirect' => url('/mypage/info_renew'), + ]); + } + + +} diff --git a/app/Mail/TemplateMail.php b/app/Mail/TemplateMail.php index 7d08c57..330e978 100644 --- a/app/Mail/TemplateMail.php +++ b/app/Mail/TemplateMail.php @@ -2,14 +2,12 @@ namespace App\Mail; -use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -use Illuminate\Contracts\Queue\ShouldQueue; -class TemplateMail extends Mailable implements ShouldQueue +class TemplateMail extends Mailable { - use Queueable, SerializesModels; + use SerializesModels; public function __construct( public string $subjectText, diff --git a/app/Models/Member/MemStRing.php b/app/Models/Member/MemStRing.php deleted file mode 100644 index 0a36a54..0000000 --- a/app/Models/Member/MemStRing.php +++ /dev/null @@ -1,34 +0,0 @@ -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/EmailAuthRepository.php b/app/Repositories/Member/EmailAuthRepository.php new file mode 100644 index 0000000..80ea6c0 --- /dev/null +++ b/app/Repositories/Member/EmailAuthRepository.php @@ -0,0 +1,208 @@ + ..., 'auth_key' => ..., 'expires_at' => 'Y-m-d H:i:s'] + */ + public function prepareEmailVerify(int $memNo, string $email, string $ip, int $expiresMinutes = 30): array + { + return DB::transaction(function () use ($memNo, $email, $ip, $expiresMinutes) { + + // 1) mem_auth: row lock (없으면 생성, 있으면 N으로 초기화) + $row = DB::table('mem_auth') + ->where('mem_no', $memNo) + ->where('auth_type', 'email') + ->lockForUpdate() + ->first(); + + $today = now()->toDateString(); + + if (!$row) { + DB::table('mem_auth')->insert([ + 'mem_no' => $memNo, + 'auth_type' => 'email', + 'auth_state' => 'N', + 'auth_date' => $today, + ]); + } else { + DB::table('mem_auth') + ->where('mem_no', $memNo) + ->where('auth_type', 'email') + ->update([ + 'auth_state' => 'N', + 'auth_date' => $today, + ]); + } + + // 2) auth_key 생성 + $authKey = now()->format('HisYmd') . $ip . '-' . bin2hex(random_bytes(8)); + $expiresAt = now()->addMinutes($expiresMinutes)->format('Y-m-d H:i:s'); + + $emailInfo = [ + 'type' => 'mem_level', + 'auth_hit' => 'n', + 'user_email' => $email, + 'auth_key' => $authKey, + 'auth_effective_time' => $expiresAt, + 'redate' => now()->format('Y-m-d H:i:s'), + ]; + + // 3) mem_auth_log: P (Pending) + DB::table('mem_auth_log')->insert([ + 'mem_no' => $memNo, + 'type' => 'email', + 'state' => 'P', + 'info' => json_encode($emailInfo, JSON_UNESCAPED_UNICODE), + 'rgdate' => now()->format('Y-m-d H:i:s'), + ]); + + // 4) mem_auth_info upsert (auth_info JSON) + $authInfoRow = DB::table('mem_auth_info') + ->where('mem_no', $memNo) + ->lockForUpdate() + ->first(); + + if (!$authInfoRow) { + DB::table('mem_auth_info')->insert([ + 'mem_no' => $memNo, + 'auth_info' => json_encode(['email' => $emailInfo], JSON_UNESCAPED_UNICODE), + ]); + } else { + $current = json_decode((string)$authInfoRow->auth_info, true) ?: []; + $current['email'] = $emailInfo; + + DB::table('mem_auth_info') + ->where('mem_no', $memNo) + ->update([ + 'auth_info' => json_encode($current, JSON_UNESCAPED_UNICODE), + ]); + } + + return [ + 'email' => $email, + 'auth_key' => $authKey, + 'expires_at' => $expiresAt, + ]; + }); + } + + /** + * 인증 완료 처리 (CI3 로직 동일): + * - 이미 Y면 예외 + * - auth_info.email 존재/키/시간 체크 + * - mem_auth: Y 업데이트 + * - mem_auth_info: auth_hit=y + remote/agent/시간 merge + * - mem_auth_log: S insert + */ + public function confirmEmailVerify(int $memNo, string $encKey, string $ip, string $agent): array + { + return DB::transaction(function () use ($memNo, $encKey, $ip, $agent) { + + // 1) mem_auth 상태 확인 (lock) + $auth = DB::table('mem_auth') + ->where('mem_no', $memNo) + ->where('auth_type', 'email') + ->lockForUpdate() + ->first(); + + if ($auth && $auth->auth_state === 'Y') { + return ['ok' => false, 'message' => '이미 인증이 완료되었습니다.']; + } + + // 2) mem_auth_info 가져오기 (lock) + $infoRow = DB::table('mem_auth_info') + ->where('mem_no', $memNo) + ->lockForUpdate() + ->first(); + + if (!$infoRow || empty($infoRow->auth_info)) { + return ['ok' => false, 'message' => '잘못된 접근입니다.']; + } + + $authJson = json_decode((string)$infoRow->auth_info, true); + $emailAuth = $authJson['email'] ?? null; + + if (!$emailAuth || empty($emailAuth['auth_hit'])) { + return ['ok' => false, 'message' => '정상적인 경로로 이용하세요.']; + } + if (($emailAuth['auth_hit'] ?? '') === 'y') { + return ['ok' => false, 'message' => '이미 인증되었습니다.']; + } + + if (($emailAuth['auth_key'] ?? '') !== $encKey) { + return ['ok' => false, 'message' => '잘못된 접근입니다.']; + } + + $effective = (string)($emailAuth['auth_effective_time'] ?? ''); + if ($effective === '' || $effective < now()->format('Y-m-d H:i:s')) { + return ['ok' => false, 'message' => '인증시간이 초과되었습니다.']; + } + + // 3) auth_hit = y + merge info + $emailAuth['auth_hit'] = 'y'; + $emailAuth = array_merge($emailAuth, [ + 'remote_addr' => $ip, + 'agent' => $agent, + 'auth_redate' => now()->format('Y-m-d H:i:s'), + ]); + + $authJson['email'] = $emailAuth; + + // 4) mem_auth: Y 업데이트 (없으면 insert) + $today = now()->toDateString(); + if (!$auth) { + DB::table('mem_auth')->insert([ + 'mem_no' => $memNo, + 'auth_type' => 'email', + 'auth_state' => 'Y', + 'auth_date' => $today, + ]); + } else { + DB::table('mem_auth') + ->where('mem_no', $memNo) + ->where('auth_type', 'email') + ->update([ + 'auth_state' => 'Y', + 'auth_date' => $today, + ]); + } + + // 5) mem_auth_info 업데이트 + DB::table('mem_auth_info') + ->where('mem_no', $memNo) + ->update([ + 'auth_info' => json_encode($authJson, JSON_UNESCAPED_UNICODE), + ]); + + // 6) mem_auth_log: S (Success) + $logInfo = [ + 'remote_addr' => $ip, + 'agent' => $agent, + 'auth_redate' => now()->format('Y-m-d H:i:s'), + ]; + + DB::table('mem_auth_log')->insert([ + 'mem_no' => $memNo, + 'type' => 'email', + 'state' => 'S', + 'info' => json_encode($logInfo, JSON_UNESCAPED_UNICODE), + 'rgdate' => now()->format('Y-m-d H:i:s'), + ]); + + return [ + 'ok' => true, + 'message' => '이메일 인증이 확인되었습니다.', + 'email' => (string)($emailAuth['user_email'] ?? ''), + ]; + }); + } +} diff --git a/app/Repositories/Member/MemInfoRepository.php b/app/Repositories/Member/MemInfoRepository.php new file mode 100644 index 0000000..ee47a03 --- /dev/null +++ b/app/Repositories/Member/MemInfoRepository.php @@ -0,0 +1,21 @@ +where('mem_no', $memNo)->first(); + } + + public function emailsMatch(int $memNo, string $email): bool + { + $mem = $this->findByMemNo($memNo); + if (!$mem || empty($mem->email)) return false; + + return strcasecmp((string)$mem->email, $email) === 0; + } +} diff --git a/app/Repositories/Member/MemStRingRepository.php b/app/Repositories/Member/MemStRingRepository.php index 56c2bbd..0a36a54 100644 --- a/app/Repositories/Member/MemStRingRepository.php +++ b/app/Repositories/Member/MemStRingRepository.php @@ -1,64 +1,34 @@ toDateTimeString(); + $col = 'str_' . (int)$set; // str_0 / str_1 / str_2 - [$str0, $str1, $str2] = CiPassword::makeAll($plainPassword); - $pass2 = CiPassword::makePass2($pin2); + $row = DB::table('mem_st_ring') + ->select($col) + ->where('mem_no', $memNo) + ->first(); - 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, - ] - ); + if (!$row) return null; + + $val = $row->{$col} ?? null; + $val = is_string($val) ? trim($val) : null; + + return $val !== '' ? $val : null; } - /** - * CI3 modify_type == "1_passwd" 대응 (1차만) - */ - public function upsertPassword1(int $memNo, string $plainPassword, ?string $dtNow = null): void + public function getLegacyPass2(int $memNo): ?string { - $dt = $dtNow ?: Carbon::now()->toDateTimeString(); - [$str0, $str1, $str2] = CiPassword::makeAll($plainPassword); + $val = DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->value('passwd2'); - 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] - ); + $val = is_string($val) ? trim($val) : null; + return $val !== '' ? $val : null; } } diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php index 0b7baf9..13a0266 100644 --- a/app/Repositories/Member/MemberAuthRepository.php +++ b/app/Repositories/Member/MemberAuthRepository.php @@ -206,16 +206,16 @@ class MemberAuthRepository // 2) 이미 회원인지 체크 $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, // ✅ 필요시 -// ]); -// } + 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); @@ -601,6 +601,92 @@ class MemberAuthRepository ]); } + public function findByEmailAndName(string $emailLower, string $name): ?MemInfo + { + // ⚠️ 성명 컬럼이 name이 아니면 여기만 바꾸면 됨 + return MemInfo::query() + ->whereNotNull('email')->where('email', '<>', '') + ->whereRaw('LOWER(email) = ?', [$emailLower]) + ->where('name', $name) + ->orderByDesc('mem_no') + ->first(); + } + + public function findByMemNo(int $memNo): ?MemInfo + { + return MemInfo::query() + ->where('mem_no', $memNo) + ->first(); + } + + public function existsByEncryptedPhone(string $phoneEnc): bool + { + return DB::table('mem_info') + ->whereNotNull('email') + ->where('email', '<>', '') + ->where('cell_phone', $phoneEnc) + ->exists(); + } + + /** + * 해당 암호화 휴대폰으로 가입된 이메일 목록(중복 제거) + */ + public function getEmailsByEncryptedPhone(string $phoneEnc): array + { + // 여러 mem_no에서 같은 email이 나올 수 있으니 distinct 처리 + return DB::table('mem_info') + ->select('email') + ->whereNotNull('email') + ->where('email', '<>', '') + ->where('cell_phone', $phoneEnc) + ->orderByDesc('mem_no') + ->distinct() + ->pluck('email') + ->values() + ->all(); + } + + //비밀번호 수정 + public function updatePasswordOnly(int $memNo, string $pwPlain): void + { + if ($memNo <= 0 || $pwPlain === '') { + throw new \InvalidArgumentException('invalid_payload'); + } + + [$str0, $str1, $str2] = CiPassword::makeAll($pwPlain); + $now = now()->format('Y-m-d H:i:s'); + + $affected = DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->update([ + 'str_0' => $str0, + 'str_1' => $str1, + 'str_2' => $str2, + 'dt_reg' => $now, + ]); + + // ✅ 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리 + if ($affected <= 0) { + throw new \RuntimeException('mem_st_ring_not_found'); + } + } + + + public function logPasswordResetSuccess(int $memNo, string $ip, string $agent): void + { + $now = now()->format('Y-m-d H:i:s'); + DB::table('mem_passwd_modify')->insert([ + 'state' => 'E', + 'info' => json_encode([ + 'mem_no' => (string)$memNo, + 'redate' => $now, + 'remote_addr' => $ip, + 'agent' => $agent, + ], JSON_UNESCAPED_UNICODE), + 'rgdate' => $now, + ]); + } + } diff --git a/app/Repositories/Mypage/MypageInfoRepository.php b/app/Repositories/Mypage/MypageInfoRepository.php new file mode 100644 index 0000000..1aae14c --- /dev/null +++ b/app/Repositories/Mypage/MypageInfoRepository.php @@ -0,0 +1,74 @@ +select([ + self::COL_MEM_NO, + self::COL_CI, + self::COL_CELL, + ]) + ->where(self::COL_MEM_NO, $memNo) + ->first(); + } + + /** + * mem_info.ci 조회 + */ + public function getMemberCi(int $memNo): string + { + $row = DB::table(self::TABLE_MEM_INFO) + ->select([self::COL_CI]) + ->where(self::COL_MEM_NO, $memNo) + ->first(); + + return (string) ($row->{self::COL_CI} ?? ''); + } + + /** + * 암호화된 휴대폰(cell) 값이 이미 존재하는지 체크 + * - excludeMemNo가 있으면 해당 회원은 제외하고 검색 + */ + public function existsEncryptedCell(string $encCell, ?int $excludeMemNo = null): bool + { + if ($encCell === '') return false; + + $q = DB::table(self::TABLE_MEM_INFO) + ->where(self::COL_CELL, $encCell); + + if ($excludeMemNo !== null && $excludeMemNo > 0) { + $q->where(self::COL_MEM_NO, '!=', $excludeMemNo); + } + + return $q->exists(); + } + + /** + * mem_info.cell 업데이트 + * @return int 영향을 받은 row 수 + */ + public function updateEncryptedCell(int $memNo, string $encCell): int + { + return DB::table(self::TABLE_MEM_INFO) + ->where(self::COL_MEM_NO, $memNo) + ->update([ + self::COL_CELL => $encCell, + ]); + } +} diff --git a/app/Services/EmailVerificationService.php b/app/Services/EmailVerificationService.php new file mode 100644 index 0000000..ca0c13e --- /dev/null +++ b/app/Services/EmailVerificationService.php @@ -0,0 +1,78 @@ +session()->get('auth_user', []); + + $memNo = (int)($au['mem_no'] ?? 0); + $email = (string)($au['email'] ?? ''); + $expiresAt = (int)($au['expires_at'] ?? 0); + + if ($memNo <= 0 || $email === '' || $expiresAt <= 0 || now()->timestamp > $expiresAt) { + $request->session()->forget('auth_user'); + return []; + } + + return ['mem_no' => $memNo, 'email' => $email, 'expires_at' => $expiresAt]; + } + + public function sendVerifyMail(Request $request, int $memNo, string $email): array + { + $prep = $this->emailAuthRepo->prepareEmailVerify( + $memNo, + $email, + (string)$request->ip(), + 30 + ); + + $link = URL::temporarySignedRoute( + 'web.auth.email.verify', + now()->addMinutes(30), + [ + 'mem_no' => $memNo, + 'k' => $prep['auth_key'], + ] + ); + + $this->mail->sendTemplate( + $email, + '[PIN FOR YOU] 이메일 인증을 완료해 주세요', + 'mail.auth.verify_email', + [ + 'email' => $email, + 'link' => $link, + 'expires_min' => 30, + 'accent' => '#E4574B', + 'brand' => 'PIN FOR YOU', + 'siteUrl' => config('app.url'), + ], + queue: false + ); + + return ['ok' => true, 'status' => 200, 'message' => '인증메일을 발송했습니다. 메일함을 확인해 주세요.']; + } + + public function verifySignedLink(Request $request, int $memNo, string $k): array + { + return $this->emailAuthRepo->confirmEmailVerify( + $memNo, + $k, + (string)$request->ip(), + (string)$request->userAgent() + ); + } +} diff --git a/app/Services/FindPasswordService.php b/app/Services/FindPasswordService.php new file mode 100644 index 0000000..09af488 --- /dev/null +++ b/app/Services/FindPasswordService.php @@ -0,0 +1,246 @@ +ip(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $sec = RateLimiter::availableIn($key); + return [ + 'ok' => false, + 'status' => 429, + 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요.", + ]; + } + RateLimiter::hit($key, 600); + + // ✅ 가입자 확인(DB) + $member = $this->members->findByEmailAndName($emailLower, $name); + if (!$member) { + return [ + 'ok' => false, + 'status' => 404, + 'code' => 'NOT_MATCHED', + 'message' => '입력하신 이메일/성명 정보가 일치하지 않습니다.', + 'step' => 1, + ]; + } + + $memNo = (int)$member->mem_no; + + // ✅ DB 저장 없이 signed URL만 생성 + $nonce = bin2hex(random_bytes(10)); + + $link = URL::temporarySignedRoute( + 'web.auth.find_password.verify', + now()->addMinutes($expiresMinutes), + [ + 'mem_no' => $memNo, + 'n' => $nonce, + ] + ); + + // ✅ 메일 발송 + try { + $this->mail->sendTemplate( + $emailLower, + '[PIN FOR YOU] 비밀번호 재설정 링크 안내', + 'mail.auth.reset_password', + [ + 'email' => $emailLower, + 'name' => $name, + 'link' => $link, + 'expires_min' => $expiresMinutes, + 'accent' => '#E4574B', + 'brand' => 'PIN FOR YOU', + 'siteUrl' => config('app.url'), + ], + queue: false + ); + } catch (\Throwable $e) { + Log::error('FindPassword sendResetMail failed', [ + 'mem_no' => $memNo, + 'email' => $emailLower, + 'error' => $e->getMessage(), + ]); + + return [ + 'ok' => false, + 'status' => 500, + 'message' => '메일 발송 중 오류가 발생했습니다.', + ]; + } + + // 컨트롤러가 세션에 저장할 payload를 서비스에서 만들어줌 + return [ + 'ok' => true, + 'status' => 200, + 'message' => '재설정 메일을 발송했습니다. 메일함을 확인해 주세요.', + 'expires_in' => $expiresMinutes * 60, + 'step' => 2, + 'session' => [ + 'sent' => true, + 'verified' => false, + 'mem_no' => $memNo, + 'email' => $emailLower, + 'name' => $name, + 'sent_at' => now()->timestamp, + ], + ]; + } + + /** + * 링크 클릭 검증(서명/만료 + 회원존재) + * - DB 조회는 여기서 처리 + * - 컨트롤러는 redirect만 담당 + */ + public function verifyResetLink(Request $request, int $memNo): array + { + // signed 미들웨어를 라우트에 붙이면 여기 체크는 사실상 안전장치 + if (!$request->hasValidSignature()) { + return [ + 'ok' => false, + 'message' => '링크가 유효하지 않거나 만료되었습니다. 다시 진행해 주세요.', + ]; + } + + if ($memNo <= 0) { + return [ + 'ok' => false, + 'message' => '잘못된 접근입니다.', + ]; + } + + $member = $this->members->findByMemNo($memNo); + if (!$member) { + return [ + 'ok' => false, + 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.', + ]; + } + + return [ + 'ok' => true, + 'message' => '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.', + 'session' => [ + 'sent' => true, + 'verified' => true, + 'verified_at' => now()->timestamp, + 'mem_no' => $memNo, + 'email' => (string)($member->email ?? ''), + 'name' => (string)($member->name ?? ''), + ], + ]; + } + + public function resetPasswordFinal(Request $request, string $newPassword): array + { + $sess = (array) $request->session()->get('find_pw', []); + + // 0) 세션 체크: 이메일 링크 인증 완료 상태인지 + if (empty($sess['verified']) || empty($sess['mem_no'])) { + return [ + 'ok' => false, + 'status' => 403, + 'message' => '이메일 인증이 필요합니다. 메일 링크를 통해 다시 진행해 주세요.', + ]; + } + + $memNo = (int) ($sess['mem_no'] ?? 0); + if ($memNo <= 0) { + $request->session()->forget('find_pw'); + return [ + 'ok' => false, + 'status' => 403, + 'message' => '세션 정보가 올바르지 않습니다. 다시 진행해 주세요.', + ]; + } + + // 1) 인증 후 유효시간 정책 (예: 인증 후 10분 내 변경) + $verifiedAt = (int) ($sess['verified_at'] ?? 0); + if ($verifiedAt <= 0 || (now()->timestamp - $verifiedAt) > (10 * 60)) { + $request->session()->forget('find_pw'); + return [ + 'ok' => false, + 'status' => 403, + 'message' => '인증이 만료되었습니다. 비밀번호 찾기를 다시 진행해 주세요.', + ]; + } + + // 2) 레이트리밋 (비번 변경 시도) + $key = 'findpw:reset:' . $memNo . ':' . (string)$request->ip(); + if (RateLimiter::tooManyAttempts($key, 10)) { + $sec = RateLimiter::availableIn($key); + return [ + 'ok' => false, + 'status' => 429, + 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요.", + ]; + } + RateLimiter::hit($key, 600); + + // 3) 방어적 비번 정책 체크(컨트롤러에서 검증하지만 한번 더) + if (!is_string($newPassword) || $newPassword === '') { + return ['ok'=>false,'status'=>422,'message'=>'새 비밀번호를 입력해 주세요.']; + } + if (strlen($newPassword) < 8 || strlen($newPassword) > 20) { + return ['ok'=>false,'status'=>422,'message'=>'비밀번호는 8~20자리로 입력해 주세요.']; + } + if (!preg_match('/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/', $newPassword)) { + return ['ok'=>false,'status'=>422,'message'=>'비밀번호는 영문+숫자+특수문자를 포함해야 합니다.']; + } + + // 4) 회원 존재 확인 (DB는 Repo 통해서) + $member = $this->members->findByMemNo($memNo); + if (!$member) { + $request->session()->forget('find_pw'); + return [ + 'ok' => false, + 'status' => 404, + 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.', + ]; + } + + // 5) 비밀번호 저장 (mem_st_ring str_0~2) + 성공 로그 + $this->members->updatePasswordOnly($memNo, $newPassword); + $this->members->logPasswordResetSuccess( + $memNo, + (string) $request->ip(), + (string) $request->userAgent() + ); + + // 6) 세션 정리 + $request->session()->forget('find_pw'); + $request->session()->save(); + + return [ + 'ok' => true, + 'status' => 200, + 'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.', + 'redirect_url' => route('web.auth.login'), + ]; + } +} diff --git a/app/Services/MailService.php b/app/Services/MailService.php index 0c430a7..9e88ea7 100644 --- a/app/Services/MailService.php +++ b/app/Services/MailService.php @@ -2,23 +2,51 @@ namespace App\Services; -use Illuminate\Support\Facades\Mail; use App\Mail\TemplateMail; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; class MailService { - /** - * CI macro.sendmail 같은 역할 - * @param string|array $to - */ - public function sendTemplate($to, string $subject, string $view, array $data = []): void + public function sendTemplate($to, string $subject, string $view, array $data = [], bool $queue = false): void { $toList = is_array($to) ? $to : [$to]; + // ✅ 웹 요청 기준으로 실제 mail 설정을 로그로 남김 + Log::info('mail_send_debug', [ + 'queue' => $queue, + 'queue_default' => config('queue.default'), + 'mail_default' => config('mail.default'), + 'smtp' => [ + 'host' => config('mail.mailers.smtp.host'), + 'port' => config('mail.mailers.smtp.port'), + 'encryption' => config('mail.mailers.smtp.encryption'), + 'timeout' => config('mail.mailers.smtp.timeout'), + 'local_domain' => config('mail.mailers.smtp.local_domain'), + ], + 'from' => config('mail.from'), + ]); + foreach ($toList as $toEmail) { - Mail::send($view, $data, function ($m) use ($toEmail, $subject) { - $m->to($toEmail)->subject($subject); - }); + $mailable = new TemplateMail( + subjectText: $subject, + viewName: $view, + payload: $data + ); + + Log::info('mail_send_attempt', [ + 'to' => $toEmail, + 'subject' => $subject, + 'view' => $view, + ]); + + if ($queue) { + Mail::to($toEmail)->queue($mailable); + Log::info('mail_send_queued', ['to' => $toEmail]); + } else { + Mail::to($toEmail)->send($mailable); + Log::info('mail_send_done', ['to' => $toEmail]); + } } } } diff --git a/app/Services/MemInfoService.php b/app/Services/MemInfoService.php index ebe31c4..5bc54bc 100644 --- a/app/Services/MemInfoService.php +++ b/app/Services/MemInfoService.php @@ -408,7 +408,7 @@ class MemInfoService $this->insertLoginLog($yearTable, $log); if ($failCnt >= 4) { - return ['ok'=>false, 'message'=>"5회이상 실패시 비밀번호찾기 후 이용 바랍니다.(클릭)"]; + return ['ok'=>false, 'message'=>"비밀번호 입력 5회이상 실패 하셨습니다.\n 비밀번호찾기 후 이용 바랍니다."]; } return ['ok'=>false, 'message'=>"비밀번호가 일치하지 않습니다.\n비밀번호 실패횟수 : ".($failCnt+1)."\n5회 이상 실패시 인증을 다시받아야 합니다."]; @@ -418,8 +418,11 @@ class MemInfoService $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", + 'ok' => false, + 'reason' => 'email_unverified', + 'email' => (string)$mem->email, + 'mem_no' => (int)$mem->mem_no, + 'message' => '이메일 인증이 필요합니다.', ]; } diff --git a/app/Services/MypageInfoService.php b/app/Services/MypageInfoService.php new file mode 100644 index 0000000..ad39825 --- /dev/null +++ b/app/Services/MypageInfoService.php @@ -0,0 +1,134 @@ +bool, 'message'=>string, 'enc_phone'=>string, 'raw_phone'=>string, 'ci'=>string, 'carrier'=>string] + */ + public function validatePassPhoneChange(array $sess, array $passPayload): array + { + $memNo = (int) ($sess['_mno'] ?? 0); + if ($memNo <= 0) { + return $this->fail('로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.'); + } + + $rawPhone = $this->normalizePhone((string) Arr::get($passPayload, 'PHONE', '')); + $ci = (string) Arr::get($passPayload, 'CI', ''); + $carrier = strtoupper((string) Arr::get($passPayload, 'CARRIER', '')); + + if ($rawPhone === '' || $ci === '') { + return $this->fail('인증 정보가 올바르지 않습니다. 다시 시도해 주세요.'); + } + + // 통신사 제한: SKT/KTF/LGT만 허용, 나머지 전부 차단 + MVNO 차단 + $allowCarriers = ['SKT', 'KTF', 'LGT']; + if (!in_array($carrier, $allowCarriers, true) || $carrier === 'MVNO') { + return $this->fail('죄송합니다. SKT/KT/LG U+ 휴대전화만 인증할 수 있습니다. (알뜰폰/기타 통신사 불가)'); + } + + // PASS 전화번호 암호화 (CI 레거시 방식) + $encPassPhone = (string) $this->seed->encrypt($rawPhone); + + // (1) 기존 회원 전화번호와 동일하면 변경 불가 + $encMemberPhone = (string) ($sess['_mcell'] ?? ''); + if ($encMemberPhone !== '' && $encPassPhone !== '' && hash_equals($encMemberPhone, $encPassPhone)) { + return $this->fail('현재 등록된 연락처와 동일합니다. 다른 번호로 인증해 주세요.'); + } + + // (2) PASS 전화번호가 DB에 존재하면 변경 불가 + // 요구사항: "존재한다면 이전에 가입된 전화번호가 있습니다. 관리자 문의" + if ($this->repo->existsEncryptedCell($encPassPhone, $memNo)) { + return $this->fail('이미 가입된 휴대폰 번호가 있습니다. 관리자에게 문의해 주세요.'); + } + + // (3) CI가 회원정보 mem_info.ci와 일치해야 통과 + $memberCi = $this->repo->getMemberCi($memNo); + if ($memberCi === '' || !hash_equals($memberCi, $ci)) { + return $this->fail('가입된 회원정보와 일치하지 않습니다. 관리자에게 문의해 주세요.'); + } + + return [ + 'ok' => true, + 'message' => '검증이 완료되었습니다.', + 'enc_phone' => $encPassPhone, + 'raw_phone' => $rawPhone, + 'ci' => $ci, + 'carrier' => $carrier, + ]; + } + + /** + * 최종 저장: mem_info.cell 업데이트 + * - 컨트롤러는 DB 처리 안 한다고 했으니, /mypage/info 저장 버튼에서 이 메서드만 호출하면 됨. + * + * @return array ['ok'=>bool, 'message'=>string, '_cell'=>string] + */ + public function commitPhoneChange(int $memNo, array $passPayload): array + { + $Phone = $this->normalizePhone((string) Arr::get($passPayload, 'PHONE', '')); + $encPhone = (string) $this->seed->encrypt($Phone); + + if ($memNo <= 0 || $encPhone === '') { + return $this->fail('저장할 정보가 올바르지 않습니다.'); + } + + return DB::transaction(function () use ($memNo, $encPhone) { + // 한번 더 중복 방어(레이스 컨디션) + if ($this->repo->existsEncryptedCell($encPhone, $memNo)) { + return $this->fail('이미 가입된 휴대폰 번호가 있습니다. 관리자에게 문의해 주세요.'); + } + + $affected = $this->repo->updateEncryptedCell($memNo, $encPhone); + if ($affected < 1) { + return $this->fail('연락처 저장에 실패했습니다. 잠시 후 다시 시도해 주세요.'); + } + + return [ + 'ok'=>true, + 'message'=>'연락처가 변경되었습니다.', + '_cell' => $encPhone, + ]; + }); + } + + /** + * 휴대폰 정규화 (숫자만) + */ + private function normalizePhone(string $v): string + { + return preg_replace('/\D+/', '', $v) ?: ''; + } + + private function fail(string $message): array + { + return [ + 'ok' => false, + 'message' => $message, + 'enc_phone' => '', + 'raw_phone' => '', + 'ci' => '', + 'carrier' => '', + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 101c279..6bd6985 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Support\Facades\Route; +use Illuminate\Routing\Exceptions\InvalidSignatureException; return Application::configure(basePath: dirname(__DIR__)) // (A) Routing: 도메인별 라우트 분리 @@ -40,7 +41,8 @@ return Application::configure(basePath: dirname(__DIR__)) // - 도메인 제외, path만 // - 네 라우트 정의 기준: POST register/danal/result $middleware->validateCsrfTokens(except: [ - 'auth/register/danal/result', //다날 PASS 콜백 (외부 서버가 호출) + 'auth/register/danal/result', //다날 PASS 회원가입 콜백 (외부 서버가 호출) + 'mypage/info/danal/result', //다날 PASS 전화번호 변경 콜백 (외부 서버가 호출) ]); //페이지 접근권한 미들웨어 등록 @@ -49,10 +51,15 @@ return Application::configure(basePath: dirname(__DIR__)) 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, //게스트 접근가능페이지 ]); - }) + + }) ->withExceptions(function (Exceptions $exceptions): void { - // + + //서명 만료/위변조(temporarySignedRoute + signed middleware) + $exceptions->render(function (InvalidSignatureException $e, $request) { + return redirect('/')->with('alert', '잘못된 접근입니다.'); + }); }) ->create(); diff --git a/config/app.php b/config/app.php index 423eed5..a8a8f51 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'Asia/Seoul'), /* |-------------------------------------------------------------------------- diff --git a/config/mail.php b/config/mail.php index 522b284..7d629cb 100644 --- a/config/mail.php +++ b/config/mail.php @@ -35,70 +35,25 @@ return [ | */ - 'mailers' => [ + // config/mail.php + 'mailers' => [ 'smtp' => [ 'transport' => 'smtp', - 'scheme' => env('MAIL_SCHEME'), - 'url' => env('MAIL_URL'), 'host' => env('MAIL_HOST', '127.0.0.1'), - 'port' => env('MAIL_PORT', 2525), + 'port' => (int) env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), // STARTTLS 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + 'timeout' => (int) env('MAIL_TIMEOUT', 60), //timeout 적용 + 'local_domain' => env( + 'MAIL_EHLO_DOMAIN', + parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST) + ), ], - - 'ses' => [ - 'transport' => 'ses', - ], - - 'postmark' => [ - 'transport' => 'postmark', - // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), - // 'client' => [ - // 'timeout' => 5, - // ], - ], - - 'resend' => [ - 'transport' => 'resend', - ], - - 'sendmail' => [ - 'transport' => 'sendmail', - 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), - ], - - 'log' => [ - 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), - ], - - 'array' => [ - 'transport' => 'array', - ], - - 'failover' => [ - 'transport' => 'failover', - 'mailers' => [ - 'smtp', - 'log', - ], - 'retry_after' => 60, - ], - - 'roundrobin' => [ - 'transport' => 'roundrobin', - 'mailers' => [ - 'ses', - 'postmark', - ], - 'retry_after' => 60, - ], - ], + /* |-------------------------------------------------------------------------- | Global "From" Address diff --git a/resources/css/web.css b/resources/css/web.css index fa91502..5629e4f 100644 --- a/resources/css/web.css +++ b/resources/css/web.css @@ -1,3 +1,6 @@ +@charset "UTF-8"; +@import "./web/mypage.css"; + /* ========================================= Voucher Mall Design System Theme: White base + Blue accent diff --git a/resources/css/web/mypage.css b/resources/css/web/mypage.css new file mode 100644 index 0000000..6d59c8e --- /dev/null +++ b/resources/css/web/mypage.css @@ -0,0 +1,485 @@ +/* ========================================= + MYPAGE 공통: Gate 카드 (비밀번호 재확인) + 적용 범위: body.is-mypage 내부만 + ========================================= */ + +/* ✅ 래퍼: 가운데 정렬 + 여백 */ +.is-mypage .mypage-gate-wrap{ + display:flex; + justify-content:center; + padding: 6px 0 18px; +} + +/* ✅ 카드 */ +.is-mypage .mypage-gate-card{ + width:100%; + max-width: 680px; + background:#fff; + border:1px solid rgba(16,24,40,.08); + border-radius: 18px; + box-shadow: 0 10px 30px rgba(16,24,40,.08); + overflow:hidden; + margin-top: 50px; + margin-bottom: 50px; +} + +/* ✅ 바디 */ +.is-mypage .mypage-gate-body{ + padding: 28px; +} + +/* ✅ 타이틀/설명 */ +.is-mypage .mypage-gate-title{ + font-size: 20px; + font-weight: 800; + letter-spacing: -.2px; + margin: 0 0 6px; + color: #101828; +} +.is-mypage .mypage-gate-desc{ + margin: 0 0 14px; + color:#667085; + font-size: 14px; + line-height: 1.45; +} + +/* ✅ 안내 박스 */ +.is-mypage .mypage-gate-note{ + margin: 0 0 18px; + padding: 12px 14px; + border-radius: 14px; + background: #f8fafc; + border: 1px solid rgba(16,24,40,.06); + color:#344054; + font-size: 13px; + line-height: 1.5; +} + +/* ✅ 한 줄 입력 + 버튼 */ +.is-mypage .mypage-gate-row{ + display:flex; + gap: 10px; + align-items:center; +} + +.is-mypage .mypage-gate-input{ + flex: 1; + height: 46px; + border-radius: 12px; + border: 1px solid #d0d5dd; + padding: 0 14px; +} + +.is-mypage .mypage-gate-input:focus{ + border-color: #ff7a00; + box-shadow: 0 0 0 4px rgba(255,122,0,.14); +} + +/* ✅ 버튼 */ +.is-mypage .mypage-gate-btn{ + height: 46px; + padding: 0 18px; + border-radius: 12px; + font-weight: 800; + white-space: nowrap; +} + +/* primary 버튼 */ +.is-mypage .btn-mypage-primary{ + background: linear-gradient(135deg, #ff7a00, #ff3d00); + border: none; + color: #fff; +} +.is-mypage .btn-mypage-primary:hover{ + filter: brightness(.98); + color:#fff; +} + +.is-mypage .mypage-gate-card .mypage-gate-input, +.is-mypage .mypage-gate-card input.form-control { + height: 52px; + min-height: 52px; + padding: 0 16px; + font-size: 16px; /* ✅ 모바일에서 얇아보이는/줌 이슈 방지 */ + line-height: 52px; /* ✅ 세로 가운데 정렬 확실 */ + border-radius: 14px; + border: 1px solid #d0d5dd; + box-sizing: border-box; +} + +.is-mypage .mypage-gate-card .mypage-gate-input:focus, +.is-mypage .mypage-gate-card input.form-control:focus { + border-color: #ff7a00; + box-shadow: 0 0 0 4px rgba(255,122,0,.15); +} + +/* textarea 같은 멀티라인이 섞일 가능성 대비(선택) */ +.is-mypage .mypage-gate-card textarea.form-control { + line-height: 1.4; + padding: 12px 16px; + height: auto; + min-height: 120px; +} + +/* ✅ 모바일: 버튼 아래로 */ +@media (max-width: 575.98px){ + .is-mypage .mypage-gate-card{ + margin-top: 0px; + margin-bottom: 0px; + } + + .is-mypage .mypage-gate-body{ + padding: 20px; + } + .is-mypage .mypage-gate-row{ + flex-direction: column; + align-items: stretch; + gap: 10px; + } + .is-mypage .mypage-gate-btn{ + width: 100%; + } +} + +/* ================================ + MYPAGE Renew (Info hub) + ================================ */ + +.is-mypage .mypage-hero{ + border: 1px solid #eef0f4; + border-radius: 16px; + background: linear-gradient(135deg, #ffffff, #fbfcff); + box-shadow: 0 10px 28px rgba(16, 24, 40, 0.06); +} + +.is-mypage .mypage-hero__inner{ + display: grid; + grid-template-columns: 1.2fr .8fr; + gap: 18px; + padding: 18px; + align-items: stretch; +} + +.is-mypage .mypage-hero__kicker{ + font-size: 12px; + font-weight: 800; + letter-spacing: .08em; + color: #667085; +} + +.is-mypage .mypage-hero__title{ + margin-top: 6px; + font-size: 22px; + font-weight: 900; + line-height: 1.2; + color: #101828; +} + +.is-mypage .mypage-hero__desc{ + margin-top: 8px; + color: #667085; + font-size: 13px; + line-height: 1.55; +} + +.is-mypage .mypage-hero__right{ + border-radius: 14px; + border: 1px solid #eef0f4; + background: #fff; + padding: 14px 14px 12px; + display: flex; + flex-direction: column; + gap: 10px; + justify-content: space-between; +} + +/* 상태 chip */ +.is-mypage .mypage-chip{ + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + padding: 0 12px; + border-radius: 999px; + font-weight: 800; + font-size: 12px; + border: 1px solid #e4e7ec; + background: #f9fafb; + color: #344054; + width: fit-content; +} +.is-mypage .mypage-chip.is-ok{ + border-color: rgba(16,185,129,.35); + background: rgba(16,185,129,.10); + color: #067647; +} +.is-mypage .mypage-chip.is-warn{ + border-color: rgba(245,158,11,.35); + background: rgba(245,158,11,.10); + color: #92400e; +} + +/* 인증 상태 표 */ +.is-mypage .mypage-reauth{ + border-radius: 12px; + background: #f8fafc; + border: 1px solid #eef0f4; + padding: 10px 12px; +} +.is-mypage .mypage-reauth__row{ + display: flex; + justify-content: space-between; + gap: 10px; + padding: 4px 0; + font-size: 12px; +} +.is-mypage .mypage-reauth__label{ color: #667085; font-weight: 700; } +.is-mypage .mypage-reauth__value{ color: #101828; font-weight: 800; } + +/* 우측 버튼 */ +.is-mypage .mypage-hero__actions .btn{ + width: 100%; + height: 44px; + border-radius: 12px; + font-weight: 800; +} + +/* 카드 그리드 */ +.is-mypage .mypage-grid{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.is-mypage .mypage-card{ + border: 1px solid #eef0f4; + border-radius: 16px; + background: #fff; + padding: 14px; + box-shadow: 0 10px 24px rgba(16, 24, 40, 0.05); + display: grid; + grid-template-columns: 42px 1fr 16px; + align-items: center; + gap: 12px; + text-decoration: none; + transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease; +} + +.is-mypage .mypage-card:hover{ + transform: translateY(-1px); + border-color: rgba(255,122,0,.35); + box-shadow: 0 14px 30px rgba(16, 24, 40, 0.08); +} + +.is-mypage .mypage-card__icon{ + width: 42px; + height: 42px; + border-radius: 14px; + display: grid; + place-items: center; + background: radial-gradient(600px 180px at 30% 30%, rgba(255,140,0,.18), transparent 55%), + radial-gradient(520px 160px at 70% 30%, rgba(94,92,230,.14), transparent 55%), + #f8fafc; + font-size: 18px; +} + +.is-mypage .mypage-card__title{ + font-weight: 900; + color: #101828; + font-size: 14px; + margin: 0; +} +.is-mypage .mypage-card__desc{ + margin-top: 4px; + color: #667085; + font-size: 12.5px; + line-height: 1.45; +} +.is-mypage .mypage-card__meta{ + margin-top: 8px; + font-size: 12px; + font-weight: 800; + color: #98a2b3; +} +.is-mypage .mypage-card__arrow{ + color: #98a2b3; + font-size: 20px; + font-weight: 900; + line-height: 1; +} + +/* 하단 안내 */ +.is-mypage .mypage-note{ + border: 1px solid #eef0f4; + border-radius: 16px; + background: #fff; + padding: 14px 16px; +} +.is-mypage .mypage-note__title{ + font-weight: 900; + color: #101828; + margin-bottom: 8px; +} +.is-mypage .mypage-note__list{ + margin: 0; + padding-left: 18px; + color: #475467; + font-size: 13px; + line-height: 1.55; +} + +/* 반응형 */ +@media (max-width: 991.98px){ + .is-mypage .mypage-hero__inner{ + grid-template-columns: 1fr; + } +} +@media (max-width: 575.98px){ + .is-mypage .mypage-grid{ + grid-template-columns: 1fr; + } + .is-mypage .mypage-hero__inner{ + padding: 14px; + } +} + +/* 카드가 button일 때 기본 버튼 스타일 제거 */ +.is-mypage .mypage-card--btn{ + width: 100%; + text-align: left; + border: 1px solid #eef0f4; + background: #fff; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} +.is-mypage .mypage-card--btn:focus{ + outline: none; +} +.is-mypage .mypage-card--btn:focus-visible{ + border-color: rgba(255,122,0,.55); + box-shadow: 0 0 0 4px rgba(255,122,0,.14); + transform: translateY(-1px); +} + +/* ========================= + MYPAGE: info_renew 섹션 카드 UI + ========================= */ +.is-mypage .mypage-section { margin-bottom: 18px; } + +.is-mypage .mypage-card{ + border: 1px solid #eef0f4; + border-radius: 16px; + background: #fff; + box-shadow: 0 10px 30px rgba(16,24,40,.06); + overflow: hidden; +} + +.is-mypage .mypage-card__head{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap: 12px; + padding: 18px 18px 14px; + border-bottom: 1px solid #f2f4f7; +} + +.is-mypage .mypage-card__title{ + font-size: 16px; + font-weight: 800; + line-height: 1.2; + margin: 0 0 6px; +} + +.is-mypage .mypage-card__desc{ + font-size: 13px; + color: #667085; + margin: 0; +} + +.is-mypage .mypage-card__body{ + padding: 16px 18px 18px; +} + +.is-mypage .mypage-btn{ + height: 44px; + padding: 0 14px; + border-radius: 12px; + font-weight: 800; + white-space: nowrap; +} + +.is-mypage .mypage-kv{ + display:flex; + gap: 14px; + flex-wrap: wrap; +} + +.is-mypage .mypage-kv__item{ + flex: 1 1 220px; + border: 1px solid #f2f4f7; + border-radius: 14px; + padding: 12px 14px; + background: #fcfcfd; +} + +.is-mypage .mypage-kv__label{ + font-size: 12px; + color: #667085; + margin-bottom: 6px; +} + +.is-mypage .mypage-kv__value{ + font-size: 14px; + font-weight: 800; + color: #101828; +} + +.is-mypage .mypage-badge{ + display:inline-flex; + align-items:center; + height: 26px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid #e4e7ec; + background: #fff; + font-size: 12px; + font-weight: 800; + color: #344054; +} + +.is-mypage .mypage-badge--ok{ + border-color: rgba(255,122,0,.35); + background: rgba(255,122,0,.10); + color: #b54708; +} + +.is-mypage .mypage-help{ + border: 1px solid #eef0f4; + border-radius: 14px; + background: #f8fafc; + padding: 12px 14px; + color: #344054; + font-size: 13px; +} + +.is-mypage .mypage-help ul{ + margin: 0; + padding-left: 18px; +} + +.is-mypage .mypage-help li{ + margin: 3px 0; +} + +/* 모바일에서도 버튼/정렬 깔끔하게 */ +@media (max-width: 575.98px){ + .is-mypage .mypage-card__head{ + flex-direction: column; + align-items: stretch; + } + .is-mypage .mypage-btn{ + width: 100%; + } +} + diff --git a/resources/views/mail/auth/otp_code.blade.php b/resources/views/mail/auth/otp_code.blade.php new file mode 100644 index 0000000..f4d4736 --- /dev/null +++ b/resources/views/mail/auth/otp_code.blade.php @@ -0,0 +1,28 @@ +@extends('mail.layouts.base') + +@section('content') +
+
{{ $title ?? '인증번호 안내' }}
+
+ +
+ {{ $email }} 회원님,
+ 아래 인증번호를 입력해 주세요.
+ (인증번호는 {{ $expires_min ?? 3 }}분 후 만료됩니다.) +
+ +
+ +
+
+ {{ $code }} +
+
+ +
+ +
+ 본인이 요청하지 않았다면 이 메일을 무시해 주세요. +
+
+@endsection diff --git a/resources/views/mail/auth/reset_password.blade.php b/resources/views/mail/auth/reset_password.blade.php new file mode 100644 index 0000000..656c2f5 --- /dev/null +++ b/resources/views/mail/auth/reset_password.blade.php @@ -0,0 +1,23 @@ +@extends('mail.layouts.base') + +@section('content') +
+

비밀번호 재설정 안내

+ +

+ 아래 버튼을 눌러 비밀번호 재설정을 진행해 주세요.
+ 링크 유효시간: {{ $expires_min ?? 30 }}분 +

+ +

+ + 비밀번호 재설정하기 + +

+ +

+ 버튼이 동작하지 않으면 아래 링크를 복사해 브라우저에 붙여넣어 주세요.
+ {{ $link }} +

+
+@endsection diff --git a/resources/views/mail/auth/verify_email.blade.php b/resources/views/mail/auth/verify_email.blade.php new file mode 100644 index 0000000..944ec40 --- /dev/null +++ b/resources/views/mail/auth/verify_email.blade.php @@ -0,0 +1,35 @@ +@extends('mail.layouts.base') + +@section('content') +
+
이메일 주소 인증
+
+ +
+ {{ $email }} 회원님,
+ 아래 버튼을 클릭하여 이메일 인증을 완료해 주세요.
+ (인증 링크는 {{ $expires_min ?? 30 }}분 후 만료됩니다.) +
+ +
+ + + + + +
+ + 이메일 인증하기 + +
+ +
+ +
+ 버튼이 동작하지 않으면 아래 주소를 복사해 브라우저에 붙여넣으세요.
+ {{ $link }} +
+
+@endsection diff --git a/resources/views/mail/layouts/base.blade.php b/resources/views/mail/layouts/base.blade.php new file mode 100644 index 0000000..6078918 --- /dev/null +++ b/resources/views/mail/layouts/base.blade.php @@ -0,0 +1,65 @@ +@php + $brand = $brand ?? 'PIN FOR YOU'; + $accent = $accent ?? '#E4574B'; + $siteUrl = $siteUrl ?? config('app.url'); +@endphp + + + + + + + + + + + + +
+ + {{-- Header --}} + + + + + {{-- Body --}} + + + + + {{-- Footer --}} + + + +
+
+ {{ $brand }} +
+
+ 안전하고 빠른 상품권 거래 +
+
+ @yield('content') +
+
+ 본 메일은 발신전용입니다. 문의는 고객센터를 이용해 주세요.
+ + {{ $brand }} 바로가기 + +
+
+ © {{ date('Y') }} {{ $brand }}. All rights reserved. +
+
+ + {{-- small safe spacing --}} +
+
+ + diff --git a/resources/views/mail/legacy/i_withdrawal_1.blade.php b/resources/views/mail/legacy/i_withdrawal_1.blade.php deleted file mode 100644 index 2d03caa..0000000 --- a/resources/views/mail/legacy/i_withdrawal_1.blade.php +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - - - - -
-

출금 알림

-
- ㅇㅇㅇ님!
- 회원님의 입금계좌로 KRW가 출금되어 하기와 같이 안내 해 드립니다. -
-

출금 정보

- 출금일시 : 2017년 00.00. 00:00:00
- 출금 금액 : 0000 KRW
- 은행 : 00은행
- 계좌번호 : 0000000000
- 예금주 : 000 -
- 출금내역 확인하기 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_20230621_event.blade.php b/resources/views/mail/legacy/noti_20230621_event.blade.php deleted file mode 100644 index e7b88b4..0000000 --- a/resources/views/mail/legacy/noti_20230621_event.blade.php +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - -
-

- -

-
-
-

- 회원님 온라인 상품권 쇼핑채널 "핀포유" 입니다. -

-

- 고객님은 지급대상입니다. -

-
-
-
- 상품권 수령방법 -
-
- - 1. 핀포유(pinforyou.com) 접속
- 2. 로그인 후 "고객센터>1:1문의" 접수
-   ㄴ문의분류선택 : "이벤트문의"
-   ㄴ문의제목 : 이벤트참여
-   ㄴ문의내용 : 주세요.
-
-
-
- -
- PINFORYOU 바로가기 -
- - - - - - - - - - - - - - - - - -
- - 1:1문의로 이벤트참여 문의글만 남겨도 (을)를 100% 무조건 지급!
- - 상품권은 핀번호 형태로 지급되며, 상담사가 대상자 확인 후 답변으로 지급드립니다.
- - 본 이벤트 외 추가 지급 이벤트가 있으니 지금 바로 참여해서 상품권 받고 추가 이벤트도 참여하세요!
- -
- - - - - - - - - -
- 핀포유매입 - - 핀포유만의 특별혜택
- "입금수수료 무료! 1분안에 자동입금!" -
- 컴퓨터 아이콘 -
-
-
-
- -
- - - - - - - - - - - - -
- - 2023년 06월 23일 발송 이메일입니다.
- - 본 이메일은 발신전용 이메일로 [2023년 06월 23일]기준,
이메일 수신동의 여부를 확인한 결과 회원님께서 수신에 동의하신 것으로 확인되어 발송되었습니다.
-
-
-
- - 주소전북특별자치도 전주시 완산구 용머리로 94, 4층 451호 - - - 대표이사송병수 - - - 사업자등록번호121-88-01191 - -
-
- - 전화1833-4856 - - - 통신판매업신고번호제 2018-전주완산-0705호 - - master@plusmaker.co.kr -
-

Copyright (c) 2018 Pin For You. All Rights Reserved.

-
diff --git a/resources/views/mail/legacy/noti_center_reply_1.blade.php b/resources/views/mail/legacy/noti_center_reply_1.blade.php deleted file mode 100644 index 4380c7d..0000000 --- a/resources/views/mail/legacy/noti_center_reply_1.blade.php +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - -
-

고객센터 답변

-
- 회원님.
-
- 고객센터 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_deposit_1.blade.php b/resources/views/mail/legacy/noti_deposit_1.blade.php deleted file mode 100644 index 8f3dc3d..0000000 --- a/resources/views/mail/legacy/noti_deposit_1.blade.php +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - - - - - - - -
-

입금 알림

-
- ㅇㅇㅇ님!
- 회원님의 계정으로 암호화폐가 입금되어 하기와 같이 안내 해 드립니다. -
-

입금 정보

- 충전 일시 : 2017년 00.00. 00:00:00
- 금액 : 0000 KRW
- 보내는사람 : 외부 지갑
- 거래번호 : 00000000000000000000000000000 -
- 하기 입금 내역 확인하기를 클릭 하시면 회원님의 입금 내역을 확인 하실 수 있습니다. -
- 입금내역 확인하기 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_dormancy_email.blade.php b/resources/views/mail/legacy/noti_dormancy_email.blade.php deleted file mode 100644 index a221181..0000000 --- a/resources/views/mail/legacy/noti_dormancy_email.blade.php +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - -
-

휴면계정 해제하기

-
- 회원님.
- 아래 버튼을 클릭해 휴면계정 해제 페이지로 이동하세요.
- 해당 URL은 3시간 동안만 유효합니다. -
- 휴면계정 해제 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
diff --git a/resources/views/mail/legacy/noti_email_auth_1.blade.php b/resources/views/mail/legacy/noti_email_auth_1.blade.php deleted file mode 100644 index 8f251fd..0000000 --- a/resources/views/mail/legacy/noti_email_auth_1.blade.php +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - -
-

이메일 주소 인증

-
- 회원님.
- 이메일 인증 버튼을 클릭하여 이메일 인증을 완료 하시기 바랍니다. -
- 이메일 인증 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_login_1.blade.php b/resources/views/mail/legacy/noti_login_1.blade.php deleted file mode 100644 index e0bb4d9..0000000 --- a/resources/views/mail/legacy/noti_login_1.blade.php +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - - - - - - - -
-

로그인 알림

-
- ㅇㅇㅇ님!
핀포유(">http://)에 로그인 되었습니다. -
-

접속 정보

- 로그인 일시 : 2017년 00.00
- 접속장소 : 국가
- IP :
- 접속기기
-
- 회원님이 로그인 하시지 않은 알림을 받으신 경우, 계정의 보안을 위해 - 비밀번호를 즉시 변경 하시기 바라며, 고객센터로 문의 하시기 바랍니다. -
- 비밀번호 변경 -
-
- 비밀번호 변경을 누르시면 비밀번호 변경 페이지로 이동합니다. -
- 고객센터 문의하기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_mem_modi_1.blade.php b/resources/views/mail/legacy/noti_mem_modi_1.blade.php deleted file mode 100644 index b98fcbe..0000000 --- a/resources/views/mail/legacy/noti_mem_modi_1.blade.php +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - -
-

회원정보수정

-
- 회원님.
- 계정의 회원정보가 수정되었음을 안내해 드립니다. -
- 고객센터 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_qna_admin_push.blade.php b/resources/views/mail/legacy/noti_qna_admin_push.blade.php deleted file mode 100644 index 8923385..0000000 --- a/resources/views/mail/legacy/noti_qna_admin_push.blade.php +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - - - - -
-

고객센터 1:1 고객문의 알림

-
-
-
- 제목 :
- 등록시간 : -
- -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_set_pass_1.blade.php b/resources/views/mail/legacy/noti_set_pass_1.blade.php deleted file mode 100644 index ac5168e..0000000 --- a/resources/views/mail/legacy/noti_set_pass_1.blade.php +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - -
-

비밀번호 찾기

-
- 회원님.
- 아래 버튼을 클릭해 비밀번호 변경페이지로 이동하세요.
- 해당 URL은 3시간 동안만 유효합니다. -
- 비밀번호 변경 -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/mail/legacy/noti_sign_up_1.blade.php b/resources/views/mail/legacy/noti_sign_up_1.blade.php deleted file mode 100644 index aa64df4..0000000 --- a/resources/views/mail/legacy/noti_sign_up_1.blade.php +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - -
-

핀포유 (PinForYou)

-
- - - - - - - - - - - - - - - - - - - - - - - - -
-

회원가입 인증메일

-
- 온라인 상품권 쇼핑채널 '핀포유' 가입을 축하드립니다!

- - 회원님. 핀포유 가입을 진심으로 환영하며, 가입한 계정정보 확인 후 이메일 인증 버튼을 클릭하여 이메일 인증을 완료 하시기 바랍니다. -
-

가입 계정 정보

- 핀포유 : http:///
- 아이디 : -
-
- - SNS지인사칭, 공무원사칭 피싱사기 주의 (필독) -

- 1. 카카오톡/SNS메신저로 가족, 지인을 사칭한 피싱사기가 급증하고 있습니다. 누군가의 부탁으로 개인정보, 휴대폰 인증번호 노출 또는 결제부탁을 받으신 경우 - 메신저 대화를 중단 하시고 반드시 해당 지인과 통화를 하여 사실관계를 확인바랍니다.

- - 2. 검찰 또는 수사관(공무원)을 사칭하는 사람에게 전화를 받고 회원 가입을 하거나, 구인광고를 통한 구매대행/고액알바 등의 아르바이트를 이유로 저희 “핀포유” 에 가입 후 - 상품권 구매를 지시/요청 받았다면 99.9% 보이스피싱과 같은 금융 사고/사기일 가능성이 높습니다.

- 이점 이용에 유의하여 주시기 바랍니다. -
-
- 이메일 인증 -
- 이메일 인증후 사이트 이용이 가능합니다. -
-
- " target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기 -
\ No newline at end of file diff --git a/resources/views/web/auth/email_required.blade.php b/resources/views/web/auth/email_required.blade.php new file mode 100644 index 0000000..ea85810 --- /dev/null +++ b/resources/views/web/auth/email_required.blade.php @@ -0,0 +1,148 @@ +@extends('web.layouts.auth') + +@section('title', '이메일 인증 필요 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 이메일 인증 안내 페이지입니다.') +@section('canonical', url('/auth/email-required')) + +@section('h1', '이메일 인증이 필요합니다') +@section('desc', '인증 후 사이트 이용이 가능합니다. 등록된 이메일로 인증 메일을 보내드릴게요.') +@section('card_aria', '이메일 인증 안내') +@section('show_cs_links', true) + +@section('auth_content') +
+ @csrf + {{-- HERO 이미지 (아이디 찾기와 동일 스타일) --}} + + +
+
+ + + + +
+ 위 이메일 주소로 인증 링크를 발송합니다. 링크는 30분 동안만 유효합니다. +
+
+ +
+ {{-- ✅ id 추가 (JS가 찾을 수 있게) --}} + +
+
+
+@endsection + +@section('auth_bottom') + {{-- 필요 시 하단 문구 --}} +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/web/auth/email_verified.blade.php b/resources/views/web/auth/email_verified.blade.php new file mode 100644 index 0000000..0854a73 --- /dev/null +++ b/resources/views/web/auth/email_verified.blade.php @@ -0,0 +1,60 @@ +@extends('web.layouts.auth') + +@section('title', '이메일 인증 완료 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 이메일 인증 완료 안내 페이지입니다.') +@section('canonical', url('/auth/email-verified')) + +@section('h1', '이메일 인증이 완료되었습니다') +@section('desc', '인증이 정상적으로 확인되었습니다. 잠시 후 로그인 페이지로 이동합니다.') +@section('card_aria', '이메일 인증 완료 안내') +@section('show_cs_links', true) + +@section('auth_content') +
+
+
+ {{ $email }}
+ 인증이 완료되었습니다. +
+
+ +
+ + 로그인하기 + +
+ +
+ 5초 후 로그인 페이지로 이동합니다. +
+
+@endsection + +@section('auth_bottom') + {{-- 필요 시 하단 문구 --}} +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/web/auth/find_id.blade.php b/resources/views/web/auth/find_id.blade.php index f5ed92e..ca13135 100644 --- a/resources/views/web/auth/find_id.blade.php +++ b/resources/views/web/auth/find_id.blade.php @@ -8,6 +8,13 @@ @section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.') @section('card_aria', '아이디 찾기 폼') @section('show_cs_links', true) +{{-- ✅ reCAPTCHA 스크립트/공통함수는 이 페이지에서만 로드 --}} +@push('recaptcha') + + + +@endpush + @section('auth_content')
@@ -37,7 +44,6 @@
-
※ 현재는 UI만 구성되어 있어 실제 발송/검증은 동작하지 않습니다.
@@ -58,7 +64,7 @@
@@ -153,6 +159,29 @@ // -------- helpers ---------- const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const getRecaptchaToken = async (action) => { + try { + // recaptcha-v3.js에서 제공하는 함수명이 다를 수 있어서 2가지 케이스 처리 + if (typeof window.recaptchaV3Execute === 'function') { + const t = await window.recaptchaV3Execute(action); + return (t || '').toString(); + } + + // fallback: grecaptcha 직접 호출 + if (window.grecaptcha && window.__recaptchaSiteKey) { + return await new Promise((resolve) => { + grecaptcha.ready(() => { + grecaptcha.execute(window.__recaptchaSiteKey, { action }) + .then((t) => resolve((t || '').toString())) + .catch(() => resolve('')); + }); + }); + } + } catch (e) {} + + return ''; + }; + const postJson = async (url, data) => { const res = await fetch(url, { method: 'POST', @@ -268,7 +297,11 @@ setMsg('확인 중입니다...', 'info'); try { - const json = await postJson(@json(route('web.auth.find_id.send_code')), { phone: raw }); + const token = await getRecaptchaToken('find_id'); // ✅ 컨트롤러 Rule action과 동일 + const json = await postJson(@json(route('web.auth.find_id.send_code')), { + phone: raw, + 'g-recaptcha-response': token, + }); // ✅ 성공 (ok true) setMsg(json.message || '인증번호를 발송했습니다.', 'success'); @@ -278,9 +311,9 @@ startTimer(json.expires_in || 180); - if(json.dev_code){ - setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info'); - } + // if(json.dev_code){ + // setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info'); + // } } catch (err) { // ✅ 여기서 404(PHONE_NOT_FOUND)도 UX로 처리 @@ -310,7 +343,11 @@ setMsg('인증 확인 중입니다...', 'info'); - const json = await postJson(@json(route('web.auth.find_id.verify')), { code }); + const token = await getRecaptchaToken('find_id'); // 또는 'find_id_verify'로 분리해도 됨(서버와 동일해야 함) + const json = await postJson(@json(route('web.auth.find_id.verify')), { + code, + 'g-recaptcha-response': token, + }); // ✅ 먼저 step 이동 + 렌더 (패널 표시 보장) diff --git a/resources/views/web/auth/find_password.blade.php b/resources/views/web/auth/find_password.blade.php index ac23ab0..2be4aba 100644 --- a/resources/views/web/auth/find_password.blade.php +++ b/resources/views/web/auth/find_password.blade.php @@ -5,19 +5,21 @@ @section('canonical', url('/auth/find-password')) @section('h1', '비밀번호 찾기') -@section('desc', '가입된 이메일과 인증을 통해 새 비밀번호를 설정합니다.') +@section('desc', '가입된 이메일과 성명 확인 후, 비밀번호 재설정 링크를 이메일로 보내드립니다.') @section('card_aria', '비밀번호 찾기 폼') @section('show_cs_links', true) -@section('auth_content') -
-
1. 계정 확인
-
2. 인증
-
3. 재설정
-
+{{-- ✅ reCAPTCHA 스크립트/공통함수는 이 페이지에서만 로드 --}} +@push('recaptcha') + + + +@endpush +@section('auth_content')
- {{-- STEP 1 --}} + + {{-- STEP 1: 이메일 + 성명 --}}
@@ -27,37 +29,58 @@ placeholder="example@domain.com" autocomplete="username" value="{{ $email ?? '' }}"> -
가입된 이메일을 입력하면 인증번호를 발송합니다.
+
가입된 이메일을 입력해 주세요.
+
+ +
+ + +
가입 시 등록한 성명을 입력해 주세요.
- + 로그인으로 돌아가기
- {{-- STEP 2 --}} + {{-- STEP 2: 메일 발송 안내 + 유효시간 + 재발송 --}}
- - -
인증번호 유효시간 내에 입력해 주세요.
+
+ 입력하신 정보가 확인되면 비밀번호 재설정 링크를 이메일로 보내드립니다.
+ 메일이 오지 않으면 스팸함/격리함을 확인해 주세요. +
+ + {{-- 타이머 + 재발송 UI --}} +
+ + +
+ +
+ 링크는 30분 동안만 유효합니다. +
-
- {{-- STEP 3 --}} + {{-- STEP 3: 새 비밀번호 입력 (링크 검증 완료 시 진입) --}}
- +
-
+
- +{{-- 로그인하기--}}
- 인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다. + 이메일 링크 인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다.
@@ -101,11 +124,28 @@ let step = Number(@json($initialStep ?? 1)); const $email = document.getElementById('fp_email'); - const $code = document.getElementById('fp_code'); + const $name = document.getElementById('fp_name'); const $newPw = document.getElementById('fp_new'); const $newPw2= document.getElementById('fp_new2'); - // 메시지 영역: 항상 활성 패널의 actions 위에 위치 + const btnSend = document.getElementById('btnSendMail'); + const btnResend = document.getElementById('btnResendMail'); + + // recaptcha hidden input (없으면 생성) + const ensureRecaptchaInput = () => { + let el = root.querySelector('input[name="g-recaptcha-response"]'); + if(!el){ + el = document.createElement('input'); + el.type = 'hidden'; + el.name = 'g-recaptcha-response'; + el.id = 'g-recaptcha-response'; + el.value = ''; + root.prepend(el); + } + return el; + }; + + // ---------- message ---------- const mkMsg = () => { let el = root.querySelector('.auth-msg'); if(!el){ @@ -132,7 +172,6 @@ }; const render = () => { - // 전환 전 포커스 제거 const activeEl = document.activeElement; if (activeEl && root.contains(activeEl)) activeEl.blur(); @@ -157,13 +196,37 @@ mkMsg(); - // 전환 후 포커스 이동 const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`); target?.focus?.(); }; // -------- helpers ---------- - const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + + // ✅ recaptcha token getter + const getRecaptchaToken = async (action) => { + // production에서만 검증하지만, 프론트는 그냥 항상 시도해도 OK + const siteKey = window.__recaptchaSiteKey || ''; + if(!siteKey) return ''; + + // grecaptcha 로딩 체크 + if(typeof window.grecaptcha === 'undefined' || !window.grecaptcha?.execute){ + return ''; + } + + try{ + // ready 보장 + await new Promise((resolve) => window.grecaptcha.ready(resolve)); + const token = await window.grecaptcha.execute(siteKey, { action: action || 'find_pass' }); + + const input = ensureRecaptchaInput(); + input.value = token || ''; + + return token || ''; + }catch(e){ + return ''; + } + }; const postJson = async (url, data) => { const res = await fetch(url, { @@ -198,50 +261,57 @@ return json ?? { ok: true }; }; + // -------- sending button animation ---------- + const makeSendingAnimator = () => { + let timer = null; + return (btn, busy, finalTextAfter = null) => { + if (!btn) return; + + if (busy) { + btn.disabled = true; + btn.dataset.prevText = btn.dataset.prevText || btn.textContent.trim(); + btn.setAttribute('aria-busy', 'true'); + + let dots = 0; + btn.textContent = '발송중'; + timer = setInterval(() => { + dots = (dots + 1) % 4; + btn.textContent = '발송중' + '.'.repeat(dots); + }, 320); + } else { + btn.disabled = false; + btn.removeAttribute('aria-busy'); + if (timer) { clearInterval(timer); timer = null; } + + if (finalTextAfter) { + btn.dataset.prevText = finalTextAfter; + btn.textContent = finalTextAfter; + } else { + btn.textContent = btn.dataset.prevText || btn.textContent; + } + } + }; + }; + const setBtnBusy = makeSendingAnimator(); + const setResendLabel = () => { + if (btnSend) btnSend.textContent = '메일 재발송'; + if (btnResend) btnResend.textContent = '메일 재발송'; + }; + // -------- timer ---------- let timerId = null; let remain = 0; - const ensureTimerUI = () => { - let wrap = root.querySelector('.fp-timer'); - if(!wrap){ - wrap = document.createElement('div'); - wrap.className = 'fp-timer'; - wrap.style.marginTop = '8px'; - wrap.style.display = 'flex'; - wrap.style.gap = '10px'; - wrap.style.alignItems = 'center'; - - const t = document.createElement('span'); - t.className = 'fp-timer__text'; - t.style.fontSize = '13px'; - t.style.color = '#c7c7c7'; - - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'auth-btn auth-btn--ghost fp-resend'; - btn.textContent = '재전송'; - btn.style.padding = '10px 12px'; - - wrap.appendChild(t); - wrap.appendChild(btn); - - const step2Field = root.querySelector('[data-step="2"] .auth-field'); - step2Field?.appendChild(wrap); - } - return wrap; - }; - const tick = () => { - const wrap = ensureTimerUI(); - const t = wrap.querySelector('.fp-timer__text'); - const btn = wrap.querySelector('.fp-resend'); - + const t = root.querySelector('.fp-timer__text'); const mm = String(Math.floor(remain/60)).padStart(2,'0'); const ss = String(remain%60).padStart(2,'0'); - t.textContent = remain > 0 ? `유효시간 ${mm}:${ss}` : '인증번호가 만료되었습니다. 재전송 해주세요.'; - btn.disabled = remain > 0; + if (t) { + t.textContent = remain > 0 + ? `링크 유효시간 ${mm}:${ss}` + : '링크가 만료되었습니다. 메일 재발송을 진행해 주세요.'; + } if(remain <= 0){ clearInterval(timerId); @@ -252,67 +322,62 @@ }; const startTimer = (sec) => { - remain = Number(sec || 180); + remain = Number(sec || 1800); // 30분 기본 if(timerId) clearInterval(timerId); timerId = setInterval(tick, 1000); tick(); }; // -------- actions ---------- - const sendCode = async () => { + const sendResetMail = async (fromResend = false) => { const email = ($email?.value || '').trim(); + const name = ($name?.value || '').trim(); + if(!email){ setMsg('이메일을 입력해 주세요.', 'error'); + step = 1; render(); + return; + } + if(!name){ + setMsg('성명을 입력해 주세요.', 'error'); + step = 1; render(); return; } setMsg('확인 중입니다...', 'info'); - try { - const json = await postJson(@json(route('web.auth.find_password.send_code')), { email }); + const targetBtn = fromResend ? btnResend : btnSend; + setBtnBusy(targetBtn, true); - setMsg(json.message || '인증번호를 발송했습니다.', 'success'); + try { + // ✅ 요청 직전에 토큰 생성해서 body에 포함 + const token = await getRecaptchaToken('find_pass'); + + if (!token) { + // 로컬/개발에선 괜찮을 수 있지만, production이면 여기서 막아도 됨 + // 너 정책대로: production에서만 required니까 일단 안내만. + setMsg('보안 검증(캡챠) 로딩에 실패했습니다. 새로고침 후 다시 시도해 주세요.', 'error'); + return; + } + + const json = await postJson(@json(route('web.auth.find_password.send_mail')), { + email, + name, + 'g-recaptcha-response': token, + }); + + setMsg(json.message || '재설정 메일을 발송했습니다. 메일함을 확인해 주세요.', 'success'); step = 2; render(); - startTimer(json.expires_in || 180); + startTimer(json.expires_in || 1800); + setResendLabel(); - if(json.dev_code){ - setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info'); - } - } catch (err) { - const p = err.payload || {}; - - if (err.status === 404 && p.code === 'EMAIL_NOT_FOUND') { - step = 1; - render(); - setMsg(p.message || '해당 이메일로 가입된 계정을 찾을 수 없습니다.', 'error'); - return; - } - - setMsg(err.message || '오류가 발생했습니다.', 'error'); - } - }; - - const verifyCode = async () => { - const code = ($code?.value || '').trim(); - if(!/^\d{6}$/.test(code)){ - setMsg('인증번호 6자리를 입력해 주세요.', 'error'); - return; - } - - setMsg('인증 확인 중입니다...', 'info'); - - try { - const json = await postJson(@json(route('web.auth.find_password.verify')), { code }); - - step = 3; - render(); - - setMsg(json.message || '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.', 'success'); } catch (err) { setMsg(err.message || '오류가 발생했습니다.', 'error'); + } finally { + setBtnBusy(targetBtn, false, '메일 재발송'); } }; @@ -332,6 +397,7 @@ setMsg('변경 처리 중입니다...', 'info'); try { + // (비번 변경도 캡챠 걸 거면 여기에도 token 추가하면 됨) const json = await postJson(@json(route('web.auth.find_password.reset')), { new_password: pw1, new_password_confirmation: pw2 @@ -349,66 +415,30 @@ // -------- events ---------- root.addEventListener('click', async (e) => { - const resend = e.target.closest('.fp-resend'); - const next = e.target.closest('[data-next]'); + const send = e.target.closest('[data-send]'); + const resend = e.target.closest('[data-resend]'); const prev = e.target.closest('[data-prev]'); const reset = e.target.closest('[data-reset]'); - try{ - if(resend){ - await sendCode(); - return; - } - - if(next){ - if(step === 1) await sendCode(); - else if(step === 2) await verifyCode(); - return; - } - - if(reset){ - await resetPassword(); - return; - } - - if(prev){ - step = Math.max(1, step - 1); - render(); - return; - } - }catch(err){ - const stepFromServer = err?.payload?.step; - if(stepFromServer){ - step = Number(stepFromServer); - render(); - } - setMsg(err.message || '오류가 발생했습니다.', 'error'); - } + if(send){ await sendResetMail(false); return; } + if(resend){ await sendResetMail(true); return; } + if(prev){ step = Math.max(1, step - 1); render(); return; } + if(reset){ await resetPassword(); return; } }); - // Enter 키 UX + // Enter UX $email?.addEventListener('keydown', (e) => { - if(e.key === 'Enter'){ - e.preventDefault(); - root.querySelector('[data-step="1"] [data-next]')?.click(); - } + if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); } }); - - $code?.addEventListener('keydown', (e) => { - if(e.key === 'Enter'){ - e.preventDefault(); - root.querySelector('[data-step="2"] [data-next]')?.click(); - } + $name?.addEventListener('keydown', (e) => { + if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); } }); - $newPw2?.addEventListener('keydown', (e) => { - if(e.key === 'Enter'){ - e.preventDefault(); - root.querySelector('[data-step="3"] [data-reset]')?.click(); - } + if(e.key === 'Enter'){ e.preventDefault(); root.querySelector('[data-reset]')?.click(); } }); render(); })(); + @endpush diff --git a/resources/views/web/auth/login.blade.php b/resources/views/web/auth/login.blade.php index ca3326d..8424cd0 100644 --- a/resources/views/web/auth/login.blade.php +++ b/resources/views/web/auth/login.blade.php @@ -52,7 +52,7 @@
@@ -73,93 +73,150 @@ @endsection + @push('scripts') - @push('scripts') - - @endpush - + })(); + @endpush + + diff --git a/resources/views/web/layouts/layout.blade.php b/resources/views/web/layouts/layout.blade.php index 504a5e7..9cb48bc 100644 --- a/resources/views/web/layouts/layout.blade.php +++ b/resources/views/web/layouts/layout.blade.php @@ -53,7 +53,7 @@ @yield('head') - + {{-- Header --}} @include('web.company.header') diff --git a/resources/views/web/mypage/info/gate.blade.php b/resources/views/web/mypage/info/gate.blade.php new file mode 100644 index 0000000..8de446a --- /dev/null +++ b/resources/views/web/mypage/info/gate.blade.php @@ -0,0 +1,132 @@ +@php + $pageTitle = '나의정보'; + $pageDesc = '연락처, 비밀번호, 보안 관련 설정을 여기서 정리하세요.'; + + $breadcrumbs = [ + ['label' => '홈', 'url' => url('/')], + ['label' => '마이페이지', 'url' => url('/mypage/info')], + ['label' => '나의정보', 'url' => url()->current()], + ]; + + $mypageActive = 'info'; +@endphp + +@extends('web.layouts.subpage') + +@section('title', '나의정보 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 마이페이지 나의정보 입니다. 회원 정보 및 설정을 확인하세요.') +@section('canonical', url('/mypage/info')) + +@section('subcontent') +
+ @include('web.partials.content-head', [ + 'title' => '나의정보', + 'desc' => '내 계정 정보를 확인하고 필요한 항목을 관리하세요.' + ]) + +
+
+
+
+
+

비밀번호 재확인

+

회원정보 변경을 위해 비밀번호를 한 번 더 확인합니다.

+
+ +
+ 인증 후 일정 시간 동안만 정보 변경이 가능합니다. + 공용 PC에서는 사용 후 반드시 로그아웃하세요. +
+ +
+ @csrf + +
+ + + + +
+
+
+
+
+ + + {{-- ✅ 서브메뉴(사이드바) --}} + +
+
+ + @push('scripts') + + @endpush + +@endsection + + diff --git a/resources/views/web/mypage/info/renew.blade.php b/resources/views/web/mypage/info/renew.blade.php new file mode 100644 index 0000000..1cb7dc0 --- /dev/null +++ b/resources/views/web/mypage/info/renew.blade.php @@ -0,0 +1,557 @@ +@php + $pageTitle = '나의정보'; + $pageDesc = '계정 정보 관리'; + + $breadcrumbs = [ + ['label' => '홈', 'url' => url('/')], + ['label' => '마이페이지', 'url' => url('/mypage/info')], + ['label' => '나의정보 변경', 'url' => url()->current()], + ]; + $mypageActive = 'info'; +@endphp + +@extends('web.layouts.subpage') + +@section('title', '나의정보 변경 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 나의정보 변경 페이지입니다.') +@section('canonical', url('/mypage/info_renew')) + +@section('subcontent') +
+ + @include('web.partials.content-head', [ + 'title' => '나의정보 변경', + 'desc' => '계정 보안과 개인정보를 안전하게 관리하세요.' + ]) + + {{-- ✅ 상단 상태 카드 --}} +
+
+
+
ACCOUNT SETTINGS
+
내 정보 관리
+
+
+ 성명 + {{ $memberName ?: '-' }} +
+
+ 이메일 + {{ $memberEmail ?: '-' }} +
+
+ 휴대폰 + {{ $memberPhone ?: '-' }} +
+
+ 가입일 + {{ $memberDtReg ?: '-' }} +
+
+
+ +
+
+ 연락처·비밀번호·보안 설정을 한 곳에서 관리합니다. + 변경 작업은 보안을 위해 제한된 시간 동안만 가능합니다. +
+
+
+ 인증 허용 잔여시간 + + + {{ sprintf('%02d:%02d', intdiv((int)$remainSec, 60), (int)$remainSec % 60) }} + + +
+
+ + +
+
+
+ + {{-- ✅ 설정 카드 그리드 --}} +
+ + + +
🔒
+
+
비밀번호 변경
+
보안을 위해 주기적으로 변경을 권장해요
+
준비중
+
+
+
+ + +
🔐
+
+
2차비밀번호 변경
+
민감 기능 이용 시 추가로 확인하는 비밀번호
+
준비중
+
+
+
+ + +
🏦
+
+
출금계좌번호
+
+ {{ $hasWithdrawAccount ? '등록된 출금계좌 정보를 수정합니다.' : '출금계좌를 등록해 주세요.' }} +
+
+ {{ $hasWithdrawAccount ? '수정' : '등록' }} +
+
+
+
+ + + + +
+ + {{-- ✅ 안내/주의사항 --}} +
+
안내
+ +
+
+ + + + @push('styles') + + @endpush + @push('scripts') + + @endpush + + + + + +@endsection diff --git a/resources/views/web/partials/auth/auth-footer-links.blade.php b/resources/views/web/partials/auth/auth-footer-links.blade.php index aedc713..22b77c6 100644 --- a/resources/views/web/partials/auth/auth-footer-links.blade.php +++ b/resources/views/web/partials/auth/auth-footer-links.blade.php @@ -5,5 +5,5 @@ · 아이디 찾기 · - 비밀번호 찾기 + 비밀번호 찾기 diff --git a/routes/web.php b/routes/web.php index 41ed7db..54f4db8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,7 +6,9 @@ use App\Http\Controllers\Web\Auth\FindIdController; use App\Http\Controllers\Web\Auth\FindPasswordController; use App\Http\Controllers\Web\Auth\RegisterController; use App\Http\Controllers\Web\Auth\LoginController; +use App\Http\Controllers\Web\Mypage\InfoGateController; use App\Http\Controllers\Web\Cs\NoticeController; +use App\Http\Controllers\Web\Auth\EmailVerificationController; Route::view('/', 'web.home')->name('web.home'); @@ -34,7 +36,16 @@ Route::prefix('cs')->name('web.cs.')->group(function () { Route::prefix('mypage')->name('web.mypage.') ->middleware('legacy.auth') ->group(function () { - Route::view('info', 'web.mypage.info.index')->name('info.index'); + Route::get('info', [InfoGateController::class, 'show'])->name('info.index'); + Route::post('info', [InfoGateController::class, 'verify'])->name('info.verify'); + + Route::get('info_renew', [InfoGateController::class, 'info_renew'])->name('info.renew'); + + Route::post('info/pass/ready', [InfoGateController::class, 'passReady'])->name('info.pass.ready'); + Route::post('info/danal/start', [InfoGateController::class, 'danalStart'])->name('info.danal.start'); + Route::post('info/danal/result', [InfoGateController::class, 'danalResult'])->name('info.danal.result'); + Route::get('info/gate-reset', [InfoGateController::class, 'gateReset'])->name('info.gate_reset'); + Route::view('usage', 'web.mypage.usage.index')->name('usage.index'); Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index'); Route::view('qna', 'web.mypage.qna.index')->name('qna.index'); @@ -45,7 +56,7 @@ Route::prefix('mypage')->name('web.mypage.') | Policy |-------------------------------------------------------------------------- */ -//로그인 후 접근하면 안됨 +//일반접근 Route::prefix('policy')->name('web.policy.')->group(function () { Route::view('/', 'web.policy.index')->name('index'); Route::view('privacy', 'web.policy.privacy.index')->name('privacy.index'); @@ -96,18 +107,27 @@ Route::prefix('auth')->name('web.auth.')->group(function () { Route::post('find-id/reset', [FindIdController::class, 'reset'])->name('find_id.reset'); // 비밀번호 찾기 - Route::get('find-password', [FindPasswordController::class, 'show'])->name('find_password'); - Route::post('find-password/send-code', [FindPasswordController::class, 'sendCode'])->name('find_password.send_code'); - Route::post('find-password/verify', [FindPasswordController::class, 'verify'])->name('find_password.verify'); - Route::post('find-password/reset', [FindPasswordController::class, 'reset'])->name('find_password.reset'); - Route::post('find-password/reset-session', [FindPasswordController::class, 'resetSession'])->name('find_password.reset_session'); + Route::get('find-password', [FindPasswordController::class, 'show'])->name('find_password.show'); + Route::post('find-password/send-mail', [FindPasswordController::class, 'sendMail'])->name('find_password.send_mail'); + Route::get('find-password/verify', [FindPasswordController::class, 'verifyLink'])->name('find_password.verify')->middleware('signed'); + Route::post('find-password/reset', [FindPasswordController::class, 'reset'])->name('find_password.reset'); + + //이메일인증 + Route::get('email-required', [EmailVerificationController::class, 'requiredPage'])->name('email.required'); + Route::post('email/send-verify', [EmailVerificationController::class, 'sendVerify'])->name('email.send_verify'); + Route::get('email/verify', [EmailVerificationController::class, 'verify'])->name('email.verify')->middleware('signed'); }); - Route::middleware('legacy.auth')->group(function () { - Route::post('logout', [LoginController::class, 'logout'])->name('logout'); - }); + Route::post('logout', [LoginController::class, 'logout'])->name('logout'); }); +Route::prefix('auth')->name('web.auth.')->group(function () { + Route::middleware('legacy.guest')->group(function () { + Route::get('email-required', [EmailVerificationController::class, 'requiredPage'])->name('email.required'); + Route::post('email/send-verify', [EmailVerificationController::class, 'sendVerify'])->name('email.send_verify'); + Route::get('email/verify', [EmailVerificationController::class, 'verify'])->name('email.verify')->middleware('signed'); // 만료/위변조 자동 차단 + }); +}); /* |-------------------------------------------------------------------------- @@ -117,12 +137,6 @@ Route::prefix('auth')->name('web.auth.')->group(function () { Route::get('/login', fn() => redirect()->route('web.auth.login'))->name('web.login'); Route::get('/register', fn() => redirect()->route('web.auth.register'))->name('web.signup'); - - - - - - /* |-------------------------------------------------------------------------- | Dev Lab (로컬에서만 + 파일 존재할 때만 라우트 등록) @@ -150,6 +164,8 @@ if (app()->environment(['local', 'development', 'testing']) + + /* 개발용 페이지 세션 보기*/ use Illuminate\Http\Request;