본문으로 건너뛰기

보안 베스트 프랙티스

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)
  • 비밀번호 정책 검토
  • 토큰 만료 정책 검토
  • 감사 로그 검토
  • 접근 권한 검토

관련 문서