middleware('identity.policy:core.profile.password_change'); * * 어느 모드든 단일 정책 enforce 절차는 동일: * - 요청의 verification_token 이 verified+미소비+purpose 일치+target_hash 일치 → 통과 * (IdentityGuardInterceptor 의 verify 직후 재시도 흐름) * - 토큰 미동봉/무효 → IdentityPolicyService::enforce() 가 grace_minutes 윈도우 검사 후 * 통과 또는 IdentityVerificationRequiredException throw * * @since 7.0.0-beta.4 */ class EnforceIdentityPolicy { /** * @param IdentityPolicyService $policyService 정책 유스케이스 Service * @param IdentityPolicyRepositoryInterface $policyRepository 정책 Repository */ public function __construct( protected IdentityPolicyService $policyService, protected IdentityPolicyRepositoryInterface $policyRepository, protected IdentityVerificationLogRepositoryInterface $logRepository, ) {} /** * 미들웨어 진입점. * * @param Request $request HTTP 요청 * @param Closure $next 다음 파이프라인 * @param string|null $policyKey 정책 키 (identity_policies.key) * @return Response * * @throws \App\Exceptions\IdentityVerificationRequiredException 정책 위반 시 */ public function handle(Request $request, Closure $next, ?string $policyKey = null): Response { $policies = $this->resolvePolicies($request, $policyKey); foreach ($policies as $policy) { $this->enforcePolicy($policy, $request); } return $next($request); } /** * 강제 대상 정책 목록을 결정합니다. * * - 명시 모드: $policyKey 로 단일 정책 조회 (enabled 만 통과) * - 자동 매핑 모드: 라우트 이름으로 캐시된 인덱스에서 매칭 정책 컬렉션 조회 * * 라우트 이름이 없는 요청 (예: 404 / health check) 은 빈 배열 반환 → 미들웨어 즉시 통과. * * @return iterable<\App\Models\IdentityPolicy> */ protected function resolvePolicies(Request $request, ?string $policyKey): iterable { if ($policyKey !== null && $policyKey !== '') { $policy = $this->policyRepository->findByKey($policyKey); return ($policy && $policy->enabled) ? [$policy] : []; } $routeName = $request->route()?->getName(); if (! is_string($routeName) || $routeName === '') { return []; } $index = $this->policyRepository->getRouteScopeIndex(); return $index[$routeName] ?? []; } /** * 단일 정책에 대해 enforce 절차를 수행합니다 (token bypass → grace 윈도우 → exception). * * @throws \App\Exceptions\IdentityVerificationRequiredException 정책 위반 시 */ protected function enforcePolicy(\App\Models\IdentityPolicy $policy, Request $request): void { // verification_token 우회 — IdentityGuardInterceptor 가 verify 직후 토큰을 query/body 에 부착해 // 원 요청을 재실행할 때 grace_minutes 윈도우와 무관하게 통과시킨다. // 검사 순서: ① 토큰이 verified + 미소비 + purpose 일치 → ② target_hash 매칭 (요청 email vs 토큰 발급 시 email). $token = (string) $request->input('verification_token', ''); if ($token !== '') { $verifiedLog = $this->logRepository->findVerifiedForToken($token, $policy->purpose); if ($verifiedLog !== null && $this->tokenTargetMatches($verifiedLog, $request)) { return; } } $context = [ 'http_method' => $request->getMethod(), 'user_roles' => $this->collectUserRoles($request), 'user_is_admin' => $this->resolveUserIsAdmin($request), 'target_email' => $this->resolveTargetEmail($request), 'origin_type' => IdentityOriginType::Route->value, 'origin_identifier' => $request->route()?->getName() ?: $request->path(), 'origin_policy_key' => $policy->key, 'return_request' => [ 'method' => $request->getMethod(), 'url' => $request->fullUrl(), ], ]; $this->policyService->enforce($policy, $request->user(), $context); } /** * 토큰이 가리키는 challenge 의 target_hash 가 현재 요청의 식별자(이메일)와 일치하는지 확인합니다. * * 인증 사용자: user.email 우선 * 게스트: 요청 body 의 email * * 요청에 식별자가 전혀 없으면(예: 별도 정책에서 email 외 식별자 사용) 검사를 건너뛰고 통과시킵니다 — * 이 경우 다운스트림 listener 가 자기 도메인의 식별자로 매칭을 강제해야 합니다. * * @param \App\Models\IdentityVerificationLog $log 토큰이 가리키는 verified 로그 * @param Request $request HTTP 요청 * @return bool target_hash 일치 여부 (식별자 미존재 시 true) */ protected function tokenTargetMatches(\App\Models\IdentityVerificationLog $log, Request $request): bool { $user = $request->user(); $email = $user instanceof User && $user->email ? $user->email : (string) $request->input('email', ''); if ($email === '') { return true; } return $log->target_hash === hash('sha256', mb_strtolower($email)); } /** * 현재 요청 사용자의 역할 식별자 목록을 수집합니다. * * @param Request $request HTTP 요청 * @return array 역할 식별자 배열 */ protected function collectUserRoles(Request $request): array { $user = $request->user(); if (! $user instanceof User) { return []; } if (method_exists($user, 'roles')) { return $user->roles()->pluck('identifier')->all(); } return []; } /** * 현재 요청 사용자의 admin 여부를 permission 기반으로 판정합니다 (User::isAdmin() 위임). * 게스트는 항상 false. * * @param Request $request HTTP 요청 * @return bool admin 여부 */ protected function resolveUserIsAdmin(Request $request): bool { $user = $request->user(); if (! $user instanceof User) { return false; } try { return (bool) $user->isAdmin(); } catch (\Throwable) { return false; } } /** * 게스트 라우트(register/forgot/reset)에서 폼 입력 email 을 정책 컨텍스트에 노출합니다. * 인증 사용자의 경우는 IdentityPolicyService::resolveTargetHash() 가 user->email 을 우선 사용합니다. * * @param Request $request HTTP 요청 * @return string|null 요청 input.email 또는 null */ protected function resolveTargetEmail(Request $request): ?string { $email = $request->input('email'); if (is_string($email) && $email !== '') { return $email; } return null; } }