구글캡챠v3 작업 및 readme 수정

This commit is contained in:
sungro815 2026-01-19 14:45:08 +09:00
parent 86424023c8
commit cd9c2bb1f7
31 changed files with 1140 additions and 132 deletions

View File

@ -1,59 +1,65 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# PIN FOR YOU (Gifticon Platform)
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
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

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Web\Auth;
use App\Http\Controllers\Controller;
use App\Repositories\Member\MemberAuthRepository;
use App\Rules\RecaptchaV3Rule;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
{
public function showStep0()
{
return view('web.auth.register');
}
public function showTerms(Request $request)
{
// Step0 스킵 방지 (최소)
if (($request->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'),
]);
}
}

View File

@ -1,8 +0,0 @@
<?php
namespace App\Models\Member\Concerns;
trait HasNoTimestamps
{
public $timestamps = false;
}

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemAddress extends Model
{
use HasNoTimestamps;
protected $table = 'mem_address';
protected $primaryKey = 'seq';

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemAuth extends Model
{
use HasNoTimestamps;
protected $table = 'mem_auth';

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemAuthInfo extends Model
{
use HasNoTimestamps;
protected $table = 'mem_auth_info';
protected $primaryKey = 'mem_no';

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemAuthLog extends Model
{
use HasNoTimestamps;
protected $table = 'mem_auth_log';
protected $primaryKey = 'seq';

View File

@ -5,11 +5,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemInfo extends Model
{
use HasNoTimestamps;
//
protected $table = 'mem_info';
protected $primaryKey = 'mem_no';

View File

@ -3,11 +3,11 @@
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemJoinFilter extends Model
{
use HasNoTimestamps;
protected $table = 'mem_join_filter';
protected $primaryKey = 'seq';

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemJoinLog extends Model
{
use HasNoTimestamps;
protected $table = 'mem_join_log';
protected $primaryKey = 'seq';

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemLoginRecent extends Model
{
use HasNoTimestamps;
protected $table = 'mem_login_recent';
protected $primaryKey = 'seq';

View File

@ -3,7 +3,7 @@
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use App\Models\Member\Concerns\HasNoTimestamps;
/**
* 연도별 테이블(mem_login_2026 ) 런타임에 붙이는 모델.
@ -11,7 +11,7 @@ use App\Models\Member\Concerns\HasNoTimestamps;
*/
class MemLoginYear extends Model
{
use HasNoTimestamps;
protected $primaryKey = 'seq';
public $incrementing = true;

View File

@ -3,11 +3,11 @@
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemModLog extends Model
{
use HasNoTimestamps;
protected $table = 'mem_mod_log';
protected $primaryKey = 'seq';

View File

@ -3,11 +3,11 @@
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemPasswdModify extends Model
{
use HasNoTimestamps;
protected $table = 'mem_passwd_modify';
protected $primaryKey = 'seq';

View File

@ -4,11 +4,11 @@ namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Member\Concerns\HasNoTimestamps;
class MemStRing extends Model
{
use HasNoTimestamps;
protected $table = 'mem_st_ring';
protected $primaryKey = 'mem_no';

View File

@ -5,15 +5,18 @@ namespace App\Repositories\Member;
use App\Models\Member\MemAuth;
use App\Models\Member\MemAuthInfo;
use App\Models\Member\MemAuthLog;
use App\Models\Member\MemInfo;
use App\Models\Member\MemJoinFilter;
use App\Models\Member\MemJoinLog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class MemberAuthRepository
{
/**
* mem_auth (mem_no, auth_type) 업서트
* - auth_date는 date 컬럼: Y-m-d
*/
/* =========================================================
* mem_auth (기존)
* ========================================================= */
public function upsertState(
int $memNo,
string $authType,
@ -48,14 +51,9 @@ class MemberAuthRepository
$this->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];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Rules;
use App\Services\RecaptchaV3;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class RecaptchaV3Rule implements ValidationRule
{
public function __construct(private string $action) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$token = (string) $value;
// ✅ 개발환경에서만 + 전용 로그파일
if (app()->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('보안 검증에 실패했습니다. 다시 시도해 주세요.');
}
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class RecaptchaV3
{
/**
* Google reCAPTCHA v3 server-side verify
*
* @return array Google response array
*/
public function verify(string $token, string $expectedAction, ?string $ip = null): array
{
$secret = (string) config('services.recaptcha.secret');
// secret 없으면 무조건 실패(운영 안전)
if ($secret === '') {
if ($this->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']);
}
}

View File

@ -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,
],
],
];

View File

@ -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),
],
];

238
docs/security/recaptcha.md Normal file
View File

@ -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` (프로젝트 전체 레이아웃)에서 `</body>` 직전에 추가:
```blade
@stack('recaptcha')
@stack('scripts')
```
### 4.2 Blade 컴포넌트 (hidden input만)
`resources/views/components/recaptcha-v3.blade.php`
```blade
<input type="hidden" name="g-recaptcha-response" value="">
```
### 4.3 공통 JS 함수
`public/assets/js/recaptcha-v3.js`
```js
(function () {
function ensureReady() {
return new Promise((resolve, reject) => {
if (!window.grecaptcha) return reject(new Error('grecaptcha not loaded'));
window.grecaptcha.ready(resolve);
});
}
window.recaptchaV3Token = async function (action, formEl) {
if (!window.__recaptchaSiteKey) throw new Error('__recaptchaSiteKey missing');
await ensureReady();
const token = await window.grecaptcha.execute(window.__recaptchaSiteKey, { action });
if (formEl) {
const input = formEl.querySelector('input[name="g-recaptcha-response"]');
if (input) input.value = token;
}
return token;
};
})();
```
---
## 5. 서버 검증 (필수)
### 5.1 Service
`app/Services/RecaptchaV3.php`
- Google `siteverify` 호출
- 정책 판단: `success` + `action` 일치 + `score >= min_score`
> (구현은 `app/Services/RecaptchaV3.php` 파일 참고)
### 5.2 Rule
`app/Rules/RecaptchaV3Rule.php`
- Validator에서 사용
- 개발환경에서만 전용 로그 채널로 기록
---
## 6. 페이지 적용 방법 (폼마다)
### 6.1 Blade 폼 안에 hidden input 추가
```blade
<form id="someForm" onsubmit="return false;">
@csrf
<x-recaptcha-v3 />
...
</form>
```
### 6.2 해당 페이지에서만 로더 로드 (권장)
페이지 하단에 추가:
```blade
@push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
@endpush
```
> 폼 페이지가 많아지면 `web.layouts.auth` 같은 **폼 전용 레이아웃**에서 위 push를 공통화하면 더 편합니다.
### 6.3 fetch/AJAX 요청에 토큰 포함
```js
const token = await window.recaptchaV3Token('auth_register_phone_check', form);
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({
...payload,
'g-recaptcha-response': token,
}),
});
```
### 6.4 서버 Validator에 Rule 추가
```php
use App\Rules\RecaptchaV3Rule;
Validator::make($request->all(), [
'g-recaptcha-response' => ['required', new RecaptchaV3Rule('auth_register_phone_check')],
]);
```
**주의:** 프론트 `action` 문자열과 서버 Rule의 `action`이 **완전히 동일**해야 통과합니다.
---
## 7. Action 네이밍 규칙 (권장)
일관된 규칙으로 운영하면 로그 분석/정책 튜닝이 쉬워집니다.
- `auth_register_phone_check`
- `auth_register_terms`
- `auth_login`
- `auth_findpw_send`
- `auth_findpw_verify`
- `cs_qna_create`
---
## 8. 운영 튜닝
- `RECAPTCHA_MIN_SCORE`는 초기 `0.3~0.5` 권장
- score가 낮은 정상 사용자가 나오면 점수 기준을 낮추고, **RateLimiter/IP 제한**과 병행
---
## 9. 정상 동작 확인
### 9.1 브라우저 확인
Network 탭에서 요청 payload에 아래가 포함되어야 합니다.
- `phone`
- `g-recaptcha-response`
### 9.2 서버 로그 확인
`storage/logs/google_recaptcha.log` 예시:
- `success: true`
- `score: 0.x`
- `action` 일치
---
## 10. 실패 테스트(개발용)
프론트에서 action을 일부러 틀리게 발급:
```js
await window.recaptchaV3Token('auth_register_phone_check__FAIL_TEST', form);
```
서버에서 action mismatch로 실패해야 정상입니다.

189
docs/security/sms.md Normal file
View File

@ -0,0 +1,189 @@
# SMS 발송/인증 적용 메뉴얼 (Laravel)
이 문서는 **SMS 인증번호 발송/검증**을 프로젝트에서 일관되게 적용하기 위한 기준을 정리합니다.
> 목적: 자동 입력/도배 방지 + 인증 품질 향상 + 운영 중 추적 가능(로그/레이트리밋)
---
## 0. 권장 설계 개요
- **발송(send)** / **검증(verify)** API를 분리
- 휴대폰 번호는 서버에서 표준화(normalize) 후 저장/비교
- 과도한 시도 방지: **RateLimiter** + (선택) IP/기기 세션 제한
- 코드 저장: (권장) 캐시/DB 모두 가능
- 캐시(예: Redis) 사용 시 만료(TTL) 관리가 쉬움
- DB 저장은 감사/추적(감사로그)에 유리
---
## 1. 엔드포인트 권장 스펙
### 1.1 인증번호 발송
- `POST /auth/phone/send-code`
- request
- `phone` (string)
- `g-recaptcha-response` (string, v3 권장)
- response
- `{ ok: true }` 또는 `{ ok: false, message }`
### 1.2 인증번호 검증
- `POST /auth/phone/verify-code`
- request
- `phone`
- `code`
- `g-recaptcha-response`
- response
- `{ ok: true, redirect: ... }` 또는 `{ ok: false, message }`
---
## 2. 번호 표준화 (필수)
서버에서 입력값을 한국 휴대폰 숫자만으로 정규화:
- 입력: `010-1234-5678`, `010 1234 5678`, `+82 10-1234-5678`
- 저장/비교: `01012345678`
권장 함수 예시:
```php
private function normalizeKoreanPhone(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw);
if (!$digits) return null;
// +82 처리(예: 8210xxxxxxxx)
if (str_starts_with($digits, '82')) {
$digits = '0' . substr($digits, 2);
}
// 010/011/... 휴대폰 기준(프로젝트 정책에 맞게 조정)
if (!preg_match('/^01[016789]\d{7,8}$/', $digits)) {
return null;
}
// 최종 10~11자리
if (strlen($digits) < 10 || strlen($digits) > 11) return null;
return $digits;
}
```
---
## 3. RateLimiter 정책 (권장)
### 3.1 휴대폰 기준 발송 제한
예: 10분에 5회
- key: `sms:send:{phone}`
- limit: 5
- decay: 600초
### 3.2 IP 기준 제한(선택)
- key: `sms:sendip:{ip}`
### 3.3 코드 검증 시도 제한
예: 10분에 10회
- key: `sms:verify:{phone}`
---
## 4. 인증코드 생성/저장/만료
### 4.1 코드 생성
- 6자리 숫자
- 예: `random_int(100000, 999999)`
### 4.2 저장(권장: Cache/Redis)
- key: `sms:code:{phone}`
- value: 해시 저장 권장
- ttl: 180초~300초
예시:
```php
$code = (string) random_int(100000, 999999);
$hash = hash_hmac('sha256', $code, config('app.key'));
Cache::put("sms:code:{$phone}", [
'hash' => $hash,
'created_at' => now()->toDateTimeString(),
], now()->addMinutes(3));
```
검증 시:
```php
$stored = Cache::get("sms:code:{$phone}");
if (!$stored) { /* 만료 */ }
$hash = hash_hmac('sha256', $inputCode, config('app.key'));
if (!hash_equals($stored['hash'], $hash)) { /* 실패 */ }
Cache::forget("sms:code:{$phone}"); // 성공 시 1회용
```
---
## 5. SMS Provider 연동 위치
- 실제 발송은 `app/Services/SmsService.php` 같은 서비스 레이어로 분리 권장
- 컨트롤러는:
1) validate
2) rate limit
3) code 생성/저장
4) SmsService 호출
5) 응답 반환
---
## 6. 로깅/감사(권장)
- 민감정보는 최소화
- 전화번호는 마스킹 로그 권장: `010****5678`
- 인증코드 원문 로그 금지
권장 전용 로그 파일:
- `storage/logs/sms.log`
(logging channel을 별도 구성하면 recaptcha와 동일한 방식으로 분리 가능)
---
## 7. 보안 체크리스트
- [ ] reCAPTCHA(v3) 적용 (send/verify 모두)
- [ ] RateLimiter 적용
- [ ] 인증코드 TTL 적용
- [ ] 성공 시 1회용 처리(Cache forget)
- [ ] 실패 횟수 제한(verify)
- [ ] 동일 번호 반복 요청 시 UX 메시지(남은 시간 안내 등)
- [ ] 운영 로그 분리(필요 시)
---
## 8. API 응답 메시지 정책(권장)
공격자에게 힌트가 되지 않게, 실패 메시지는 통일:
- 발송 실패: `처리에 실패했습니다. 잠시 후 다시 시도해 주세요.`
- 검증 실패: `인증번호가 올바르지 않습니다.` (횟수 초과/만료는 별도 가능)
---
## 9. 운영 튜닝 포인트
- 발송 제한(10분 5회)은 초기 보수적으로 시작 → 운영 로그 보고 조정
- 통신사/지연으로 인증 도착이 늦어질 수 있으니 TTL 3~5분 권장

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -0,0 +1,22 @@
(function () {
function ensureReady() {
return new Promise((resolve, reject) => {
if (!window.grecaptcha) return reject(new Error('grecaptcha not loaded'));
window.grecaptcha.ready(resolve);
});
}
window.recaptchaV3Token = async function (action, formEl) {
if (!window.__recaptchaSiteKey) throw new Error('__recaptchaSiteKey missing');
await ensureReady();
const token = await window.grecaptcha.execute(window.__recaptchaSiteKey, { action });
// formEl이 있으면 hidden input 자동 세팅
if (formEl) {
const input = formEl.querySelector('input[name="g-recaptcha-response"]');
if (input) input.value = token;
}
return token;
};
})();

View File

@ -0,0 +1 @@
<input type="hidden" name="g-recaptcha-response" value="">

View File

@ -5,70 +5,189 @@
@section('canonical', url('/auth/register'))
@section('h1', '회원가입')
@section('desc', '간단한 정보 입력 후 본인인증을 진행합니다.')
@section('card_aria', '회원가입 ')
@section('desc', '휴대폰 번호 확인 후 본인인증을 진행합니다.')
@section('card_aria', '회원가입 Step0 - 휴대폰 확인')
@section('show_cs_links', true)
@section('auth_content')
<form class="auth-form" onsubmit="return false;">
<div class="auth-field">
<label class="auth-label" for="reg_email">
아이디(이메일) <small>로그인에 사용</small>
</label>
<input class="auth-input" id="reg_email" type="email"
placeholder="example@domain.com" autocomplete="email">
</div>
<div class="auth-field">
<label class="auth-label" for="reg_pw">
비밀번호 <small>영문/숫자/특수문자 권장</small>
</label>
<input class="auth-input" id="reg_pw" type="password"
placeholder="비밀번호" autocomplete="new-password">
{{-- Step0 Hero Image + 안내문구 --}}
<div class="reg-step0-hero" aria-hidden="true">
<img
class="reg-step0-hero__img"
src="{{ asset('assets/images/web/member/register_step0.webp') }}"
alt=""
loading="lazy"
onerror="this.style.display='none';"
/>
<div class="reg-step0-hero__text" aria-hidden="true">
<div class="reg-step0-hero__line">휴대폰 번호로 가입 여부를 먼저 확인한 ,</div>
<div class="reg-step0-hero__line">PASS 본인인증을 진행합니다.</div>
</div>
</div>
<div class="auth-field">
<label class="auth-label" for="reg_pw2">비밀번호 확인</label>
<input class="auth-input" id="reg_pw2" type="password"
placeholder="비밀번호 재입력" autocomplete="new-password">
</div>
<style>
.reg-step0-hero{
display:flex;
flex-direction:column;
align-items:center;
gap:10px;
margin: 6px 0 14px;
}
.reg-step0-hero__img{
width:100%;
max-width: 300px;
height:auto;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,.08);
background: #fff;
}
.reg-step0-hero__text{
text-align:center;
font-size: 13px;
line-height: 1.4;
color: rgba(0,0,0,.65);
}
.reg-step0-hero__line{ margin: 0; }
</style>
<form class="auth-form" id="regStep0Form" onsubmit="return false;">
@csrf
{{-- hidden input만 생성(토큰은 JS에서 발급 payload에 포함) --}}
<x-recaptcha-v3 />
<div class="auth-field">
<label class="auth-label" for="reg_phone">
휴대폰 번호 <small>본인 확인용</small>
휴대폰 번호 <small>가입 여부 확인</small>
</label>
<input class="auth-input" id="reg_phone" type="tel"
placeholder="010-0000-0000" autocomplete="tel">
<input class="auth-input" id="reg_phone" name="phone" type="tel"
placeholder="010-0000-0000"
autocomplete="tel"
inputmode="numeric"
maxlength="13">
<div class="auth-help" id="reg_phone_help" style="display:none;"></div>
</div>
<div class="auth-divider">약관 동의</div>
<label class="auth-check">
<input type="checkbox">
(필수) 이용약관 동의
</label>
<label class="auth-check">
<input type="checkbox">
(필수) 개인정보처리방침 동의
</label>
<label class="auth-check">
<input type="checkbox">
(선택) 마케팅 수신 동의
</label>
<div class="auth-actions">
<button class="auth-btn auth-btn--primary" type="submit">가입하기</button>
<button class="auth-btn auth-btn--primary" id="reg_next_btn" type="submit">다음</button>
<a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">
이미 계정이 있어요 (로그인)
</a>
</div>
</form>
{{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
@push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
@endpush
<script>
(function () {
const form = document.getElementById('regStep0Form');
const input = document.getElementById('reg_phone');
const help = document.getElementById('reg_phone_help');
const btn = document.getElementById('reg_next_btn');
function showMsg(msg) {
help.style.display = 'block';
help.textContent = msg;
}
function clearMsg() {
help.style.display = 'none';
help.textContent = '';
}
// 숫자만 남기고 010-0000-0000 형태로 포맷
function formatPhone(value) {
const digits = (value || '').replace(/\D/g, '').slice(0, 11);
if (digits.length <= 3) return digits;
if (digits.length <= 7) return digits.slice(0, 3) + '-' + digits.slice(3);
return digits.slice(0, 3) + '-' + digits.slice(3, 7) + '-' + digits.slice(7);
}
// 서버 전송용(숫자만)
function toDigits(value) {
return (value || '').replace(/\D/g, '');
}
input.addEventListener('input', function () {
const formatted = formatPhone(input.value);
if (input.value !== formatted) input.value = formatted;
});
input.addEventListener('keydown', function (e) {
const allowKeys = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End'];
if (allowKeys.includes(e.key)) return;
if (e.ctrlKey || e.metaKey) return;
if (!/^\d$/.test(e.key)) e.preventDefault();
});
input.addEventListener('paste', function (e) {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
const digits = text.replace(/\D/g, '');
input.value = formatPhone(digits);
});
form.addEventListener('submit', async function () {
clearMsg();
const phoneDigits = toDigits(input.value);
if (!phoneDigits) {
showMsg('휴대폰 번호를 입력해 주세요.');
input.focus();
return;
}
if (phoneDigits.length < 10) {
showMsg('휴대폰 번호를 끝까지 입력해 주세요.');
input.focus();
return;
}
btn.disabled = true;
try {
// ✅ 공통 함수로 토큰 발급 (한 줄)
const token = await window.recaptchaV3Token('register_phone_check', form);
const res = await fetch("{{ route('web.auth.register.phone_check') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": "{{ csrf_token() }}",
"Accept": "application/json"
},
body: JSON.stringify({
phone: phoneDigits,
"g-recaptcha-response": token
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
showMsg(data.message || '처리에 실패했습니다.');
return;
}
if (data.redirect) {
window.location.href = data.redirect;
return;
}
showMsg('처리에 실패했습니다.');
} catch (e) {
showMsg('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
} finally {
btn.disabled = false;
}
});
})();
</script>
@endsection
@section('auth_bottom')
{{-- 로그인 페이지처럼 하단에 CS 링크 들어가게 하고 싶으면(선택) --}}
{{-- auth 레이아웃에서 show_cs_links=true 처리 중이면 섹션은 비워도 됩니다. --}}
@endsection

View File

@ -0,0 +1,22 @@
@extends('web.layouts.auth')
@section('title', '회원가입 약관동의 | PIN FOR YOU')
@section('meta_description', '회원가입 약관동의 단계입니다.')
@section('canonical', url('/auth/register/terms'))
@section('h1', '약관 동의')
@section('desc', '다음 단계에서 본인인증을 진행합니다.')
@section('card_aria', '회원가입 Step1 - 약관 동의')
@section('show_cs_links', true)
@section('auth_content')
<div class="auth-divider">임시 페이지</div>
<p style="opacity:.8; line-height:1.6;">
Step0(휴대폰 확인) 통과했습니다.<br>
다음 단계에서 약관 UI와 저장 로직을 붙일 예정입니다.
</p>
<div class="auth-actions">
<a class="auth-btn auth-btn--primary" href="{{ route('web.auth.register') }}">처음으로</a>
</div>
@endsection

View File

@ -35,3 +35,4 @@
</div>
@endsection

View File

@ -67,6 +67,7 @@
@include('web.company.footer')
{{-- 페이지별 스크립트 추가용 --}}
@stack('recaptcha')
@stack('scripts')
</body>
</html>

View File

@ -4,8 +4,10 @@ use Illuminate\Support\Facades\Route;
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\Cs\NoticeController;
Route::view('/', 'web.home')->name('web.home');
/*
@ -53,9 +55,16 @@ Route::prefix('policy')->name('web.policy.')->group(function () {
*/
Route::prefix('auth')->name('web.auth.')->group(function () {
// 정적 페이지 (컨트롤러 불필요)
Route::view('login', 'web.auth.login')->name('login');
Route::view('register', 'web.auth.register')->name('register');
// 정적 페이지
Route::view('login', 'web.auth.login')->name('login');
// ✅ 회원가입 Step0
Route::get('register', [RegisterController::class, 'showStep0'])->name('register');
Route::post('register/phone-check', [RegisterController::class, 'postPhoneCheck'])->name('register.phone_check');
// ✅ Step1(약관) - 지금은 임시 화면
Route::get('register/terms', [RegisterController::class, 'showTerms'])->name('register.terms');
// 아이디 찾기 (컨트롤러)
Route::get('find-id', [FindIdController::class, 'show'])->name('find_id');
@ -71,6 +80,8 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
Route::post('find-password/reset-session', [FindPasswordController::class, 'resetSession'])->name('find_password.reset_session');
});
/*
|--------------------------------------------------------------------------
| Legacy redirects