Files
Gnuboard7/app/Http/Resources/UserResource.php
T
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

438 lines
18 KiB
PHP

<?php
namespace App\Http\Resources;
use App\Enums\ConsentType;
use App\Enums\UserStatus;
use App\Extension\HookManager;
use App\Helpers\TimezoneHelper;
use App\Traits\HasCountryAttributes;
use Carbon\Carbon;
use Illuminate\Http\Request;
class UserResource extends BaseApiResource
{
use HasCountryAttributes;
/**
* 사용자 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> 변환된 사용자 데이터 배열
*/
public function toArray(Request $request): array
{
$data = [
'uuid' => $this->getValue('uuid'),
'name' => $this->getValue('name'),
'nickname' => $this->getValue('nickname'),
'email' => $this->getValue('email'),
'avatar' => $this->resource->getAvatarUrl(),
'language' => $this->getValue('language'),
'language_label' => $this->getValue('language')
? __('user.language.'.$this->getValue('language'))
: null,
'country' => $this->getValue('country'),
'status' => $this->getValue('status'),
'status_label' => $this->getStatusLabel(),
'status_variant' => $this->getStatusVariant(),
'is_admin' => $this->resource->isAdmin(),
'homepage' => $this->getValue('homepage'),
'mobile' => $this->getValue('mobile'),
'phone' => $this->getValue('phone'),
'zipcode' => $this->getValue('zipcode'),
'address' => $this->getValue('address'),
'address_detail' => $this->getValue('address_detail'),
'signature' => $this->getValue('signature'),
'bio' => $this->getValue('bio'),
'last_login_at' => $this->getValue('last_login_at')
? $this->formatDateTimeStringForUser($this->getValue('last_login_at'))
: null,
'email_verified_at' => $this->getValue('email_verified_at')
? $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
: null,
'timezone' => $this->getValue('timezone'),
// 관계형 데이터 (필요시 로드)
'modules_count' => $this->when(
isset($this->modules_count),
$this->getValue('modules_count')
),
'plugins_count' => $this->when(
isset($this->plugins_count),
$this->getValue('plugins_count')
),
'menus_count' => $this->when(
isset($this->menus_count),
$this->getValue('menus_count')
),
// 관계형 데이터
'modules' => $this->whenLoaded('modules', function () {
return $this->getValue('modules', collect())->map(function ($module) {
$moduleResource = new ModuleResource($module);
return [
'id' => $moduleResource->getValue('id'),
'name' => $module->getLocalizedName(),
'slug' => $moduleResource->getValue('slug'),
'is_active' => $moduleResource->getValue('is_active'),
];
});
}),
'plugins' => $this->whenLoaded('plugins', function () {
return $this->getValue('plugins', collect())->map(function ($plugin) {
$pluginResource = new PluginResource($plugin);
return [
'id' => $pluginResource->getValue('id'),
'name' => $plugin->getLocalizedName(),
'slug' => $pluginResource->getValue('slug'),
'is_active' => $pluginResource->getValue('is_active'),
];
});
}),
'menus' => $this->whenLoaded('menus', function () {
return $this->getValue('menus', collect())->map(function ($menu) {
$menuResource = new MenuResource($menu);
return [
'id' => $menuResource->getValue('id'),
'title' => $menu->getLocalizedName(),
'url' => $menuResource->getValue('url'),
'is_active' => $menuResource->getValue('is_active'),
];
});
}),
'roles' => $this->whenLoaded('roles', function () {
return $this->getValue('roles', collect())->map(function ($role) {
return [
'id' => $role->id,
'identifier' => $role->identifier,
'name' => $role->getLocalizedName(),
];
});
}),
// 권한은 역할을 통해 간접 로드됨 (roles.permissions)
'permissions' => $this->whenLoaded('roles', function () {
$allPermissions = collect();
foreach ($this->resource->roles as $role) {
if ($role->relationLoaded('permissions')) {
$allPermissions = $allPermissions->merge($role->permissions);
}
}
return $allPermissions->unique('id')->map(function ($permission) {
return [
'id' => $permission->id,
'identifier' => $permission->identifier,
'name' => $permission->getLocalizedName(),
];
})->values();
}),
// 약관 동의 이력 (전체 배열 - 플러그인 참조용)
'consents' => $this->whenLoaded('consents', function () {
return $this->resource->consents->map(fn ($c) => [
'consent_type' => $c->consent_type->value,
'agreed_at' => $this->formatDateTimeStringForUser($c->agreed_at),
'revoked_at' => $this->formatDateTimeStringForUser($c->revoked_at),
]);
}),
// 코어 UI용 개별 동의 키 (관리자 화면 직접 바인딩용)
'terms_consent' => $this->whenLoaded('consents', function () {
$consent = $this->resource->consents
->first(fn ($c) => $c->consent_type === ConsentType::Terms);
return $consent ? [
'agreed_at' => $this->formatDateTimeStringForUser($consent->agreed_at),
] : null;
}),
'privacy_consent' => $this->whenLoaded('consents', function () {
$consent = $this->resource->consents
->first(fn ($c) => $c->consent_type === ConsentType::Privacy);
return $consent ? [
'agreed_at' => $this->formatDateTimeStringForUser($consent->agreed_at),
] : null;
}),
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
return $data;
}
/**
* 인증 사용자(/api/auth/user, currentUser 출처) 응답용 배열을 반환합니다.
*
* 기본 toArray() 에 더해 core.user.filter_resource_data 필터를 적용해
* 모듈이 자신의 데이터(결제 통화 등)를 병합할 수 있게 한다. 프론트(_user_base.json)는
* 로그인 유저면 이 값으로 _global.preferredCurrency 를 초기화해
* "로그인 시 계정 통화로 덮어씀"(D-LOGIN-CUR)을 구조적으로 충족한다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로 $this->when() 대신
* 삼항 연산자를 사용해야 한다. (toProfileArray/withAdminInfo 와 동일 규약)
*
* @param Request|null $request HTTP 요청
* @return array<string, mixed> 모듈 필드가 병합된 인증 사용자 데이터
*/
public function toAuthArray(?Request $request = null): array
{
$data = $this->toArray($request ?? request());
// Filter 훅: 모듈이 자신의 데이터를 응답에 병합 (toProfileArray 와 동일)
return HookManager::applyFilters('core.user.filter_resource_data', $data, $this->resource);
}
/**
* 리소스별 권한 매핑을 반환합니다.
*
* @return array<string, string>
*/
protected function abilityMap(): array
{
return [
'can_read' => 'core.users.read',
'can_create' => 'core.users.create',
'can_update' => 'core.users.update',
'can_delete' => 'core.users.delete',
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
// (core.permissions.update: 역할에 권한을 가감)이 아니다. 부여 가능한 개별 역할의
// 범위는 서버 상한(PermissionEscalationGuard)이 역할별로 강제한다.
'can_assign_roles' => 'core.users.update',
];
}
/**
* 리소스별 권한을 해석합니다.
*
* 슈퍼관리자 계정은 시스템 불변식상 삭제 불가하므로 can_delete=false 로 강제합니다.
* 그 외 사용자(관리자 포함)의 삭제 가능 여부는 G7 역할/퍼미션/스코프 시스템
* (PermissionHelper::checkScopeAccess) 이 평가합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, bool> 권한 배열
*/
protected function resolveAbilities(Request $request): array
{
$abilities = parent::resolveAbilities($request);
if ($this->resource->isSuperAdmin()) {
$abilities['can_delete'] = false;
}
return $abilities;
}
/**
* 소유자 필드를 반환합니다.
*
* User 리소스의 경우 자기 자신이 소유자이므로 id 필드를 사용합니다.
*
* @return string|null 소유자 필드명
*/
protected function ownerField(): ?string
{
return 'id';
}
/**
* 관리자용 상세 정보를 포함한 배열을 반환합니다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
*
* @return array<string, mixed> 상세 정보가 포함된 사용자 데이터
*/
public function withAdminInfo(): array
{
$data = array_merge($this->toArray(request()), [
'admin_memo' => $this->getValue('admin_memo'),
'ip_address' => $this->getValue('ip_address'),
'withdrawn_at' => $this->getValue('withdrawn_at')
? $this->formatDateTimeStringForUser($this->getValue('withdrawn_at'))
: null,
'blocked_at' => $this->getValue('blocked_at')
? $this->formatDateTimeStringForUser($this->getValue('blocked_at'))
: null,
// 로그인 실패 누적 잠금 상태 — 관리자만 확인·해제할 수 있어야 하므로 관리자 응답에만 노출
'failed_login_attempts' => (int) ($this->getValue('failed_login_attempts') ?? 0),
'locked_permanently' => (bool) $this->getValue('locked_permanently'),
'locked_until' => $this->getValue('locked_until')
? $this->formatDateTimeStringForUser($this->getValue('locked_until'))
: null,
'is_locked' => (bool) $this->getValue('locked_permanently')
|| ($this->getValue('locked_until') !== null && $this->resource->locked_until?->isFuture()),
]);
// Filter 훅: 모듈이 자신의 데이터를 응답에 병합
return HookManager::applyFilters('core.user.filter_resource_data', $data, $this->resource);
}
/**
* 목록용 간단한 형태의 배열을 반환합니다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
* $this->when()은 Laravel이 toArray()를 처리할 때만 MissingValue를 필터링합니다.
*
* @param Request|null $request HTTP 요청
* @return array<string, mixed> 간략한 사용자 정보
*/
public function toListArray(?Request $request = null): array
{
$request = $request ?? request();
$countryCode = $this->getValue('country');
$language = $this->getValue('language');
$status = $this->getValue('status');
$userStatus = $status ? UserStatus::tryFrom($status) : null;
return [
'uuid' => $this->getValue('uuid'),
'name' => $this->getValue('name'),
'nickname' => $this->getValue('nickname'),
'email' => $this->getValue('email'),
'language' => $language,
'language_label' => $language ? __('user.language.'.$language) : null,
'country' => $countryCode,
'country_flag' => $countryCode ? $this->getCountryFlag($countryCode) : null,
'country_name' => $countryCode ? $this->getCountryName($countryCode) : null,
'status' => $status,
'status_label' => $userStatus?->label(),
'status_variant' => $userStatus?->variant(),
'mobile' => $this->getValue('mobile'),
'roles' => $this->resource->relationLoaded('roles')
? $this->resource->roles->map(fn ($role) => [
'id' => $role->id,
'identifier' => $role->identifier,
'name' => $role->getLocalizedName(),
])->toArray()
: [],
'email_verified_at' => $this->getValue('email_verified_at')
? $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
: null,
'last_login_at' => $this->getValue('last_login_at')
? $this->formatDateTimeStringForUser($this->getValue('last_login_at'))
: null,
'created_at' => $this->getValue('created_at')
? $this->formatDateForUser($this->getValue('created_at'))
: null,
...$this->resourceMeta($request),
];
}
/**
* 프로필용 안전한 형태의 배열을 반환합니다 (민감한 정보 제외).
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
*
* @return array<string, mixed> 안전한 프로필 정보
*/
public function toProfileArray(): array
{
$status = $this->getValue('status');
$userStatus = $status ? UserStatus::tryFrom($status) : null;
$data = [
'uuid' => $this->getValue('uuid'),
'name' => $this->getValue('name'),
'nickname' => $this->getValue('nickname'),
'email' => $this->getValue('email'),
'avatar' => $this->resource->getAvatarUrl(),
'language' => $this->getValue('language'),
'timezone' => $this->getValue('timezone'),
'country' => $this->getValue('country'),
'status' => $status,
'status_label' => $userStatus?->label(),
'status_variant' => $userStatus?->variant(),
'homepage' => $this->getValue('homepage'),
'mobile' => $this->getValue('mobile'),
'phone' => $this->getValue('phone'),
'zipcode' => $this->getValue('zipcode'),
'address' => $this->getValue('address'),
'address_detail' => $this->getValue('address_detail'),
'signature' => $this->getValue('signature'),
'bio' => $this->getValue('bio'),
'is_super' => $this->resource->isSuperAdmin(),
'is_admin' => $this->resource->isAdmin(),
'withdrawn_at' => $this->getValue('withdrawn_at')
? $this->formatDateTimeStringForUser($this->getValue('withdrawn_at'))
: null,
'last_login_at' => $this->getValue('last_login_at')
? $this->formatDateTimeStringForUser($this->getValue('last_login_at'))
: null,
'last_login_human' => $this->getValue('last_login_at')
? TimezoneHelper::toUserCarbon(Carbon::parse($this->getValue('last_login_at')))?->diffForHumans()
: null,
'created_at' => $this->getValue('created_at')
? $this->formatDateTimeStringForUser($this->getValue('created_at'))
: null,
'is_owner' => request()->user()?->uuid === $this->resource->uuid,
];
// Filter 훅: 모듈이 자신의 데이터를 프로필 응답에 병합
return HookManager::applyFilters('core.user.filter_resource_data', $data, $this->resource);
}
/**
* 통계용 데이터 배열을 반환합니다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
*
* @return array<string, mixed> 통계 정보가 포함된 사용자 데이터
*/
public function toStatisticsArray(): array
{
return [
'uuid' => $this->getValue('uuid'),
'name' => $this->getValue('name'),
'email' => $this->getValue('email'),
'modules_count' => $this->getValue('modules_count', 0),
'plugins_count' => $this->getValue('plugins_count', 0),
'menus_count' => $this->getValue('menus_count', 0),
'last_login_at' => $this->getValue('last_login_at')
? $this->formatDateTimeStringForUser($this->getValue('last_login_at'))
: null,
'created_at' => $this->getValue('created_at')
? $this->formatDateTimeStringForUser($this->getValue('created_at'))
: null,
];
}
/**
* 사용자 상태 라벨을 반환합니다.
*
* @return string|null 상태 라벨
*/
protected function getStatusLabel(): ?string
{
$status = $this->getValue('status');
$userStatus = $status ? UserStatus::tryFrom($status) : null;
return $userStatus?->label();
}
/**
* 사용자 상태 variant를 반환합니다.
*
* @return string|null 상태 variant (success, secondary, danger, warning)
*/
protected function getStatusVariant(): ?string
{
$status = $this->getValue('status');
$userStatus = $status ? UserStatus::tryFrom($status) : null;
return $userStatus?->variant();
}
}