Files
Gnuboard7/app/Models/User.php
T
HeuJung cf1d8ab52c fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
2026-08-17 15:46:38 +09:00

678 lines
23 KiB
PHP

<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Contracts\UniqueIdServiceInterface;
use App\Enums\MenuPermissionType;
use App\Enums\PermissionType;
use App\Enums\ScopeType;
use App\Enums\UserStatus;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements HasLocalePreference
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**
* 닉네임 컬럼 최대 길이 (마이그레이션에서 명시한 값)
*/
public const NICKNAME_MAX_LENGTH = 50;
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
'nickname' => ['label_key' => 'activity_log.fields.nickname', 'type' => 'text'],
'email' => ['label_key' => 'activity_log.fields.email', 'type' => 'text'],
'language' => ['label_key' => 'activity_log.fields.language', 'type' => 'text'],
'timezone' => ['label_key' => 'activity_log.fields.timezone', 'type' => 'text'],
'country' => ['label_key' => 'activity_log.fields.country', 'type' => 'text'],
'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => UserStatus::class],
'is_super' => ['label_key' => 'activity_log.fields.is_super', 'type' => 'boolean'],
'homepage' => ['label_key' => 'activity_log.fields.homepage', 'type' => 'text'],
'mobile' => ['label_key' => 'activity_log.fields.mobile', 'type' => 'text'],
'phone' => ['label_key' => 'activity_log.fields.phone', 'type' => 'text'],
'zipcode' => ['label_key' => 'activity_log.fields.zipcode', 'type' => 'text'],
'address' => ['label_key' => 'activity_log.fields.address', 'type' => 'text'],
'address_detail' => ['label_key' => 'activity_log.fields.address_detail', 'type' => 'text'],
'bio' => ['label_key' => 'activity_log.fields.bio', 'type' => 'text'],
'admin_memo' => ['label_key' => 'activity_log.fields.admin_memo', 'type' => 'text'],
];
/**
* 권한별 effective scope 캐시 (인스턴스 레벨)
*
* @var array<string, string|null|false>
*/
protected array $effectiveScopeCache = [];
/**
* 보유 권한 부여 내역 캐시 (인스턴스 레벨)
*
* 한 요청에서 권한 판정은 수십~수백 번 일어난다 — 레이아웃 노드마다, 목록 행마다,
* 리소스 필드마다 부른다. 판정마다 DB 에 물으면 노드/행 수에 비례해 쿼리가 늘어난다.
* 첫 판정에서 `roles.permissions` 를 한 번 적재해 두고 이후로는 배열만 본다.
*
* 캐시 수명은 **모델 인스턴스**, 즉 요청 스코프다. 크로스 요청 캐시를 두지 않으므로
* 권한을 바꾸면 다음 요청부터 즉시 반영된다.
*
* 구조: 권한 식별자 ⇒ [{type, scope_type}, ...] (같은 권한을 여러 역할이 주면 여러 건)
*
* @var array<string, array<int, array{type: string|null, scope_type: string|null}>>|null
*/
protected ?array $permissionGrantsCache = null;
/**
* 테이블명
*
* @var string
*/
protected $table = 'users';
/**
* 기본키
*
* @var string
*/
protected $primaryKey = 'id';
/**
* 타임스탬프 사용 여부
*
* @var bool
*/
public $timestamps = true;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'uuid',
'name',
'nickname',
'email',
'password',
'language',
'is_super',
'timezone',
'country',
'status',
'homepage',
'mobile',
'phone',
'zipcode',
'address',
'address_detail',
'signature',
'bio',
'avatar',
'admin_memo',
'ip_address',
'last_login_at',
'withdrawn_at',
'blocked_at',
'identity_verified_at',
'identity_verified_provider',
'identity_verified_purpose_last',
'identity_hash',
'mobile_verified_at',
'failed_login_attempts',
'locked_until',
'locked_permanently',
'last_failed_login_at',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'id',
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'last_login_at' => 'datetime',
'withdrawn_at' => 'datetime',
'blocked_at' => 'datetime',
'identity_verified_at' => 'datetime',
'mobile_verified_at' => 'datetime',
'is_super' => 'boolean',
'failed_login_attempts' => 'integer',
'locked_until' => 'datetime',
'locked_permanently' => 'boolean',
'last_failed_login_at' => 'datetime',
];
}
/**
* Route Model Binding에 사용할 키 이름을 반환합니다.
*
* @return string 라우트 키 이름
*/
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* 알림 발송 시 사용할 수신자 선호 로케일을 반환합니다.
*
* 사용자 언어 SSoT 는 users.language 컬럼입니다. 지원 로케일이면 해당 값을,
* 빈값/미지원 로케일이면 사이트 기본 로케일을 반환합니다. (요청자 locale 폴백 없음)
*
* @return string|null 지원 로케일이면 해당 값, 아니면 사이트 기본 로케일
*/
public function preferredLocale(): ?string
{
$supported = config('app.supported_locales', ['ko', 'en']);
return ($this->language && in_array($this->language, $supported, true))
? $this->language
: config('app.locale', 'ko');
}
/**
* 모델 부팅 시 UUID 자동 생성을 등록합니다.
*/
protected static function boot(): void
{
parent::boot();
static::creating(function (self $user) {
if (empty($user->uuid)) {
$user->uuid = app(UniqueIdServiceInterface::class)->generateUuid();
}
});
}
/**
* 사용자의 약관 동의 이력들과의 관계를 정의합니다.
*/
public function consents(): HasMany
{
return $this->hasMany(UserConsent::class);
}
/**
* 사용자가 생성한 모듈들과의 관계를 정의합니다.
*
* @return HasMany
*/
public function modules()
{
return $this->hasMany(Module::class, 'created_by');
}
/**
* 사용자가 생성한 플러그인들과의 관계를 정의합니다.
*
* @return HasMany
*/
public function plugins()
{
return $this->hasMany(Plugin::class, 'created_by');
}
/**
* 사용자가 가진 역할들과의 관계를 정의합니다.
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'user_roles')
->withPivot(['assigned_at', 'assigned_by'])
->withTimestamps();
}
/**
* 사용자의 개별 메뉴 권한들과의 관계를 정의합니다.
*
* @deprecated role_menus 피벗 테이블 사용. 역할 기반 권한을 사용하세요.
*/
public function menuPermissions(): HasMany
{
return $this->hasMany(MenuPermission::class);
}
/**
* 특정 권한을 가지고 있는지 확인합니다.
*
* @param string $permission 권한 식별자
* @param PermissionType|null $type 권한 타입 (null이면 타입 구분 없이 체크)
*/
public function hasPermission(string $permission, ?PermissionType $type = null): bool
{
$grants = $this->permissionGrants()[$permission] ?? null;
if ($grants === null) {
return false;
}
if ($type === null) {
return true;
}
foreach ($grants as $grant) {
if ($grant['type'] === $type->value) {
return true;
}
}
return false;
}
/**
* 보유 권한 부여 내역을 반환합니다 (인스턴스 캐시).
*
* 역할 → 권한을 한 번만 적재하고, 이후 판정은 전부 이 배열에서 이루어집니다.
*
* @return array<string, array<int, array{type: string|null, scope_type: string|null}>> 권한 식별자 ⇒ 부여 내역
*/
protected function permissionGrants(): array
{
if ($this->permissionGrantsCache !== null) {
return $this->permissionGrantsCache;
}
$grants = [];
foreach ($this->roles()->with('permissions')->get() as $role) {
foreach ($role->permissions as $permission) {
$grants[$permission->identifier][] = [
'type' => $this->enumValue($permission->type),
'scope_type' => $this->enumValue($permission->pivot->scope_type ?? null),
];
}
}
return $this->permissionGrantsCache = $grants;
}
/**
* Enum 또는 원시 값을 문자열로 정규화합니다.
*
* 캐스팅 설정 유무에 따라 Enum 인스턴스와 문자열이 섞여 들어오므로 한 형태로 맞춥니다.
*
* @param mixed $value Enum 인스턴스 · 문자열 · null
* @return string|null 정규화된 문자열 (부재 시 null)
*/
private function enumValue(mixed $value): ?string
{
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
return $value === null ? null : (string) $value;
}
/**
* 권한 판정 캐시를 비웁니다.
*
* 같은 인스턴스에서 역할을 바꾼 직후 다시 판정해야 하는 경우에 호출합니다.
* (역할 동기화 서비스가 호출하며, 일반 조회 경로에서는 필요하지 않습니다)
*/
public function flushPermissionCaches(): void
{
$this->permissionGrantsCache = null;
$this->effectiveScopeCache = [];
}
/**
* 여러 권한을 가지고 있는지 확인합니다.
*
* @param array $permissions 권한 식별자 배열
* @param bool $requireAll 모든 권한이 필요한지 여부 (true: AND, false: OR)
* @param PermissionType|null $type 권한 타입 (null이면 타입 구분 없이 체크)
*/
public function hasPermissions(array $permissions, bool $requireAll = true, ?PermissionType $type = null): bool
{
// 같은 권한 집합을 hasPermission 과 공유한다 — 권한 개수만큼 쿼리가 늘지 않는다.
// 비교 기준(고유 매칭 수 vs 인자 개수)은 종전과 동일하게 유지한다.
$matched = 0;
foreach (array_unique($permissions) as $permission) {
if ($this->hasPermission($permission, $type)) {
$matched++;
}
}
return $requireAll ? $matched === count($permissions) : $matched > 0;
}
/**
* 해당 권한에 대한 effective scope를 반환합니다.
*
* 사용자가 보유한 역할들의 scope_type을 수집하여 union 정책을 적용합니다.
* 우선순위: null(전체) > 'role'(소유역할) > 'self'(본인)
* 인스턴스 레벨 캐싱으로 동일 권한 반복 조회 시 DB 쿼리를 방지합니다.
*
* @param string $identifier 권한 식별자
* @return string|null null(전체), 'role'(소유역할), 'self'(본인)
*/
public function getEffectiveScopeForPermission(string $identifier): ?string
{
// 캐시 히트 시 즉시 반환 (false = 캐시된 null과 구분)
if (array_key_exists($identifier, $this->effectiveScopeCache)) {
return $this->effectiveScopeCache[$identifier];
}
// 같은 권한 집합에서 scope_type 만 뽑는다 — 권한마다 쿼리를 다시 내지 않는다.
$values = array_column($this->permissionGrants()[$identifier] ?? [], 'scope_type');
// 권한 미보유 시 null 반환 (기본값: 전체 접근)
if ($values === []) {
return $this->effectiveScopeCache[$identifier] = null;
}
// union 정책: 하나라도 null → 전체 접근
if (in_array(null, $values, true)) {
return $this->effectiveScopeCache[$identifier] = null;
}
// 하나라도 'role' → role 적용
if (in_array(ScopeType::Role->value, $values, true)) {
return $this->effectiveScopeCache[$identifier] = 'role';
}
// 모두 'self' → self
return $this->effectiveScopeCache[$identifier] = 'self';
}
/**
* 특정 역할을 가지고 있는지 확인합니다.
*
* @param string $role 역할 식별자
*/
public function hasRole(string $role): bool
{
return $this->roles()->where('identifier', $role)->exists();
}
/**
* 여러 역할을 가지고 있는지 확인합니다.
*
* @param array $roles 역할 식별자 배열
* @param bool $requireAll 모든 역할이 필요한지 여부 (true: AND, false: OR)
*/
public function hasRoles(array $roles, bool $requireAll = true): bool
{
$userRoles = $this->roles()->whereIn('identifier', $roles)->count();
return $requireAll ? $userRoles === count($roles) : $userRoles > 0;
}
/**
* 관리자인지 확인합니다.
*
* 사용자가 보유한 권한 중 type='admin'인 권한이 하나라도 있으면 관리자로 판단합니다.
*
* @return bool 관리자 여부
*/
public function isAdmin(): bool
{
// 같은 권한 집합을 재사용한다 — 관리자 판정은 미들웨어·리소스·레이아웃에서 반복 호출된다.
foreach ($this->permissionGrants() as $grants) {
foreach ($grants as $grant) {
if ($grant['type'] === PermissionType::Admin->value) {
return true;
}
}
}
return false;
}
/**
* 슈퍼 관리자인지 확인합니다.
*
* 슈퍼 관리자는 삭제할 수 없으며, 다른 관리자의 권한을 관리할 수 있습니다.
*
* @return bool 슈퍼 관리자 여부
*/
public function isSuperAdmin(): bool
{
return $this->is_super === true;
}
/**
* 슈퍼 관리자만 조회합니다.
*
* @param Builder $query
* @return Builder
*/
public function scopeSuperAdmins($query)
{
return $query->where('is_super', true);
}
/**
* 특정 메뉴에 대한 권한을 가지고 있는지 확인합니다.
*
* role_menus 피벗 테이블을 통해 역할 기반 권한을 확인합니다.
*
* @param int $menuId 메뉴 ID
* @param MenuPermissionType|string $permissionType 권한 유형 (read, write, delete)
*/
public function hasMenuPermission(int $menuId, MenuPermissionType|string $permissionType = 'read'): bool
{
$type = $permissionType instanceof MenuPermissionType
? $permissionType->value
: $permissionType;
// 역할 기반 권한 확인 (role_menus 피벗 테이블)
return $this->roles()
->whereHas('menus', function ($query) use ($menuId, $type) {
$query->where('menus.id', $menuId)
->wherePivot('permission_type', $type);
})
->exists();
}
/**
* 특정 slug의 메뉴에 대한 접근 권한을 가지고 있는지 확인합니다.
*
* role_menus 피벗 테이블을 통해 역할 기반 메뉴 접근 권한을 확인합니다.
*
* @param string $slug 메뉴 slug (예: 'admin-users')
* @param MenuPermissionType|string $permissionType 권한 유형 (read, write, delete)
* @return bool 메뉴 접근 권한 보유 여부
*/
public function hasMenuAccessBySlug(string $slug, MenuPermissionType|string $permissionType = 'read'): bool
{
$type = $permissionType instanceof MenuPermissionType
? $permissionType->value
: $permissionType;
return $this->roles()
->whereHas('menus', function ($query) use ($slug, $type) {
$query->where('menus.slug', $slug)
->where('role_menus.permission_type', $type);
})
->exists();
}
/**
* 사용자가 생성한 메뉴들과의 관계를 정의합니다.
*
* @return HasMany
*/
public function menus()
{
return $this->hasMany(Menu::class, 'created_by');
}
/**
* 사용자의 시간대를 반환합니다.
* 설정되지 않은 경우 기본 사용자 시간대를 반환합니다.
*/
public function getTimezone(): string
{
return $this->timezone ?? config('app.default_user_timezone', 'Asia/Seoul');
}
/**
* 사용자의 아바타 첨부파일과의 관계를 정의합니다.
*
* attachments 테이블의 다형성 관계를 사용합니다.
* collection='avatar'로 아바타 전용 첨부파일을 구분합니다.
*/
public function avatarAttachment(): MorphOne
{
return $this->morphOne(Attachment::class, 'attachmentable')
->where('collection', 'avatar');
}
/**
* 아바타 이미지 URL을 반환합니다.
*
* attachments 테이블의 다형성 관계를 사용하여 아바타를 조회합니다.
* 관계가 없으면 레거시 avatar 필드를 확인합니다.
*
* @return string|null 아바타 URL (없으면 null)
*/
public function getAvatarUrl(): ?string
{
// 새로운 방식: attachments 테이블 다형성 관계
$attachment = $this->avatarAttachment;
if ($attachment) {
return $attachment->download_url;
}
// 레거시 방식: avatar 필드 (하위 호환)
if (! empty($this->avatar)) {
return url('storage/attachments/avatars/'.$this->avatar);
}
return null;
}
/**
* 활성화된 사용자만 조회합니다.
*/
public function scopeActive(Builder $query): void
{
$query->whereNull('withdrawn_at')
->where('status', '!=', UserStatus::Withdrawn);
}
/**
* 탈퇴한 사용자만 조회합니다.
*/
public function scopeWithdrawn(Builder $query): void
{
$query->whereNotNull('withdrawn_at')
->where('status', UserStatus::Withdrawn);
}
/**
* 탈퇴한 사용자인지 확인합니다.
*
* @return bool 탈퇴 여부
*/
public function isWithdrawn(): bool
{
return $this->withdrawn_at !== null && $this->status === UserStatus::Withdrawn->value;
}
/**
* 사용자를 탈퇴 처리합니다.
*
* 이름, 이메일, 닉네임에 suffix를 추가하여 익명화하고,
* 상태를 'withdrawn'으로 변경하며 탈퇴 일시를 기록합니다.
*
* @return bool 저장 성공 여부
*/
public function withdraw(): bool
{
// 멱등 가드 — 이미 탈퇴한 계정에 다시 호출해도 접미사가 겹쳐 붙지 않는다.
if ($this->isWithdrawn()) {
return true;
}
$now = now();
$dateSuffix = $now->format('Ymd'); // 예: 20260127
// name/email 은 마이그레이션에서 길이를 지정하지 않은 문자열 컬럼이므로
// 스키마 기본 길이(AppServiceProvider 의 defaultStringLength)를 그대로 따른다.
// 여기에 리터럴을 박으면 기본 길이를 바꾸는 순간 조용히 어긋난다.
$defaultLength = SchemaBuilder::$defaultStringLength ?? 255;
// 이름에 suffix 추가 (있는 경우만)
if ($this->name) {
$this->name = $this->appendWithdrawnSuffix($this->name, '_탈퇴_'.$dateSuffix, $defaultLength);
}
// 이메일에 suffix 추가 (필수)
//
// 접미사에 사용자 ID 를 포함해 구조적으로 유일하게 만든다. 날짜만 붙이면
// 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴할 때 email unique 에
// 걸려 탈퇴가 실패한다(공개이슈 #112).
$this->email = $this->appendWithdrawnSuffix(
$this->email,
'_deleted_'.$dateSuffix.'_'.$this->id,
$defaultLength,
);
// 닉네임에 suffix 추가 (있는 경우만, 날짜 없이)
if ($this->nickname) {
// nickname 은 마이그레이션에서 길이를 명시(50)한 컬럼이다.
// 이 접미사는 유일성 토큰(id)이 없다 — 현재 nickname/name 에 unique 인덱스가
// 없어 무해하지만, 향후 unique 인덱스를 추가하면 email 과 동일한 충돌
// (같은 값 재가입 후 재탈퇴 실패)이 재발하므로 그때 id 부착으로 전환할 것.
$this->nickname = $this->appendWithdrawnSuffix($this->nickname, '_탈퇴', self::NICKNAME_MAX_LENGTH);
}
// 상태 변경
$this->status = UserStatus::Withdrawn->value;
$this->withdrawn_at = $now;
return $this->save();
}
/**
* 탈퇴 접미사를 컬럼 길이 안에서 부착합니다.
*
* 원값이 길면 접미사 길이만큼 앞을 잘라 붙입니다 — 자르지 않으면 접미사가 컬럼
* 길이를 넘겨 strict mode 저장 예외가 나고, 탈퇴가 중간에서 실패합니다.
*
* @param string $value 원본 값
* @param string $suffix 부착할 접미사
* @param int $maxLength 컬럼 최대 길이
* @return string 접미사가 부착된 값
*/
protected function appendWithdrawnSuffix(string $value, string $suffix, int $maxLength): string
{
$available = $maxLength - mb_strlen($suffix);
if ($available < 0) {
$available = 0;
}
return mb_substr($value, 0, $available).$suffix;
}
}