위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던 결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다. - 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는 DB 쓰기에 선행하여 거부 시 상태 불변. - 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체. 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형. - secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙 소유권·비밀·발행 상태 검사 통일. - 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를 런타임·저장측·정적검사 3층 동형화. - 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
438 lines
18 KiB
PHP
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();
|
|
}
|
|
}
|