diff --git a/README.md b/README.md index 0165a77..613aaa4 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,65 @@ -
+# PIN FOR YOU (Gifticon Platform) - +PIN FOR YOU는 **상품권/모바일 교환권(기프티콘) 거래/구매**를 위한 웹 서비스입니다. +보안(핀번호/민감정보), 결제 연동, 회원 인증, 운영 안정성을 최우선으로 설계합니다. -## About Laravel +--- -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +## 문서(Documentation) -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +- 보안 + - [Google reCAPTCHA v3 적용 가이드](docs/security/recaptcha.md) + - [SMS 발송/인증 가이드](docs/security/sms.md) -Laravel is accessible, powerful, and provides tools required for large, robust applications. +> 새로운 설명 문서는 `docs/` 아래에 계속 추가합니다. +> 예: `docs/deploy/`, `docs/dev/`, `docs/ops/` 등 -## Learning Laravel +--- -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. +## 기술 스택(Tech Stack) -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +- Backend: **PHP / Laravel 12** +- DB: **MySQL 또는 MariaDB** +- Cache/Queue: Redis (선택/구성에 따라) +- Web: Nginx (또는 Apache) + PHP-FPM +- Frontend: Vite 기반 리소스 빌드 (`@vite`) -## Laravel Sponsors +--- -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). +## 서버/환경 정보(Environment) -### Premium Partners +> 아래는 운영/개발 환경에 맞게 값만 채워주세요. -- **[Vehikl](https://vehikl.com)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development)** -- **[Active Logic](https://activelogic.com)** +- Service Domain: `https://four.syye.net` +- Admin Domain: (예: `https://shot.syye.net`) +- PHP: `8.x` +- Laravel: `12.x` +- DB: `MySQL 8.x` 또는 `MariaDB 10.x/11.x` +- Redis: `x.x` (사용 시) +- OS: Ubuntu `xx.xx` / Docker 사용 여부: `Yes/No` -## Contributing +--- -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +## 레포지토리 구조(요약) -## Code of Conduct +- `app/` : Laravel 앱 코드 +- `resources/` : Blade / CSS / JS 소스 +- `routes/` : 라우팅 정의 +- `public/` : 정적 파일, 빌드 산출물 +- `docs/` : 운영/개발/보안 문서 (지속 확장) + - `docs/security/` : 보안 관련 문서 -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +--- -## Security Vulnerabilities +## 실행/개발(Development) -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +### 1) 필수 준비물 +- PHP / Composer +- Node.js / npm (Vite 빌드) +- MySQL/MariaDB +- (선택) Redis -## License - -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +### 2) 설치 +```bash +composer install +npm install diff --git a/app/Http/Controllers/Web/Auth/RegisterController.php b/app/Http/Controllers/Web/Auth/RegisterController.php new file mode 100644 index 0000000..de35131 --- /dev/null +++ b/app/Http/Controllers/Web/Auth/RegisterController.php @@ -0,0 +1,72 @@ +session()->get('signup.step') ?? 0) < 1) { + return redirect()->route('web.auth.register'); + } + + return view('web.auth.register_terms'); + } + + + public function postPhoneCheck(Request $request, MemberAuthRepository $repo) + { + $v = Validator::make($request->all(), [ + 'phone' => ['required', 'string', 'max:20'], + 'g-recaptcha-response' => ['required', new RecaptchaV3Rule('register_phone_check')], + ], [ + 'phone.required' => '휴대폰 번호를 입력해 주세요.', + 'g-recaptcha-response.required' => '보안 검증에 실패했습니다. 다시 시도해 주세요.', + ]); + + if ($v->fails()) { + return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); + } + + $ip4 = $request->ip() ?: ''; + $result = $repo->step0PhoneCheck((string)$request->input('phone'), $ip4); + + if (!$result['ok']) { + $status = ($result['reason'] ?? '') === 'blocked' ? 403 : 422; + return response()->json(['ok' => false, 'message' => $result['message'] ?? '처리 실패'], $status); + } + + if (($result['reason'] ?? '') === 'already_member') { + return response()->json([ + 'ok' => true, + 'reason' => 'already_member', + 'redirect' => route('web.auth.find_id'), + ]); + } + + $request->session()->put('signup.phone', $result['phone']); + $request->session()->put('signup.step', 1); + $request->session()->put('signup.ip4', $ip4); + $request->session()->put('signup.ip4_c', $repo->ipToCClass($ip4)); + $request->session()->put('signup.checked_at', now()->toDateTimeString()); + + return response()->json([ + 'ok' => true, + 'reason' => 'ok', + 'redirect' => route('web.auth.register.terms'), + ]); + } + +} diff --git a/app/Models/Member/Concerns/HasNoTimestamps.php b/app/Models/Member/Concerns/HasNoTimestamps.php deleted file mode 100644 index 0a0bb12..0000000 --- a/app/Models/Member/Concerns/HasNoTimestamps.php +++ /dev/null @@ -1,8 +0,0 @@ -setStateWithLog($memNo, $authType, MemAuth::STATE_N, MemAuthLog::STATE_F, $logInfo); } - /** - * mem_auth_info.auth_info JSON에 타입별로 병합 저장 - * - 예: ["email" => [...], "cell" => [...]] - */ public function mergeAuthInfo(int $memNo, string $authType, array $payload): void { DB::transaction(function () use ($memNo, $authType, $payload) { - /** @var MemAuthInfo $row */ $row = MemAuthInfo::query()->find($memNo); if (!$row) { @@ -72,9 +70,6 @@ class MemberAuthRepository }); } - /** - * mem_auth 상태 변경 + mem_auth_log 기록을 한 트랜잭션으로 - */ private function setStateWithLog( int $memNo, string $authType, @@ -107,4 +102,156 @@ class MemberAuthRepository { return $this->getState($memNo, $authType) === MemAuth::STATE_Y; } + + /* ========================================================= + * Step0: phone check + join_filter + join_log + * ========================================================= */ + + public function normalizeKoreanPhone(string $raw): ?string + { + $digits = preg_replace('/\D+/', '', $raw ?? ''); + if (!$digits) return null; + + // 82 국제형 → 0 시작으로 변환 + if (str_starts_with($digits, '82')) { + $digits = '0' . substr($digits, 2); + } + + // 010/011/016/017/018/019 + 10~11자리 + if (!preg_match('/^01[016789]\d{7,8}$/', $digits)) { + return null; + } + + return $digits; + } + + public function ipToCClass(string $ip): string + { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return ''; + } + $p = explode('.', $ip); + return count($p) === 4 ? ($p[0] . '.' . $p[1] . '.' . $p[2] . '.0') : ''; + } + + public function isAlreadyMemberByPhone(string $phone): bool + { + return MemInfo::query() + ->where('cell_phone', $phone) + ->where('dt_out', '0000-00-00 00:00:00') + ->exists(); + } + + /** + * filter 컬럼에 phone/ip/ip_c가 들어있다는 전제의 기본 구현. + * - join_block: A 차단 / S 주의(알림) / N 비활성 + */ + public function checkJoinFilter(string $phone, string $ip4 = '', string $ip4c = ''): ?array + { + $targets = array_values(array_filter([$phone, $ip4, $ip4c])); + if (!$targets) return null; + + $rows = MemJoinFilter::query() + ->whereIn('filter', $targets) + ->where(function ($q) { + $q->whereNull('join_block')->orWhere('join_block', '!=', 'N'); + }) + ->orderByDesc('seq') + ->get(); + + if ($rows->isEmpty()) return null; + + foreach ($rows as $r) { + if ((string)$r->join_block === 'A') { + return ['hit' => true, 'block' => true, 'gubun' => $r->gubun ?? 'filter_block', 'row' => $r]; + } + } + foreach ($rows as $r) { + if ((string)$r->join_block === 'S') { + return ['hit' => true, 'block' => false, 'gubun' => $r->gubun ?? 'filter_notice', 'row' => $r]; + } + } + + $r = $rows->first(); + return ['hit' => true, 'block' => false, 'gubun' => $r->gubun ?? 'filter_hit', 'row' => $r]; + } + + public function writeJoinLog(array $data): void + { + MemJoinLog::query()->create([ + 'gubun' => $data['gubun'] ?? null, + 'mem_no' => (int)($data['mem_no'] ?? 0), + 'cell_corp' => $data['cell_corp'] ?? 'n', + 'cell_phone' => $data['cell_phone'] ?? '', + 'email' => $data['email'] ?? null, + 'ip4' => $data['ip4'] ?? '', + 'ip4_c' => $data['ip4_c'] ?? '', + 'error_code' => $data['error_code'] ?? '', + 'dt_reg' => Carbon::now()->toDateTimeString(), + ]); + } + + /** + * Step0 통합 처리 + */ + public function step0PhoneCheck(string $rawPhone, string $ip4 = ''): array + { + $phone = $this->normalizeKoreanPhone($rawPhone); + if (!$phone) { + return [ + 'ok' => false, + 'reason' => 'invalid_phone', + 'message' => '휴대폰 번호 형식이 올바르지 않습니다.', + ]; + } + + $ip4c = $this->ipToCClass($ip4); + + // already member + if ($this->isAlreadyMemberByPhone($phone)) { + $this->writeJoinLog([ + 'gubun' => 'already_member', + 'mem_no' => 0, + 'cell_phone' => $phone, + 'ip4' => $ip4, + 'ip4_c' => $ip4c, + 'error_code' => 'J2', + ]); + + return ['ok' => true, 'reason' => 'already_member', 'phone' => $phone]; + } + + // join filter + $filter = $this->checkJoinFilter($phone, $ip4, $ip4c); + if ($filter && ($filter['block'] ?? false) === true) { + $this->writeJoinLog([ + 'gubun' => $filter['gubun'] ?? 'filter_block', + 'mem_no' => 0, + 'cell_phone' => $phone, + 'ip4' => $ip4, + 'ip4_c' => $ip4c, + 'error_code' => 'J1', + ]); + + return [ + 'ok' => false, + 'reason' => 'blocked', + 'phone' => $phone, + 'filter' => $filter, + 'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.', + ]; + } + + // pass + $this->writeJoinLog([ + 'gubun' => $filter['gubun'] ?? 'ok', + 'mem_no' => 0, + 'cell_phone' => $phone, + 'ip4' => $ip4, + 'ip4_c' => $ip4c, + 'error_code' => 'J0', + ]); + + return ['ok' => true, 'reason' => 'ok', 'phone' => $phone, 'filter' => $filter]; + } } diff --git a/app/Rules/RecaptchaV3Rule.php b/app/Rules/RecaptchaV3Rule.php new file mode 100644 index 0000000..b3cc1c9 --- /dev/null +++ b/app/Rules/RecaptchaV3Rule.php @@ -0,0 +1,62 @@ +environment(['local', 'development', 'staging'])) { + Log::channel('google_recaptcha')->info('[incoming]', [ + 'expected_action' => $this->action, + 'attribute' => $attribute, + 'token_len' => strlen($token), + 'ip' => request()->ip(), + 'path' => request()->path(), + ]); + } + + if ($token === '') { + $fail('보안 검증에 실패했습니다. 다시 시도해 주세요.'); + return; + } + + $svc = app(RecaptchaV3::class); + $data = $svc->verify($token, $this->action, request()->ip()); + + if (app()->environment(['local', 'development', 'staging'])) { + Log::channel('google_recaptcha')->info('[response]', [ + 'expected_action' => $this->action, + 'success' => $data['success'] ?? null, + 'score' => $data['score'] ?? null, + 'action' => $data['action'] ?? null, + 'hostname' => $data['hostname'] ?? null, + 'error_codes' => $data['error-codes'] ?? null, + ]); + } + + if (!$svc->isPass($data, $this->action)) { + if (app()->environment(['local', 'development', 'staging'])) { + Log::channel('google_recaptcha')->warning('[failed]', [ + 'expected_action' => $this->action, + 'got_action' => $data['action'] ?? null, + 'score' => $data['score'] ?? null, + 'success' => $data['success'] ?? null, + 'error_codes' => $data['error-codes'] ?? null, + ]); + } + + $fail('보안 검증에 실패했습니다. 다시 시도해 주세요.'); + } + } +} diff --git a/app/Services/RecaptchaV3.php b/app/Services/RecaptchaV3.php new file mode 100644 index 0000000..dda01a8 --- /dev/null +++ b/app/Services/RecaptchaV3.php @@ -0,0 +1,111 @@ +isDev()) { + Log::warning('[recaptcha] secret missing', [ + 'expected_action' => $expectedAction, + ]); + } + return [ + 'success' => false, + 'error-codes' => ['missing-input-secret'], + ]; + } + + try { + $resp = Http::asForm() + ->timeout(3) // 너무 길게 잡지 말기 + ->connectTimeout(2) + ->retry(1, 200) // 네트워크 순간 오류 대비(1회만) + ->post('https://www.google.com/recaptcha/api/siteverify', [ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $ip, + ]); + + $data = $resp->json(); + + if (!is_array($data)) { + if ($this->isDev()) { + Log::warning('[recaptcha] invalid json', [ + 'status' => $resp->status(), + 'body_snippet' => mb_substr((string)$resp->body(), 0, 200), + 'expected_action' => $expectedAction, + ]); + } + + return [ + 'success' => false, + 'error-codes' => ['invalid-json'], + ]; + } + + // (선택) 개발환경에서만 핵심만 로깅 (token/secret 절대 로그 금지) + if ($this->isDev()) { + Log::info('[recaptcha] verify ok', [ + 'expected_action' => $expectedAction, + 'success' => $data['success'] ?? null, + 'score' => $data['score'] ?? null, + 'action' => $data['action'] ?? null, + 'hostname' => $data['hostname'] ?? null, + 'error_codes' => $data['error-codes'] ?? null, + ]); + } + + return $data; + + } catch (Throwable $e) { + if ($this->isDev()) { + Log::error('[recaptcha] verify exception', [ + 'expected_action' => $expectedAction, + 'message' => $e->getMessage(), + ]); + } + + return [ + 'success' => false, + 'error-codes' => ['http-exception'], + ]; + } + } + + /** + * 정책 판단: success + action 일치 + score >= min_score + */ + public function isPass(array $data, string $expectedAction): bool + { + if (!($data['success'] ?? false)) return false; + + // action은 v3에서 꼭 맞춰주는 게 좋음 + if (($data['action'] ?? '') !== $expectedAction) return false; + + $min = (float) config('services.recaptcha.min_score', 0.5); + $score = (float) ($data['score'] ?? 0); + + return $score >= $min; + } + + private function isDev(): bool + { + // 네 환경명에 맞춰 추가 가능: staging 등 + return app()->environment(['local', 'development', 'staging']); + } +} diff --git a/config/logging.php b/config/logging.php index 9e998a4..e3cc611 100644 --- a/config/logging.php +++ b/config/logging.php @@ -127,6 +127,14 @@ return [ 'path' => storage_path('logs/laravel.log'), ], + 'google_recaptcha' => [ + 'driver' => 'single', + 'path' => storage_path('logs/google_recaptcha.log'), + 'level' => env('RECAPTCHA_LOG_LEVEL', 'info'), + 'replace_placeholders' => true, + ], + + ], ]; diff --git a/config/services.php b/config/services.php index 6a90eb8..25365aa 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,10 @@ return [ ], ], + 'recaptcha' => [ + 'site_key' => env('RECAPTCHA_SITE_KEY'), + 'secret' => env('RECAPTCHA_SECRET_KEY'), + 'min_score' => (float) env('RECAPTCHA_MIN_SCORE', 0.5), + ], + ]; diff --git a/docs/security/recaptcha.md b/docs/security/recaptcha.md new file mode 100644 index 0000000..b972cba --- /dev/null +++ b/docs/security/recaptcha.md @@ -0,0 +1,238 @@ +# Google reCAPTCHA v3 적용 메뉴얼 (Laravel 12) + +이 문서는 **PIN FOR YOU / gifticon-platform**에서 Google reCAPTCHA v3(Score 기반)를 **공통 컴포넌트 + 공통 JS + 서버 검증 Rule**로 적용/운영하는 기준을 정리합니다. + +--- + +## 0. 목표 + +- reCAPTCHA v3는 **사용자 화면에 체크박스가 나타나지 않습니다**. (백그라운드 토큰+점수) +- 폼/요청마다 **action**을 지정하고 서버에서 **action 일치 + score 기준**으로 판정합니다. +- 디버깅은 `storage/logs/google_recaptcha.log` 전용 로그로 확인합니다. + +--- + +## 1. Google 콘솔 설정 (1회) + +1. Google reCAPTCHA Admin Console에서 **v3**로 사이트 등록 +2. 도메인 등록 (예: `four.syye.net`, `super.pinforyou.com` 등) +3. **Site Key / Secret Key** 발급 + +--- + +## 2. 환경변수/설정 (1회) + +### 2.1 `.env` + +```env +RECAPTCHA_SITE_KEY=xxxx +RECAPTCHA_SECRET_KEY=yyyy +RECAPTCHA_MIN_SCORE=0.5 +RECAPTCHA_LOG_LEVEL=info +``` + +### 2.2 `config/services.php` + +```php +'recaptcha' => [ + 'site_key' => env('RECAPTCHA_SITE_KEY'), + 'secret' => env('RECAPTCHA_SECRET_KEY'), + 'min_score' => (float) env('RECAPTCHA_MIN_SCORE', 0.5), +], +``` + +--- + +## 3. 로깅 분리 (1회) + +### 3.1 `config/logging.php` 채널 추가 + +`channels` 배열에 아래를 추가: + +```php +'google_recaptcha' => [ + 'driver' => 'single', + 'path' => storage_path('logs/google_recaptcha.log'), + 'level' => env('RECAPTCHA_LOG_LEVEL', 'info'), + 'replace_placeholders' => true, +], +``` + +로그 파일 위치: + +- `storage/logs/google_recaptcha.log` + +--- + +## 4. 공통 파일 (프로젝트 공통) + +### 4.1 Layout에 스택 추가 + +`resources/views/.../layout.blade.php` (프로젝트 전체 레이아웃)에서 `