diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 4028764..a47d305 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -14,21 +14,51 @@ class ApiKeyMiddleware { public function handle(Request $request, Closure $next) { - // 요청 정보 저장 (예: DB, Log 파일 등) - Log::info('API Request', [ - 'ip' => $request->ip(), - 'user_id' => optional($request->user())->id, - 'method' => $request->method(), - 'uri' => $request->getRequestUri(), - 'input' => $request->all(), - 'headers' => $request->headers->all(), - ]); + // 화이트리스트(인증 예외 라우트) - API Key 검증 제외 + $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 + ]; + $currentRoute = $request->route()?->uri() ?? $request->path(); + + // 화이트리스트 패턴 매칭 (와일드카드 지원) + $isPublicRoute = false; + foreach ($publicRoutes as $pattern) { + if ($pattern === $currentRoute || fnmatch($pattern, $currentRoute)) { + $isPublicRoute = true; + break; + } + } + + // 공개 라우트가 아닌 경우에만 요청 정보 로깅 + if (! $isPublicRoute) { + Log::info('API Request', [ + 'ip' => $request->ip(), + 'user_id' => optional($request->user())->id, + 'method' => $request->method(), + 'uri' => $request->getRequestUri(), + 'input' => $request->except(['password', 'password_confirmation']), // 민감 정보 제외 + 'user_agent' => $request->userAgent(), + ]); + } + + // 공개 라우트는 API Key 검증 스킵 + if ($isPublicRoute) { + return $next($request); + } + + // API Key 검증 $apiKey = $request->header('X-API-KEY'); - $validApiKey = false; - // 1. API 키가 유효한지 확인 if ($apiKey) { $validApiKey = DB::table('api_keys') ->where('key', $apiKey) @@ -37,6 +67,14 @@ public function handle(Request $request, Closure $next) } if (! $validApiKey) { + // 보안 로그 기록 (API Key 없이 접근 시도) + Log::warning('Unauthorized API access attempt', [ + 'ip' => $request->ip(), + 'uri' => $request->getRequestUri(), + 'method' => $request->method(), + 'user_agent' => $request->userAgent(), + ]); + return response()->json(['message' => 'Unauthorized. Invalid or missing API key'], 401); } diff --git a/app/Http/Middleware/ApiRateLimiter.php b/app/Http/Middleware/ApiRateLimiter.php new file mode 100644 index 0000000..2210051 --- /dev/null +++ b/app/Http/Middleware/ApiRateLimiter.php @@ -0,0 +1,45 @@ +limiter = $limiter; + } + + public function handle(Request $request, Closure $next) + { + $key = 'api-key-attempts:'.$request->ip(); + + // API Key가 없거나 유효하지 않은 경우 Rate Limiting 적용 + if (! $request->header('X-API-KEY')) { + if ($this->limiter->tooManyAttempts($key, 10)) { + $seconds = $this->limiter->availableIn($key); + + Log::warning('API Rate Limit Exceeded', [ + 'ip' => $request->ip(), + 'uri' => $request->getRequestUri(), + 'retry_after' => $seconds, + ]); + + return response()->json([ + 'message' => 'Too many attempts. Please try again later.', + 'retry_after' => $seconds, + ], 429); + } + + $this->limiter->hit($key, 60); // 1분 동안 유지 + } + + return $next($request); + } +} diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index 41a2b29..64fa711 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -5,6 +5,7 @@ use App\Models\Commons\Menu; use App\Models\Members\User; use App\Models\Members\UserTenant; +use App\Models\Tenants\Department; use App\Models\Tenants\Tenant; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -237,24 +238,42 @@ public static function getUserInfoForLogin(int $userId): array ]; // 4. 메뉴 권한 체크 (menu:{menu_id}.view 패턴) - // 4-1. 역할 기반 권한 + 직접 권한 조회 (하이브리드) - $rolePermissions = DB::table('model_has_roles') + // 4-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', $tenant->id) ->where('permissions.name', 'like', 'menu:%.view') - ->select('permissions.name') - ->union( - 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', $tenant->id) - ->where('permissions.name', 'like', 'menu:%.view') - ->select('permissions.name') - ) + ->select('permissions.name'); + + // 4-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', $tenant->id) + ->where('permissions.name', 'like', 'menu:%.view') + ->select('permissions.name'); + + // 4-3. 부서 역할 기반 권한 (User → department_user → Department → role → permissions) + $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', $tenant->id) + ->where('permissions.name', 'like', 'menu:%.view') + ->select('permissions.name'); + + // 4-4. 모든 권한 통합 (UNION) + $rolePermissions = $userRolePermissions + ->union($userDirectPermissions) + ->union($departmentRolePermissions) ->pluck('name') ->toArray(); diff --git a/bootstrap/app.php b/bootstrap/app.php index 4157c81..5632204 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Exceptions\Handler; use App\Http\Middleware\ApiKeyMiddleware; +use App\Http\Middleware\ApiRateLimiter; use App\Http\Middleware\CheckPermission; use App\Http\Middleware\CheckSwaggerAuth; use App\Http\Middleware\CorsMiddleware; @@ -19,10 +20,13 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + // 글로벌 미들웨어 (모든 요청에 적용, 순서 중요) $middleware->append(CorsMiddleware::class); + $middleware->append(ApiRateLimiter::class); // 1. Rate Limiting 먼저 체크 + $middleware->append(ApiKeyMiddleware::class); // 2. API Key 검증 $middleware->alias([ - 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth + 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth (alias 유지) 'swagger.auth' => CheckSwaggerAuth::class, 'perm.map' => PermMapper::class, // 전처리: 라우트명 → perm 키 생성/주입 'permission' => CheckPermission::class, // 검사: perm 키로 접근 허용/차단