Skip to main content

보안 베스트 프랙티스

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

관련 문서​