# SAM API 보안 가이드 ## 개요 SAM API는 다층 보안 구조를 통해 무단 접근과 악의적 공격으로부터 시스템을 보호합니다. **최종 업데이트:** 2025-12-26 --- ## 보안 아키텍처 ### 다층 방어 구조 (Defense in Depth) ``` ┌─────────────────────────────────────────────────┐ │ Layer 1: Nginx (L7 Application Layer) │ │ - 악의적 경로 패턴 차단 │ │ - 의심스러운 User-Agent 차단 │ │ - Rate Limiting (Nginx 레벨) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ Layer 2: Laravel Rate Limiting │ │ - IP 기반 속도 제한 (10회/분) │ │ - API Key 없는 요청 차단 │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ Layer 3: API Key 검증 (글로벌 미들웨어) │ │ - 모든 요청 API Key 필수 │ │ - 화이트리스트 라우트 제외 │ │ - 보안 로그 자동 기록 │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ Layer 4: Sanctum 토큰 인증 │ │ - Bearer 토큰 검증 │ │ - 사용자 컨텍스트 설정 │ │ - 테넌트 격리 │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ Layer 5: 권한 검증 (Permission Check) │ │ - 메뉴 기반 권한 체크 │ │ - Role 기반 접근 제어 │ └─────────────────────────────────────────────────┘ ``` --- ## Layer 1: Nginx 보안 ### 악의적 경로 패턴 차단 **위치:** `docker/nginx/nginx.conf` ```nginx # 경로 탐색 공격 차단 if ($request_uri ~* "(\.\.\/|\.\.\\|etc\/passwd|\.env|\.git|\.htaccess|\.sql|@fs\/)") { return 403; } ``` **차단 패턴:** - `../`, `..\` - 디렉토리 탐색 공격 - `etc/passwd` - 시스템 파일 접근 시도 - `.env` - 환경 변수 파일 접근 - `.git`, `.htaccess`, `.sql` - 민감한 파일 접근 - `@fs/` - Vite 경로 탐색 공격 **응답:** 403 Forbidden ### 의심스러운 User-Agent 차단 ```nginx # 보안 스캔 도구 차단 if ($http_user_agent ~* "(sqlmap|nikto|nmap|masscan|metasploit|nessus)") { return 403; } ``` **차단 도구:** - sqlmap - SQL 인젝션 스캐너 - nikto - 웹 서버 스캐너 - nmap - 포트 스캐너 - masscan - 대량 포트 스캐너 - metasploit - 침투 테스트 프레임워크 - nessus - 취약점 스캐너 **응답:** 403 Forbidden --- ## Layer 2: Rate Limiting ### Laravel Rate Limiter **위치:** `app/Http/Middleware/ApiRateLimiter.php` ```php // IP 기반 속도 제한 $key = 'api-key-attempts:' . $request->ip(); if ($this->limiter->tooManyAttempts($key, 10)) { return response()->json([ 'message' => 'Too many attempts. Please try again later.', 'retry_after' => $seconds, ], 429); } $this->limiter->hit($key, 60); // 1분 동안 유지 ``` **설정:** - **제한:** 10회/분 (IP별) - **대상:** API Key 없는 요청 - **유지 시간:** 60초 - **응답 코드:** 429 Too Many Requests **로그:** ```php Log::warning('API Rate Limit Exceeded', [ 'ip' => $request->ip(), 'uri' => $request->getRequestUri(), 'retry_after' => $seconds, ]); ``` --- ## Layer 3: API Key 인증 ### 글로벌 미들웨어 **위치:** `bootstrap/app.php` ```php $middleware->append(ApiRateLimiter::class); // 1. Rate Limiting $middleware->append(ApiKeyMiddleware::class); // 2. API Key 검증 ``` **실행 순서:** Rate Limiting → API Key 검증 ### API Key 검증 로직 **위치:** `app/Http/Middleware/ApiKeyMiddleware.php` ```php // 1. 화이트리스트 체크 $publicRoutes = [ 'api/v1/login', 'api/v1/signup', 'api/v1/register', 'api/v1/refresh', 'api/v1/debug-apikey', 'api-docs', // Swagger UI 'api-docs/*', // Swagger 하위 경로 'docs/api-docs.json', // Swagger JSON 'up', // Health check ]; // 2. API Key 검증 $apiKey = $request->header('X-API-KEY'); $validApiKey = DB::table('api_keys') ->where('key', $apiKey) ->where('is_active', true) ->exists(); // 3. 보안 로그 기록 if (!$validApiKey) { Log::warning('Unauthorized API access attempt', [ 'ip' => $request->ip(), 'uri' => $request->getRequestUri(), 'method' => $request->method(), 'user_agent' => $request->userAgent(), ]); } ``` ### 화이트리스트 (인증 제외 라우트) **공개 엔드포인트:** - `api/v1/login` - 로그인 - `api/v1/signup` - 회원가입 - `api/v1/register` - 테넌트 등록 - `api/v1/refresh` - 토큰 갱신 - `api/v1/debug-apikey` - API Key 디버깅 - `api-docs/*` - Swagger UI - `docs/api-docs.json` - Swagger JSON - `up` - Health check **특징:** - 와일드카드 지원 (`fnmatch()` 사용) - 공개 라우트는 로깅 제외 - API Key 검증 스킵 ### 보안 로그 **로그 레벨:** - **Log::info** - 정상 API 요청 - **Log::warning** - 무단 접근 시도 **로그 내용:** ```json { "ip": "213.136.76.215", "uri": "/@fs/etc/passwd", "method": "GET", "user_agent": "Mozilla/5.0 ..." } ``` **민감 정보 제외:** - `password` - `password_confirmation` --- ## Layer 4: Sanctum 토큰 인증 ### 토큰 구조 **액세스 토큰:** - 만료 시간: 2시간 (120분) - 용도: API 호출 인증 - 형식: `{token_id}|{plain_text_token}` **리프레시 토큰:** - 만료 시간: 7일 (10080분) - 용도: 액세스 토큰 갱신 - 특징: 일회성 사용 (사용 후 삭제) ### 토큰 갱신 플로우 ``` 1. 클라이언트: POST /api/v1/refresh Headers: X-API-KEY, Authorization: Bearer {refresh_token} 2. 서버: 리프레시 토큰 검증 - 유효성 체크 - 만료 시간 체크 - 사용자 확인 3. 서버: 기존 리프레시 토큰 삭제 - 일회성 사용 보장 4. 서버: 새 토큰 발급 - 새 액세스 토큰 생성 - 새 리프레시 토큰 생성 5. 응답: { "access_token": "...", "refresh_token": "...", "expires_in": 7200, "expires_at": "2025-11-13 21:30:00" } ``` ### 토큰 만료 에러 처리 **위치:** `app/Exceptions/Handler.php` ```php if ($exception instanceof AuthenticationException) { $bearerToken = $request->bearerToken(); if ($bearerToken) { $token = PersonalAccessToken::findToken($bearerToken); if ($token && $token->expires_at && $token->expires_at->isPast()) { return response()->json([ 'success' => false, 'message' => __('error.token_expired'), 'error_code' => 'TOKEN_EXPIRED', ], 401); } } } ``` **프론트엔드 처리:** ```javascript if (response.error_code === 'TOKEN_EXPIRED') { // 자동 리프레시 토큰으로 재발급 const newTokens = await refreshToken(refreshToken); // 원래 요청 재시도 return retryRequest(originalRequest, newTokens.access_token); } ``` --- ## Layer 5: 권한 검증 (Permission System) ### 권한 시스템 개요 SAM은 **Spatie Permission** 패키지를 기반으로 한 다층 권한 시스템을 사용합니다. **3단계 권한 구조:** ``` ┌────────────────────────────────────────┐ │ 1. 사용자 역할 권한 │ │ User → Role → Permissions │ │ (model_has_roles → role_has_perms) │ └────────────────────────────────────────┘ + ┌────────────────────────────────────────┐ │ 2. 사용자 직접 권한 │ │ User → Permissions │ │ (model_has_permissions) │ └────────────────────────────────────────┘ + ┌────────────────────────────────────────┐ │ 3. 부서 역할 권한 │ │ User → Department → Role → Perms │ │ (department_user → model_has_roles) │ └────────────────────────────────────────┘ ↓ ┌────────────────────────────────────────┐ │ UNION (중복 제거) │ │ → 최종 사용자 권한 목록 │ └────────────────────────────────────────┘ ``` **특징:** - 멀티테넌트 지원 (tenant_id로 격리) - 메뉴 기반 세분화된 권한 - 다형성(Polymorphic) 구조로 유연한 확장 - Permission Override로 임시/긴급 권한 제어 --- ### 권한 패턴 및 타입 **권한 명명 규칙:** ``` menu:{menu_id}.{permission_type} ``` **권한 타입 (7가지):** | 타입 | 약자 | 설명 | 예시 | |------|------|------|------| | view | V | 조회 | 목록/상세 보기 | | create | C | 생성 | 신규 데이터 등록 | | update | U | 수정 | 기존 데이터 편집 | | delete | D | 삭제 | 데이터 삭제 | | approve | A | 승인 | 워크플로우 승인 | | export | E | 내보내기 | Excel/PDF 다운로드 | | manage | M | 관리 | 전체 관리 권한 | **권한 예시:** ``` menu:1.view → 대시보드 보기 menu:2.create → 제품 생성 menu:2.update → 제품 수정 menu:2.delete → 제품 삭제 menu:3.approve → 주문 승인 menu:4.export → 재고 내보내기 menu:5.manage → 사용자 관리 ``` --- ### 권한 조회 로직 **구현 위치:** - **API:** `app/Services/MemberService.php` - `getUserInfoForLogin()` - **Admin:** `app/Filament/Resources/Users/Tables/UsersTable.php` - `getAccessibleMenusCount()` **권한 조회 쿼리 구조:** ```php // 1. 사용자 역할 권한 $userRolePermissions = DB::table('model_has_roles') ->join('role_has_permissions', 'model_has_roles.role_id', '=', 'role_has_permissions.role_id') ->join('permissions', 'role_has_permissions.permission_id', '=', 'permissions.id') ->where('model_has_roles.model_type', User::class) ->where('model_has_roles.model_id', $userId) ->where('model_has_roles.tenant_id', $tenantId) ->where('permissions.name', 'like', 'menu:%.view') ->select('permissions.name'); // 2. 사용자 직접 권한 $userDirectPermissions = DB::table('model_has_permissions') ->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id') ->where('model_has_permissions.model_type', User::class) ->where('model_has_permissions.model_id', $userId) ->where('model_has_permissions.tenant_id', $tenantId) ->where('permissions.name', 'like', 'menu:%.view') ->select('permissions.name'); // 3. 부서 역할 권한 $departmentRolePermissions = DB::table('department_user') ->join('model_has_roles', function ($join) { $join->on('department_user.department_id', '=', 'model_has_roles.model_id') ->where('model_has_roles.model_type', '=', Department::class); }) ->join('role_has_permissions', 'model_has_roles.role_id', '=', 'role_has_permissions.role_id') ->join('permissions', 'role_has_permissions.permission_id', '=', 'permissions.id') ->where('department_user.user_id', $userId) ->where('department_user.tenant_id', $tenantId) ->where('permissions.name', 'like', 'menu:%.view') ->select('permissions.name'); // 4. 모든 권한 통합 (UNION + 중복 제거) $allPermissions = $userRolePermissions ->union($userDirectPermissions) ->union($departmentRolePermissions) ->pluck('name') ->toArray(); ``` **권한 파싱:** ```php // menu:123.view → 메뉴 ID 123 추출 foreach ($allPermissions as $permName) { if (preg_match('/^menu:(\d+)\.view$/', $permName, $matches)) { $allowedMenuIds[] = (int) $matches[1]; } } ``` --- ### Permission Override (우선순위 제어) **시간 기반 권한 제어:** ```php // permission_overrides 테이블 [ 'tenant_id' => 1, 'model_type' => 'App\Models\Members\User', 'model_id' => 123, 'permission_id' => 456, 'effect' => 1, // 1=ALLOW, -1=DENY 'effective_from' => '2025-11-13 00:00:00', 'effective_to' => '2025-11-20 23:59:59', ] ``` **우선순위:** 1. **Override DENY** (-1) - 최우선 차단 2. **Override ALLOW** (1) - 명시적 허용 3. **Base Permission** - 역할/부서/직접 권한 **최종 권한 계산:** ```php foreach ($allMenuPermissions as $permName) { if (preg_match('/^menu:(\d+)\.view$/', $permName, $matches)) { $menuId = (int) $matches[1]; // Override DENY 체크 (강제 차단) if (isset($overrides[$permName]) && $overrides[$permName]->effect === -1) { continue; // 이 메뉴는 차단됨 } // Override ALLOW 또는 기본 권한 if ( (isset($overrides[$permName]) && $overrides[$permName]->effect === 1) || in_array($permName, $basePermissions, true) ) { $allowedMenuIds[] = $menuId; } } } ``` **사용 사례:** - **임시 권한 부여:** 프로젝트 기간 동안만 특정 메뉴 접근 허용 - **긴급 권한 차단:** 보안 사고 발생 시 즉시 권한 제거 - **휴가 기간 제한:** 특정 기간 동안 권한 자동 차단 - **시간대별 접근 제어:** 업무 시간에만 권한 부여 --- ### 메뉴별 권한 매트릭스 뷰 **Admin 패널:** `http://admin.sam.kr/admin/permissions` **테이블 구조:** | 메뉴 ID | 메뉴명 | V (조회) | C (생성) | U (수정) | D (삭제) | A (승인) | E (내보내기) | M (관리) | |---------|--------|----------|----------|----------|----------|----------|-------------|----------| | 1 | 대시보드 | 홍길동, 김철수 | - | - | - | - | - | - | | 2 | 제품 관리 | 홍길동, 김철수 | 홍길동 | 홍길동 | 관리자 | - | 김철수 | 관리자 | | 3 | 주문 관리 | 전체팀 | 영업팀 | 영업팀 | 관리자 | 관리자 | 회계팀 | 관리자 | **특징:** - 각 Row = 하나의 메뉴 - 각 권한 타입별 Column에 해당 권한을 가진 사용자 목록 표시 - 사용자 배지에 마우스 오버 시 `user_id` 툴팁 표시 - 권한 없는 경우 `-` 표시 - 3가지 권한 소스 (역할/부서/직접) 모두 통합하여 표시 **구현 코드:** ```php // app/Filament/Resources/Permissions/Tables/PermissionsTable.php protected static function getUsersWithPermission(int $menuId, string $permissionType): string { $permissionName = "menu:{$menuId}.{$permissionType}"; // 3가지 권한 소스 UNION $userIds = $userRoleQuery ->union($userDirectQuery) ->union($departmentRoleQuery) ->pluck('user_id') ->unique() ->toArray(); // 사용자 배지 HTML 생성 $users = User::whereIn('id', $userIds)->orderBy('name')->get(); foreach ($users as $user) { $badges[] = sprintf( '%s', htmlspecialchars($user->user_id), htmlspecialchars($user->name) ); } return implode(', ', $badges); } ``` --- ### 권한 할당 방법 **1. 역할에 권한 할당:** ```php $role = Role::findByName('영업팀', 'web'); $role->givePermissionTo([ 'menu:2.view', 'menu:2.create', 'menu:3.view', ]); ``` **2. 사용자에게 직접 권한 할당:** ```php $user = User::find(123); $user->givePermissionTo('menu:5.manage'); ``` **3. 부서에 역할 할당:** ```php $department = Department::find(1); $department->assignRole('영업팀'); ``` **4. Permission Override 설정:** ```php DB::table('permission_overrides')->insert([ 'tenant_id' => 1, 'model_type' => User::class, 'model_id' => 123, 'permission_id' => 456, 'effect' => -1, // DENY 'effective_from' => now(), 'effective_to' => now()->addDays(7), ]); ``` --- ### 권한 체크 (Controller/Service) **CheckPermission Middleware:** ```php // routes/api.php Route::get('/products', [ProductController::class, 'index']) ->middleware(['auth:sanctum', 'permission:menu:2.view']); ``` **서비스 레이어:** ```php if (!auth()->user()->can('menu:2.create')) { throw new \Exception(__('error.permission_denied'), 403); } ``` **권한 확인 헬퍼:** ```php // 단일 권한 체크 auth()->user()->can('menu:2.view'); // 여러 권한 중 하나라도 있으면 auth()->user()->hasAnyPermission(['menu:2.view', 'menu:2.manage']); // 모든 권한 필요 auth()->user()->hasAllPermissions(['menu:2.view', 'menu:2.create']); ``` --- ## 보안 모니터링 ### 로그 파일 **1. 보안 로그** ```bash # 무단 접근 시도 tail -f storage/logs/laravel.log | grep "Unauthorized API access attempt" # Rate Limit 초과 tail -f storage/logs/laravel.log | grep "API Rate Limit Exceeded" ``` **2. Nginx 로그** ```bash # 접근 로그 tail -f /var/log/nginx/api.sam.kr_access.log # 에러 로그 (403 차단) tail -f /var/log/nginx/api.sam.kr_error.log ``` ### 공격 패턴 분석 **자주 발생하는 공격:** 1. **경로 탐색 (Path Traversal)** ``` GET /@fs/etc/passwd GET /../../../etc/passwd GET /api/../.env ``` **대응:** Nginx에서 403 차단 2. **보안 스캔** ``` User-Agent: sqlmap/1.0 User-Agent: nikto/2.1.5 ``` **대응:** Nginx User-Agent 필터링 3. **무차별 대입 (Brute Force)** ``` POST /api/v1/login (반복) ``` **대응:** Rate Limiting (10회/분) 4. **API Key 누락** ``` GET /api/v1/users (X-API-KEY 없음) ``` **대응:** 401 Unauthorized + 보안 로그 --- ## 보안 체크리스트 ### 개발 시 - [ ] 모든 API 엔드포인트에 `auth.apikey` 미들웨어 적용 - [ ] 민감한 정보 로깅 제외 (`password`, `password_confirmation`) - [ ] FormRequest로 입력 검증 - [ ] SQL 인젝션 방지 (Eloquent ORM 사용) - [ ] XSS 방지 (출력 시 이스케이핑) - [ ] CSRF 보호 (Sanctum 자동 적용) ### 배포 전 - [ ] `.env` 파일 보안 설정 확인 - [ ] API Key 로테이션 - [ ] Nginx 보안 규칙 테스트 - [ ] Rate Limiting 임계값 검토 - [ ] HTTPS 인증서 유효성 확인 - [ ] 방화벽 규칙 설정 ### 운영 중 - [ ] 매일 보안 로그 검토 - [ ] 주간 공격 패턴 분석 - [ ] 월간 토큰 만료 정책 검토 - [ ] 분기별 API Key 갱신 - [ ] 반기별 침투 테스트 --- ## 보안 사고 대응 ### 1단계: 즉시 조치 ```bash # 1. 의심스러운 IP 차단 (Nginx) # /etc/nginx/conf.d/blocked_ips.conf deny 213.136.76.215; # 2. Nginx 재시작 sudo systemctl reload nginx # 3. 활성 세션 강제 종료 php artisan sanctum:prune-expired --hours=0 # 4. API Key 비활성화 UPDATE api_keys SET is_active = 0 WHERE key = 'suspicious_key'; ``` ### 2단계: 로그 분석 ```bash # 공격 패턴 분석 grep "213.136.76.215" /var/log/nginx/api.sam.kr_access.log # 영향받은 엔드포인트 확인 grep "Unauthorized API access attempt" storage/logs/laravel.log | grep "213.136.76.215" # 시간대별 요청 횟수 awk '{print $4}' /var/log/nginx/api.sam.kr_access.log | cut -d: -f1-2 | uniq -c ``` ### 3단계: 복구 및 강화 ```bash # 1. 모든 사용자 비밀번호 초기화 (필요 시) # 2. 새 API Key 발급 # 3. 토큰 만료 시간 단축 (임시) # 4. Rate Limiting 임계값 강화 # 5. 추가 보안 규칙 적용 ``` --- ## FAQ ### Q1. API Key는 어디서 발급받나요? **A:** 관리자 패널(admin.sam.kr)에서 발급합니다. ```sql -- api_keys 테이블 구조 id, tenant_id, name, key, is_active, created_at, updated_at ``` ### Q2. Rate Limiting이 너무 엄격해요. **A:** `ApiRateLimiter.php`에서 임계값 조정: ```php if ($this->limiter->tooManyAttempts($key, 10)) { // 10 → 20으로 변경 ``` ### Q3. 화이트리스트에 라우트를 추가하려면? **A:** `ApiKeyMiddleware.php` 수정: ```php $publicRoutes = [ // 기존 라우트... 'api/v1/public-data', // 추가 ]; ``` ### Q4. 특정 IP만 허용하려면? **A:** Nginx 설정 추가: ```nginx # 화이트리스트 IP만 허용 allow 203.0.113.0/24; allow 198.51.100.50; deny all; ``` ### Q5. 토큰 만료 시간을 변경하려면? **A:** `.env` 파일 수정: ```env SANCTUM_ACCESS_TOKEN_EXPIRATION=120 # 2시간 → 4시간 (240) SANCTUM_REFRESH_TOKEN_EXPIRATION=10080 # 7일 → 14일 (20160) ``` --- ## 참고 문서 - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - [Laravel Security Best Practices](https://laravel.com/docs/12.x/security) - [Sanctum Documentation](https://laravel.com/docs/12.x/sanctum) - [Nginx Security Tips](https://nginx.org/en/docs/http/ngx_http_access_module.html) --- **작성일:** 2025-12-26 **버전:** 1.0 **담당자:** SAM Development Team