Files
Gnuboard7/app/Http/Middleware/PermissionMiddleware.php
T
2026-05-11 11:29:41 +09:00

210 lines
7.5 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Enums\PermissionType;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Models\Role;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PermissionMiddleware
{
/**
* guest role 캐시
*
* @var Role|null
*/
protected static $guestRoleCache = null;
/**
* 특정 권한을 가진 사용자 또는 비회원(guest)의 접근을 허용합니다.
*
* 사용법:
* - permission:admin,core.users.read (admin 타입의 권한 체크)
* - permission:user,core.users.read (user 타입의 권한 체크)
* - permission:admin,core.users.read|core.users.create (여러 권한, 기본 AND)
* - permission:admin,core.users.read|core.users.create,false (여러 권한, OR 조건)
* - permission:user,sirsoft-board.{slug}.posts.create (동적 파라미터 사용)
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @param string $type 권한 타입 (admin 또는 user)
* @param string $permission 필요한 권한명 (파이프로 여러 권한 구분)
* @param string|null $requireAll 여러 권한시 모두 필요한지 여부 ('true' 또는 'false')
* @return Response HTTP 응답
*/
public function handle(Request $request, Closure $next, string $type, string $permission, ?string $requireAll = null): Response
{
// Step 1: 권한 타입 유효성 검증
if (! PermissionType::isValid($type)) {
return ResponseHelper::forbidden('auth.invalid_permission_type', [
'type' => $type,
'valid_types' => implode(', ', array_map(fn ($case) => $case->value, PermissionType::cases())),
]);
}
$permissionType = PermissionType::from($type);
// Step 2: 권한 식별자 동적 파라미터 치환
$permissions = explode('|', $permission);
$resolvedPermissions = array_map(
fn ($perm) => $this->resolvePermissionIdentifier($perm, $request),
$permissions
);
// Step 3: 권한 확인
$user = $request->user();
if ($user) {
$hasPermission = count($resolvedPermissions) === 1
? $user->hasPermission($resolvedPermissions[0], $permissionType)
: $user->hasPermissions($resolvedPermissions, filter_var($requireAll ?? 'true', FILTER_VALIDATE_BOOLEAN), $permissionType);
if (! $hasPermission) {
return ResponseHelper::forbidden('auth.permission_denied', [
'required_permissions' => implode(', ', $resolvedPermissions),
]);
}
} else {
$hasPermission = count($resolvedPermissions) === 1
? $this->checkGuestPermission($resolvedPermissions[0], $permissionType)
: collect($resolvedPermissions)->contains(fn ($perm) => $this->checkGuestPermission($perm, $permissionType));
if (! $hasPermission) {
return ResponseHelper::unauthorized('auth.guest_permission_denied', [
'required_permissions' => implode(', ', $resolvedPermissions),
]);
}
}
// Step 4: scope_type 스코프 체크 (인증된 사용자 + 상세 엔드포인트만)
if ($user) {
foreach ($resolvedPermissions as $identifier) {
$resourceRouteKey = PermissionHelper::getResourceRouteKey($identifier);
// resource_route_key가 없으면 스코프 체크 불필요
if (! $resourceRouteKey) {
continue;
}
// 라우트에서 모델 resolve
$model = $request->route($resourceRouteKey);
// 모델이 없으면 목록 엔드포인트 → 스코프 체크 스킵
if (! $model instanceof Model) {
continue;
}
// 스코프 접근 체크
if (! PermissionHelper::checkScopeAccess($model, $identifier, $user)) {
return ResponseHelper::forbidden('auth.scope_denied');
}
}
}
return $next($request);
}
/**
* 비회원(guest) role의 권한을 확인합니다.
*
* @param string $permission 권한 식별자
* @param PermissionType $type 권한 타입
* @return bool 권한 존재 여부
*/
protected function checkGuestPermission(string $permission, PermissionType $type): bool
{
$guestRole = $this->getGuestRole();
if (! $guestRole) {
return false;
}
return $guestRole->permissions()
->where('identifier', $permission)
->where('type', $type)
->exists();
}
/**
* guest role을 캐싱하여 조회합니다.
*
* @return Role|null guest 역할
*/
protected function getGuestRole(): ?Role
{
if (self::$guestRoleCache === null) {
self::$guestRoleCache = Role::where('identifier', 'guest')
->with('permissions')
->first();
}
return self::$guestRoleCache;
}
/**
* guest role 캐시를 무효화합니다.
*
* RolePermissionSeeder 실행 후 자동 호출 — 시드 직후 권한 변경이 즉시 반영되도록 보장.
* 운영 환경에서는 권한 재시드 (코어 업데이트 / 확장 install) 시점에 캐시 정합 유지.
* 테스트 환경에서는 RefreshDatabase 트랜잭션 rollback 후 새 시드의 stale id 회귀 방지.
*
* @return void
*/
public static function clearGuestRoleCache(): void
{
self::$guestRoleCache = null;
}
/**
* 권한 식별자의 동적 파라미터를 URL 파라미터 값으로 치환합니다.
*
* 예시: sirsoft-board.{slug}.posts.create + {slug: 'notice'}
* → sirsoft-board.notice.posts.create
*
* @param string $permission 권한 식별자 (예: sirsoft-board.{slug}.posts.create)
* @param Request $request HTTP 요청 객체
* @return string 치환된 권한 식별자
*/
protected function resolvePermissionIdentifier(string $permission, Request $request): string
{
// {slug}, {id} 등의 플레이스홀더 찾기
preg_match_all('/\{(\w+)\}/', $permission, $matches);
if (empty($matches[1])) {
return $permission; // 동적 파라미터 없음
}
$routeParams = $request->route()->parameters();
$resolvedPermission = $permission;
foreach ($matches[1] as $paramName) {
if (! isset($routeParams[$paramName])) {
// URL 파라미터가 없는 경우 경고 로그
Log::warning('Permission parameter not found in route', [
'permission' => $permission,
'param' => $paramName,
'route' => $request->route()->getName(),
'available_params' => array_keys($routeParams),
]);
// 원본 반환 (권한 체크 실패로 이어짐)
return $permission;
}
$resolvedPermission = str_replace(
"{{$paramName}}",
$routeParams[$paramName],
$resolvedPermission
);
}
return $resolvedPermission;
}
}