diff --git a/app/Http/Controllers/Web/Auth/RegisterController.php b/app/Http/Controllers/Web/Auth/RegisterController.php index 290f607..00d5fb9 100644 --- a/app/Http/Controllers/Web/Auth/RegisterController.php +++ b/app/Http/Controllers/Web/Auth/RegisterController.php @@ -449,7 +449,7 @@ class RegisterController extends Controller ], ]; - // 비밀번호는 로그에 남기면 사고남 → 마스킹/제거 + // 비밀번호는 마스킹/제거 $logFinal = $final; $logFinal['password_plain'] = '***masked***'; $logFinal['pin2_plain'] = '***masked***'; diff --git a/app/Http/Controllers/Web/Mypage/InfoGateController.php b/app/Http/Controllers/Web/Mypage/InfoGateController.php index c842d27..2cb1edd 100644 --- a/app/Http/Controllers/Web/Mypage/InfoGateController.php +++ b/app/Http/Controllers/Web/Mypage/InfoGateController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web\Mypage; use App\Http\Controllers\Controller; use App\Services\MemInfoService; use App\Services\Danal\DanalAuthtelService; +use App\Services\Dozn\DoznAccountAuthService; use App\Repositories\Member\MemberAuthRepository; use App\Support\LegacyCrypto\CiSeedCrypto; use App\Support\LegacyCrypto\CiPassword; @@ -40,7 +41,7 @@ final class InfoGateController extends Controller public function info_renew(Request $request) { - // ✅ gate (기존 그대로) + // gate (기존 그대로) $gate = (array) $request->session()->get('mypage_gate', []); $gateOk = (bool) Arr::get($gate, 'ok', false); $gateAt = (int) Arr::get($gate, 'at', 0); @@ -51,14 +52,14 @@ final class InfoGateController extends Controller $remainSec = ($expireTs > 0) ? max(0, $expireTs - $nowTs) : 0; $isGateValid = ($remainSec > 0); - // ✅ 회원정보: _sess 기반 + // 회원정보: _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(); @@ -91,20 +92,35 @@ final class InfoGateController extends Controller /** - * 재인증을 위한 세셔초기화 + * 재인증을 위한 연장 및 세셔초기화 */ public function gateReset(Request $request) { - // 게이트 인증 세션만 초기화 - $request->session()->forget('mypage_gate'); + $ttlSec = 5 * 60; + $now = time(); - // (선택) reauth도 같이 초기화하고 싶으면 - $request->session()->forget('mypage.reauth.at'); - $request->session()->forget('mypage.reauth.until'); + $gate = (array) $request->session()->get('mypage_gate', []); + $gateOk = (bool)($gate['ok'] ?? false); + $gateAt = (int) ($gate['at'] ?? 0); - $request->session()->save(); + $expireTs = ($gateOk && $gateAt > 0) ? ($gateAt + $ttlSec) : 0; + $isValid = ($expireTs > 0) && ($expireTs > $now); - // 게이트 페이지로 이동 + if ($isValid) { + // 남은 시간이 있으면: 5분 연장(= at를 현재로 갱신) + $request->session()->put('mypage_gate', [ + 'ok' => true, + 'email' => (string)($gate['email'] ?? ''), + 'at' => $now, + ]); + $request->session()->save(); + + }else{ + // 시간이 끝났으면: 초기화 + $request->session()->forget('mypage_gate'); + $request->session()->save(); + } + // 인덱스로 보내기 return redirect()->route('web.mypage.info.index'); } @@ -271,7 +287,7 @@ final class InfoGateController extends Controller ]); } - // ✅ 목적이 마이페이지 연락처 변경일 때만 추가 검증 + // 목적이 마이페이지 연락처 변경일 때만 추가 검증 $purpose = (string) $request->session()->get('mypage.pass_purpose', ''); if ($purpose === 'mypage_phone_change') { @@ -331,5 +347,278 @@ final class InfoGateController extends Controller ]); } + /*비밀번호 변경 저장*/ + public function passwordUpdate(Request $request, MemInfoService $memInfoService, MemberAuthRepository $repo) + { + if (!$this->isGateOk($request)) { + return $this->gateFailJson($request, '인증 시간이 만료되었습니다. 다시 인증해 주세요.'); + } + + // 로그인 세션 + $sess = (array) $request->session()->get('_sess', []); + $isLogin = (bool) ($sess['_login_'] ?? false); + $email = (string) ($sess['_mid'] ?? ''); + $memNo = (int) ($sess['_mno'] ?? 0); + + if (!$isLogin || $email === '' || $memNo <= 0) { + return response()->json([ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인해 주세요.', + 'redirect' => route('web.auth.login'), + ], 401); + } + + // 검증 (요청하신 메시지 그대로) + $request->validate([ + 'current_password' => ['required', 'string'], + 'password' => [ + 'required', + 'string', + 'min:8', + 'max:20', + // 영문+숫자+특수문자 포함 + 'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,20}$/', + 'confirmed', // password_confirmation + ], + ], [ + 'password.required' => '비밀번호를 입력해 주세요.', + 'password.min' => '비밀번호는 8자리 이상이어야 합니다.', + 'password.max' => '비밀번호는 20자리를 초과할 수 없습니다.', + 'password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.', + ]); + + $currentPw = (string) $request->input('current_password'); + $newPw = (string) $request->input('password'); + + // 현재 비밀번호 확인(기존 attemptLegacyLogin 로직 재사용) + $res = $memInfoService->attemptLegacyLogin([ + 'email' => $email, + 'password' => $currentPw, + 'ip' => $request->ip(), + 'ua' => substr((string) $request->userAgent(), 0, 500), + 'return_url' => url('/mypage/info_renew'), + ]); + + $ok = (bool)($res['ok'] ?? $res['success'] ?? false); + if (!$ok) { + // 레이어에서 보여줄 메시지 + return response()->json([ + 'ok' => false, + 'message' => (string)($res['message'] ?? '현재 비밀번호가 일치하지 않습니다.'), + ], 422); + } + + // 변경 저장(레포 이미 존재) + 성공로그(레포 이미 존재) + $repo->updatePasswordOnly($memNo, $newPw); + $repo->logPasswordResetSuccess( + $memNo, + (string) $request->ip(), + (string) $request->userAgent(), + 'S' + ); + + + return response()->json([ + 'ok' => true, + 'message' => '비밀번호가 변경되었습니다.', + ]); + } + + //2차 비밀번호 변경 + public function updatePin2( + Request $request, + MemInfoService $memInfoService, + MemberAuthRepository $repo + ) { + if (!$this->isGateOk($request)) { + return response()->json([ + 'ok' => false, + 'message' => '인증 시간이 만료되었습니다. 다시 인증해 주세요.', + ], 419); + } + + // 입력 검증(메시지 그대로) + $request->validate([ + 'current_password' => ['required', 'string'], + 'current_pin2' => ['required', 'digits:4'], + 'pin2' => ['required', 'digits:4'], + 'pin2_confirmation' => ['required', 'same:pin2'], + ], [ + 'current_password.required' => '이전 비밀번호를 입력해 주세요.', + + 'current_pin2.required' => '이전 2차 비밀번호(숫자 4자리)를 입력해 주세요.', + 'current_pin2.digits' => '이전 2차 비밀번호는 숫자 4자리여야 합니다.', + + 'pin2.required' => '2차 비밀번호(숫자 4자리)를 입력해 주세요.', + 'pin2.digits' => '2차 비밀번호는 숫자 4자리여야 합니다.', + 'pin2_confirmation.required' => '2차 비밀번호 확인을 입력해 주세요.', + 'pin2_confirmation.same' => '2차 비밀번호 확인이 일치하지 않습니다.', + ]); + + // 세션에서 mem_no/email 확보 + $sess = (array) $request->session()->get('_sess', []); + $isLogin = (bool) ($sess['_login_'] ?? false); + $email = (string) ($sess['_mid'] ?? ''); + $memNo = (int) ($sess['_mno'] ?? 0); + + if (!$isLogin || $email === '' || $memNo <= 0) { + return response()->json([ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.', + ], 401); + } + + // 1) 로그인 비밀번호(1차) 검증 (기존 attemptLegacyLogin 재사용) + $currentPw = (string) $request->input('current_password'); + + $res = $memInfoService->attemptLegacyLogin([ + 'email' => $email, + 'password' => $currentPw, + 'ip' => $request->ip(), + 'ua' => substr((string) $request->userAgent(), 0, 500), + 'return_url' => url('/mypage/info_renew'), + ]); + + $pwOk = (bool)($res['ok'] ?? $res['success'] ?? false); + if (!$pwOk) { + return response()->json([ + 'ok' => false, + 'reason' => 'invalid_current_password', + 'message' => (string)($res['message'] ?? '이전 비밀번호가 일치하지 않습니다.'), + ], 422); + } + + // 2) 현재 2차 비밀번호 검증 + $currentPin2 = (string) $request->input('current_pin2'); + $pin2Ok = $repo->verifyPin2($memNo, $currentPin2); + + if (!$pin2Ok) { + return response()->json([ + 'ok' => false, + 'reason' => 'invalid_current_pin2', + 'message' => '이전 2차 비밀번호가 일치하지 않습니다.', + ], 422); + } + + // 3) 새 2차 비밀번호 저장 + $newPin2 = (string) $request->input('pin2'); + + try { + $repo->updatePin2Only($memNo, $newPin2); + } catch (\Throwable $e) { + Log::error('[mypage] pin2 update failed', [ + 'mem_no' => $memNo, + 'err' => $e->getMessage(), + ]); + + return response()->json([ + 'ok' => false, + 'message' => '2차 비밀번호 변경에 실패했습니다. 잠시 후 다시 시도해 주세요.', + ], 500); + } + + return response()->json([ + 'ok' => true, + 'message' => '2차 비밀번호가 변경되었습니다.', + ]); + } + + + public function verifyOut(Request $request, DoznAccountAuthService $dozn, MemberAuthRepository $repo) + { + // 게이트 만료 처리(프로젝트 정책대로) + if (!$this->isGateOk($request)) { + return $this->gateFailJson($request, '인증이 만료되었습니다. 다시 인증해 주세요.'); + } + + // 레거시 세션 기준으로 memNo / 실명 확정 + $sess = (array) $request->session()->get('_sess', []); + $memNo = (int) ($sess['_mno'] ?? 0); + $memberName = trim((string) ($sess['_mname'] ?? '')); + + if ($memNo <= 0 || $memberName === '') { + return response()->json([ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.', + 'redirect' => route('web.auth.login'), + ], 401); + } + + $request->validate([ + 'pin2' => ['required','digits:4'], + 'bank_code' => ['required','string'], + 'account' => ['required','regex:/^\d+$/'], + 'depositor' => ['required','string','max:50'], + ], [ + 'pin2.required' => '2차 비밀번호(숫자 4자리)를 입력해 주세요.', + 'pin2.digits' => '2차 비밀번호는 숫자 4자리여야 합니다.', + 'bank_code.required' => '은행을 선택해 주세요.', + 'account.required' => '계좌번호를 입력해 주세요.', + 'account.regex' => '계좌번호는 숫자만 입력해 주세요.', + 'depositor.required' => '예금주(성명)를 입력해 주세요.', + ]); + + $pin2 = trim((string) $request->input('pin2', '')); + $bankCode = trim((string) $request->input('bank_code', '')); + $account = trim((string) $request->input('account', '')); + $depositor = trim((string) $request->input('depositor', '')); + + if ($depositor !== $memberName) { + return response()->json([ + 'ok' => false, + 'message' => '가입자 성명과 예금주가 일치하지 않습니다.', + 'errors' => ['depositor' => ['가입자 성명과 예금주가 일치하지 않습니다.']], + ], 422); + } + + // 1) 2차비번 검증 먼저 + $okPin2 = $repo->verifyPin2($memNo, $pin2); + if (!$okPin2) { + return response()->json([ + 'ok' => false, + 'message' => '2차 비밀번호가 올바르지 않습니다.', + 'errors' => ['pin2' => ['2차 비밀번호가 올바르지 않습니다.']], + ], 422); + } + + // 2) Dozn 인증 + 저장 + $res = $dozn->verifyAndSaveOutAccount( + $memNo, + $memberName, // 세션 실명 + $depositor, // 위에서 실명 일치 확인됨 + $bankCode, + $account, + true + ); + + return response()->json($res, 200); + } + + /** + * gate 유효성 체크 (mypage_gate.at만 사용, TTL=5분) + */ + private function isGateOk(Request $request, int $ttlSec = 300): bool + { + $gate = (array) $request->session()->get('mypage_gate', []); + $ok = (bool)($gate['ok'] ?? false); + $at = (int)($gate['at'] ?? 0); + + if (!$ok || $at <= 0) return false; + + return (time() - $at) <= $ttlSec; + } + + /** + * gate 만료 공통 JSON 응답 + */ + private function gateFailJson(Request $request, string $message = '인증이 필요합니다.') + { + return response()->json([ + 'ok' => false, + 'message' => $message, + 'redirect' => route('web.mypage.info.index'), // gate 페이지로 유도 + ], 401); + } + } diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php index 13a0266..21965f6 100644 --- a/app/Repositories/Member/MemberAuthRepository.php +++ b/app/Repositories/Member/MemberAuthRepository.php @@ -432,10 +432,6 @@ class MemberAuthRepository $ipfResult = (string) data_get($sessionAll, 'signup.ipf_result', ''); $ipfSeq = (int) data_get($sessionAll, 'signup.ipf_seq', 0); -// if ($ipfResult === 'A') { -// // 이 케이스는 원래 step0에서 막혀야 정상이지만, 방어적으로 한번 더 -// throw new \RuntimeException('회원가입이 제한된 IP입니다.'); -// } if ($ipfResult === 'S' && $ipfSeq > 0) { $this->updateJoinLogAfterSignup($ipfSeq, $memNo, $email); } @@ -665,29 +661,85 @@ class MemberAuthRepository 'dt_reg' => $now, ]); - // ✅ 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리 + // 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리 + if ($affected <= 0) { + throw new \RuntimeException('mem_st_ring_not_found'); + } + } + + // 2차 비밀번호 검증 + public function verifyPin2(int $memNo, string $pin2Plain): bool + { + if ($memNo <= 0) return false; + + // 4자리 숫자만 허용 + if (!preg_match('/^\d{4}$/', $pin2Plain)) return false; + + $stored = (string) DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->value('passwd2'); + + if ($stored === '') return false; + + $hash = CiPassword::makePass2($pin2Plain); + + return hash_equals($stored, $hash); + } + + + // 2차 비밀번호 수정 + public function updatePin2Only(int $memNo, string $pin2Plain): void + { + if ($memNo <= 0 || $pin2Plain === '') { + throw new \InvalidArgumentException('invalid_payload'); + } + + // 4자리 숫자 강제 (컨트롤러에서도 validate 하지만 2중 방어) + if (!preg_match('/^\d{4}$/', $pin2Plain)) { + throw new \InvalidArgumentException('invalid_pin2'); + } + + $passwd2 = CiPassword::makePass2($pin2Plain); + $now = now()->format('Y-m-d H:i:s'); + + // mem_st_ring 존재 전제: 없으면 데이터 이상으로 실패 처리(비번 변경과 동일 정책) + $affected = DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->update([ + 'passwd2' => $passwd2, + 'passwd2_reg' => $now, + ]); + if ($affected <= 0) { throw new \RuntimeException('mem_st_ring_not_found'); } } - public function logPasswordResetSuccess(int $memNo, string $ip, string $agent): void + public function logPasswordResetSuccess(int $memNo, string $ip, string $agent, string $state = 'E'): void { $now = now()->format('Y-m-d H:i:s'); + + // state: E=비밀번호찾기 변경, S=직접 변경 + $state = strtoupper(trim($state)); + if (!in_array($state, ['E', 'S'], true)) { + $state = 'E'; + } + DB::table('mem_passwd_modify')->insert([ - 'state' => 'E', + 'state' => $state, 'info' => json_encode([ 'mem_no' => (string)$memNo, 'redate' => $now, 'remote_addr' => $ip, - 'agent' => $agent, + 'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리) ], JSON_UNESCAPED_UNICODE), 'rgdate' => $now, ]); } + } diff --git a/app/Services/Dozn/DoznAccountAuthService.php b/app/Services/Dozn/DoznAccountAuthService.php new file mode 100644 index 0000000..760e28b --- /dev/null +++ b/app/Services/Dozn/DoznAccountAuthService.php @@ -0,0 +1,360 @@ +mode = (string)($cfg['mode'] ?? 'real'); + $this->orgCode = (string)($cfg['org_code'] ?? ''); + $this->apiKey = (string)($cfg['api_key'] ?? ''); + $this->urlReal = (string)($cfg['url_real'] ?? ''); + $this->urlTest = (string)($cfg['url_test'] ?? ''); + $this->timeout = (int)($cfg['timeout'] ?? 10); + } + + /** + * 출금(out) 계좌 성명 인증 + 저장까지 한 번에 처리 + * + * @param int $memNo 회원번호 + * @param string $memberRealName 회원 실명(서버에서 꺼낸 값) + * @param string $inputDepositor 입력한 예금주(화면 입력) + * @param string $bankCode 은행코드(001~) + * @param string $account 계좌번호(숫자만 권장) + * @param bool $requireSameName 입력 예금주 == 회원실명 강제 여부 + * + * @return array { ok, code, message, status, depositor, telegram_no } + */ + public function verifyAndSaveOutAccount( + int $memNo, + string $memberRealName, + string $inputDepositor, + string $bankCode, + string $account, + bool $requireSameName = true + ): array { + $memNo = (int)$memNo; + $memberRealName = trim($memberRealName); + $inputDepositor = trim($inputDepositor); + $bankCode = trim($bankCode); + $account = preg_replace('/[^0-9]/', '', (string)$account); + + // 은행코드 검증 (config/bank_code.php) + $bankMap = (array) config('bank_code.flat', []); + if ($bankCode === '' || !isset($bankMap[$bankCode])) { + return $this->fail('INVALID_BANK', '은행코드가 올바르지 않습니다.'); + } + if ($account === '') { + return $this->fail('INVALID_ACCOUNT', '계좌번호를 입력해 주세요.'); + } + if ($inputDepositor === '') { + return $this->fail('INVALID_DEPOSITOR', '예금주를 입력해 주세요.'); + } + + // 입력 예금주와 회원 실명 일치 강제 (네 CI3 로직과 동일) + if ($requireSameName && $memberRealName !== '' && $memberRealName !== $inputDepositor) { + // act_state=4(정보불일치) 기록(선택) + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '4', [ + 'reason' => 'member_name_mismatch', + 'member_real_name' => $memberRealName, + 'input_depositor' => $inputDepositor, + ]); + + return $this->fail('NAME_MISMATCH', '가입자 성명과 예금주가 일치하지 않습니다!'); + } + + // telegram_no 발급 (일별 6자리) + $telegramNo = $this->issueTelegramNo(); + + // 요청 페이로드 (Dozn 스펙) + $payload = [ + 'org_code' => $this->orgCode, + 'api_key' => $this->apiKey, + 'telegram_no' => $telegramNo, + 'bank_code' => $bankCode, + 'account' => $account, + 'check_depositor' => 'N', // 기존 코드 유지 (실명체크 identify_no는 추후) + // 'identify_no' => '761127', + ]; + + // 요청 로그 insert + $logSeq = $this->logRequest($memNo, $payload, [ + 'input_depositor' => $inputDepositor, + 'mode' => $this->mode, + ]); + + // 진행상태 기록 (2: 인증진행중) + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '2', [ + 'log_seq' => $logSeq, + 'telegram_no' => $telegramNo, + ]); + + // Dozn 호출 + $resp = $this->callDozn($payload); + + // 결과 로그 update + $this->logResult($logSeq, $resp['raw'] ?? []); + + $status = (string)($resp['status'] ?? ''); + $depositor = (string)($resp['depositor'] ?? ''); + + // 성공(200) 처리 + if ($status === '200') { + if ($depositor === '' || $depositor !== $inputDepositor) { + // 정보 불일치 (4) + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '4', [ + 'status' => $status, + 'returned_depositor' => $depositor, + 'input_depositor' => $inputDepositor, + 'telegram_no' => $telegramNo, + ]); + + return [ + 'ok' => false, + 'code' => 'DEPOSITOR_MISMATCH', + 'message' => '예금주가 일치하지 않습니다!', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // 성공(3): 계좌 저장 + $this->saveOutAccountSuccess($memNo, $bankCode, $account, $depositor, [ + 'status' => $status, + 'telegram_no' => $telegramNo, + 'result' => $resp['raw'] ?? [], + ]); + + return [ + 'ok' => true, + 'code' => 'OK', + 'message' => '인증완료 및 계좌번호가 등록되었습니다.', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // 잘못된 계좌(520) + if ($status === '520') { + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '5', [ + 'status' => $status, + 'telegram_no' => $telegramNo, + 'result' => $resp['raw'] ?? [], + ]); + + return [ + 'ok' => false, + 'code' => 'INVALID_ACCOUNT', + 'message' => '잘 못된 계좌번호 입니다.', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // 기타 오류 + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '5', [ + 'status' => $status, + 'telegram_no' => $telegramNo, + 'result' => $resp['raw'] ?? [], + ]); + + return [ + 'ok' => false, + 'code' => 'DOZN_ERROR', + 'message' => ($status ?: 'ERR') . '|인증에 문제가 발생했습니다. 잠시후 다시 시도해주세요.', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // ------------------------------------------------------------------ + // Dozn HTTP call + // ------------------------------------------------------------------ + private function callDozn(array $payload): array + { + $url = $this->mode === 'test' ? $this->urlTest : $this->urlReal; + if ($url === '' || $this->orgCode === '' || $this->apiKey === '') { + return [ + 'status' => 'CONFIG', + 'depositor' => '', + 'raw' => ['error' => 'dozn_config_missing'], + ]; + } + + try { + $res = Http::timeout($this->timeout) + ->acceptJson() + ->asJson() + ->post($url, $payload); + + $json = $res->json(); + if (!is_array($json)) $json = []; + + return [ + 'status' => (string)($json['status'] ?? ''), + 'depositor' => (string)($json['depositor'] ?? ''), + 'raw' => $json, + ]; + } catch (\Throwable $e) { + Log::warning('[dozn] request failed', [ + 'err' => $e->getMessage(), + ]); + + return [ + 'status' => 'HTTP', + 'depositor' => '', + 'raw' => ['error' => 'http_exception', 'message' => $e->getMessage()], + ]; + } + } + + // ------------------------------------------------------------------ + // telegram_no: mem_account_telegram_no 사용 (일별 6자리) + // ------------------------------------------------------------------ + private function issueTelegramNo(): int + { + $today = Carbon::today()->toDateString(); + + return (int) DB::transaction(function () use ($today) { + // 오늘 발급된 마지막 telegram_no를 잠그고 다음 번호 생성 + $last = DB::table('mem_account_telegram_no') + ->where('date', $today) + ->lockForUpdate() + ->max('telegram_no'); + + $next = $last ? ((int)$last + 1) : 1; + if ($next > 999999) $next = 1; + + DB::table('mem_account_telegram_no')->insert([ + 'telegram_no' => $next, + 'date' => $today, + ]); + + return $next; + }); + } + + // ------------------------------------------------------------------ + // Logs: mem_account_log + // ------------------------------------------------------------------ + private function logRequest(int $memNo, array $payload, array $meta = []): int + { + $now = Carbon::now()->format('Y-m-d H:i:s'); + + $req = array_merge($payload, $meta); + + $reqJson = json_encode($req, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($reqJson === false) $reqJson = '{}'; + + return (int) DB::table('mem_account_log')->insertGetId([ + 'mem_no' => $memNo, + 'request_data' => $reqJson, + 'request_time' => $now, + 'result_data' => '{}', + 'result_time' => $now, + ]); + } + + private function logResult(int $seq, array $result): void + { + if ($seq <= 0) return; + + $now = Carbon::now()->format('Y-m-d H:i:s'); + + $resJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($resJson === false) $resJson = '{}'; + + DB::table('mem_account_log') + ->where('seq', $seq) + ->update([ + 'result_data' => $resJson, + 'result_time' => $now, + ]); + } + + + + // ------------------------------------------------------------------ + // mem_account: 회원당 out 1개 유지 (스키마 유지, 코드로 강제) + // ------------------------------------------------------------------ + private function upsertOutAccountState( + int $memNo, + string $bankCode, + string $account, + string $depositor, + string $actState, + array $confirmLog = [] + ): void { + $bankMap = (array) config('bank_code.flat', []); + $bankName = (string)($bankMap[$bankCode] ?? ''); + + $now = Carbon::now()->format('Y-m-d H:i:s'); + + DB::transaction(function () use ($memNo, $bankCode, $bankName, $account, $depositor, $actState, $confirmLog, $now) { + // mem_no + act_type='out' 기준으로 row 잠금 + $row = DB::table('mem_account') + ->where('mem_no', $memNo) + ->where('act_type', 'out') + ->lockForUpdate() + ->first(); + + $data = [ + 'mem_no' => $memNo, + 'act_type' => 'out', + 'act_state' => $actState, + 'bank_code' => $bankCode, + 'bank_name' => $bankName, + 'bank_act_name' => $depositor, + 'bank_act_num' => $account, + 'in_date' => $now, + 'act_date' => $now, + 'confirm_log' => json_encode($confirmLog ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; + + if ($row) { + DB::table('mem_account')->where('seq', $row->seq)->update($data); + } else { + DB::table('mem_account')->insert($data); + } + }); + } + + private function saveOutAccountSuccess( + int $memNo, + string $bankCode, + string $account, + string $depositor, + array $confirmLog = [] + ): void { + // 성공은 act_state=3 + $this->upsertOutAccountState($memNo, $bankCode, $account, $depositor, '3', $confirmLog); + } + + private function fail(string $code, string $message): array + { + return [ + 'ok' => false, + 'code' => $code, + 'message' => $message, + ]; + } +} diff --git a/app/Services/FindPasswordService.php b/app/Services/FindPasswordService.php index 09af488..c6540b6 100644 --- a/app/Services/FindPasswordService.php +++ b/app/Services/FindPasswordService.php @@ -229,9 +229,11 @@ class FindPasswordService $this->members->logPasswordResetSuccess( $memNo, (string) $request->ip(), - (string) $request->userAgent() + (string) $request->userAgent(), + 'E' ); + // 6) 세션 정리 $request->session()->forget('find_pw'); $request->session()->save(); diff --git a/app/Services/MypageInfoService.php b/app/Services/MypageInfoService.php index ad39825..0d48f73 100644 --- a/app/Services/MypageInfoService.php +++ b/app/Services/MypageInfoService.php @@ -50,19 +50,19 @@ final class MypageInfoService // 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에 존재하면 변경 불가 - // 요구사항: "존재한다면 이전에 가입된 전화번호가 있습니다. 관리자 문의" + // PASS 전화번호가 DB에 존재하면 변경 불가 + // "존재한다면 이전에 가입된 전화번호가 있습니다. 관리자 문의" if ($this->repo->existsEncryptedCell($encPassPhone, $memNo)) { return $this->fail('이미 가입된 휴대폰 번호가 있습니다. 관리자에게 문의해 주세요.'); } - // (3) CI가 회원정보 mem_info.ci와 일치해야 통과 + // CI가 회원정보 mem_info.ci와 일치해야 통과 $memberCi = $this->repo->getMemberCi($memNo); if ($memberCi === '' || !hash_equals($memberCi, $ci)) { return $this->fail('가입된 회원정보와 일치하지 않습니다. 관리자에게 문의해 주세요.'); @@ -80,8 +80,6 @@ final class MypageInfoService /** * 최종 저장: mem_info.cell 업데이트 - * - 컨트롤러는 DB 처리 안 한다고 했으니, /mypage/info 저장 버튼에서 이 메서드만 호출하면 됨. - * * @return array ['ok'=>bool, 'message'=>string, '_cell'=>string] */ public function commitPhoneChange(int $memNo, array $passPayload): array diff --git a/config/bank_code.php b/config/bank_code.php new file mode 100644 index 0000000..ba0c9bf --- /dev/null +++ b/config/bank_code.php @@ -0,0 +1,214 @@ + list + * - Grouped for UI rendering + flat for fast lookup + * + * Usage: + * - name by code: config('bank_code.flat.004') // "KB국민은행" + * - groups: config('bank_code.groups.bank_1st') + */ + +return [ + + // ✅ UI용 그룹 (원하는 순서대로 렌더링) + 'groups' => [ + 'bank_1st' => [ + 'label' => '메이저 1금융권', + 'items' => [ + '001' => '한국은행', + '002' => '산업은행', + '003' => '기업은행', + '004' => 'KB국민은행', + '011' => 'NH농협은행', + '020' => '우리은행', + '023' => 'SC제일은행', + '027' => '한국씨티은행', + '031' => '대구은행', + '032' => '부산은행', + '034' => '광주은행', + '035' => '제주은행', + '037' => '전북은행', + '039' => '경남은행', + '071' => '우체국', + '081' => '하나은행', + '088' => '신한은행', + '089' => '케이뱅크', + '090' => '카카오뱅크', + '007' => '수협은행', + '008' => '수출입은행', + ], + ], + + 'bank_2nd' => [ + 'label' => '2금융권/협동조합/서민금융', + 'items' => [ + '012' => '농․축협', + '045' => '새마을금고', + '048' => '신협', + '050' => '저축은행', + '064' => '산림조합중앙회', + '102' => '대신저축은행', + '103' => '에스비아이저축은행', + '104' => '에이치케이저축은행', + '105' => '웰컴저축은행', + '106' => '신한저축은행', + ], + ], + + 'global' => [ + 'label' => '글로벌/외국계 은행', + 'items' => [ + '052' => '모건스탠리은행', + '054' => 'HSBC은행', + '055' => '도이치은행', + '057' => '제이피모간체이스은행', + '058' => '미즈호은행', + '059' => '엠유에프지은행', + '060' => 'BOA은행', + '061' => '비엔피파리바은행', + '062' => '중국공상은행', + '063' => '중국은행', + '065' => '대화은행', + '066' => '교통은행', + '067' => '중국건설은행', + ], + ], + + 'securities' => [ + 'label' => '증권사', + 'items' => [ + '209' => '유안타증권', + '218' => 'KB증권', + '221' => '상상인증권', + '222' => '한양증권', + '223' => '리딩투자증권', + '224' => 'BNK투자증권', + '225' => 'IBK투자증권', + '227' => 'KTB투자증권', + '238' => '미래에셋대우', + '240' => '삼성증권', + '243' => '한국투자증권', + '247' => 'NH투자증권', + '261' => '교보증권', + '262' => '하이투자증권', + '263' => '현대차증권', + '264' => '키움증권', + '265' => '이베스트투자증권', + '266' => 'SK증권', + '267' => '대신증권', + '269' => '한화투자증권', + '270' => '하나금융투자', + '278' => '신한금융투자', + '279' => 'DB금융투자', + '280' => '유진투자증권', + '287' => '메리츠증권', + '288' => '카카오페이증권', + '290' => '부국증권', + '291' => '신영증권', + '292' => '케이프투자증권', + '293' => '한국증권금융', + '294' => '한국포스증권', + ], + ], + + // 필요하면 UI에서 숨겨도 됨 (flat에는 유지) + 'others' => [ + 'label' => '기타/유관기관', + 'items' => [ + '041' => '우리카드', + '044' => '외환카드', + '076' => '신용보증기금', + '077' => '기술보증기금', + '101' => '한국신용정보원', + ], + ], + ], + + /** + * ✅ 코드→이름 빠른 조회용 flat 맵 + */ + 'flat' => [ + '001' => '한국은행', + '002' => '산업은행', + '003' => '기업은행', + '004' => 'KB국민은행', + '007' => '수협은행', + '008' => '수출입은행', + '011' => 'NH농협은행', + '012' => '농․축협', + '020' => '우리은행', + '023' => 'SC제일은행', + '027' => '한국씨티은행', + '031' => '대구은행', + '032' => '부산은행', + '034' => '광주은행', + '035' => '제주은행', + '037' => '전북은행', + '039' => '경남은행', + '041' => '우리카드', + '044' => '외환카드', + '045' => '새마을금고', + '048' => '신협', + '050' => '저축은행', + '052' => '모건스탠리은행', + '054' => 'HSBC은행', + '055' => '도이치은행', + '057' => '제이피모간체이스은행', + '058' => '미즈호은행', + '059' => '엠유에프지은행', + '060' => 'BOA은행', + '061' => '비엔피파리바은행', + '062' => '중국공상은행', + '063' => '중국은행', + '064' => '산림조합중앙회', + '065' => '대화은행', + '066' => '교통은행', + '067' => '중국건설은행', + '071' => '우체국', + '076' => '신용보증기금', + '077' => '기술보증기금', + '081' => '하나은행', + '088' => '신한은행', + '089' => '케이뱅크', + '090' => '카카오뱅크', + '101' => '한국신용정보원', + '102' => '대신저축은행', + '103' => '에스비아이저축은행', + '104' => '에이치케이저축은행', + '105' => '웰컴저축은행', + '106' => '신한저축은행', + '209' => '유안타증권', + '218' => 'KB증권', + '221' => '상상인증권', + '222' => '한양증권', + '223' => '리딩투자증권', + '224' => 'BNK투자증권', + '225' => 'IBK투자증권', + '227' => 'KTB투자증권', + '238' => '미래에셋대우', + '240' => '삼성증권', + '243' => '한국투자증권', + '247' => 'NH투자증권', + '261' => '교보증권', + '262' => '하이투자증권', + '263' => '현대차증권', + '264' => '키움증권', + '265' => '이베스트투자증권', + '266' => 'SK증권', + '267' => '대신증권', + '269' => '한화투자증권', + '270' => '하나금융투자', + '278' => '신한금융투자', + '279' => 'DB금융투자', + '280' => '유진투자증권', + '287' => '메리츠증권', + '288' => '카카오페이증권', + '290' => '부국증권', + '291' => '신영증권', + '292' => '케이프투자증권', + '293' => '한국증권금융', + '294' => '한국포스증권', + ], +]; diff --git a/config/services.php b/config/services.php index 25365aa..dfd3632 100644 --- a/config/services.php +++ b/config/services.php @@ -41,4 +41,13 @@ return [ 'min_score' => (float) env('RECAPTCHA_MIN_SCORE', 0.5), ], + 'dozn' => [ + 'mode' => env('DOZN_MODE', 'real'), // real|test + 'org_code' => env('DOZN_ORG_CODE', ''), + 'api_key' => env('DOZN_API_KEY', ''), + 'url_real' => env('DOZN_URL_REAL', ''), + 'url_test' => env('DOZN_URL_TEST', ''), + 'timeout' => (int) env('DOZN_TIMEOUT', 10), + ], + ]; diff --git a/public/assets/js/mypage_renew.js b/public/assets/js/mypage_renew.js new file mode 100644 index 0000000..5214731 --- /dev/null +++ b/public/assets/js/mypage_renew.js @@ -0,0 +1,963 @@ +/* public/assets/js/mypage_renew.js */ +(function () { + 'use strict'; + + const CFG = window.mypageRenew || {}; + const URLS = (CFG.urls || {}); + + const $ = (sel, root = document) => root.querySelector(sel); + + function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + } + + function isMobileUA() { + const ua = navigator.userAgent || ''; + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); + } + + // ----------------------------- + // 1) 재인증 타이머 (항상 실행) + // - view는 data-expire(unix ts), 기존은 data-until(ISO) + // - 둘 다 지원하도록 수정 + // ----------------------------- + (function reauthCountdown() { + const box = document.querySelector('.mypage-reauth--countdown'); + const out = document.getElementById('reauthCountdown'); + if (!box || !out) return; + + const untilStr = (box.getAttribute('data-until') || '').trim(); + const expireTs = parseInt((box.getAttribute('data-expire') || '0').trim(), 10); + const remainFallback = parseInt(box.getAttribute('data-remain') || '0', 10); + + function parseUntilMsFromISO(s) { + if (!s) return null; + const isoLike = s.includes('T') ? s : s.replace(' ', 'T'); + const ms = Date.parse(isoLike); + return Number.isFinite(ms) ? ms : null; + } + + let untilMs = parseUntilMsFromISO(untilStr); + + // ✅ data-expire가 있으면 우선 사용 (unix seconds) + if (!untilMs && Number.isFinite(expireTs) && expireTs > 0) { + untilMs = expireTs * 1000; + } + + let remain = Math.max(0, Number.isFinite(remainFallback) ? remainFallback : 0); + let timer = null; + let expiredOnce = false; + + function fmt(sec) { + sec = Math.max(0, sec | 0); + const m = Math.floor(sec / 60); + const s = sec % 60; + return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); + } + + function getRemainSec() { + if (untilMs !== null) { + const diffMs = untilMs - Date.now(); + return Math.max(0, Math.floor(diffMs / 1000)); + } + return Math.max(0, remain); + } + + async function onExpiredOnce() { + if (expiredOnce) return; + expiredOnce = true; + + await showMsg( + "인증 허용 시간이 만료되었습니다.\n\n보안을 위해 다시 재인증이 필요합니다.", + { type: 'alert', title: '인증 만료' } + ); + + window.location.href = URLS.gateReset || '/mypage/info'; + } + + function tick() { + const sec = getRemainSec(); + out.textContent = fmt(sec); + + if (sec <= 0) { + if (timer) clearInterval(timer); + onExpiredOnce(); + return; + } + if (untilMs === null) remain -= 1; + } + + tick(); + timer = setInterval(tick, 1000); + })(); + + // ------------------------------------------- + // 2) PASS 연락처 변경 (기존 로직 유지) + // ------------------------------------------- + (function phoneChange() { + const btn = document.querySelector('[data-action="phone-change"]'); + if (!btn) return; + + const readyUrl = btn.getAttribute('data-ready-url') || URLS.passReady; + if (!readyUrl) return; + + function openIframeModal(popupName = 'danal_authtel_popup', w = 420, h = 750) { + const old = document.getElementById(popupName); + if (old) old.remove(); + + const wrap = document.createElement('div'); + wrap.id = popupName; + + wrap.innerHTML = ` +
+ + `; + + document.body.appendChild(wrap); + + function removeModal() { + const el = document.getElementById(popupName); + if (el) el.remove(); + } + + async function askCancelAndGo() { + const ok = await showMsg( + "인증을 중단하시겠습니까?\n\n닫으면 현재 변경 진행이 취소됩니다.", + { + type: "confirm", + title: "인증취소", + closeOnBackdrop: false, + closeOnX: false, + closeOnEsc: false, + } + ); + + if (!ok) return; + + const ifr = document.getElementById(popupName + "_iframe"); + if (ifr) ifr.src = "about:blank"; + + removeModal(); + } + + const closeBtn = wrap.querySelector('#' + popupName + '_close'); + if (closeBtn) closeBtn.addEventListener('click', askCancelAndGo); + + window.closeIframe = function () { + removeModal(); + }; + + return popupName + '_iframe'; + } + + function postToIframe(url, targetName, fieldsObj) { + const temp = document.createElement('form'); + temp.method = 'POST'; + temp.action = url; + temp.target = targetName; + + const csrf = document.createElement('input'); + csrf.type = 'hidden'; + csrf.name = '_token'; + csrf.value = csrfToken(); + temp.appendChild(csrf); + + const fields = document.createElement('input'); + fields.type = 'hidden'; + fields.name = 'fields'; + fields.value = JSON.stringify(fieldsObj || {}); + temp.appendChild(fields); + + const plat = document.createElement('input'); + plat.type = 'hidden'; + plat.name = 'platform'; + plat.value = isMobileUA() ? 'mobile' : 'web'; + temp.appendChild(plat); + + document.body.appendChild(temp); + temp.submit(); + temp.remove(); + } + + window.addEventListener('message', async (ev) => { + const d = ev.data || {}; + if (d.type !== 'danal_result') return; + + if (typeof window.closeIframe === 'function') window.closeIframe(); + + await showMsg( + d.message || (d.ok ? '본인인증이 완료되었습니다.' : '본인인증에 실패했습니다.'), + { type: 'alert', title: d.ok ? '인증 완료' : '인증 실패' } + ); + + if (d.redirect) window.location.href = d.redirect; + }); + + btn.addEventListener('click', async () => { + const ok = await showMsg( + `연락처를 변경하시겠습니까? + +• PASS 본인인증은 가입자 본인 명의 휴대전화로만 가능합니다. +• 인증 정보가 기존 회원정보와 일치하지 않으면 변경할 수 없습니다. + +계속 진행할까요?`, + { type: 'confirm', title: '연락처 변경' } + ); + if (!ok) return; + + btn.disabled = true; + + try { + const res = await fetch(readyUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ purpose: 'mypage_phone_change' }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok || data.ok === false) { + await showMsg(data.message || '본인인증 준비에 실패했습니다.', { type: 'alert', title: '오류' }); + return; + } + + if (data.reason === 'danal_ready' && data.popup && data.popup.url) { + const targetName = openIframeModal('danal_authtel_popup', 420, 750); + postToIframe(data.popup.url, targetName, data.popup.fields || {}); + return; + } + + await showMsg('ready 응답이 올바르지 않습니다. 서버 응답 형식을 확인해 주세요.', { type: 'alert', title: '오류' }); + } catch (e) { + await showMsg('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' }); + } finally { + btn.disabled = false; + } + }); + })(); + + // ------------------------------------------- + // 공통: 모달 스타일/유틸 + // ------------------------------------------- + function ensureCommonModalStyle(styleId, cssText) { + if (document.getElementById(styleId)) return; + const st = document.createElement('style'); + st.id = styleId; + st.textContent = cssText; + document.head.appendChild(st); + } + + function makeErrorSetter(wrap, errSel) { + const errBox = $(errSel, wrap); + return function setError(message) { + if (!errBox) return; + const msg = (message || '').trim(); + if (msg === '') { + errBox.style.display = 'none'; + errBox.textContent = ''; + return; + } + errBox.textContent = msg; + errBox.style.display = 'block'; + }; + } + + // ------------------------------------------- + // 3) 비밀번호 변경 레이어 팝업 (기존 유지 + 내부 에러) + // ------------------------------------------- + (function passwordChange() { + const trigger = document.querySelector('[data-action="pw-change"]'); + if (!trigger) return; + + const postUrl = URLS.passwordUpdate; + if (!postUrl) { + trigger.addEventListener('click', async () => { + await showMsg('비밀번호 변경 URL이 설정되지 않았습니다.', { type: 'alert', title: '오류' }); + }); + return; + } + + ensureCommonModalStyle('mypagePwModalStyle', ` + .mypage-pwmodal{position:fixed;inset:0;z-index:220000;display:flex;align-items:center;justify-content:center} + .mypage-pwmodal__dim{position:absolute;inset:0;background:#000;opacity:.55} + .mypage-pwmodal__box{position:relative;width:min(420px,calc(100% - 28px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35)} + .mypage-pwmodal__hd{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(0,0,0,.04);border-bottom:1px solid rgba(0,0,0,.08)} + .mypage-pwmodal__ttl{font-weight:900;font-size:14px;color:#111} + .mypage-pwmodal__close{width:34px;height:34px;border-radius:10px;border:1px solid rgba(0,0,0,.12);background:#fff;cursor:pointer;font-size:18px;line-height:1;color:#111} + .mypage-pwmodal__bd{padding:14px} + .mypage-pwmodal__row{margin-top:10px} + .mypage-pwmodal__label{display:block;font-size:12px;font-weight:800;color:#667085;margin-bottom:6px} + .mypage-pwmodal__inp{width:100%;height:42px;border-radius:12px;border:1px solid #e5e7eb;padding:0 12px;font-size:14px;outline:none} + .mypage-pwmodal__hint{margin-top:10px;font-size:12px;color:#667085;line-height:1.4} + .mypage-pwmodal__error{margin-top:12px;padding:10px 12px;border-radius:12px;background:rgba(220,38,38,.08);border:1px solid rgba(220,38,38,.25);color:#b91c1c;font-weight:800;font-size:12px;display:none;white-space:pre-line} + .mypage-pwmodal__ft{display:flex;gap:10px;padding:12px 14px;border-top:1px solid rgba(0,0,0,.08);background:#fff} + .mypage-pwmodal__btn{flex:1;height:42px;border-radius:12px;border:1px solid rgba(0,0,0,.12);background:#fff;font-weight:900;cursor:pointer} + .mypage-pwmodal__btn--primary{border:none;background:#111;color:#fff} + .mypage-pwmodal__btn[disabled]{opacity:.6;cursor:not-allowed} + `); + + function validateNewPw(pw) { + if (!pw) return '비밀번호를 입력해 주세요.'; + if (pw.length < 8) return '비밀번호는 8자리 이상이어야 합니다.'; + if (pw.length > 20) return '비밀번호는 20자리를 초과할 수 없습니다.'; + const re = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,20}$/; + if (!re.test(pw)) return '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.'; + return ''; + } + + function openModal() { + const old = $('#mypagePwModal'); + if (old) old.remove(); + + const wrap = document.createElement('div'); + wrap.className = 'mypage-pwmodal'; + wrap.id = 'mypagePwModal'; + + wrap.innerHTML = ` + +