Files
sam-docs/architecture/security-policy.md
kent 66eac6b39f docs: [문서정리] 전체 문서 업데이트 및 admin→mng 전환 반영
- Phase 1-3: 핵심/보조 문서 업데이트, 버전 최신화
- Phase 4: 오래된 파일 정리 및 아카이브
  - D0.8 Storyboard → history/2025-12/ 이동
  - admin 참조 4개 파일 수정 (docker-setup, git-conventions, project-launch-roadmap, remote-work-setup)
  - 빈 디렉토리 6개 삭제
- 버전 정보: React 19.2.1, Next.js 15.5.7
- remote-work-setup.md DEPRECATED 표시
2025-12-26 16:47:36 +09:00

22 KiB

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

# 경로 탐색 공격 차단
if ($request_uri ~* "(\.\.\/|\.\.\\|etc\/passwd|\.env|\.git|\.htaccess|\.sql|@fs\/)") {
    return 403;
}

차단 패턴:

  • ../, ..\ - 디렉토리 탐색 공격
  • etc/passwd - 시스템 파일 접근 시도
  • .env - 환경 변수 파일 접근
  • .git, .htaccess, .sql - 민감한 파일 접근
  • @fs/ - Vite 경로 탐색 공격

응답: 403 Forbidden

의심스러운 User-Agent 차단

# 보안 스캔 도구 차단
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

// 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

로그:

Log::warning('API Rate Limit Exceeded', [
    'ip' => $request->ip(),
    'uri' => $request->getRequestUri(),
    'retry_after' => $seconds,
]);

Layer 3: API Key 인증

글로벌 미들웨어

위치: bootstrap/app.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

// 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 - 무단 접근 시도

로그 내용:

{
  "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

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);
        }
    }
}

프론트엔드 처리:

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()

권한 조회 쿼리 구조:

// 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();

권한 파싱:

// menu:123.view → 메뉴 ID 123 추출
foreach ($allPermissions as $permName) {
    if (preg_match('/^menu:(\d+)\.view$/', $permName, $matches)) {
        $allowedMenuIds[] = (int) $matches[1];
    }
}

Permission Override (우선순위 제어)

시간 기반 권한 제어:

// 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 - 역할/부서/직접 권한

최종 권한 계산:

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가지 권한 소스 (역할/부서/직접) 모두 통합하여 표시

구현 코드:

// 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(
            '<span title="%s">%s</span>',
            htmlspecialchars($user->user_id),
            htmlspecialchars($user->name)
        );
    }

    return implode(', ', $badges);
}

권한 할당 방법

1. 역할에 권한 할당:

$role = Role::findByName('영업팀', 'web');
$role->givePermissionTo([
    'menu:2.view',
    'menu:2.create',
    'menu:3.view',
]);

2. 사용자에게 직접 권한 할당:

$user = User::find(123);
$user->givePermissionTo('menu:5.manage');

3. 부서에 역할 할당:

$department = Department::find(1);
$department->assignRole('영업팀');

4. Permission Override 설정:

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:

// routes/api.php
Route::get('/products', [ProductController::class, 'index'])
    ->middleware(['auth:sanctum', 'permission:menu:2.view']);

서비스 레이어:

if (!auth()->user()->can('menu:2.create')) {
    throw new \Exception(__('error.permission_denied'), 403);
}

권한 확인 헬퍼:

// 단일 권한 체크
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. 보안 로그

# 무단 접근 시도
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 로그

# 접근 로그
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단계: 즉시 조치

# 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단계: 로그 분석

# 공격 패턴 분석
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단계: 복구 및 강화

# 1. 모든 사용자 비밀번호 초기화 (필요 시)
# 2. 새 API Key 발급
# 3. 토큰 만료 시간 단축 (임시)
# 4. Rate Limiting 임계값 강화
# 5. 추가 보안 규칙 적용

FAQ

Q1. API Key는 어디서 발급받나요?

A: 관리자 패널(admin.sam.kr)에서 발급합니다.

-- api_keys 테이블 구조
id, tenant_id, name, key, is_active, created_at, updated_at

Q2. Rate Limiting이 너무 엄격해요.

A: ApiRateLimiter.php에서 임계값 조정:

if ($this->limiter->tooManyAttempts($key, 10)) {  // 10 → 20으로 변경

Q3. 화이트리스트에 라우트를 추가하려면?

A: ApiKeyMiddleware.php 수정:

$publicRoutes = [
    // 기존 라우트...
    'api/v1/public-data',  // 추가
];

Q4. 특정 IP만 허용하려면?

A: Nginx 설정 추가:

# 화이트리스트 IP만 허용
allow 203.0.113.0/24;
allow 198.51.100.50;
deny all;

Q5. 토큰 만료 시간을 변경하려면?

A: .env 파일 수정:

SANCTUM_ACCESS_TOKEN_EXPIRATION=120    # 2시간 → 4시간 (240)
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080 # 7일 → 14일 (20160)

참고 문서


작성일: 2025-12-26 버전: 1.0 담당자: SAM Development Team