보안 베스트 프랙티스
Multi-SaaS Kit의 보안 권장사항과 구현 가이드입니다.
개요
멀티테넌트 SaaS 플랫폼에서 보안은 가장 중요한 요소입니다. 데이터 격리, 인증/인가, API 보안 등 여러 계층에서 보안을 확보해야 합니다.
보안 계층
| 계층 | 목적 | 구현 방법 |
|---|---|---|
| 데이터 격리 | 테넌트 간 데이터 분리 | RLS, TenantScope |
| 인증 | 사용자 신원 확인 | Sanctum, 세션 |
| 인가 | 접근 권한 검증 | Policy, Gate, Middleware |
| API 보안 | API 남용 방지 | Rate Limiting, CORS |
| 입력 검증 | 악성 입력 차단 | Validation, Sanitization |
데이터 격리 보안
파괴적 DB 명령 차단
MSK는 Core DatabaseSafetyServiceProvider와 루트 Makefile에서 migrate:fresh, migrate:refresh, migrate:reset, db:wipe, schema:dump --prune을 기본 차단합니다. 운영 또는 장기 보존 데이터가 있는 환경에서 AI 에이전트나 사용자가 실수로 DB를 초기화하지 못하게 하는 안전장치입니다.
정말 필요한 경우에는 명령별 1회성 MSK_ALLOW_DESTRUCTIVE_ARTISAN token을 해당 실행에만 주입합니다. token을 .env에 저장하지 않습니다.
자세한 정책은 DB 안전 가드를 참고하세요.
RLS (Row-Level Security) 적용
모든 테넌트 데이터는 자동으로 격리되어야 합니다.
// BelongsToTenant trait 사용
class Order extends Model
{
use BelongsToTenant;
// tenant_id가 자동으로 설정되고 쿼리 시 필터링됨
}
테넌트 컨텍스트 검증
// 미들웨어에서 테넌트 컨텍스트 검증
public function handle(Request $request, Closure $next)
{
$user = $request->user();
// 테넌트 레벨 사용자는 반드시 tenant_id 필요
if ($user->isTenantLevel() && !$user->tenant_id) {
abort(403, 'Tenant context required');
}
// 세션의 tenant_id와 사용자의 tenant_id 일치 확인
if ($user->tenant_id && session('tenant_id') !== $user->tenant_id) {
session(['tenant_id' => $user->tenant_id]);
}
return $next($request);
}
Cross-Tenant 접근 방지
// 직접 ID로 다른 테넌트 데이터 접근 차단
public function show(Order $order)
{
// TenantScope가 적용되어 다른 테넌트 데이터는 자동으로 조회 불가
// 추가 보안: Policy에서도 검증
$this->authorize('view', $order);
return new OrderResource($order);
}
// Policy 정의
public function view(User $user, Order $order): bool
{
// Platform Admin은 모든 데이터 접근 가능
if ($user->isPlatformAdmin()) {
return true;
}
// 같은 테넌트만 접근 가능
return $user->tenant_id === $order->tenant_id;
}
인증 보안
비밀번호 정책
// app/Http/Requests/Auth/RegisterRequest.php
public function rules(): array
{
return [
'password' => [
'required',
'string',
'min:8', // 최소 8자
'confirmed', // 확인 필드 일치
Password::defaults(), // Laravel 기본 정책
],
];
}
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Password::defaults(function () {
return Password::min(8)
->mixedCase() // 대소문자 혼합
->numbers() // 숫자 포함
->symbols() // 특수문자 포함
->uncompromised(); // 유출된 비밀번호 체크
});
}
세션 보안
// config/session.php
return [
'driver' => env('SESSION_DRIVER', 'redis'), // Redis 권장
'lifetime' => 120, // 2시간
'expire_on_close' => false,
'encrypt' => true, // 세션 암호화
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only
'http_only' => true, // JavaScript 접근 차단
'same_site' => 'lax', // CSRF 방지
];
Sanctum 토큰 관리
// 토큰 생성 시 만료 시간 설정
$token = $user->createToken('api-token', ['*'], now()->addHours(24));
// 토큰 능력(abilities) 제한
$token = $user->createToken('read-only', ['read']);
// 토큰 폐기
$user->tokens()->where('name', 'api-token')->delete();
// 현재 토큰 폐기 (로그아웃)
$request->user()->currentAccessToken()->delete();
로그인 보호
// 로그인 시도 제한 (Rate Limiting)
protected function throttleKey(Request $request): string
{
return Str::transliterate(
Str::lower($request->input('email')).'|'.$request->ip()
);
}
// 브루트포스 공격 방지
RateLimiter::hit($this->throttleKey($request), 60); // 60초 동안
if (RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'email' => ["Too many login attempts. Try again in {$seconds} seconds."],
]);
}
인가 보안
Policy 필수 적용
모든 리소스에는 반드시 Policy를 정의하고 적용해야 합니다.
// app/Policies/OrderPolicy.php
class OrderPolicy
{
public function viewAny(User $user): bool
{
// 모든 인증된 사용자 조회 가능 (테넌트 스코프 적용)
return true;
}
public function view(User $user, Order $order): bool
{
return $user->isPlatformAdmin()
|| $user->tenant_id === $order->tenant_id;
}
public function create(User $user): bool
{
// Level 4 이상만 주문 생성 가능
return $user->hasLevel(UserLevel::WORKSPACE_ADMIN);
}
public function update(User $user, Order $order): bool
{
// 소유자 또는 관리자만 수정 가능
return $user->isPlatformAdmin()
|| ($user->tenant_id === $order->tenant_id
&& $user->hasLevel(UserLevel::ORGANIZATION_ADMIN));
}
public function delete(User $user, Order $order): bool
{
// Tenant Admin 이상만 삭제 가능
return $user->isPlatformAdmin()
|| ($user->tenant_id === $order->tenant_id
&& $user->hasLevel(UserLevel::TENANT_ADMIN));
}
}
Controller에서 인가 적용
class OrderController extends Controller
{
public function __construct()
{
// 자동으로 모든 메서드에 Policy 적용
$this->authorizeResource(Order::class, 'order');
}
// 또는 개별 메서드에서
public function show(Order $order)
{
$this->authorize('view', $order);
return new OrderResource($order);
}
}
Gate 정의
// app/Providers/AuthServiceProvider.php
public function boot(): void
{
Gate::define('access-admin-panel', function (User $user) {
return $user->hasLevel(UserLevel::TENANT_ADMIN);
});
Gate::define('manage-users', function (User $user) {
return $user->hasLevel(UserLevel::ORGANIZATION_ADMIN);
});
Gate::define('view-all-tenants', function (User $user) {
return $user->isPlatformAdmin() || $user->isSaasAdmin();
});
}
// 사용
if (Gate::allows('manage-users')) {
// 사용자 관리 기능 표시
}
Gate::authorize('access-admin-panel');
Middleware 계층별 접근 제어
// routes/web.php
Route::middleware(['auth', 'ensure.level:0'])->group(function () {
Route::get('/platform', [PlatformController::class, 'index']);
});
Route::middleware(['auth', 'ensure.level:0,1,2'])->group(function () {
Route::get('/tenant', [TenantController::class, 'index']);
});
// EnsureUserLevel Middleware
public function handle(Request $request, Closure $next, string $levels): Response
{
$allowedLevels = array_map('intval', explode(',', $levels));
$user = $request->user();
if (!$user) {
return redirect()->route('login');
}
if (!in_array($user->level, $allowedLevels)) {
abort(403, 'Access denied');
}
return $next($request);
}
API 보안
Rate Limiting
// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting(): void
{
// 기본 API 제한
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// 인증 엔드포인트 제한 (더 엄격)
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// 프리미엄 사용자 제한 완화
RateLimiter::for('premium-api', function (Request $request) {
return $request->user()?->isPremium()
? Limit::perMinute(1000)->by($request->user()->id)
: Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('orders', OrderController::class);
});
Route::middleware(['throttle:auth'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
CORS 설정
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('APP_URL'),
env('FRONTEND_URL'),
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true, // 쿠키 포함 요청 허용
];
API 버전 관리
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('users', Api\V1\UserController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', Api\V2\UserController::class);
});
입력 검증 및 보안
SQL Injection 방지
// ❌ 취약한 코드
$users = DB::select("SELECT * FROM users WHERE name = '$name'");
// ✅ 안전한 코드 - 바인딩 사용
$users = DB::select("SELECT * FROM users WHERE name = ?", [$name]);
// ✅ 안전한 코드 - Eloquent 사용 (권장)
$users = User::where('name', $name)->get();
// ✅ 안전한 코드 - whereIn 사용
$users = User::whereIn('id', $ids)->get();
XSS 방지
// Blade에서 자동 이스케이프
{{ $userInput }} // 자동으로 htmlspecialchars 적용
// Raw 출력이 필요한 경우 (주의!)
{!! $trustedHtml !!}
// 명시적 이스케이프
{{ e($userInput) }}
// JavaScript 변수로 전달 시
<script>
const data = @json($data); // JSON 인코딩으로 XSS 방지
</script>
CSRF 보호
// Blade 폼에서
<form method="POST" action="/order">
@csrf
<!-- 폼 필드 -->
</form>
// API 요청 시 (Sanctum)
// SPA는 먼저 /sanctum/csrf-cookie 호출
axios.get('/sanctum/csrf-cookie').then(response => {
axios.post('/api/order', data);
});
// 특정 라우트 CSRF 제외 (웹훅 등)
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'webhook/*',
'stripe/*',
];
Mass Assignment 보호
class User extends Model
{
// 허용할 필드만 명시
protected $fillable = [
'name',
'email',
'password',
];
// 또는 보호할 필드 명시
protected $guarded = [
'id',
'is_admin',
'level',
'tenant_id', // 테넌트 ID는 자동 설정되어야 함
];
}
// 컨트롤러에서
public function store(StoreUserRequest $request)
{
// validated() 사용으로 검증된 데이터만 사용
$user = User::create($request->validated());
}
파일 업로드 보안
public function store(Request $request)
{
$request->validate([
'file' => [
'required',
'file',
'max:10240', // 10MB 제한
'mimes:pdf,doc,docx,jpg,png', // 허용 확장자
],
]);
$file = $request->file('file');
// 원본 파일명 사용 금지, 해시 사용
$path = $file->store('uploads', 'private');
// 또는 커스텀 파일명
$filename = Str::uuid() . '.' . $file->extension();
$path = $file->storeAs('uploads', $filename, 'private');
return response()->json(['path' => $path]);
}
민감 데이터 보호
환경변수 관리
# .env (Git 제외)
APP_KEY=base64:...
DB_PASSWORD=secret
API_SECRET=secret
# 절대 하드코딩 금지
# ❌ $apiKey = 'sk_live_xxx';
# ✅ $apiKey = config('services.stripe.secret');
암호화
use Illuminate\Support\Facades\Crypt;
// 데이터 암호화
$encrypted = Crypt::encryptString($sensitiveData);
// 데이터 복호화
$decrypted = Crypt::decryptString($encrypted);
// 모델 속성 자동 암호화
class User extends Model
{
protected $casts = [
'ssn' => 'encrypted',
'api_key' => 'encrypted',
];
}
로그에서 민감 정보 제외
// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'],
'ignore_exceptions' => false,
],
],
// app/Http/Middleware/TrustProxies.php 등에서
protected $dontLog = [
'password',
'password_confirmation',
'credit_card',
'ssn',
];
// Exception Handler에서
public function context(): array
{
return array_filter([
'url' => request()->fullUrl(),
'user_id' => auth()->id(),
// password, token 등 민감 정보 제외
]);
}
보안 헤더
// app/Http/Middleware/SecurityHeaders.php
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (app()->environment('production')) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
감사 로그
// Auditable trait 사용
class Order extends Model
{
use Auditable;
// 변경 사항이 자동으로 audit_logs 테이블에 기록됨
}
// 수동 감사 로그
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'export',
'model_type' => User::class,
'model_id' => null,
'changes' => ['exported_count' => 100],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
보안 체크리스트
배포 전 필수 확인
-
APP_DEBUG=false설정 -
APP_ENV=production설정 - HTTPS 강제 (
SESSION_SECURE_COOKIE=true) - 모든 API에 Rate Limiting 적용
- 모든 리소스에 Policy 적용
- 환경변수에 민감 정보 저장
- 로그에서 민감 정보 제외
- 보안 헤더 적용
- CORS 화이트리스트 설정
정기 점검 항목
- 의존성 보안 업데이트 (
composer audit) - 비밀번호 정책 검토
- 토큰 만료 정책 검토
- 감사 로그 검토
- 접근 권한 검토